diff --git a/backend/src/api.rs b/backend/src/api.rs index 90ac7d3..87225fc 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -159,7 +159,7 @@ impl AppState { *dyn_config = SafeHavenOptions::load(conn).await; } - pub fn generate_refresh_token(&self, claims: T) -> String + pub fn generate_token(&self, claims: T) -> String where T: serde::Serialize, { diff --git a/backend/src/api/admin.rs b/backend/src/api/admin.rs index e43547b..a6fd77b 100644 --- a/backend/src/api/admin.rs +++ b/backend/src/api/admin.rs @@ -195,7 +195,7 @@ async fn admin_login( Err(err) => return err.into_response(), }; - let new_cookies = auth::login(&app_state, jar, &auth_user, request.remember_me); + let new_cookies = auth::set_auth_cookies(&app_state, jar, &auth_user, request.remember_me); let body = LoginResponse { is_admin: auth_user.is_admin, }; diff --git a/backend/src/api/admin/auth.rs b/backend/src/api/admin/auth.rs index 6b05c86..116797a 100644 --- a/backend/src/api/admin/auth.rs +++ b/backend/src/api/admin/auth.rs @@ -6,17 +6,16 @@ use axum::http::request::Parts; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum_extra::extract::cookie::{Cookie, CookieJar, Expiration, SameSite}; -use chrono::{TimeDelta, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -// Consts const EPHEMERAL_TOKEN_COOKIE_NAME: &str = "ephemeral_token"; const REFRESH_TOKEN_COOKIE_NAME: &str = "refresh_token"; -const REFRESH_TOKEN_MAX_INACTIVE_DAYS: i64 = 3; -const REFRESH_TOKEN_MAX_INACTIVE_DAYS_REMEMBER_ME: i64 = 31; +const EPHEMERAL_TOKEN_DURATION: time::Duration = time::Duration::hours(1); +const REFRESH_TOKEN_DURATION: time::Duration = time::Duration::hours(8); +const REFRESH_TOKEN_REMEMBER_ME_DURATION: time::Duration = time::Duration::days(7); #[derive(Clone, Serialize, ToSchema)] pub struct AdminUserIdentity { @@ -33,6 +32,14 @@ impl AdminUserIdentity { is_admin: claims.is_admin, } } + + fn from_user(user: &User) -> Self { + Self { + admin_id: user.id, + username: user.name.clone(), + is_admin: user.is_admin, + } + } } #[async_trait] @@ -70,111 +77,112 @@ struct AdminRefreshTokenClaims { pub iat: usize, } +async fn authenticate_request( + app_state: &AppState, + input_cookies: CookieJar, + new_cookies: &mut Option, + mut conn: sqlx::pool::PoolConnection, +) -> Result { + // If an ephemeral token is present, try to use it + // An invalid ephemeral token is equivalent to no token at all + if let Some(ephemeral_token_str) = input_cookies.get(EPHEMERAL_TOKEN_COOKIE_NAME) { + // Decode the ephemeral token + let token_data = jsonwebtoken::decode::( + ephemeral_token_str.value(), + &jsonwebtoken::DecodingKey::from_secret(app_state.config.token_secret.as_ref()), + &jsonwebtoken::Validation::default(), + ); + + // If the token is valid we just return the claims + if let Ok(data) = token_data { + tracing::debug!("valid ephemeral token"); + return Ok(AdminUserIdentity::from_claims(&data.claims)); + } + } + + // If no ephemeral token is present, or the ephemeral token is invalid, + // generate one using the refresh token. + + // Get the refresh token jwt. if missing or invalid, the user is unauthorized + let refresh_claims = { + let refresh_token = input_cookies + .get(REFRESH_TOKEN_COOKIE_NAME) + .ok_or(AppError::Unauthorized) + .inspect_err(|_| tracing::debug!("missing refresh token"))? + .value(); + + // decode and validate refresh token claims + match jsonwebtoken::decode::( + refresh_token, + &jsonwebtoken::DecodingKey::from_secret(app_state.config.token_secret.as_ref()), + &jsonwebtoken::Validation::default(), + ) { + Ok(jsonwebtoken::TokenData { claims, .. }) => claims, + Err(_) => { + tracing::debug!("invalid refresh token"); + // If the refresh token is invalid, clear it and return an error + *new_cookies = Some(expire_cookies(app_state, input_cookies)); + return Err(AppError::Unauthorized); + } + } + }; + + // get the user and create corresponding claims + let user = match User::get(refresh_claims.admin_id, &mut conn).await { + Ok(user) => user, + // if the user is not found, clear the cookie jar: the user was deleted + Err(AppError::Database(sqlx::Error::RowNotFound)) => { + tracing::debug!("cannot find the refresh token user"); + *new_cookies = Some(expire_cookies(app_state, input_cookies)); + return Err(AppError::Unauthorized); + } + Err(err) => { + tracing::debug!("user get error: {:?}", err); + return Err(err); + } + }; + + // Regenerate and update tokens + tracing::debug!("refreshing auth cookies"); + *new_cookies = Some(set_auth_cookies( + app_state, + input_cookies, + &user, + refresh_claims.remember_me, + )); + Ok(AdminUserIdentity::from_user(&user)) +} + pub async fn authentication_middleware( State(app_state): State, - DbConn(mut conn): DbConn, + DbConn(conn): DbConn, cookies: CookieJar, mut request: Request, next: Next, -) -> Result { - // Get the token from the cookies - let token = cookies - .get(EPHEMERAL_TOKEN_COOKIE_NAME) - .ok_or_else(|| AppError::Unauthorized.into_response())? - .value(); - - // 1) Decode the ephemeral token: - // - If valid, continue on - // - If invalid, - // If - - // Decode the ephemeral token - let token_data = jsonwebtoken::decode::( - token, - &jsonwebtoken::DecodingKey::from_secret(app_state.config.token_secret.as_ref()), - &jsonwebtoken::Validation::default(), - ); - - let (ephemeral_claims, update_cookie) = match token_data { - // If the token is valid we just return the claims - Ok(data) => (data.claims, false), - - // If the ephemeral token is invalid try to use the refresh token to generate a new one - Err(_) => { - // get the refresh token - let refresh_token = cookies - .get(REFRESH_TOKEN_COOKIE_NAME) - .ok_or_else(|| AppError::Unauthorized.into_response())? - .value(); - - // decode and validate refresh token claims - let Ok(jsonwebtoken::TokenData { claims, .. }) = - jsonwebtoken::decode::( - refresh_token, - &jsonwebtoken::DecodingKey::from_secret(app_state.config.token_secret.as_ref()), - &jsonwebtoken::Validation::default(), - ) - else { - // If the refresh token is invalid return an error - let purged_jar = expire_cookies(&app_state, cookies); - return Ok((purged_jar, AppError::Unauthorized).into_response()); - }; - - // get the user and create corresponding claims - match User::get(claims.admin_id, &mut conn).await { - Ok(user) => { - let new_ephemeral_claims = create_user_claim(&user); - (new_ephemeral_claims, true) - } - // if the user is not found, clear the cookie jar - Err(AppError::Database(sqlx::Error::RowNotFound)) => { - let purged_jar = expire_cookies(&app_state, cookies); - return Ok((purged_jar, AppError::Unauthorized).into_response()); - } - Err(err) => return Err(err.into_response()), - } +) -> Response { + // attempt to authenticate the request + let mut new_cookies = None; + let response = match authenticate_request(&app_state, cookies, &mut new_cookies, conn).await { + Err(err) => return err.into_response(), + Ok(user_identity) => { + // attach user identity to the request + request.extensions_mut().insert(user_identity); + + // Get the response from the middleware chain + next.run(request).await } }; - // put the token claims in the request - request - .extensions_mut() - .insert(AdminUserIdentity::from_claims(&ephemeral_claims)); - - // Execute the chain - let response = next.run(request).await; - - Ok(if update_cookie { - // If a new ephemerala token is generated we need to send it back to the client - let ephemeral_cookie = create_ephemeral_cookie(ephemeral_claims, &app_state); - let new_cookies = cookies.add(ephemeral_cookie); + // attach cookies to the response, regardless of success + if let Some(new_cookies) = new_cookies { (new_cookies, response).into_response() } else { - // Otherwise just return the response response - }) -} - -pub fn expire_cookies(app_state: &AppState, cookies: CookieJar) -> CookieJar { - [EPHEMERAL_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME] - .into_iter() - .fold(cookies, |jar, cookie_name| { - jar.remove(create_admin_cookie(cookie_name, app_state)) - }) -} - -fn create_user_claim(user: &User) -> AdminEphemeralTokenClaims { - AdminEphemeralTokenClaims { - admin_id: user.id, - username: user.name.clone(), - is_admin: user.is_admin, - iat: Utc::now().timestamp() as usize, - exp: (Utc::now() + TimeDelta::hours(1)).timestamp() as usize, } } // this helper is used to add and remove cookies -fn create_admin_cookie<'a>( +fn admin_cookie<'a>( base: impl Into>, app_state: &AppState, ) -> cookie::CookieBuilder<'a> { @@ -185,47 +193,59 @@ fn create_admin_cookie<'a>( .same_site(SameSite::Strict) } -fn create_ephemeral_cookie<'a>( - claims: AdminEphemeralTokenClaims, - app_state: &AppState, -) -> Cookie<'a> { - let token = app_state.generate_refresh_token(claims); - create_admin_cookie((EPHEMERAL_TOKEN_COOKIE_NAME, token), app_state).build() -} - -fn create_refresh_cookie<'a>(user_id: Uuid, app_state: &AppState, remember_me: bool) -> Cookie<'a> { - // sadly, the cookie library uses `time` and the jwt library uses `chrono` - let inactive_days = if remember_me { - REFRESH_TOKEN_MAX_INACTIVE_DAYS_REMEMBER_ME - } else { - REFRESH_TOKEN_MAX_INACTIVE_DAYS - }; - let time_now = time::OffsetDateTime::now_utc(); - let token_exp = time_now + time::Duration::days(inactive_days); - - let token = app_state.generate_refresh_token(AdminRefreshTokenClaims { - admin_id: user_id, - iat: time_now.unix_timestamp() as usize, - exp: token_exp.unix_timestamp() as usize, - remember_me, - }); - - create_admin_cookie((REFRESH_TOKEN_COOKIE_NAME, token), app_state) - .expires(if remember_me { - Expiration::DateTime(time_now + time::Duration::days(inactive_days)) - } else { - Expiration::Session - }) - .build() -} - -pub fn login( +pub fn set_auth_cookies( app_state: &AppState, cookies: CookieJar, auth_user: &User, remember_me: bool, ) -> CookieJar { - let auth_cookie = create_ephemeral_cookie(create_user_claim(auth_user), app_state); - let refresh_cookie = create_refresh_cookie(auth_user.id, app_state, remember_me); - cookies.add(auth_cookie).add(refresh_cookie) + let user_id = auth_user.id; + let time_now = time::OffsetDateTime::now_utc(); + + let ephemeral_token_exp = time_now + EPHEMERAL_TOKEN_DURATION; + let refresh_token_exp = time_now + + if remember_me { + REFRESH_TOKEN_REMEMBER_ME_DURATION + } else { + REFRESH_TOKEN_DURATION + }; + + let ephemeral_cookie = { + let token = app_state.generate_token(AdminEphemeralTokenClaims { + admin_id: auth_user.id, + username: auth_user.name.clone(), + is_admin: auth_user.is_admin, + iat: time_now.unix_timestamp() as usize, + exp: ephemeral_token_exp.unix_timestamp() as usize, + }); + admin_cookie((EPHEMERAL_TOKEN_COOKIE_NAME, token), app_state) + .expires(Expiration::DateTime(ephemeral_token_exp)) + .build() + }; + + let refresh_cookie = { + let token = app_state.generate_token(AdminRefreshTokenClaims { + admin_id: user_id, + iat: time_now.unix_timestamp() as usize, + exp: refresh_token_exp.unix_timestamp() as usize, + remember_me, + }); + + admin_cookie((REFRESH_TOKEN_COOKIE_NAME, token), app_state) + .expires(if remember_me { + Expiration::DateTime(refresh_token_exp) + } else { + Expiration::Session + }) + .build() + }; + cookies.add(ephemeral_cookie).add(refresh_cookie) +} + +pub fn expire_cookies(app_state: &AppState, cookies: CookieJar) -> CookieJar { + [EPHEMERAL_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME] + .into_iter() + .fold(cookies, |jar, cookie_name| { + jar.remove(admin_cookie(cookie_name, app_state)) + }) } diff --git a/backend/src/api/root.rs b/backend/src/api/root.rs index 39a72f4..dd027e5 100644 --- a/backend/src/api/root.rs +++ b/backend/src/api/root.rs @@ -103,7 +103,7 @@ async fn bootstrap( tracing::trace!("Bootstrapping: found access token"); - let signed_token = app_state.generate_refresh_token(MapUserTokenClaims { + let signed_token = app_state.generate_token(MapUserTokenClaims { iat: Utc::now().timestamp() as usize, exp: (Utc::now() + TimeDelta::try_minutes(5).expect("valid duration")).timestamp() as usize, perms: perms.clone(), diff --git a/frontend/pages/admin/login.vue b/frontend/pages/admin/login.vue index 50a36dd..8772caa 100644 --- a/frontend/pages/admin/login.vue +++ b/frontend/pages/admin/login.vue @@ -43,7 +43,7 @@ toggle-mask class="w-full -mt-2" /> -