From d41a92d07a6eed48f5e22dfba9b911bb6a0f591f Mon Sep 17 00:00:00 2001 From: "Z. Charles Dziura" Date: Sat, 5 Oct 2024 08:09:46 -0400 Subject: [PATCH] Adding a service to handle caching --- api/.env | 1 + api/Cargo.toml | 11 ++- api/src/db/mod.rs | 4 +- api/src/db/user.rs | 24 +++++- api/src/main.rs | 21 +++-- api/src/models/api_response.rs | 2 +- api/src/models/environment.rs | 78 ++++++++++++++----- api/src/models/error.rs | 56 +++++++++---- api/src/models/mod.rs | 2 + api/src/models/session.rs | 32 ++++++++ api/src/requests/auth/login/handler.rs | 32 +++++++- api/src/requests/auth/login/mod.rs | 1 + api/src/requests/auth/login/models/mod.rs | 5 ++ api/src/requests/auth/login/models/request.rs | 20 +++++ .../requests/auth/login/models/response.rs | 19 +++++ api/src/requests/mod.rs | 32 +++++--- api/src/requests/user/create/handler.rs | 43 +++++++--- api/src/requests/user/create/models/mod.rs | 8 +- .../{registration_request.rs => request.rs} | 0 .../{registration_response.rs => response.rs} | 2 +- api/src/requests/user/verify/handler.rs | 3 +- .../requests/user/verify/models/response.rs | 4 +- api/src/services/auth_token.rs | 32 ++++---- api/src/services/cache.rs | 64 +++++++++++++++ api/src/services/hasher.rs | 14 ---- api/src/services/mod.rs | 8 +- api/src/services/password_hasher.rs | 25 ++++++ api/src/services/user_session.rs | 38 +++++++++ 28 files changed, 466 insertions(+), 115 deletions(-) create mode 100644 api/src/models/session.rs create mode 100644 api/src/requests/auth/login/models/mod.rs create mode 100644 api/src/requests/auth/login/models/request.rs create mode 100644 api/src/requests/auth/login/models/response.rs rename api/src/requests/user/create/models/{registration_request.rs => request.rs} (100%) rename api/src/requests/user/create/models/{registration_response.rs => response.rs} (91%) create mode 100644 api/src/services/cache.rs delete mode 100644 api/src/services/hasher.rs create mode 100644 api/src/services/password_hasher.rs create mode 100644 api/src/services/user_session.rs diff --git a/api/.env b/api/.env index b0548e2..d86f937 100644 --- a/api/.env +++ b/api/.env @@ -1,4 +1,5 @@ ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets +CACHE_URL=redis://debt_pirate:H553jOui2734@192.168.122.251:6379 DATABASE_URL=postgres://debt_pirate:HRURqlUmtjIy@192.168.122.251/debt_pirate HOSTNAME=localhost MAINTENANCE_USER_ACCOUNT=debt_pirate:HRURqlUmtjIy diff --git a/api/Cargo.toml b/api/Cargo.toml index e1c3b75..52f2c3a 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -11,10 +11,12 @@ axum = { version = "0.7", features = [ "ws", ] } base64 = "0.22" +bb8-redis = "0.17" +blake3 = { version = "1.5", features = ["serde"] } dotenvy = "0.15" futures = "0.3" http = "1.0" -humantime = "2.1.0" +humantime = "2.1" humantime-serde = "1.1" hyper = { version = "1.1", features = ["full"] } lettre = { version = "0.11", default-features = false, features = [ @@ -27,6 +29,7 @@ lettre = { version = "0.11", default-features = false, features = [ ] } num_cpus = "1.16" pasetors = "0.7" +redis = { version = "0.27", features = ["aio"] } serde = { version = "1.0", features = ["derive", "rc", "std"] } serde_json = "1.0" serde_with = "3.9" @@ -37,11 +40,11 @@ sqlx = { version = "0.8", features = [ "runtime-tokio", ] } syslog-tracing = "0.3.1" -time = { version = "0.3.36", features = ["formatting", "macros"] } +time = { version = "0.3", features = ["formatting", "macros"] } tokio = { version = "1.35", features = ["full"] } tower = "0.5" tower-http = { version = "0.6", features = ["full"] } tracing = "0.1.40" -tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time"] } -ulid = "1.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "time"] } +url = { version = "2.5.2", features = ["expose_internals"] } uuid = { version = "1.10", features = ["serde", "v7"] } diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index 6903f20..c03d04d 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -7,12 +7,12 @@ use crate::models::AppError; pub type DbPool = Pool; -pub async fn create_connection_pool(connection_uri: &str) -> DbPool { +pub async fn create_db_connection_pool(connection_uri: String) -> DbPool { let num_cpus = num_cpus::get() as u32; PgPoolOptions::new() .max_connections(num_cpus) - .connect(connection_uri) + .connect(connection_uri.as_str()) .await .unwrap() } diff --git a/api/src/db/user.rs b/api/src/db/user.rs index fc4f13d..e71b62c 100644 --- a/api/src/db/user.rs +++ b/api/src/db/user.rs @@ -59,13 +59,35 @@ pub async fn insert_new_user( }) } +#[derive(Debug, FromRow)] +pub struct UserIdAndHashedPassword { + pub id: i32, + pub password: String, +} + +pub async fn get_username_and_password_by_username( + pool: &DbPool, + username: String, +) -> Result { + sqlx::query_as::<_, UserIdAndHashedPassword>( + "SELECT id, password FROM public.user WHERE username = $1;", + ) + .bind(username) + .fetch_one(pool) + .await + .map_err(|err| { + error!(%err, "Unable to find user"); + AppError::from(err) + }) +} + pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> { sqlx::query("UPDATE public.user SET status_id = 1, updated_at = now() WHERE id = $1;") .bind(user_id) .execute(pool) .await .map_err(|err| { - eprintln!("Error verifying user with id '{user_id}'."); + error!(%err, user_id, "Error verifying user"); AppError::from(err) }) .map(|_| ()) diff --git a/api/src/main.rs b/api/src/main.rs index 8e483bd..295fcd8 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -7,11 +7,13 @@ mod models; mod requests; mod services; -use db::{create_connection_pool, run_migrations}; +use db::{create_db_connection_pool, run_migrations}; use requests::start_app; -use services::{initialize_logger, start_emailer_service, UserConfirmationMessage}; +use services::{ + create_cache_connection_pool, initialize_logger, start_emailer_service, UserConfirmationMessage, +}; use tokio::runtime::Handle; -use tracing::info; +use tracing::{error, info}; #[tokio::main] async fn main() { @@ -28,11 +30,18 @@ async fn main() { initialize_logger(&env); info!("Initializing database connection pool..."); - let pool = create_connection_pool(env.db_connection_uri()).await; + let db_pool = create_db_connection_pool(env.db_connection_uri().to_string()).await; info!("Database connection pool created successfully."); + info!("Initializing cache service connection pool..."); + let cache_pool = create_cache_connection_pool(env.cache_url().to_string()) + .await + .inspect_err(|err| error!(?err)) + .unwrap(); + info!("Cache service connection pool created successfully."); + info!("Running database schema migrations..."); - if let Err(err) = run_migrations(&pool).await { + if let Err(err) = run_migrations(&db_pool).await { eprintln!("{err:?}"); process::exit(2); } @@ -42,7 +51,7 @@ async fn main() { start_emailer_service(Handle::current(), env.assets_dir(), rx); info!("Email service started successfully."); - if let Err(err) = start_app(pool, env).await { + if let Err(err) = start_app(db_pool, cache_pool, env).await { eprintln!("{err:?}"); process::exit(3); } diff --git a/api/src/models/api_response.rs b/api/src/models/api_response.rs index 9fb0c73..d2c6493 100644 --- a/api/src/models/api_response.rs +++ b/api/src/models/api_response.rs @@ -50,7 +50,7 @@ impl ApiResponse<()> { } } - pub fn error(error: &'static str) -> Self { + pub fn _error(error: &'static str) -> Self { Self { meta: None, data: None, diff --git a/api/src/models/environment.rs b/api/src/models/environment.rs index b33cd1d..e79d7a8 100644 --- a/api/src/models/environment.rs +++ b/api/src/models/environment.rs @@ -4,6 +4,8 @@ use std::{ }; use pasetors::{keys::SymmetricKey, version4::V4}; +use tracing::trace; +use url::Url; use crate::services::UserConfirmationMessage; @@ -12,7 +14,8 @@ use super::AppError; #[derive(Clone)] pub struct Environment { assets_dir: PathBuf, - database_url: String, + cache_url: Url, + database_url: Url, email_sender: Sender, hostname: String, port: u32, @@ -29,6 +32,7 @@ impl Environment { .filter_map(|item| item.ok()) .for_each(|(key, value)| match key.as_str() { "ASSETS_DIR" => builder.with_assets_dir(value), + "CACHE_URL" => builder.with_cache_url(value), "DATABASE_URL" => builder.with_database_url(value), "HOSTNAME" => builder.with_hostname(value), "PORT" => builder.with_port(value), @@ -46,6 +50,22 @@ impl Environment { } } + pub fn assets_dir(&self) -> &Path { + self.assets_dir.as_path() + } + + pub fn cache_url(&self) -> &Url { + &self.cache_url + } + + pub fn db_connection_uri(&self) -> &Url { + &self.database_url + } + + pub fn email_sender(&self) -> &Sender { + &self.email_sender + } + pub fn hostname(&self) -> &str { self.hostname.as_str() } @@ -54,22 +74,6 @@ impl Environment { self.port } - pub fn token_key(&self) -> &SymmetricKey { - &self.token_key - } - - pub fn db_connection_uri(&self) -> &str { - self.database_url.as_str() - } - - pub fn assets_dir(&self) -> &Path { - self.assets_dir.as_path() - } - - pub fn email_sender(&self) -> &Sender { - &self.email_sender - } - pub fn rust_log(&self) -> &str { self.rust_log.as_str() } @@ -77,12 +81,17 @@ impl Environment { pub fn send_verification_email(&self) -> bool { self.send_verification_email } + + pub fn token_key(&self) -> &SymmetricKey { + &self.token_key + } } impl From for Environment { fn from(builder: EnvironmentObjectBuilder) -> Self { let EnvironmentObjectBuilder { assets_dir, + cache_url, database_url, email_sender, hostname, @@ -94,6 +103,7 @@ impl From for Environment { Self { assets_dir: assets_dir.unwrap(), + cache_url: cache_url.unwrap(), database_url: database_url.unwrap(), email_sender: email_sender.unwrap(), hostname: hostname.unwrap(), @@ -108,7 +118,8 @@ impl From for Environment { #[derive(Debug, Default)] pub struct EnvironmentObjectBuilder { pub assets_dir: Option, - pub database_url: Option, + pub cache_url: Option, + pub database_url: Option, pub email_sender: Option>, pub hostname: Option, pub port: Option, @@ -128,13 +139,20 @@ impl EnvironmentObjectBuilder { pub fn uninitialized_variables(&self) -> Option> { let mut missing_vars = [ ("HOSTNAME", self.hostname.as_deref()), - ("DATABASE_URL", self.database_url.as_deref()), ("RUST_LOG", self.rust_log.as_deref()), ] .into_iter() .filter_map(|(key, value)| value.map(|_| key).xor(Some(key))) .collect::>(); + if self.cache_url.is_none() { + missing_vars.push("CACHE_URL"); + } + + if self.database_url.is_none() { + missing_vars.push("DATABASE_URL"); + } + if self.token_key.is_none() { missing_vars.push("TOKEN_KEY"); } @@ -170,8 +188,28 @@ impl EnvironmentObjectBuilder { }; } + pub fn with_cache_url(&mut self, url: String) { + trace!(?url); + + let cache_url = url + .parse::() + .expect("The 'CACHE_URL' variable is not in valid URI format"); + + trace!(?cache_url); + + if cache_url.scheme().to_lowercase() != "redis" { + panic!("The 'CACHE_URL' must be a valid Redis connection string; it must use the 'redis://' scheme"); + } + + self.cache_url = Some(cache_url); + } + pub fn with_database_url(&mut self, url: String) { - self.database_url = Some(url); + let database_url = url + .parse::() + .expect("The 'DATABASE_URL' variable is not in valid URI format"); + + self.database_url = Some(database_url); } pub fn with_assets_dir(&mut self, assets_dir_path: String) { diff --git a/api/src/models/error.rs b/api/src/models/error.rs index 2df809d..6002ae1 100644 --- a/api/src/models/error.rs +++ b/api/src/models/error.rs @@ -1,8 +1,11 @@ use std::{borrow::Cow, error::Error, fmt::Display, io}; use axum::response::IntoResponse; +use bb8_redis::bb8::RunError; use http::StatusCode; +use redis::RedisError; use sqlx::{error::DatabaseError, migrate::MigrateError, Error as SqlxError}; +use tracing::trace; use super::ApiResponse; @@ -11,6 +14,8 @@ pub struct AppError { kind: ErrorKind, } +impl Error for AppError {} + impl AppError { fn new(kind: ErrorKind) -> Self { Self { kind } @@ -20,17 +25,20 @@ impl AppError { Self::new(ErrorKind::AppStartupError(error)) } + pub fn connection_info(service_name: &'static str) -> Self { + Self::new(ErrorKind::ConnectionInfo(service_name)) + } + pub fn duplicate_record(message: &str) -> Self { Self::new(ErrorKind::DuplicateRecord(message.to_owned())) } - pub fn invalid_token() -> Self { - Self::new(ErrorKind::InvalidToken) + pub fn invalid_password() -> Self { + Self::new(ErrorKind::InvalidPassword) } - #[allow(dead_code)] - pub fn invalid_token_audience(audience: &str) -> Self { - Self::new(ErrorKind::InvalidTokenAudience(audience.to_owned())) + pub fn invalid_token() -> Self { + Self::new(ErrorKind::InvalidToken) } pub fn missing_environment_variables(missing_vars: Vec<&'static str>) -> Self { @@ -74,11 +82,30 @@ impl From for AppError { } } +impl From for AppError { + fn from(other: RedisError) -> Self { + trace!(err = ?other, "Cache error"); + Self::new(ErrorKind::Cache(other.to_string())) + } +} + +impl From> for AppError { + fn from(other: RunError) -> Self { + trace!(err = ?other, "Cache pool error"); + Self::new(ErrorKind::Cache(other.to_string())) + } +} + impl Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.kind { ErrorKind::AppStartupError(err) => write!(f, "{err}"), - ErrorKind::Database => write!(f, "Unknown database error occurred."), + ErrorKind::Cache(err) => write!(f, "{err}"), + ErrorKind::ConnectionInfo(service) => write!( + f, + "Unable to connect to '{service}' service; invalid connection string" + ), + ErrorKind::Database => write!(f, "Unknown database error occurred"), ErrorKind::DbMigration(err) => write!( f, "Error occurred while initializing connection to database: {err}" @@ -86,11 +113,8 @@ impl Display for AppError { ErrorKind::DuplicateRecord(message) => { write!(f, "Duplicate database record: {message}") } - ErrorKind::InvalidToken => write!(f, "The provided token is invalid."), - ErrorKind::InvalidTokenAudience(audience) => write!( - f, - "The provided token is not valid for this endpoint: '{audience}'." - ), + ErrorKind::InvalidPassword => write!(f, "Invalid password"), + ErrorKind::InvalidToken => write!(f, "The provided token is invalid"), ErrorKind::MissingEnvironmentVariables(missing_vars) => write!( f, "Missing required environment variables: {}", @@ -99,22 +123,22 @@ impl Display for AppError { ErrorKind::Sqlx(err) => write!(f, "{err}"), ErrorKind::TokenKey => write!( f, - "Invalid PASETO symmetric key; must be in valid PASERK format." + "Invalid PASETO symmetric key; must be in valid PASERK format" ), } } } -impl Error for AppError {} - #[derive(Debug)] enum ErrorKind { AppStartupError(io::Error), + Cache(String), + ConnectionInfo(&'static str), Database, DbMigration(MigrateError), DuplicateRecord(String), + InvalidPassword, InvalidToken, - InvalidTokenAudience(String), MissingEnvironmentVariables(Vec<&'static str>), Sqlx(SqlxError), TokenKey, @@ -127,7 +151,7 @@ impl IntoResponse for AppError { StatusCode::CONFLICT, ApiResponse::new_with_error(self).into_json_response(), ), - &ErrorKind::InvalidToken | &ErrorKind::InvalidTokenAudience(_) => ( + &ErrorKind::InvalidPassword | &ErrorKind::InvalidToken => ( StatusCode::BAD_REQUEST, ApiResponse::new_with_error(self).into_json_response(), ), diff --git a/api/src/models/mod.rs b/api/src/models/mod.rs index 7446c05..df1ca68 100644 --- a/api/src/models/mod.rs +++ b/api/src/models/mod.rs @@ -1,7 +1,9 @@ mod api_response; mod environment; mod error; +mod session; pub use api_response::*; pub use environment::*; pub use error::*; +pub use session::*; diff --git a/api/src/models/session.rs b/api/src/models/session.rs new file mode 100644 index 0000000..db171c5 --- /dev/null +++ b/api/src/models/session.rs @@ -0,0 +1,32 @@ +use std::time::SystemTime; + +use humantime::format_rfc3339; +use redis::ToRedisArgs; + +#[derive(Debug)] +pub struct Session { + pub user_id: i32, + pub username: String, + pub created_at: SystemTime, + pub expires_at: SystemTime, +} + +impl Session { + pub fn to_cacheable_object<'a>( + self, + ) -> 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()), + ] + } +} diff --git a/api/src/requests/auth/login/handler.rs b/api/src/requests/auth/login/handler.rs index f842d9b..08f0aea 100644 --- a/api/src/requests/auth/login/handler.rs +++ b/api/src/requests/auth/login/handler.rs @@ -1,11 +1,39 @@ use axum::{ debug_handler, + extract::State, response::{IntoResponse, Response}, + Json, +}; +use tracing::debug; + +use crate::{ + db::{get_username_and_password_by_username, DbPool, UserIdAndHashedPassword}, + models::AppError, + requests::AppState, + services::verify_password, }; -use crate::models::AppError; +use super::models::AuthLoginRequest; #[debug_handler] -pub async fn auth_login_post_handler() -> Result { +pub async fn auth_login_post_handler( + State(state): State, + Json(body): Json, +) -> Result { + let pool = state.db_pool(); + auth_login_request(pool, body).await +} + +async fn auth_login_request(pool: &DbPool, body: AuthLoginRequest) -> Result { + debug!(?body); + + let AuthLoginRequest { username, password } = body; + let UserIdAndHashedPassword { + id: _id, + password: hashed_password, + } = get_username_and_password_by_username(pool, username).await?; + + verify_password(password, hashed_password)?; + Ok(().into_response()) } diff --git a/api/src/requests/auth/login/mod.rs b/api/src/requests/auth/login/mod.rs index 7298495..f00e7ca 100644 --- a/api/src/requests/auth/login/mod.rs +++ b/api/src/requests/auth/login/mod.rs @@ -1,3 +1,4 @@ mod handler; +mod models; pub use handler::*; diff --git a/api/src/requests/auth/login/models/mod.rs b/api/src/requests/auth/login/models/mod.rs new file mode 100644 index 0000000..b8be632 --- /dev/null +++ b/api/src/requests/auth/login/models/mod.rs @@ -0,0 +1,5 @@ +mod request; +mod response; + +pub use request::*; +pub use response::*; diff --git a/api/src/requests/auth/login/models/request.rs b/api/src/requests/auth/login/models/request.rs new file mode 100644 index 0000000..1ea4768 --- /dev/null +++ b/api/src/requests/auth/login/models/request.rs @@ -0,0 +1,20 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde_with::serde_as; + +#[serde_as] +#[derive(Deserialize)] +pub struct AuthLoginRequest { + pub username: String, + pub password: String, +} + +impl Debug for AuthLoginRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthLoginRequest") + .field("username", &self.username) + .field("password", &"********") + .finish() + } +} diff --git a/api/src/requests/auth/login/models/response.rs b/api/src/requests/auth/login/models/response.rs new file mode 100644 index 0000000..64693db --- /dev/null +++ b/api/src/requests/auth/login/models/response.rs @@ -0,0 +1,19 @@ +use std::time::SystemTime; + +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthLoginResponse { + pub user_id: i32, + pub access: AuthLoginTokenData, + pub auth: AuthLoginTokenData, +} + +#[derive(Debug, Serialize)] +pub struct AuthLoginTokenData { + pub token: String, + + #[serde(serialize_with = "humantime_serde::serialize")] + pub expiration: SystemTime, +} diff --git a/api/src/requests/mod.rs b/api/src/requests/mod.rs index c20fa62..02402d3 100644 --- a/api/src/requests/mod.rs +++ b/api/src/requests/mod.rs @@ -12,26 +12,36 @@ use humantime::format_duration; use tokio::net::TcpListener; use tower_http::trace::TraceLayer; use tracing::{error, info, info_span, warn, Span}; -use ulid::Ulid; +use uuid::Uuid; use crate::{ db::DbPool, models::{AppError, Environment}, + services::CachePool, }; #[derive(Clone)] pub struct AppState { - pool: DbPool, + db_pool: DbPool, + cache_pool: CachePool, env: Environment, } impl AppState { - pub fn new(pool: DbPool, env: Environment) -> Self { - Self { pool, env } + pub fn new(db_pool: DbPool, cache_pool: CachePool, env: Environment) -> Self { + Self { + db_pool, + cache_pool, + env, + } } - pub fn pool(&self) -> &DbPool { - &self.pool + pub fn db_pool(&self) -> &DbPool { + &self.db_pool + } + + pub fn cache_pool(&self) -> &CachePool { + &self.cache_pool } pub fn env(&self) -> &Environment { @@ -39,7 +49,11 @@ impl AppState { } } -pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { +pub async fn start_app( + db_pool: DbPool, + cache_pool: CachePool, + env: Environment, +) -> Result<(), AppError> { let address = env.hostname(); let port = env.port(); @@ -55,7 +69,7 @@ pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { .get::() .map(MatchedPath::as_str).unwrap_or(request.uri().path()); - info_span!("api_request", request_id = %Ulid::new(), method = %request.method(), %path, status = tracing::field::Empty) + info_span!("api_request", request_id = %Uuid::now_v7(), method = %request.method(), %path, status = tracing::field::Empty) }) .on_response(|response: &Response, duration: Duration, span: &Span| { let status = response.status(); @@ -68,7 +82,7 @@ pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { } }); - let state = AppState::new(pool, env); + let state = AppState::new(db_pool, cache_pool, env); let app = Router::new() .merge(user::requests(state.clone())) .merge(auth::requests(state.clone())) diff --git a/api/src/requests/user/create/handler.rs b/api/src/requests/user/create/handler.rs index c19e854..72fa5dd 100644 --- a/api/src/requests/user/create/handler.rs +++ b/api/src/requests/user/create/handler.rs @@ -1,10 +1,13 @@ -use std::sync::mpsc::Sender; +use std::{sync::mpsc::Sender, time::SystemTime}; use crate::{ db::{insert_new_user, DbPool, NewUserEntity, UserEntity}, - models::{ApiResponse, AppError}, + models::{ApiResponse, AppError, Session}, requests::AppState, - services::{auth_token::generate_new_user_token, hash_string, UserConfirmationMessage}, + services::{ + self, auth_token::generate_new_user_token, hash_password, CachePool, + UserConfirmationMessage, + }, }; use axum::{ debug_handler, @@ -27,7 +30,8 @@ pub async fn user_registration_post_handler( register_new_user_request( request, - state.pool(), + state.db_pool(), + state.cache_pool(), env.token_key(), env.send_verification_email(), env.email_sender(), @@ -37,7 +41,8 @@ pub async fn user_registration_post_handler( async fn register_new_user_request( body: UserRegistrationRequest, - pool: &DbPool, + db_pool: &DbPool, + cache_pool: &CachePool, signing_key: &SymmetricKey, send_verification_email: bool, email_sender: &Sender, @@ -51,10 +56,10 @@ async fn register_new_user_request( name, } = body; - let hashed_password = hash_string(password); + let hashed_password = hash_password(password); let new_user = NewUserEntity { - username, + username: username.clone(), password: hashed_password.to_string(), email, name, @@ -65,7 +70,7 @@ async fn register_new_user_request( name, email, .. - } = insert_new_user(pool, new_user).await.map_err(|err| { + } = insert_new_user(db_pool, new_user).await.map_err(|err| { if err.is_duplicate_record() { AppError::duplicate_record( "There is already an account associated with this username or email address.", @@ -75,7 +80,23 @@ async fn register_new_user_request( } })?; - let (verification_token, expiration) = generate_new_user_token(signing_key, user_id); + let (verification_token, token_id, expires_at) = generate_new_user_token(signing_key, user_id); + + let new_user_session = Session { + user_id, + username, + created_at: SystemTime::now(), + expires_at, + }; + + let expires_in = expires_at.duration_since(SystemTime::now()).unwrap(); + services::user_session::store_user_session( + cache_pool, + token_id, + new_user_session, + Some(expires_in), + ) + .await?; let response_body = if send_verification_email { let new_user_confirmation_message = UserConfirmationMessage { @@ -92,13 +113,13 @@ async fn register_new_user_request( UserRegistrationResponse { id: user_id, - expiration, + expires_at, verification_token: None, } } else { UserRegistrationResponse { id: user_id, - expiration, + expires_at, verification_token: Some(verification_token), } }; diff --git a/api/src/requests/user/create/models/mod.rs b/api/src/requests/user/create/models/mod.rs index d30e4ef..b8be632 100644 --- a/api/src/requests/user/create/models/mod.rs +++ b/api/src/requests/user/create/models/mod.rs @@ -1,5 +1,5 @@ -mod registration_request; -mod registration_response; +mod request; +mod response; -pub use registration_request::*; -pub use registration_response::*; +pub use request::*; +pub use response::*; diff --git a/api/src/requests/user/create/models/registration_request.rs b/api/src/requests/user/create/models/request.rs similarity index 100% rename from api/src/requests/user/create/models/registration_request.rs rename to api/src/requests/user/create/models/request.rs diff --git a/api/src/requests/user/create/models/registration_response.rs b/api/src/requests/user/create/models/response.rs similarity index 91% rename from api/src/requests/user/create/models/registration_response.rs rename to api/src/requests/user/create/models/response.rs index dca7bc4..50b1338 100644 --- a/api/src/requests/user/create/models/registration_response.rs +++ b/api/src/requests/user/create/models/response.rs @@ -11,7 +11,7 @@ pub struct UserRegistrationResponse { pub id: i32, #[serde(serialize_with = "humantime_serde::serialize")] - pub expiration: SystemTime, + pub expires_at: SystemTime, pub verification_token: Option, } diff --git a/api/src/requests/user/verify/handler.rs b/api/src/requests/user/verify/handler.rs index 81c49cc..b7faa92 100644 --- a/api/src/requests/user/verify/handler.rs +++ b/api/src/requests/user/verify/handler.rs @@ -23,7 +23,7 @@ pub async fn user_verification_get_handler( Path(user_id): Path, Query(query): Query, ) -> Result { - let pool = state.pool(); + let pool = state.db_pool(); let env = state.env(); let UserVerifyGetParams { verification_token } = query; @@ -42,7 +42,6 @@ async fn verify_new_user_request( let validation_rules = { let mut rules = ClaimsValidationRules::new(); rules.validate_audience_with(format!("/user/{user_id}/verify").as_str()); - rules }; diff --git a/api/src/requests/user/verify/models/response.rs b/api/src/requests/user/verify/models/response.rs index 98f7e1c..ada9805 100644 --- a/api/src/requests/user/verify/models/response.rs +++ b/api/src/requests/user/verify/models/response.rs @@ -12,8 +12,8 @@ pub struct UserVerifyGetResponse { 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); + 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 { diff --git a/api/src/services/auth_token.rs b/api/src/services/auth_token.rs index 939cbc3..bc0656c 100644 --- a/api/src/services/auth_token.rs +++ b/api/src/services/auth_token.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::models::AppError; -static FOURTY_FIVE_DAYS: Duration = Duration::from_secs(3_888_000); +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); @@ -51,19 +51,19 @@ pub fn verify_token( Ok(()) } -pub fn generate_access_token(key: &SymmetricKey, user_id: i32) -> (String, SystemTime) { - generate_token(key, user_id, Some(FOURTY_FIVE_DAYS), None) +pub fn generate_access_token(key: &SymmetricKey, user_id: i32) -> (String, Uuid, SystemTime) { + generate_token(key, user_id, ONE_HOUR, None) } -pub fn generate_auth_token(key: &SymmetricKey, user_id: i32) -> (String, SystemTime) { - generate_token(key, user_id, None, None) +pub fn generate_auth_token(key: &SymmetricKey, user_id: i32) -> (String, Uuid, SystemTime) { + generate_token(key, user_id, ONE_DAY, None) } -pub fn generate_new_user_token(key: &SymmetricKey, user_id: i32) -> (String, SystemTime) { +pub fn generate_new_user_token(key: &SymmetricKey, user_id: i32) -> (String, Uuid, SystemTime) { generate_token( key, user_id, - Some(FIFTEEN_MINUTES), + FIFTEEN_MINUTES, Some(format!("/user/{user_id}/verify").as_str()), ) } @@ -71,20 +71,16 @@ pub fn generate_new_user_token(key: &SymmetricKey, user_id: i32) -> (String, fn generate_token( key: &SymmetricKey, user_id: i32, - duration: Option, + expires_in: Duration, audience: Option<&str>, -) -> (String, SystemTime) { +) -> (String, Uuid, SystemTime) { let now = SystemTime::now(); - let expiration = if let Some(duration) = duration { - duration - } else { - ONE_HOUR - }; + let token_id = Uuid::now_v7(); - let token = Claims::new_expires_in(&expiration) + let token = Claims::new_expires_in(&expires_in) .and_then(|mut claims| { claims - .token_identifier(Uuid::now_v7().to_string().as_str()) + .token_identifier(token_id.to_string().as_str()) .map(|_| claims) }) .and_then(|mut claims| { @@ -116,7 +112,7 @@ fn generate_token( }) .unwrap(); - (token, now + expiration) + (token, token_id, now + expires_in) } #[cfg(test)] @@ -136,7 +132,7 @@ mod tests { .and_then(|bytes| SymmetricKey::::from(bytes.as_slice()).map_err(|_| ())) .unwrap(); - let token = generate_token(&key, 1, Some(Duration::from_secs(60)), Some("testing")).0; + let token = generate_token(&key, 1, Duration::from_secs(60), Some("testing")).0; let footer = { let mut footer = Footer::new(); diff --git a/api/src/services/cache.rs b/api/src/services/cache.rs new file mode 100644 index 0000000..4d40f6f --- /dev/null +++ b/api/src/services/cache.rs @@ -0,0 +1,64 @@ +use std::{fmt::Debug, time::Duration}; + +use bb8_redis::{ + bb8::{Pool, PooledConnection}, + RedisConnectionManager, +}; +use redis::{AsyncCommands, IntoConnectionInfo, ToRedisArgs}; + +use crate::models::AppError; + +pub type CachePool = Pool; + +pub async fn create_cache_connection_pool( + connection_info: impl IntoConnectionInfo + Clone + Debug, +) -> Result { + let manager = RedisConnectionManager::new(connection_info) + .map_err(|_| AppError::connection_info("cache"))?; + let pool = Pool::builder().build(manager).await.unwrap(); + + Ok(pool) +} + +pub async fn store_object< + 'a, + F: ToRedisArgs + Send + Sync + 'a, + V: ToRedisArgs + Send + Sync + 'a, + O: AsRef<[(F, V)]>, +>( + cache_pool: &CachePool, + key: &str, + object: O, +) -> Result<(), AppError> { + let mut conn = get_connection_from_pool(cache_pool).await?; + + let _: () = conn.hset_multiple(key, object.as_ref()).await?; + Ok(()) +} + +pub async fn store_object_with_expiration< + 'a, + F: ToRedisArgs + Send + Sync + 'a, + V: ToRedisArgs + Send + Sync + 'a, + O: AsRef<[(F, V)]>, +>( + cache_pool: &CachePool, + key: &str, + object: O, + expiration: Duration, +) -> Result<(), AppError> { + let mut conn = get_connection_from_pool(cache_pool).await?; + + let _: () = conn.hset_multiple(key, object.as_ref()).await?; + let _: () = conn + .expire(key, expiration.as_secs().try_into().unwrap()) + .await?; + + Ok(()) +} + +async fn get_connection_from_pool( + cache_pool: &CachePool, +) -> Result, AppError> { + cache_pool.get().await.map_err(From::from) +} diff --git a/api/src/services/hasher.rs b/api/src/services/hasher.rs deleted file mode 100644 index a4a0f5f..0000000 --- a/api/src/services/hasher.rs +++ /dev/null @@ -1,14 +0,0 @@ -use argon2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHashString, SaltString}, - Argon2, -}; - -pub fn hash_string(string: String) -> PasswordHashString { - let algorithm = Argon2::default(); - let salt = SaltString::generate(&mut OsRng); - - let hashed_password = - PasswordHash::generate(algorithm, string.as_bytes(), salt.as_salt()).unwrap(); - - hashed_password.serialize() -} diff --git a/api/src/services/mod.rs b/api/src/services/mod.rs index 90666d3..1b397a5 100644 --- a/api/src/services/mod.rs +++ b/api/src/services/mod.rs @@ -1,8 +1,12 @@ pub mod auth_token; -mod hasher; +mod cache; mod logger; mod mailer; +mod password_hasher; +pub mod user_session; -pub use hasher::*; pub use logger::*; pub use mailer::*; +pub use password_hasher::*; + +pub use cache::{create_cache_connection_pool, CachePool}; diff --git a/api/src/services/password_hasher.rs b/api/src/services/password_hasher.rs new file mode 100644 index 0000000..574204d --- /dev/null +++ b/api/src/services/password_hasher.rs @@ -0,0 +1,25 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHashString, SaltString}, + Argon2, PasswordVerifier, +}; + +use crate::models::AppError; + +pub fn hash_password(password: String) -> PasswordHashString { + let algorithm = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + + let hashed_password = + PasswordHash::generate(algorithm, password.as_bytes(), salt.as_salt()).unwrap(); + + hashed_password.serialize() +} + +pub fn verify_password(password: String, hashed_password: String) -> Result<(), AppError> { + let algorithm = Argon2::default(); + let hash = PasswordHash::new(hashed_password.as_str()).unwrap(); + + algorithm + .verify_password(password.as_bytes(), &hash) + .map_err(|_| AppError::invalid_password()) +} diff --git a/api/src/services/user_session.rs b/api/src/services/user_session.rs new file mode 100644 index 0000000..9152de9 --- /dev/null +++ b/api/src/services/user_session.rs @@ -0,0 +1,38 @@ +use std::time::Duration; + +use uuid::Uuid; + +use crate::{ + models::{AppError, Session}, + services::cache::CachePool, +}; + +use super::cache; + +static USER_SESSION_CACHE_KEY_PREFIX: &'static str = "debt_pirate:session:"; + +pub async fn store_user_session( + cache_pool: &CachePool, + token_id: Uuid, + session: Session, + expiration: Option, +) -> Result<(), AppError> { + let hashed_token = blake3::hash(token_id.as_bytes()); + let key = format!("{USER_SESSION_CACHE_KEY_PREFIX}{hashed_token}"); + let cacheable_object = session.to_cacheable_object(); + + match expiration { + Some(expiration) => { + cache::store_object_with_expiration( + cache_pool, + key.as_str(), + cacheable_object, + expiration, + ) + .await? + } + None => cache::store_object(cache_pool, key.as_str(), cacheable_object).await?, + } + + Ok(()) +}