From 6057c8295de641d9809786b8318fbc332e2a6d91 Mon Sep 17 00:00:00 2001 From: "Z. Charles Dziura" Date: Sun, 6 Oct 2024 14:08:26 -0400 Subject: [PATCH] Create session objects during login, also store auth token for users --- api/Cargo.toml | 2 +- .../20231221181946_create-tables.down.sql | 2 + .../20231221181946_create-tables.up.sql | 19 +++- api/src/db/user.rs | 90 +++++++++++++++++-- api/src/models/error.rs | 2 +- api/src/models/session.rs | 4 - api/src/requests/auth/login/handler.rs | 64 +++++++++---- .../requests/auth/login/models/response.rs | 4 +- api/src/requests/user/create/handler.rs | 5 +- .../requests/user/create/models/response.rs | 2 +- api/src/requests/user/verify/handler.rs | 71 ++++++++++++--- .../requests/user/verify/models/response.rs | 35 +++----- api/src/services/auth_token.rs | 2 +- api/src/services/user_session.rs | 23 ++--- 14 files changed, 230 insertions(+), 95 deletions(-) diff --git a/api/Cargo.toml b/api/Cargo.toml index 52f2c3a..149f146 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -35,7 +35,7 @@ serde_json = "1.0" serde_with = "3.9" sqlx = { version = "0.8", features = [ "default", - "chrono", + "time", "postgres", "runtime-tokio", ] } diff --git a/api/migrations/20231221181946_create-tables.down.sql b/api/migrations/20231221181946_create-tables.down.sql index 078db25..c7838b3 100644 --- a/api/migrations/20231221181946_create-tables.down.sql +++ b/api/migrations/20231221181946_create-tables.down.sql @@ -1,9 +1,11 @@ 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 3d8a982..55f6c6d 100644 --- a/api/migrations/20231221181946_create-tables.up.sql +++ b/api/migrations/20231221181946_create-tables.up.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS public.status ( - id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_at TIMESTAMP WITH TIME ZONE NULL @@ -20,7 +20,7 @@ VALUES CREATE TABLE IF NOT EXISTS public.user ( - id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, @@ -33,9 +33,20 @@ 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 INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, status_id INT NOT NULL REFERENCES status(id) DEFAULT 1, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), @@ -46,7 +57,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS permission_name_uniq_idx ON public.permission( CREATE TABLE IF NOT EXISTS public.user_permission ( - id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, permission_id INT NOT NULL REFERENCES permission(id), user_id INT NOT NULL REFERENCES public.user(id), status_id INT NOT NULL REFERENCES status(id) diff --git a/api/src/db/user.rs b/api/src/db/user.rs index 855e3f8..ce8d115 100644 --- a/api/src/db/user.rs +++ b/api/src/db/user.rs @@ -1,6 +1,12 @@ -use std::fmt::Debug; +use std::{ + fmt::Debug, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use blake3::Hash; +use serde::Serialize; use sqlx::prelude::FromRow; +use time::OffsetDateTime; use tracing::error; use crate::models::AppError; @@ -60,19 +66,17 @@ pub async fn insert_new_user( } #[derive(Debug, FromRow)] -pub struct UserAndHashedPassword { +pub struct UserIdAndHashedPasswordEntity { pub id: i32, - pub username: String, - pub name: String, pub password: String, } pub async fn get_username_and_password_by_username( pool: &DbPool, username: String, -) -> Result { - sqlx::query_as::<_, UserAndHashedPassword>( - "SELECT id, username, name, password FROM public.user WHERE username = $1;", +) -> Result { + sqlx::query_as::<_, UserIdAndHashedPasswordEntity>( + "SELECT id, password FROM public.user WHERE username = $1;", ) .bind(username) .fetch_one(pool) @@ -94,3 +98,75 @@ 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 7a49838..393d0e3 100644 --- a/api/src/models/error.rs +++ b/api/src/models/error.rs @@ -49,7 +49,7 @@ impl AppError { Self::new(ErrorKind::MissingEnvironmentVariables(missing_vars)) } - pub fn _missing_session_field(field: &'static str) -> Self { + pub fn missing_session_field(field: &'static str) -> Self { Self::new(ErrorKind::MissingSessionField(field)) } diff --git a/api/src/models/session.rs b/api/src/models/session.rs index 1f6bb1c..1df18a4 100644 --- a/api/src/models/session.rs +++ b/api/src/models/session.rs @@ -6,7 +6,6 @@ use redis::ToRedisArgs; #[derive(Debug)] pub struct Session { pub user_id: i32, - pub username: String, pub created_at: SystemTime, pub expires_at: SystemTime, } @@ -17,14 +16,12 @@ impl Session { ) -> Vec<(&'static str, impl ToRedisArgs + Send + Sync + 'a)> { let Self { user_id, - username, created_at, expires_at, } = self; vec![ ("userId", user_id.to_string()), - ("username", username), ("createdAt", format_rfc3339(created_at).to_string()), ("expiresAt", format_rfc3339(expires_at).to_string()), ] @@ -35,7 +32,6 @@ impl Default for Session { fn default() -> Self { Self { user_id: Default::default(), - username: Default::default(), created_at: UNIX_EPOCH, expires_at: UNIX_EPOCH, } diff --git a/api/src/requests/auth/login/handler.rs b/api/src/requests/auth/login/handler.rs index 5ca0a21..255c024 100644 --- a/api/src/requests/auth/login/handler.rs +++ b/api/src/requests/auth/login/handler.rs @@ -1,3 +1,5 @@ +use std::time::SystemTime; + use axum::{ debug_handler, extract::State, @@ -9,15 +11,18 @@ use pasetors::{keys::SymmetricKey, version4::V4}; use tracing::debug; use crate::{ - db::{get_username_and_password_by_username, DbPool, UserAndHashedPassword}, - models::{ApiResponse, AppError}, + db::{ + get_username_and_password_by_username, insert_new_user_auth_token, DbPool, + NewUserAuthTokenEntity, UserIdAndHashedPasswordEntity, + }, + models::{ApiResponse, AppError, Session}, requests::{ auth::login::models::{AuthLoginResponse, AuthLoginTokenData}, AppState, }, services::{ - auth_token::{generate_access_token, generate_auth_token}, - verify_password, + auth_token::{generate_auth_token, generate_session_token}, + user_session, verify_password, CachePool, }, }; @@ -28,41 +33,62 @@ pub async fn auth_login_post_handler( State(state): State, Json(body): Json, ) -> Result { - let pool = state.db_pool(); + let db_pool = state.db_pool(); + let cache_pool = state.cache_pool(); let token_key = state.env().token_key(); - auth_login_request(pool, token_key, body).await + auth_login_request(db_pool, cache_pool, token_key, body).await } async fn auth_login_request( - pool: &DbPool, + db_pool: &DbPool, + cache_pool: &CachePool, token_key: &SymmetricKey, body: AuthLoginRequest, ) -> Result { debug!(?body); let AuthLoginRequest { username, password } = body; - let UserAndHashedPassword { + let UserIdAndHashedPasswordEntity { id: user_id, - username, - name, password: hashed_password, - } = get_username_and_password_by_username(pool, username).await?; + } = get_username_and_password_by_username(db_pool, username).await?; verify_password(password, hashed_password)?; - let (access_token, _access_token_id, access_token_expiration) = - generate_access_token(token_key, user_id); + 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) = + 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 response = AuthLoginResponse { user_id, - username, - name, - access: AuthLoginTokenData { - token: access_token, - expiration: access_token_expiration, + session: AuthLoginTokenData { + token: session_token, + expiration: session_token_expiration, }, auth: AuthLoginTokenData { token: auth_token, diff --git a/api/src/requests/auth/login/models/response.rs b/api/src/requests/auth/login/models/response.rs index 4eb8270..7a9e3f2 100644 --- a/api/src/requests/auth/login/models/response.rs +++ b/api/src/requests/auth/login/models/response.rs @@ -6,9 +6,7 @@ use serde::Serialize; #[serde(rename_all = "camelCase")] pub struct AuthLoginResponse { pub user_id: i32, - pub username: String, - pub name: String, - pub access: AuthLoginTokenData, + pub session: AuthLoginTokenData, pub auth: AuthLoginTokenData, } diff --git a/api/src/requests/user/create/handler.rs b/api/src/requests/user/create/handler.rs index 72fa5dd..ae4e94a 100644 --- a/api/src/requests/user/create/handler.rs +++ b/api/src/requests/user/create/handler.rs @@ -84,7 +84,6 @@ async fn register_new_user_request( let new_user_session = Session { user_id, - username, created_at: SystemTime::now(), expires_at, }; @@ -114,13 +113,13 @@ async fn register_new_user_request( UserRegistrationResponse { id: user_id, expires_at, - verification_token: None, + session_token: None, } } else { UserRegistrationResponse { id: user_id, expires_at, - verification_token: Some(verification_token), + 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 50b1338..7b87414 100644 --- a/api/src/requests/user/create/models/response.rs +++ b/api/src/requests/user/create/models/response.rs @@ -13,5 +13,5 @@ pub struct UserRegistrationResponse { #[serde(serialize_with = "humantime_serde::serialize")] pub expires_at: SystemTime, - pub verification_token: Option, + pub session_token: Option, } diff --git a/api/src/requests/user/verify/handler.rs b/api/src/requests/user/verify/handler.rs index e6b6770..4e975c3 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; +use std::{str::FromStr, time::SystemTime}; use axum::{ debug_handler, @@ -12,10 +12,13 @@ use tracing::{debug, error}; use uuid::Uuid; use crate::{ - db::{verify_user, DbPool}, - models::{ApiResponse, AppError}, - requests::AppState, - services::{auth_token::verify_token, user_session, CachePool}, + 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, + }, }; use super::{UserVerifyGetParams, UserVerifyGetResponse}; @@ -57,29 +60,77 @@ async fn verify_new_user_request( ) .inspect_err(|err| error!(?err))?; - let token_id = verified_token + let verification_token_id = verified_token .payload_claims() .map(|claims| claims.get_claim("jti")) .flatten() .map(|jti| Uuid::from_str(jti.as_str().unwrap()).unwrap()) .unwrap(); - user_session::exists(cache_pool, token_id) + user_session::exists(cache_pool, verification_token_id) .await .and_then(|exists| { if exists { - Ok(()) + Ok(user_session::get_user_session( + cache_pool, + verification_token_id, + )) } else { Err(AppError::no_session_found()) } - })?; + })? + .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); + + let session = Session { + user_id, + created_at: SystemTime::now(), + expires_at: session_token_expiration, + }; + + 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::new(token_key, user_id))?; + .map(|_| UserVerifyGetResponse { + user_id, + session: UserVerifyGetResponseTokenAndExpiration { + token: session_token, + expires_at: session_token_expiration, + }, + auth: UserVerifyGetResponseTokenAndExpiration { + token: auth_token, + expires_at: auth_token_expiration, + }, + })?; verify_user(db_pool, user_id) .await diff --git a/api/src/requests/user/verify/models/response.rs b/api/src/requests/user/verify/models/response.rs index ada9805..68f9ba3 100644 --- a/api/src/requests/user/verify/models/response.rs +++ b/api/src/requests/user/verify/models/response.rs @@ -1,35 +1,20 @@ -use humantime::format_rfc3339_seconds; -use pasetors::{keys::SymmetricKey, version4::V4}; +use std::time::SystemTime; + use serde::Serialize; -use crate::services::auth_token::{generate_access_token, generate_auth_token}; - #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct UserVerifyGetResponse { - access: UserVerifyGetResponseTokenAndExpiration, - auth: UserVerifyGetResponseTokenAndExpiration, -} - -impl UserVerifyGetResponse { - pub fn new(key: &SymmetricKey, user_id: i32) -> Self { - let (access_token, _, access_token_expiration) = generate_access_token(key, user_id); - let (auth_token, _, auth_token_expiration) = generate_auth_token(key, user_id); - - Self { - access: UserVerifyGetResponseTokenAndExpiration { - token: access_token, - expiration: format_rfc3339_seconds(access_token_expiration).to_string(), - }, - auth: UserVerifyGetResponseTokenAndExpiration { - token: auth_token, - expiration: format_rfc3339_seconds(auth_token_expiration).to_string(), - }, - } - } + pub user_id: i32, + pub session: UserVerifyGetResponseTokenAndExpiration, + pub auth: UserVerifyGetResponseTokenAndExpiration, } #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct UserVerifyGetResponseTokenAndExpiration { pub token: String, - pub expiration: String, + + #[serde(serialize_with = "humantime_serde::serialize")] + pub expires_at: SystemTime, } diff --git a/api/src/services/auth_token.rs b/api/src/services/auth_token.rs index a14e582..cdbfce0 100644 --- a/api/src/services/auth_token.rs +++ b/api/src/services/auth_token.rs @@ -51,7 +51,7 @@ pub fn verify_token( Ok(token) } -pub fn generate_access_token(key: &SymmetricKey, user_id: i32) -> (String, Uuid, SystemTime) { +pub fn generate_session_token(key: &SymmetricKey, user_id: i32) -> (String, Uuid, SystemTime) { generate_token(key, user_id, ONE_HOUR, None) } diff --git a/api/src/services/user_session.rs b/api/src/services/user_session.rs index d6a409a..b430dc4 100644 --- a/api/src/services/user_session.rs +++ b/api/src/services/user_session.rs @@ -38,7 +38,7 @@ pub async fn store_user_session( Ok(()) } -pub async fn _get_user_session( +pub async fn get_user_session( cache_pool: &CachePool, token_id: Uuid, ) -> Result, AppError> { @@ -51,41 +51,32 @@ pub async fn _get_user_session( if let Some(object) = object { let user_id: i32 = object .get("userId") - .ok_or(AppError::_missing_session_field("userId")) + .ok_or(AppError::missing_session_field("userId")) .and_then(|value| { FromRedisValue::from_redis_value(value) - .map_err(|_| AppError::_missing_session_field("userId")) - })?; - - let username: String = object - .get("username") - .ok_or(AppError::_missing_session_field("username")) - .and_then(|value| { - FromRedisValue::from_redis_value(value) - .map_err(|_| AppError::_missing_session_field("username")) + .map_err(|_| AppError::missing_session_field("userId")) })?; let created_at: SystemTime = object .get("createdAt") - .ok_or(AppError::_missing_session_field("createdAt")) + .ok_or(AppError::missing_session_field("createdAt")) .and_then(|value| { FromRedisValue::from_redis_value(value) - .map_err(|_| AppError::_missing_session_field("createdAt")) + .map_err(|_| AppError::missing_session_field("createdAt")) }) .map(|serialized: String| parse_rfc3339(serialized.as_str()).unwrap())?; let expires_at: SystemTime = object .get("createdAt") - .ok_or(AppError::_missing_session_field("expiresAt")) + .ok_or(AppError::missing_session_field("expiresAt")) .and_then(|value| { FromRedisValue::from_redis_value(value) - .map_err(|_| AppError::_missing_session_field("expiresAt")) + .map_err(|_| AppError::missing_session_field("expiresAt")) }) .map(|serialized: String| parse_rfc3339(serialized.as_str()).unwrap())?; Ok(Some(Session { user_id, - username, created_at, expires_at, }))