diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 72356d1..15f74e7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2521,6 +2521,7 @@ dependencies = [ "chrono", "derive_more", "dotenv", + "hex", "mime_guess", "regex", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2233725..e3b965f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -19,6 +19,7 @@ axum_typed_multipart = "0.16.3" chrono = "0.4.41" derive_more = { version = "2.0.1", features = ["display"] } dotenv = "0.15.0" +hex = "0.4.3" mime_guess = "2.0.5" regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } diff --git a/backend/migrations/20250717123142_create_auth_sessions.sql b/backend/migrations/20250717123142_create_auth_sessions.sql new file mode 100644 index 0000000..7210de5 --- /dev/null +++ b/backend/migrations/20250717123142_create_auth_sessions.sql @@ -0,0 +1,6 @@ +CREATE TABLE auth_sessions ( + session_id VARCHAR NOT NULL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/src/bin/backend/main.rs b/backend/src/bin/backend/main.rs index b3cc844..fbee4da 100644 --- a/backend/src/bin/backend/main.rs +++ b/backend/src/bin/backend/main.rs @@ -39,7 +39,8 @@ async fn main() -> anyhow::Result<()> { fs_service.clone(), ); - let auth_service = domain::warren::service::auth::Service::new(postgres, metrics, notifier); + let auth_service = + domain::warren::service::auth::Service::new(postgres, metrics, notifier, config.auth); let server_config = HttpServerConfig::new( &config.server_address, diff --git a/backend/src/lib/config.rs b/backend/src/lib/config.rs index 3fe9ad8..8598470 100644 --- a/backend/src/lib/config.rs +++ b/backend/src/lib/config.rs @@ -3,6 +3,8 @@ use std::{env, str::FromStr}; use anyhow::Context as _; use tracing::level_filters::LevelFilter; +use crate::domain::warren::service::auth::AuthConfig; + const DATABASE_URL_KEY: &str = "DATABASE_URL"; const DATABASE_NAME_KEY: &str = "DATABASE_NAME"; @@ -29,24 +31,28 @@ pub struct Config { pub database_name: String, pub log_level: LevelFilter, + + pub auth: AuthConfig, } impl Config { pub fn from_env() -> anyhow::Result { - let server_address = load_env(SERVER_ADDRESS_KEY)?; - let server_port = load_env(SERVER_PORT_KEY)?.parse()?; - let cors_allow_origin = load_env(CORS_ALLOW_ORIGIN_KEY)?; + let server_address = Self::load_env(SERVER_ADDRESS_KEY)?; + let server_port = Self::load_env(SERVER_PORT_KEY)?.parse()?; + let cors_allow_origin = Self::load_env(CORS_ALLOW_ORIGIN_KEY)?; - let serve_dir = load_env(SERVE_DIRECTORY_KEY)?; - let static_frontend_dir = load_env(STATIC_FRONTEND_DIRECTORY).ok(); + let serve_dir = Self::load_env(SERVE_DIRECTORY_KEY)?; + let static_frontend_dir = Self::load_env(STATIC_FRONTEND_DIRECTORY).ok(); - let database_url = load_env(DATABASE_URL_KEY)?; - let database_name = load_env(DATABASE_NAME_KEY)?; + let database_url = Self::load_env(DATABASE_URL_KEY)?; + let database_name = Self::load_env(DATABASE_NAME_KEY)?; let log_level = - LevelFilter::from_str(&load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string())) + LevelFilter::from_str(&Self::load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string())) .context("Failed to convert the value of LOG_LEVEL to a LevelFilter")?; + let auth_config = AuthConfig::from_env()?; + Ok(Self { server_address, server_port, @@ -59,10 +65,12 @@ impl Config { database_name, log_level, + + auth: auth_config, }) } -} -fn load_env(key: &str) -> anyhow::Result { - env::var(key).context(format!("Failed to get environment variable: {key}")) + pub fn load_env(key: &str) -> anyhow::Result { + env::var(key).context(format!("Failed to get environment variable: {key}")) + } } diff --git a/backend/src/lib/domain/warren/models/auth_session/mod.rs b/backend/src/lib/domain/warren/models/auth_session/mod.rs new file mode 100644 index 0000000..ec85adf --- /dev/null +++ b/backend/src/lib/domain/warren/models/auth_session/mod.rs @@ -0,0 +1,65 @@ +use chrono::NaiveDateTime; +use derive_more::Display; +use thiserror::Error; +use uuid::Uuid; + +pub mod requests; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)] +pub struct AuthSession { + session_id: AuthSessionId, + user_id: Uuid, + expires_at: NaiveDateTime, + created_at: NaiveDateTime, +} + +impl AuthSession { + pub fn new( + session_id: AuthSessionId, + user_id: Uuid, + expires_at: NaiveDateTime, + created_at: NaiveDateTime, + ) -> Self { + Self { + session_id, + user_id, + expires_at, + created_at, + } + } + + pub fn session_id(&self) -> &AuthSessionId { + &self.session_id + } + + pub fn user_id(&self) -> &Uuid { + &self.user_id + } +} + +/// A valid auth session id +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)] +#[sqlx(transparent)] +pub struct AuthSessionId(String); + +impl AuthSessionId { + pub fn new(raw: &str) -> Result { + let trimmed = raw.trim(); + + if trimmed.is_empty() { + return Err(AuthSessionIdError::Empty); + } + + Ok(Self(trimmed.to_string())) + } + + pub fn into_string(self) -> String { + self.0 + } +} + +#[derive(Debug, Clone, Error)] +pub enum AuthSessionIdError { + #[error("An auth session id must not be empty")] + Empty, +} diff --git a/backend/src/lib/domain/warren/models/auth_session/requests.rs b/backend/src/lib/domain/warren/models/auth_session/requests.rs new file mode 100644 index 0000000..6e35242 --- /dev/null +++ b/backend/src/lib/domain/warren/models/auth_session/requests.rs @@ -0,0 +1,45 @@ +use thiserror::Error; + +use crate::domain::warren::models::user::User; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateAuthSessionRequest { + user: User, + expiration: SessionExpirationTime, +} + +impl CreateAuthSessionRequest { + pub fn new(user: User, expiration_time: SessionExpirationTime) -> Self { + Self { + user, + expiration: expiration_time, + } + } + + pub fn user(&self) -> &User { + &self.user + } + + pub fn expiration(&self) -> &SessionExpirationTime { + &self.expiration + } +} + +#[derive(Debug, Error)] +pub enum CreateAuthSessionError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SessionExpirationTime(u64); + +impl SessionExpirationTime { + pub fn new(value: u64) -> Self { + Self(value) + } + + pub fn millis(&self) -> u64 { + self.0 + } +} diff --git a/backend/src/lib/domain/warren/models/mod.rs b/backend/src/lib/domain/warren/models/mod.rs index 64eb083..b5ea86a 100644 --- a/backend/src/lib/domain/warren/models/mod.rs +++ b/backend/src/lib/domain/warren/models/mod.rs @@ -1,3 +1,4 @@ +pub mod auth_session; pub mod file; pub mod user; pub mod warren; diff --git a/backend/src/lib/domain/warren/models/user/mod.rs b/backend/src/lib/domain/warren/models/user/mod.rs index c7a99ad..7b2a6c5 100644 --- a/backend/src/lib/domain/warren/models/user/mod.rs +++ b/backend/src/lib/domain/warren/models/user/mod.rs @@ -33,6 +33,10 @@ impl User { &self.email } + pub fn password_hash(&self) -> &str { + &self.hash + } + pub fn admin(&self) -> bool { self.admin } diff --git a/backend/src/lib/domain/warren/models/user/requests.rs b/backend/src/lib/domain/warren/models/user/requests.rs index 30fbde3..86ec9e1 100644 --- a/backend/src/lib/domain/warren/models/user/requests.rs +++ b/backend/src/lib/domain/warren/models/user/requests.rs @@ -1,5 +1,7 @@ use thiserror::Error; +use crate::domain::warren::models::auth_session::requests::CreateAuthSessionError; + use super::{UserEmail, UserName}; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -87,3 +89,72 @@ impl RegisterUserRequest { &self.password } } + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VerifyUserPasswordRequest { + email: UserEmail, + password: UserPassword, +} + +impl VerifyUserPasswordRequest { + pub fn new(email: UserEmail, password: UserPassword) -> Self { + Self { email, password } + } + + pub fn email(&self) -> &UserEmail { + &self.email + } + + pub fn password(&self) -> &UserPassword { + &self.password + } +} + +impl From for VerifyUserPasswordRequest { + fn from(value: LoginUserRequest) -> Self { + Self { + email: value.email, + password: value.password, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LoginUserRequest { + email: UserEmail, + password: UserPassword, +} + +impl LoginUserRequest { + pub fn new(email: UserEmail, password: UserPassword) -> Self { + Self { email, password } + } + + pub fn email(&self) -> &UserEmail { + &self.email + } + + pub fn password(&self) -> &UserPassword { + &self.password + } +} + +#[derive(Debug, Error)] +pub enum VerifyUserPasswordError { + #[error("There is no user with this email: {0}")] + NotFound(UserEmail), + #[error("The password is incorrect")] + IncorrectPassword, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum LoginUserError { + #[error(transparent)] + VerifyUser(#[from] VerifyUserPasswordError), + #[error(transparent)] + CreateAuthToken(#[from] CreateAuthSessionError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index 4c74936..3fe2241 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -52,4 +52,10 @@ pub trait FileSystemMetrics: Clone + Send + Sync + 'static { pub trait AuthMetrics: Clone + Send + Sync + 'static { fn record_user_registration_success(&self) -> impl Future + Send; fn record_user_registration_failure(&self) -> impl Future + Send; + + fn record_user_login_success(&self) -> impl Future + Send; + fn record_user_login_failure(&self) -> impl Future + Send; + + fn record_auth_session_creation_success(&self) -> impl Future + Send; + fn record_auth_session_creation_failure(&self) -> impl Future + Send; } diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 73dccf4..bd9bac6 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -7,12 +7,16 @@ pub use notifier::*; pub use repository::*; use super::models::{ + auth_session::{ + AuthSession, + requests::{CreateAuthSessionError, CreateAuthSessionRequest}, + }, file::{ CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, - user::{RegisterUserError, RegisterUserRequest, User}, + user::{LoginUserError, LoginUserRequest, RegisterUserError, RegisterUserRequest, User}, warren::{ CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, @@ -98,4 +102,12 @@ pub trait AuthService: Clone + Send + Sync + 'static { &self, request: RegisterUserRequest, ) -> impl Future> + Send; + fn login_user( + &self, + request: LoginUserRequest, + ) -> impl Future> + Send; + fn create_auth_session( + &self, + request: CreateAuthSessionRequest, + ) -> impl Future> + Send; } diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index a2ad17a..cdf4d3a 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -1,3 +1,5 @@ +use uuid::Uuid; + use crate::domain::warren::models::{ file::{AbsoluteFilePath, File, FilePath}, user::User, @@ -72,4 +74,6 @@ pub trait FileSystemNotifier: Clone + Send + Sync + 'static { pub trait AuthNotifier: Clone + Send + Sync + 'static { fn user_registered(&self, user: &User) -> impl Future + Send; + fn user_logged_in(&self, user: &User) -> impl Future + Send; + fn auth_session_created(&self, user_id: &Uuid) -> impl Future + Send; } diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index edba598..e414bec 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -1,10 +1,17 @@ use crate::domain::warren::models::{ + auth_session::{ + AuthSession, + requests::{CreateAuthSessionError, CreateAuthSessionRequest}, + }, file::{ CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, File, FilePath, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, - user::{RegisterUserError, RegisterUserRequest, User}, + user::{ + RegisterUserError, RegisterUserRequest, User, VerifyUserPasswordError, + VerifyUserPasswordRequest, + }, warren::{FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren}, }; @@ -55,4 +62,12 @@ pub trait AuthRepository: Clone + Send + Sync + 'static { &self, request: RegisterUserRequest, ) -> impl Future> + Send; + fn verify_user_password( + &self, + request: VerifyUserPasswordRequest, + ) -> impl Future> + Send; + fn create_auth_session( + &self, + request: CreateAuthSessionRequest, + ) -> impl Future> + Send; } diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index 44dd5dd..98ff10b 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -1,8 +1,49 @@ -use crate::domain::warren::{ - models::user::{RegisterUserError, RegisterUserRequest, User}, - ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService}, +use crate::{ + config::Config, + domain::warren::{ + models::{ + auth_session::{ + AuthSession, + requests::{ + CreateAuthSessionError, CreateAuthSessionRequest, SessionExpirationTime, + }, + }, + user::{ + LoginUserError, LoginUserRequest, RegisterUserError, RegisterUserRequest, User, + }, + }, + ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService}, + }, }; +const AUTH_SESSION_EXPIRATION_KEY: &str = "AUTH_SESSION_EXPIRATION"; + +/// The authentication service configuration +/// +/// * `session_lifetime`: The amount of milliseconds a client session is valid +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthConfig { + session_lifetime: SessionExpirationTime, +} + +impl AuthConfig { + pub fn from_env() -> anyhow::Result { + let session_lifetime = { + match Config::load_env(AUTH_SESSION_EXPIRATION_KEY).ok() { + Some(raw) => SessionExpirationTime::new(raw.parse()?), + // 86.400.000 milliseconds = 1 day + None => SessionExpirationTime::new(86_400_000), + } + }; + + Ok(Self { session_lifetime }) + } + + pub fn session_lifetime(&self) -> SessionExpirationTime { + self.session_lifetime + } +} + #[derive(Debug, Clone)] pub struct Service where @@ -13,6 +54,7 @@ where repository: R, metrics: M, notifier: N, + config: AuthConfig, } impl Service @@ -21,11 +63,12 @@ where M: AuthMetrics, N: AuthNotifier, { - pub fn new(repository: R, metrics: M, notifier: N) -> Self { + pub fn new(repository: R, metrics: M, notifier: N, config: AuthConfig) -> Self { Self { repository, metrics, notifier, + config, } } } @@ -48,4 +91,44 @@ where result } + + async fn login_user(&self, request: LoginUserRequest) -> Result { + let user = self + .repository + .verify_user_password(request.into()) + .await + .map_err(|e| LoginUserError::VerifyUser(e))?; + + let result = self + .create_auth_session(CreateAuthSessionRequest::new( + user.clone(), + self.config.session_lifetime(), + )) + .await; + + if result.as_ref().is_ok() { + self.metrics.record_user_login_success().await; + self.notifier.user_logged_in(&user).await; + } else { + self.metrics.record_user_login_failure().await; + } + + result.map_err(Into::into) + } + + async fn create_auth_session( + &self, + request: CreateAuthSessionRequest, + ) -> Result { + let result = self.repository.create_auth_session(request).await; + + if let Ok(session) = result.as_ref() { + self.metrics.record_auth_session_creation_success().await; + self.notifier.auth_session_created(session.user_id()).await; + } else { + self.metrics.record_auth_session_creation_failure().await; + } + + result + } } diff --git a/backend/src/lib/inbound/http/errors.rs b/backend/src/lib/inbound/http/errors.rs index 3da8b34..3fc621d 100644 --- a/backend/src/lib/inbound/http/errors.rs +++ b/backend/src/lib/inbound/http/errors.rs @@ -1,7 +1,7 @@ use crate::{ domain::warren::models::{ file::{CreateDirectoryError, DeleteDirectoryError, DeleteFileError, ListFilesError}, - user::RegisterUserError, + user::{LoginUserError, RegisterUserError, VerifyUserPasswordError}, warren::{ CreateWarrenDirectoryError, DeleteWarrenDirectoryError, DeleteWarrenFileError, FetchWarrenError, ListWarrenFilesError, ListWarrensError, RenameWarrenEntryError, @@ -134,3 +134,21 @@ impl From for ApiError { } } } + +impl From for ApiError { + fn from(value: LoginUserError) -> Self { + match value { + LoginUserError::VerifyUser(e) => match e { + VerifyUserPasswordError::NotFound(_) => { + Self::NotFound("Could not find a user with that email".to_string()) + } + VerifyUserPasswordError::IncorrectPassword => { + Self::BadRequest("The email and password do not match".to_string()) + } + VerifyUserPasswordError::Unknown(e) => Self::InternalServerError(e.to_string()), + }, + LoginUserError::CreateAuthToken(e) => Self::InternalServerError(e.to_string()), + LoginUserError::Unknown(e) => Self::InternalServerError(e.to_string()), + } + } +} diff --git a/backend/src/lib/inbound/http/handlers/auth/login.rs b/backend/src/lib/inbound/http/handlers/auth/login.rs new file mode 100644 index 0000000..963242d --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/auth/login.rs @@ -0,0 +1,94 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + domain::warren::{ + models::{ + auth_session::AuthSession, + user::{LoginUserRequest, UserEmail, UserEmailError, UserPassword, UserPasswordError}, + }, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginUserHttpRequestBody { + email: String, + password: String, +} + +#[derive(Debug, Clone, Error)] +pub enum ParseLoginUserHttpRequestError { + #[error(transparent)] + Email(#[from] UserEmailError), + #[error(transparent)] + Password(#[from] UserPasswordError), +} + +impl From for ApiError { + fn from(value: ParseLoginUserHttpRequestError) -> Self { + match value { + ParseLoginUserHttpRequestError::Email(err) => Self::BadRequest( + match err { + UserEmailError::Empty => "The provided email is empty", + UserEmailError::Invalid => "The provided email is invalid", + } + .to_string(), + ), + ParseLoginUserHttpRequestError::Password(err) => Self::BadRequest( + match err { + UserPasswordError::Empty => "The provided password is empty", + // Best not give a potential bad actor any hints since this is the login and + // not the registration + UserPasswordError::LeadingWhitespace + | UserPasswordError::TrailingWhitespace + | UserPasswordError::TooShort + | UserPasswordError::TooLong => "", + } + .to_string(), + ), + } + } +} + +impl LoginUserHttpRequestBody { + fn try_into_domain(self) -> Result { + let email = UserEmail::new(&self.email)?; + let password = UserPassword::new(&self.password)?; + + Ok(LoginUserRequest::new(email, password)) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct LoginResponseBody { + token: String, +} + +impl From for LoginResponseBody { + fn from(value: AuthSession) -> Self { + Self { + token: value.session_id().to_string(), + } + } +} + +pub async fn login( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .auth_service + .login_user(domain_request) + .await + .map(|token| ApiSuccess::new(StatusCode::OK, token.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/auth/mod.rs b/backend/src/lib/inbound/http/handlers/auth/mod.rs index aaa5141..d071c15 100644 --- a/backend/src/lib/inbound/http/handlers/auth/mod.rs +++ b/backend/src/lib/inbound/http/handlers/auth/mod.rs @@ -1,4 +1,6 @@ +mod login; mod register; +use login::login; use register::register; use axum::{Router, routing::post}; @@ -9,5 +11,7 @@ use crate::{ }; pub fn routes() -> Router> { - Router::new().route("/register", post(register)) + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) } diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index d77d897..46bae26 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -115,10 +115,10 @@ impl FileSystemMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] File deletion failed"); } - async fn record_entry_rename_success(&self) -> () { + async fn record_entry_rename_success(&self) { tracing::debug!("[Metrics] Entry rename succeeded"); } - async fn record_entry_rename_failure(&self) -> () { + async fn record_entry_rename_failure(&self) { tracing::debug!("[Metrics] Entry rename failed"); } } @@ -130,4 +130,18 @@ impl AuthMetrics for MetricsDebugLogger { async fn record_user_registration_failure(&self) { tracing::debug!("[Metrics] User registration failed"); } + + async fn record_user_login_success(&self) { + tracing::debug!("[Metrics] User login succeeded"); + } + async fn record_user_login_failure(&self) { + tracing::debug!("[Metrics] User login failed"); + } + + async fn record_auth_session_creation_success(&self) { + tracing::debug!("[Metrics] Auth session creation succeeded"); + } + async fn record_auth_session_creation_failure(&self) { + tracing::debug!("[Metrics] Auth session creation failed"); + } } diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 6fb7d21..5c37349 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -1,3 +1,5 @@ +use uuid::Uuid; + use crate::domain::warren::{ models::{ file::{File, FilePath}, @@ -118,4 +120,12 @@ impl AuthNotifier for NotifierDebugLogger { async fn user_registered(&self, user: &User) { tracing::debug!("[Notifier] Registered user {}", user.name()); } + + async fn user_logged_in(&self, user: &User) { + tracing::debug!("[Notifier] Logged in user {}", user.name()); + } + + async fn auth_session_created(&self, user_id: &Uuid) { + tracing::debug!("[Notifier] Created auth session for user {}", user_id); + } } diff --git a/backend/src/lib/outbound/postgres.rs b/backend/src/lib/outbound/postgres.rs index 6b7567a..f92d85d 100644 --- a/backend/src/lib/outbound/postgres.rs +++ b/backend/src/lib/outbound/postgres.rs @@ -2,9 +2,13 @@ use std::str::FromStr; use anyhow::{Context, anyhow}; use argon2::{ - Argon2, - password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, + Argon2, PasswordHash, PasswordVerifier, + password_hash::{ + PasswordHasher, SaltString, + rand_core::{OsRng, RngCore as _}, + }, }; +use chrono::Utc; use sqlx::{ ConnectOptions as _, Connection as _, PgConnection, PgPool, postgres::{PgConnectOptions, PgPoolOptions}, @@ -13,7 +17,14 @@ use uuid::Uuid; use crate::domain::warren::{ models::{ - user::{RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword}, + auth_session::{ + AuthSession, + requests::{CreateAuthSessionError, CreateAuthSessionRequest, SessionExpirationTime}, + }, + user::{ + RegisterUserError, RegisterUserRequest, User, UserEmail, UserName, UserPassword, + VerifyUserPasswordError, VerifyUserPasswordRequest, + }, warren::{ FetchWarrenError, FetchWarrenRequest, ListWarrensError, ListWarrensRequest, Warren, }, @@ -151,6 +162,89 @@ impl Postgres { Ok(user) } + + async fn get_user_from_email( + &self, + connection: &mut PgConnection, + email: &UserEmail, + ) -> Result { + let user: User = sqlx::query_as( + " + SELECT + * + FROM + users + WHERE + email = $1 + ", + ) + .bind(email) + .fetch_one(connection) + .await?; + + Ok(user) + } + + fn check_user_password_against_hash( + &self, + password: &UserPassword, + hash: &str, + ) -> Result<(), VerifyUserPasswordError> { + let argon = Argon2::default(); + let hash = + PasswordHash::new(hash).map_err(|e| VerifyUserPasswordError::Unknown(anyhow!(e)))?; + + argon + .verify_password(password.as_str().as_bytes(), &hash) + .map_err(|e| match e { + argon2::password_hash::Error::Password => { + VerifyUserPasswordError::IncorrectPassword + } + _ => VerifyUserPasswordError::Unknown(anyhow!(e)), + }) + } + + async fn create_session( + &self, + connection: &mut PgConnection, + user: &User, + expiration: &SessionExpirationTime, + ) -> anyhow::Result { + let mut rng = OsRng; + let mut bytes = vec![0_u8; 32]; + rng.fill_bytes(&mut bytes); + + let session_id = hex::encode(bytes); + + let expiration_time = Utc::now().timestamp_millis() + i64::try_from(expiration.millis())?; + + let mut tx = connection.begin().await?; + + let session: AuthSession = sqlx::query_as( + " + INSERT INTO auth_sessions ( + session_id, + user_id, + expires_at + ) VALUES ( + $1, + $2, + TO_TIMESTAMP($3::double precision / 1000) + ) + RETURNING + * + ", + ) + .bind(session_id) + .bind(user.id()) + .bind(expiration_time) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(session) + } } impl WarrenRepository for Postgres { @@ -217,6 +311,50 @@ impl AuthRepository for Postgres { Ok(user) } + + async fn verify_user_password( + &self, + request: VerifyUserPasswordRequest, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let user = self + .get_user_from_email(&mut connection, request.email()) + .await + .map_err(|e| { + if is_not_found_error(&e) { + VerifyUserPasswordError::NotFound(request.email().clone()) + } else { + VerifyUserPasswordError::Unknown(anyhow!(e)) + } + })?; + + self.check_user_password_against_hash(request.password(), user.password_hash())?; + + Ok(user) + } + + async fn create_auth_session( + &self, + request: CreateAuthSessionRequest, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let session = self + .create_session(&mut connection, request.user(), request.expiration()) + .await + .context("Failed to create session")?; + + Ok(session) + } } fn is_not_found_error(err: &sqlx::Error) -> bool { diff --git a/frontend/.env.dev b/frontend/.env.dev index 19161eb..b46e796 100644 --- a/frontend/.env.dev +++ b/frontend/.env.dev @@ -1,3 +1,5 @@ # this file is ignored when the app is built since we're using SSG NUXT_PUBLIC_API_BASE="http://127.0.0.1:8080/api" +NUXT_COOKIES_SECURE="false" +NUXT_COOKIES_SAME_SITE="strict" diff --git a/frontend/composables/useAuthSession.ts b/frontend/composables/useAuthSession.ts new file mode 100644 index 0000000..1c14442 --- /dev/null +++ b/frontend/composables/useAuthSession.ts @@ -0,0 +1,20 @@ +import type { AuthSession } from '~/types/auth'; + +const SAME_SITE_SETTINGS = ['strict', 'lax', 'none'] as const; + +export function useAuthSession() { + const config = useRuntimeConfig().public; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sameSite = SAME_SITE_SETTINGS.includes(config.cookiesSameSite as any) + ? (config.cookiesSameSite as (typeof SAME_SITE_SETTINGS)[number]) + : 'strict'; + + return useCookie('auth_session', { + default: () => null as AuthSession | null, + secure: config.cookiesSecure.toLowerCase() === 'true', + sameSite, + path: '/', + httpOnly: false, + }); +} diff --git a/frontend/lib/api/auth/login.ts b/frontend/lib/api/auth/login.ts new file mode 100644 index 0000000..77fd943 --- /dev/null +++ b/frontend/lib/api/auth/login.ts @@ -0,0 +1,42 @@ +import { toast } from 'vue-sonner'; +import type { ApiResponse } from '~/types/api'; + +export async function loginUser( + email: string, + password: string +): Promise<{ success: boolean }> { + const { data, error } = await useFetch>( + getApiUrl('auth/login'), + { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + email: email, + password: password, + }), + responseType: 'json', + } + ); + + if (data.value == null) { + toast.error('Login', { + description: error.value?.data ?? 'Failed to login', + }); + + return { + success: false, + }; + } + + useAuthSession().value = { type: 'WarrenAuth', id: data.value.data.token }; + + toast.success('Login', { + description: `Successfully logged in`, + }); + + return { + success: true, + }; +} diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts new file mode 100644 index 0000000..b6d92fe --- /dev/null +++ b/frontend/lib/api/index.ts @@ -0,0 +1,15 @@ +export function getApiHeaders( + includeAuth: boolean = true +): Record { + const headers: Record = {}; + + if (includeAuth) { + const authSession = useAuthSession().value; + + if (authSession != null) { + headers['authorization'] = `${authSession.type} ${authSession.id}`; + } + } + + return headers; +} diff --git a/frontend/lib/api/warrens.ts b/frontend/lib/api/warrens.ts index c4cbb26..48cf52c 100644 --- a/frontend/lib/api/warrens.ts +++ b/frontend/lib/api/warrens.ts @@ -2,12 +2,14 @@ import { toast } from 'vue-sonner'; import type { DirectoryEntry } from '~/types'; import type { ApiResponse } from '~/types/api'; import type { Warren } from '~/types/warrens'; +import { getApiHeaders } from '.'; export async function getWarrens(): Promise> { const { data, error } = await useFetch>( getApiUrl('warrens'), { method: 'GET', + headers: getApiHeaders(), } ); diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index f11af98..252c299 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -58,6 +58,8 @@ export default defineNuxtConfig({ runtimeConfig: { public: { apiBase: '/api', + cookiesSecure: 'false', + cookiesSameSite: 'strict', }, }, }); diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index 9b008b2..3c488a2 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -7,6 +7,7 @@ import { CardContent, CardFooter, } from '@/components/ui/card'; +import { loginUser } from '~/lib/api/auth/login'; definePageMeta({ layout: 'auth', @@ -14,6 +15,35 @@ definePageMeta({ // TODO: Get this from the backend const OPEN_ID = false; +const loggingIn = ref(false); +const email = ref(''); +const password = ref(''); + +const inputValid = computed( + () => email.value.trim().length > 0 && password.value.trim().length > 0 +); + +async function submit() { + if (!inputValid.value) { + return; + } + + loggingIn.value = true; + + const { success } = await loginUser(email.value, password.value); + + if (success) { + await navigateTo({ path: '/' }); + } + + loggingIn.value = false; +} + +function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter') { + submit(); + } +}