From e6615454cd03df26008af6488a59af2d466435d5 Mon Sep 17 00:00:00 2001 From: "Z. Charles Dziura" Date: Mon, 7 Oct 2024 16:48:50 -0400 Subject: [PATCH] Implement route to generate a new session --- .../20231221181946_create-tables.down.sql | 2 - .../20231221181946_create-tables.up.sql | 13 +-- api/src/db/user.rs | 82 +------------------ api/src/models/error.rs | 10 ++- api/src/models/session.rs | 46 +++++++++-- api/src/requests/auth/login/handler.rs | 76 +++++++++-------- api/src/requests/auth/mod.rs | 23 +++++- api/src/requests/auth/session/handler.rs | 76 +++++++++++++++++ api/src/requests/auth/session/mod.rs | 4 + api/src/requests/auth/session/models/mod.rs | 3 + .../requests/auth/session/models/response.rs | 12 +++ api/src/requests/user/create/handler.rs | 4 +- .../requests/user/create/models/response.rs | 2 +- api/src/requests/user/verify/handler.rs | 64 ++++----------- api/src/services/auth_token.rs | 53 ++++++++++++ api/src/services/cache.rs | 18 ++++ 16 files changed, 295 insertions(+), 193 deletions(-) create mode 100644 api/src/requests/auth/session/handler.rs create mode 100644 api/src/requests/auth/session/mod.rs create mode 100644 api/src/requests/auth/session/models/mod.rs create mode 100644 api/src/requests/auth/session/models/response.rs diff --git a/api/migrations/20231221181946_create-tables.down.sql b/api/migrations/20231221181946_create-tables.down.sql index c7838b3..078db25 100644 --- a/api/migrations/20231221181946_create-tables.down.sql +++ b/api/migrations/20231221181946_create-tables.down.sql @@ -1,11 +1,9 @@ DROP INDEX IF EXISTS status_name_uniq_idx; -DROP INDEX IF EXISTS user_auth_token_hash_uniq_idx; DROP INDEX IF EXISTS user_username_uniq_idx; DROP INDEX IF EXISTS user_email_uniq_idx; DROP INDEX IF EXISTS permission_name_uniq_idx; DROP TABLE IF EXISTS public.user_permission; DROP TABLE IF EXISTS public.permission; -DROP TABLE IF EXISTS public.user_auth_token; DROP TABLE IF EXISTS public.user; DROP TABLE IF EXISTS public.status CASCADE; diff --git a/api/migrations/20231221181946_create-tables.up.sql b/api/migrations/20231221181946_create-tables.up.sql index 55f6c6d..a53db43 100644 --- a/api/migrations/20231221181946_create-tables.up.sql +++ b/api/migrations/20231221181946_create-tables.up.sql @@ -16,7 +16,7 @@ VALUES ('Active'), ('Unverified'), ('Removed'), - ('Quaranteened'); + ('Quarantined'); CREATE TABLE IF NOT EXISTS public.user ( @@ -33,17 +33,6 @@ CREATE TABLE IF NOT EXISTS CREATE UNIQUE INDEX IF NOT EXISTS user_username_uniq_idx ON public.user(username); CREATE UNIQUE INDEX IF NOT EXISTS user_email_uniq_idx ON public.user(email); -CREATE TABLE IF NOT EXISTS - public.user_auth_token ( - id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - user_id INT NOT NULL REFERENCES public.user(id), - token_hash VARCHAR(256) NOT NULL, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() - ); - -CREATE UNIQUE INDEX IF NOT EXISTS user_auth_token_hash_uniq_idx ON public.user_auth_token(token_hash); - CREATE TABLE IF NOT EXISTS public.permission ( id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, diff --git a/api/src/db/user.rs b/api/src/db/user.rs index ce8d115..6632b3b 100644 --- a/api/src/db/user.rs +++ b/api/src/db/user.rs @@ -1,12 +1,6 @@ -use std::{ - fmt::Debug, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; +use std::fmt::Debug; -use blake3::Hash; -use serde::Serialize; -use sqlx::prelude::FromRow; -use time::OffsetDateTime; +use sqlx::prelude::*; use tracing::error; use crate::models::AppError; @@ -98,75 +92,3 @@ pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> { }) .map(|_| ()) } - -#[derive(Clone, Debug)] -pub struct NewUserAuthTokenEntity { - pub user_id: i32, - pub token_hash: Hash, - pub expires_at: SystemTime, -} - -#[derive(Debug, FromRow)] -pub struct TmpUserAuthTokenEntity { - pub id: i32, - pub user_id: i32, - pub token_hash: String, - pub expires_at: OffsetDateTime, -} - -#[derive(Debug, Serialize)] -pub struct UserAuthTokenEntity { - pub id: i32, - pub user_id: i32, - pub token_hash: String, - pub expires_at: SystemTime, -} - -impl From for UserAuthTokenEntity { - fn from(other: TmpUserAuthTokenEntity) -> Self { - let TmpUserAuthTokenEntity { - id, - user_id, - token_hash, - expires_at, - } = other; - - Self { - id, - user_id, - token_hash, - expires_at: UNIX_EPOCH - .checked_add(Duration::new( - expires_at.clone().unix_timestamp().try_into().unwrap(), - expires_at.nanosecond(), - )) - .unwrap(), - } - } -} - -pub async fn insert_new_user_auth_token( - pool: &DbPool, - new_user_auth_token: NewUserAuthTokenEntity, -) -> Result { - let NewUserAuthTokenEntity { - user_id, - token_hash, - expires_at, - } = new_user_auth_token.clone(); - - let token_hash = token_hash.to_string(); - let expires_at = OffsetDateTime::from(expires_at); - - sqlx::query_as::<_, TmpUserAuthTokenEntity>("INSERT INTO public.user_auth_token (user_id, token_hash, expires_at) VALUES ($1, $2, $3) RETURNING id, user_id, token_hash, expires_at;") - .bind(user_id) - .bind(token_hash) - .bind(expires_at) - .fetch_one(pool) - .await - .map_err(|err| { - error!(?err, record = ?new_user_auth_token, "Cannot insert new user auth token record"); - AppError::from(err) - }) - .map(From::from) -} diff --git a/api/src/models/error.rs b/api/src/models/error.rs index 393d0e3..a3cc677 100644 --- a/api/src/models/error.rs +++ b/api/src/models/error.rs @@ -45,6 +45,10 @@ impl AppError { Self::new(ErrorKind::InvalidToken) } + pub fn missing_authorization_token() -> Self { + Self::new(ErrorKind::MissingAuthorizationToken) + } + pub fn missing_environment_variables(missing_vars: Vec<&'static str>) -> Self { Self::new(ErrorKind::MissingEnvironmentVariables(missing_vars)) } @@ -129,6 +133,7 @@ impl Display for AppError { ErrorKind::ExpiredToken => write!(f, "The provided token has expired"), ErrorKind::InvalidPassword => write!(f, "Invalid password"), ErrorKind::InvalidToken => write!(f, "The provided token is invalid"), + ErrorKind::MissingAuthorizationToken => write!(f, "Missing authorization token"), ErrorKind::MissingEnvironmentVariables(missing_vars) => write!( f, "Missing required environment variables: {}", @@ -163,6 +168,7 @@ enum ErrorKind { InvalidToken, MissingEnvironmentVariables(Vec<&'static str>), MissingSessionField(&'static str), + MissingAuthorizationToken, NoDbRecordFound, NoSessionFound, Sqlx(SqlxError), @@ -180,7 +186,9 @@ impl IntoResponse for AppError { StatusCode::BAD_REQUEST, ApiResponse::new_with_error(self).into_json_response(), ), - &ErrorKind::ExpiredToken | &ErrorKind::NoSessionFound => ( + &ErrorKind::ExpiredToken + | &ErrorKind::MissingAuthorizationToken + | &ErrorKind::NoSessionFound => ( StatusCode::UNAUTHORIZED, ApiResponse::new_with_error(self).into_json_response(), ), diff --git a/api/src/models/session.rs b/api/src/models/session.rs index 1df18a4..558d417 100644 --- a/api/src/models/session.rs +++ b/api/src/models/session.rs @@ -1,7 +1,20 @@ -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::SystemTime; +use axum::{async_trait, extract::FromRequestParts}; +use http::request::Parts; use humantime::format_rfc3339; use redis::ToRedisArgs; +use uuid::Uuid; + +use crate::{ + requests::AppState, + services::{ + auth_token::{self, verify_token}, + user_session, + }, +}; + +use super::AppError; #[derive(Debug)] pub struct Session { @@ -28,12 +41,29 @@ impl Session { } } -impl Default for Session { - fn default() -> Self { - Self { - user_id: Default::default(), - created_at: UNIX_EPOCH, - expires_at: UNIX_EPOCH, - } +#[async_trait] +impl FromRequestParts for Session { + type Rejection = AppError; + + async fn from_request_parts<'a, 'b>( + parts: &'a mut Parts, + state: &'b AppState, + ) -> Result { + let cache_pool = state.cache_pool(); + let token_key = state.env().token_key(); + + let trusted_token_str = auth_token::extract_token_string_from_http_headers(&parts.headers)?; + let trusted_token = verify_token(token_key, trusted_token_str, None)?; + + let token_id = trusted_token + .payload_claims() + .and_then(|claims| claims.get_claim("kid")) + .ok_or(AppError::invalid_token()) + .map(|value| value.as_str().unwrap()) + .and_then(|token_id| Uuid::try_from(token_id).map_err(|_| AppError::invalid_token()))?; + + user_session::get_user_session(cache_pool, token_id) + .await + .and_then(|session| session.ok_or(AppError::no_session_found())) } } diff --git a/api/src/requests/auth/login/handler.rs b/api/src/requests/auth/login/handler.rs index 255c024..5b158f4 100644 --- a/api/src/requests/auth/login/handler.rs +++ b/api/src/requests/auth/login/handler.rs @@ -11,17 +11,14 @@ use pasetors::{keys::SymmetricKey, version4::V4}; use tracing::debug; use crate::{ - db::{ - get_username_and_password_by_username, insert_new_user_auth_token, DbPool, - NewUserAuthTokenEntity, UserIdAndHashedPasswordEntity, - }, + db::{get_username_and_password_by_username, DbPool, UserIdAndHashedPasswordEntity}, models::{ApiResponse, AppError, Session}, requests::{ auth::login::models::{AuthLoginResponse, AuthLoginTokenData}, AppState, }, services::{ - auth_token::{generate_auth_token, generate_session_token}, + auth_token::{generate_auth_token, generate_session_token, store_user_auth_token}, user_session, verify_password, CachePool, }, }; @@ -55,34 +52,8 @@ async fn auth_login_request( verify_password(password, hashed_password)?; - let (session_token, session_token_id, session_token_expiration) = - generate_session_token(token_key, user_id); - - let (auth_token, auth_token_id, auth_token_expiration) = - generate_auth_token(token_key, user_id); - - insert_new_user_auth_token( - db_pool, - NewUserAuthTokenEntity { - user_id, - token_hash: blake3::hash(auth_token_id.as_bytes()), - expires_at: auth_token_expiration, - }, - ) - .await?; - - let session = Session { - user_id, - created_at: SystemTime::now(), - expires_at: auth_token_expiration, - }; - - let expiration = session_token_expiration - .duration_since(SystemTime::now()) - .unwrap(); - - user_session::store_user_session(cache_pool, session_token_id, session, Some(expiration)) - .await?; + let ((session_token, session_token_expiration), (auth_token, auth_token_expiration)) = + generate_login_auth_and_session_tokens(cache_pool, token_key, user_id).await?; let response = AuthLoginResponse { user_id, @@ -102,3 +73,42 @@ async fn auth_login_request( ) .into_response()) } + +pub async fn generate_login_auth_and_session_tokens( + cache_pool: &CachePool, + token_key: &SymmetricKey, + user_id: i32, +) -> Result<((String, SystemTime), (String, SystemTime)), AppError> { + let (session_token, session_token_id, session_token_expiration) = + generate_session_token(token_key, user_id); + + let (auth_token, _, auth_token_expiration) = generate_auth_token(token_key, user_id); + + store_user_auth_token( + cache_pool, + user_id, + auth_token.as_str(), + auth_token_expiration + .duration_since(SystemTime::now()) + .unwrap(), + ) + .await?; + + let session = Session { + user_id, + created_at: SystemTime::now(), + expires_at: auth_token_expiration, + }; + + let expiration = session_token_expiration + .duration_since(SystemTime::now()) + .unwrap(); + + user_session::store_user_session(cache_pool, session_token_id, session, Some(expiration)) + .await?; + + Ok(( + (session_token, session_token_expiration), + (auth_token, auth_token_expiration), + )) +} diff --git a/api/src/requests/auth/mod.rs b/api/src/requests/auth/mod.rs index 4a0a2f2..ef22cea 100644 --- a/api/src/requests/auth/mod.rs +++ b/api/src/requests/auth/mod.rs @@ -1,13 +1,28 @@ -use axum::{routing::post, Router}; +use axum::{ + routing::{get, post}, + Router, +}; use login::auth_login_post_handler; +use session::auth_session_get_handler; use super::AppState; mod login; +mod session; + +pub use login::generate_login_auth_and_session_tokens; pub fn requests(state: AppState) -> Router { - Router::new().route( - "/auth/login", - post(auth_login_post_handler).with_state(state), + Router::new().nest( + "/auth", + Router::new() + .route( + "/login", + post(auth_login_post_handler).with_state(state.clone()), + ) + .route( + "/session", + get(auth_session_get_handler).with_state(state.clone()), + ), ) } diff --git a/api/src/requests/auth/session/handler.rs b/api/src/requests/auth/session/handler.rs new file mode 100644 index 0000000..04f5111 --- /dev/null +++ b/api/src/requests/auth/session/handler.rs @@ -0,0 +1,76 @@ +use std::time::SystemTime; + +use axum::{ + debug_handler, + extract::State, + response::{IntoResponse, Response}, +}; +use http::{HeaderMap, StatusCode}; + +use crate::{ + models::{ApiResponse, AppError, Session}, + requests::AppState, + services::{ + auth_token::{self, generate_session_token, get_if_auth_token_exists, verify_token}, + user_session, + }, +}; + +use super::models::AuthSessionResponse; + +#[debug_handler] +pub async fn auth_session_get_handler( + State(state): State, + headers: HeaderMap, +) -> Result { + let cache_pool = state.cache_pool(); + let token_key = state.env().token_key(); + + let auth_token_str = auth_token::extract_token_string_from_http_headers(&headers)?; + let auth_token = verify_token(token_key, auth_token_str, None)?; + + let user_id = auth_token + .payload_claims() + .and_then(|claims| claims.get_claim("sub")) + .and_then(|user_id| user_id.as_str()) + .ok_or(AppError::invalid_token()) + .and_then(|user_id| { + user_id + .parse::() + .map_err(|_| AppError::invalid_token()) + }) + .unwrap(); + + let auth_token_exists = + get_if_auth_token_exists(cache_pool, user_id, auth_token_str.to_string().as_str()).await?; + + if !auth_token_exists { + return Err(AppError::no_session_found()); + } + + let (session_token, session_token_id, session_token_expiration) = + generate_session_token(token_key, user_id); + + let expiration = session_token_expiration + .duration_since(SystemTime::now()) + .unwrap(); + + let new_session = Session { + user_id, + created_at: SystemTime::now(), + expires_at: session_token_expiration, + }; + + user_session::store_user_session(cache_pool, session_token_id, new_session, Some(expiration)) + .await?; + + Ok(( + StatusCode::CREATED, + ApiResponse::new(AuthSessionResponse { + token: session_token, + expiration: session_token_expiration, + }) + .into_json_response(), + ) + .into_response()) +} diff --git a/api/src/requests/auth/session/mod.rs b/api/src/requests/auth/session/mod.rs new file mode 100644 index 0000000..f00e7ca --- /dev/null +++ b/api/src/requests/auth/session/mod.rs @@ -0,0 +1,4 @@ +mod handler; +mod models; + +pub use handler::*; diff --git a/api/src/requests/auth/session/models/mod.rs b/api/src/requests/auth/session/models/mod.rs new file mode 100644 index 0000000..1d28ef9 --- /dev/null +++ b/api/src/requests/auth/session/models/mod.rs @@ -0,0 +1,3 @@ +mod response; + +pub use response::*; diff --git a/api/src/requests/auth/session/models/response.rs b/api/src/requests/auth/session/models/response.rs new file mode 100644 index 0000000..9b8f5bb --- /dev/null +++ b/api/src/requests/auth/session/models/response.rs @@ -0,0 +1,12 @@ +use std::time::SystemTime; + +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthSessionResponse { + pub token: String, + + #[serde(serialize_with = "humantime_serde::serialize")] + pub expiration: SystemTime, +} diff --git a/api/src/requests/user/create/handler.rs b/api/src/requests/user/create/handler.rs index ae4e94a..424d40f 100644 --- a/api/src/requests/user/create/handler.rs +++ b/api/src/requests/user/create/handler.rs @@ -111,13 +111,13 @@ async fn register_new_user_request( }); UserRegistrationResponse { - id: user_id, + user_id, expires_at, session_token: None, } } else { UserRegistrationResponse { - id: user_id, + user_id, expires_at, session_token: Some(verification_token), } diff --git a/api/src/requests/user/create/models/response.rs b/api/src/requests/user/create/models/response.rs index 7b87414..4fd6c4c 100644 --- a/api/src/requests/user/create/models/response.rs +++ b/api/src/requests/user/create/models/response.rs @@ -8,7 +8,7 @@ use serde_with::{serde_as, skip_serializing_none}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UserRegistrationResponse { - pub id: i32, + pub user_id: i32, #[serde(serialize_with = "humantime_serde::serialize")] pub expires_at: SystemTime, diff --git a/api/src/requests/user/verify/handler.rs b/api/src/requests/user/verify/handler.rs index 4e975c3..8c4428f 100644 --- a/api/src/requests/user/verify/handler.rs +++ b/api/src/requests/user/verify/handler.rs @@ -1,4 +1,4 @@ -use std::{str::FromStr, time::SystemTime}; +use std::str::FromStr; use axum::{ debug_handler, @@ -12,13 +12,13 @@ use tracing::{debug, error}; use uuid::Uuid; use crate::{ - db::{insert_new_user_auth_token, verify_user, DbPool, NewUserAuthTokenEntity}, - models::{ApiResponse, AppError, Session}, - requests::{user::verify::UserVerifyGetResponseTokenAndExpiration, AppState}, - services::{ - auth_token::{generate_auth_token, generate_session_token, verify_token}, - user_session, CachePool, + db::{verify_user, DbPool}, + models::{ApiResponse, AppError}, + requests::{ + auth::generate_login_auth_and_session_tokens, + user::verify::UserVerifyGetResponseTokenAndExpiration, AppState, }, + services::{auth_token::verify_token, user_session, CachePool}, }; use super::{UserVerifyGetParams, UserVerifyGetResponse}; @@ -81,46 +81,14 @@ async fn verify_new_user_request( })? .await?; - // TODO: Add new auth token to database, cache session access token - let (session_token, session_token_id, session_token_expiration) = - generate_session_token(token_key, user_id); - let (auth_token, auth_token_id, auth_token_expiration) = - generate_auth_token(token_key, user_id); + verify_user(db_pool, user_id) + .await + .inspect_err(|err| error!(?err))?; - let session = Session { - user_id, - created_at: SystemTime::now(), - expires_at: session_token_expiration, - }; + let ((session_token, session_token_expiration), (auth_token, auth_token_expiration)) = + generate_login_auth_and_session_tokens(cache_pool, token_key, user_id).await?; - user_session::store_user_session( - cache_pool, - session_token_id, - session, - Some( - session_token_expiration - .duration_since(SystemTime::now()) - .unwrap(), - ), - ) - .await?; - - insert_new_user_auth_token( - db_pool, - NewUserAuthTokenEntity { - user_id, - token_hash: blake3::hash(auth_token_id.as_bytes()), - expires_at: auth_token_expiration, - }, - ) - .await?; - - let response = verify_token( - token_key, - verification_token.as_str(), - Some(validation_rules), - ) - .map(|_| UserVerifyGetResponse { + let response = UserVerifyGetResponse { user_id, session: UserVerifyGetResponseTokenAndExpiration { token: session_token, @@ -130,11 +98,7 @@ async fn verify_new_user_request( token: auth_token, expires_at: auth_token_expiration, }, - })?; - - verify_user(db_pool, user_id) - .await - .inspect_err(|err| error!(?err))?; + }; Ok(( StatusCode::OK, diff --git a/api/src/services/auth_token.rs b/api/src/services/auth_token.rs index cdbfce0..9642353 100644 --- a/api/src/services/auth_token.rs +++ b/api/src/services/auth_token.rs @@ -1,5 +1,7 @@ use std::time::{Duration, SystemTime}; +use http::{header::AUTHORIZATION, HeaderMap}; +use humantime::format_rfc3339_seconds; use pasetors::{ claims::{Claims, ClaimsValidationRules}, errors::{ClaimValidationError, Error as TokenError}, @@ -15,10 +17,40 @@ use uuid::Uuid; use crate::models::AppError; +use super::{cache, CachePool}; + +static AUTH_TOKEN_CACHE_KEY_PREFIX: &'static str = "debt_pirate:auth:"; static ONE_DAY: Duration = Duration::from_secs(86_400); static ONE_HOUR: Duration = Duration::from_secs(3_600); static FIFTEEN_MINUTES: Duration = Duration::from_secs(900); +pub async fn store_user_auth_token( + cache_pool: &CachePool, + user_id: i32, + auth_token: &str, + expiration: Duration, +) -> Result<(), AppError> { + let key = make_key(user_id, auth_token); + let expires_at = SystemTime::now() + expiration; + + cache::store_scalar( + cache_pool, + key.as_str(), + format_rfc3339_seconds(expires_at).to_string(), + Some(expiration), + ) + .await +} + +pub async fn get_if_auth_token_exists( + cache_pool: &CachePool, + user_id: i32, + auth_token: &str, +) -> Result { + let key = make_key(user_id, auth_token); + cache::exists(cache_pool, key.as_str()).await +} + pub fn verify_token( key: &SymmetricKey, token: &str, @@ -68,6 +100,22 @@ pub fn generate_new_user_token(key: &SymmetricKey, user_id: i32) -> (String, ) } +pub fn extract_token_string_from_http_headers<'a>( + headers: &'a HeaderMap, +) -> Result<&'a str, AppError> { + headers + .get(AUTHORIZATION) + .ok_or(AppError::missing_authorization_token()) + .map(|value| { + value + .to_str() + .unwrap() + .trim_start_matches("Bearer") + .trim_start_matches("bearer") + .trim() + }) +} + fn generate_token( key: &SymmetricKey, user_id: i32, @@ -131,6 +179,11 @@ fn map_token_error(err: TokenError) -> AppError { } } +fn make_key(user_id: i32, token: &str) -> String { + let token_hash = blake3::hash(token.as_bytes()); + format!("{AUTH_TOKEN_CACHE_KEY_PREFIX}{user_id}:{token_hash}") +} + #[cfg(test)] mod tests { use base64::prelude::*; diff --git a/api/src/services/cache.rs b/api/src/services/cache.rs index 9a9bcb2..2169acc 100644 --- a/api/src/services/cache.rs +++ b/api/src/services/cache.rs @@ -67,6 +67,24 @@ pub async fn _get_object( Ok(hash_fields) } +pub async fn store_scalar<'a, V: ToRedisArgs + Send + Sync + 'a>( + cache_pool: &CachePool, + key: &str, + scalar: V, + expiration: Option, +) -> Result<(), AppError> { + let mut conn = get_connection_from_pool(cache_pool).await?; + let _: () = conn.set(key, scalar).await?; + + if let Some(expiration) = expiration { + let _: () = conn + .expire(key, expiration.as_secs().try_into().unwrap()) + .await?; + } + + Ok(()) +} + pub async fn exists(cache_pool: &CachePool, key: &str) -> Result { let mut conn = get_connection_from_pool(cache_pool).await?; conn.exists(key).await.map_err(Into::into)