From 5986a3611ab366cd725a9cafb10cf923aa60b49a Mon Sep 17 00:00:00 2001 From: projectmoon Date: Fri, 11 Jun 2021 22:34:03 +0000 Subject: [PATCH] Implement JWT refreshing. --- Cargo.lock | 1 + api/Cargo.toml | 1 + api/src/auth.rs | 53 ++++++++++++++++++++++++++++++----- api/src/errors.rs | 32 +++++++++++++++++++++ web-ui/crate/src/api/auth.rs | 54 ++++++++++++++++++++++++------------ web-ui/crate/src/rooms.rs | 15 ++++++++++ 6 files changed, 132 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1646bf0..169cadf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3518,6 +3518,7 @@ dependencies = [ "rocket", "rocket_cors", "serde", + "serde_json", "substring", "tenebrous-rpc", "thiserror", diff --git a/api/Cargo.toml b/api/Cargo.toml index 1672bf4..3cc48b0 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -14,6 +14,7 @@ substring = "1.4" jsonwebtoken = "7.2" chrono = "0.4" serde = {version = "1.0", features = ["derive"] } +serde_json = {version = "1.0" } tenebrous-rpc = { path = "../rpc" } juniper = { git = "https://github.com/graphql-rust/juniper", branch = "master" } juniper_rocket_async = { git = "https://github.com/graphql-rust/juniper", branch = "master" } diff --git a/api/src/auth.rs b/api/src/auth.rs index 3bd1df8..e0937d9 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -2,8 +2,8 @@ use crate::config::Config; use crate::errors::ApiError; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use rocket::request::{self, FromRequest, Request}; use rocket::response::status::Custom; +use rocket::{http::SameSite, request::local_cache}; use rocket::{ http::Status, serde::{json::Json, Deserialize, Serialize}, @@ -12,6 +12,10 @@ use rocket::{ http::{Cookie, CookieJar}, outcome::Outcome, }; +use rocket::{ + outcome::IntoOutcome, + request::{self, FromRequest, Request}, +}; use rocket::{routes, Route, State}; use substring::Substring; @@ -56,7 +60,7 @@ impl<'r> FromRequest<'r> for User { } pub(crate) fn routes() -> Vec { - routes![login] + routes![login, refresh_token] } #[derive(Debug, Serialize, Deserialize)] @@ -75,7 +79,7 @@ fn create_token<'a>( username: &str, expiration: Duration, secret: &str, -) -> Result> { +) -> Result { let expiration = Utc::now() .checked_add_signed(expiration) .expect("clock went awry") @@ -90,8 +94,7 @@ fn create_token<'a>( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()), - ) - .map_err(|e| Custom(Status::InternalServerError, e.to_string()))?; + )?; Ok(token) } @@ -101,15 +104,51 @@ struct LoginResponse { jwt_token: String, } +/// A strongly-typed representation of the refresh token, used with a +/// FromRequest trait to decode it from the cookie. +struct RefreshToken(String); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RefreshToken { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let token: Option = request + .cookies() + .get_private("refresh_token") + .and_then(|cookie| cookie.value().parse::().ok()) + .map(|t| RefreshToken(t)); + + token.or_forward(()) + } +} + #[rocket::post("/login", data = "")] async fn login( request: Json>, config: &State, cookies: &CookieJar<'_>, -) -> Result, Custom> { +) -> Result, ApiError> { let token = create_token(request.username, Duration::minutes(1), &config.jwt_secret)?; let refresh_token = create_token(request.username, Duration::weeks(1), &config.jwt_secret)?; - cookies.add_private(Cookie::new("refresh_token", refresh_token)); + let mut cookie = Cookie::new("refresh_token", refresh_token); + cookie.set_same_site(SameSite::None); + cookies.add_private(cookie); + + Ok(Json(LoginResponse { jwt_token: token })) +} + +#[rocket::post("/refresh")] +async fn refresh_token( + config: &State, + refresh_token: Option, +) -> Result, ApiError> { + let refresh_token = refresh_token.ok_or(ApiError::RefreshTokenMissing)?; + let refresh_token = decode_token(&refresh_token.0, config)?; + + //TODO check if token is valid? maybe decode takes care of it. + let token = create_token(&refresh_token.sub, Duration::minutes(1), &config.jwt_secret)?; + Ok(Json(LoginResponse { jwt_token: token })) } diff --git a/api/src/errors.rs b/api/src/errors.rs index 9ae854b..bd99e3a 100644 --- a/api/src/errors.rs +++ b/api/src/errors.rs @@ -1,3 +1,7 @@ +use rocket::http::ContentType; +use rocket::response::{self, Responder, Response}; +use rocket::{http::Status, request::Request}; +use std::io::Cursor; use thiserror::Error; #[derive(Error, Debug)] @@ -11,6 +15,34 @@ pub enum ApiError { #[error("authentication token missing from request")] AuthenticationRequired, + #[error("refresh token missing from request")] + RefreshTokenMissing, + #[error("error decoding token: {0}")] TokenDecodingError(#[from] jsonwebtoken::errors::Error), } + +#[rocket::async_trait] +impl<'r> Responder<'r, 'static> for ApiError { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + let status = match self { + Self::UserDoesNotExist(_) => Status::Forbidden, + Self::AuthenticationRequired => Status::Forbidden, + Self::RefreshTokenMissing => Status::Forbidden, + Self::AuthenticationDenied(_) => Status::Forbidden, + Self::TokenDecodingError(_) => Status::InternalServerError, + }; + + //TODO certain errors might be too sensitive; need to filter them here. + let body = serde_json::json!({ + "message": self.to_string() + }) + .to_string(); + + Response::build() + .header(ContentType::JsonApi) + .status(status) + .sized_body(body.len(), Cursor::new(body)) + .ok() + } +} diff --git a/web-ui/crate/src/api/auth.rs b/web-ui/crate/src/api/auth.rs index db0c319..defefe0 100644 --- a/web-ui/crate/src/api/auth.rs +++ b/web-ui/crate/src/api/auth.rs @@ -3,17 +3,29 @@ use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; -use web_sys::{console, Request, RequestInit, RequestMode, Response}; +use web_sys::{console, Request, RequestCredentials, RequestInit, RequestMode, Response}; #[derive(Debug, Serialize, Deserialize)] struct LoginResponse { jwt_token: String, } +async fn make_request(request: Request) -> Result { + let window = web_sys::window().unwrap(); + + let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; + let resp: Response = resp_value.dyn_into().unwrap(); + + let json = JsFuture::from(resp.json()?).await?; + Ok(json) +} + pub async fn fetch_jwt() -> Result { let mut opts = RequestInit::new(); opts.method("POST"); opts.mode(RequestMode::Cors); + opts.credentials(RequestCredentials::Include); + opts.body(Some(&JsValue::from_str( r#" { "username": "@projectmoon:agnos.is", "password": "lolol" } @@ -26,21 +38,29 @@ pub async fn fetch_jwt() -> Result { request.headers().set("Content-Type", "application/json")?; request.headers().set("Accept", "application/json")?; - let window = web_sys::window().unwrap(); - let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; + //TODO don't unwrap the response. OR... change it so we have a standard response. + let response = make_request(request).await?; + let response: LoginResponse = response.into_serde().unwrap(); - // `resp_value` is a `Response` object. - assert!(resp_value.is_instance_of::()); - let resp: Response = resp_value.dyn_into().unwrap(); - - // Convert this other `Promise` into a rust `Future`. - let json = JsFuture::from(resp.json()?).await?; - - console::log_1(&json); - - // Use serde to parse the JSON into a struct. - let login_response: LoginResponse = json.into_serde().unwrap(); - - // Send the `Branch` struct back to JS as an `Object`. - Ok(login_response.jwt_token) + Ok(response.jwt_token) +} + +pub async fn refresh_jwt() -> Result { + let mut opts = RequestInit::new(); + opts.method("POST"); + opts.mode(RequestMode::Cors); + opts.credentials(RequestCredentials::Include); + + let url = format!("http://localhost:10000/refresh"); + + let request = Request::new_with_str_and_init(&url, &opts)?; + request.headers().set("Content-Type", "application/json")?; + request.headers().set("Accept", "application/json")?; + + //TODO don't unwrap the response. OR... change it so we have a standard response. + let response = make_request(request).await?; + console::log_1(&response); + let response: LoginResponse = response.into_serde().unwrap(); + + Ok(response.jwt_token) } diff --git a/web-ui/crate/src/rooms.rs b/web-ui/crate/src/rooms.rs index bf456b8..569dfdb 100644 --- a/web-ui/crate/src/rooms.rs +++ b/web-ui/crate/src/rooms.rs @@ -50,6 +50,12 @@ async fn do_jwt_stuff(dispatch: &WebUiDispatcher) -> Result<(), UiError> { Ok(()) } +async fn do_refresh_jwt(dispatch: &WebUiDispatcher) -> Result<(), UiError> { + let jwt = api::auth::refresh_jwt().await?; + dispatch.send(Action::UpdateJwt(jwt)); + Ok(()) +} + impl Component for YewduxRoomList { type Message = (); type Properties = WebUiDispatcher; @@ -84,6 +90,7 @@ impl Component for YewduxRoomList { fn view(&self) -> Html { let dispatch = Arc::new(self.dispatch.clone()); let dispatch2 = dispatch.clone(); + let dispatch3 = dispatch.clone(); let the_future = self.link.callback(move |_| { let dispatch = dispatch.clone(); @@ -105,10 +112,18 @@ impl Component for YewduxRoomList { }); }); + let refresh_jwt = self.link.callback(move |_| { + let dispatch = dispatch3.clone(); + spawn_local(async move { + do_refresh_jwt(&*dispatch).await; + }); + }); + html! {
+
{ "Current JWT: " } { self.dispatch.state().jwt_token.as_ref().unwrap_or(&"[not set]".to_string()) }
    {