From 1cc9b1db9e2bef617eaba9bf4e97c849418dc18f Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:35:07 -0500 Subject: [PATCH 01/17] Add agent context --- AGENTS.md | 2 ++ CLAUDE.md | 1 + 2 files changed, 3 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c25586f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,2 @@ +# Pixles + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 0b41858297ad2a663d49200cbdb991754bb519e6 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:39:28 -0500 Subject: [PATCH 02/17] Init sidecar --- .gitignore | 10 ++++++++++ AGENTS.md | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/.gitignore b/.gitignore index 264021b..c610868 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,13 @@ target/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# sidecar/td +.todos/ +.sidecar/ +.sidecar-agent +.sidecar-task +.sidecar-pr +.sidecar-start.sh +.sidecar-base +.td-root diff --git a/AGENTS.md b/AGENTS.md index c25586f..679cd7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,2 +1,7 @@ # Pixles +## MANDATORY: Use td for Task Management + +You must run td usage --new-session at conversation start (or after /clear) to see current work. +Use td usage -q for subsequent reads. + From 5c4ea4c0e1ebd5aee65842e1a929c0c39053ee74 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:41:08 -0500 Subject: [PATCH 03/17] feat(auth): implement GET /auth/devices endpoint Replaces todo!() in get_devices handler with full implementation that lists active sessions for the authenticated user from the session store. - Add last_active_at field to Session and Device structs - Add get_sessions_for_user() to SessionManager (skips expired sessions) - Handler validates bearer token, fetches user sessions, marks is_current --- pixles-api/auth/src/models/responses.rs | 1 + pixles-api/auth/src/routes/auth.rs | 52 ++++++++++++++++++++++++- pixles-api/auth/src/session/mod.rs | 22 ++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/pixles-api/auth/src/models/responses.rs b/pixles-api/auth/src/models/responses.rs index 59f2b47..78d2406 100644 --- a/pixles-api/auth/src/models/responses.rs +++ b/pixles-api/auth/src/models/responses.rs @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize}; pub struct Device { pub id: String, pub created_at: i64, + pub last_active_at: i64, pub user_agent: Option, pub ip_address: Option, pub is_current: bool, diff --git a/pixles-api/auth/src/routes/auth.rs b/pixles-api/auth/src/routes/auth.rs index 900c08a..0ee0349 100644 --- a/pixles-api/auth/src/routes/auth.rs +++ b/pixles-api/auth/src/routes/auth.rs @@ -6,7 +6,7 @@ use crate::claims::Claims; use crate::errors::ClaimValidationError; use crate::models::requests::{LoginRequest, RefreshTokenRequest, RegisterRequest}; use crate::models::responses::{ - GetDevicesResponses, LoginResponses, LogoutResponses, RefreshTokenResponses, + Device, GetDevicesResponses, LoginResponses, LogoutResponses, RefreshTokenResponses, RegisterUserResponses, ValidateTokenResponses, }; use crate::state::AppState; @@ -155,5 +155,53 @@ pub async fn logout(req: &mut Request, depot: &mut Depot) -> LogoutResponses { /// Get all active devices (sessions) #[endpoint(operation_id = "get_devices", tags("auth"), security(("bearer" = [])))] pub async fn get_devices(req: &mut Request, depot: &mut Depot) -> GetDevicesResponses { - todo!("Implement get_devices") + let state = depot.obtain::().unwrap(); + let headers = req.headers(); + + let token_string = match get_token_from_headers(headers) { + Ok(token_string) => token_string, + Err(e) => return e.into(), + }; + + let claims = match state.auth_service.get_claims(token_string.expose_secret()) { + Ok(claims) => claims, + Err(e) => return e.into(), + }; + + if let Err(e) = claims.validate_access_token() { + return e.into(); + } + + let current_sid = claims.sid.clone(); + + let sessions = match state + .session_manager + .get_sessions_for_user(&claims.sub) + .await + { + Ok(sessions) => sessions, + Err(e) => return e.into(), + }; + + let devices: Vec = sessions + .into_iter() + .map(|(sid, session)| { + let is_current = current_sid.as_deref() == Some(sid.as_str()); + let last_active_at = if session.last_active_at == 0 { + session.created_at + } else { + session.last_active_at + }; + Device { + id: sid, + created_at: session.created_at, + last_active_at, + user_agent: session.user_agent, + ip_address: session.ip_address, + is_current, + } + }) + .collect(); + + GetDevicesResponses::Success(devices) } diff --git a/pixles-api/auth/src/session/mod.rs b/pixles-api/auth/src/session/mod.rs index 38ec4c2..df51ce7 100644 --- a/pixles-api/auth/src/session/mod.rs +++ b/pixles-api/auth/src/session/mod.rs @@ -15,6 +15,8 @@ pub struct Session { pub created_at: i64, pub user_agent: Option, pub ip_address: Option, + #[serde(default)] + pub last_active_at: i64, } #[derive(Clone)] @@ -53,11 +55,13 @@ impl SessionManager { ip_address: Option, ) -> Result { let sid = nanoid::nanoid!(); + let now = chrono::Utc::now().timestamp(); let session = Session { user_id: user_id.clone(), - created_at: chrono::Utc::now().timestamp(), + created_at: now, user_agent, ip_address, + last_active_at: now, }; let session_data = serde_json::to_string(&session).map_err(InternalServerError::from)?; @@ -89,6 +93,22 @@ impl SessionManager { self.storage.delete_session(sid).await } + /// Returns all active sessions for a user as (session_id, Session) pairs. + /// Sessions that have expired (not found in storage) are silently skipped. + pub async fn get_sessions_for_user( + &self, + user_id: &str, + ) -> Result, InternalServerError> { + let session_ids = self.storage.get_user_sessions(user_id).await?; + let mut sessions = Vec::new(); + for sid in session_ids { + if let Some(session) = self.get_session(&sid).await? { + sessions.push((sid, session)); + } + } + Ok(sessions) + } + pub async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), InternalServerError> { let sessions = self.storage.get_user_sessions(user_id).await?; From 08e4fdfe9b9fb18a7c260e8b830d07b3f4eed853 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:52:27 -0500 Subject: [PATCH 04/17] feat(users): add registered_via column to track account creation origin Adds a nullable registered_via VARCHAR(32) column to the users table. Existing and seeded rows remain NULL; newly invited users will be set to 'invitation' once the invite flow is implemented (td-cde837). - Migration: m20250302_000000_add_registered_via - Entity, model, service, auth, and GraphQL User types updated --- pixles-api/auth/src/models/mod.rs | 4 ++ pixles-api/auth/src/service/auth.rs | 1 + pixles-api/entity/src/user.rs | 3 ++ pixles-api/library/src/schema/user/queries.rs | 1 + pixles-api/library/src/schema/user/types.rs | 3 ++ pixles-api/migration/src/lib.rs | 6 ++- .../m20250302_000000_add_registered_via.rs | 37 +++++++++++++++++++ pixles-api/model/src/user.rs | 5 +++ pixles-api/service/src/user/mod.rs | 1 + pixles-api/service/src/user/mutation.rs | 2 + 10 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 pixles-api/migration/src/m20250302_000000_add_registered_via.rs diff --git a/pixles-api/auth/src/models/mod.rs b/pixles-api/auth/src/models/mod.rs index 78565af..e94f227 100644 --- a/pixles-api/auth/src/models/mod.rs +++ b/pixles-api/auth/src/models/mod.rs @@ -73,6 +73,8 @@ pub struct UserProfile { pub is_admin: bool, pub created_at: String, pub updated_at: String, + /// How the account was created (e.g. 'invitation'); null for seeded/legacy/unknown + pub registered_via: Option, } impl From for UserProfile { @@ -88,6 +90,7 @@ impl From for UserProfile { created_at, modified_at, deleted_at: _, + registered_via, } = user; Self { user_id: id, @@ -97,6 +100,7 @@ impl From for UserProfile { is_admin, created_at: created_at.to_rfc3339(), updated_at: modified_at.to_rfc3339(), + registered_via, } } } diff --git a/pixles-api/auth/src/service/auth.rs b/pixles-api/auth/src/service/auth.rs index 8507022..aa1c333 100644 --- a/pixles-api/auth/src/service/auth.rs +++ b/pixles-api/auth/src/service/auth.rs @@ -79,6 +79,7 @@ impl AuthService { name, email, password_hash, + registered_via: None, }, ) .await diff --git a/pixles-api/entity/src/user.rs b/pixles-api/entity/src/user.rs index 9677da0..308b646 100644 --- a/pixles-api/entity/src/user.rs +++ b/pixles-api/entity/src/user.rs @@ -53,6 +53,9 @@ pub struct Model { /// Date when the user was deleted if not NULL #[sea_orm(column_type = "TimestampWithTimeZone", nullable, indexed)] pub deleted_at: Option>, + /// How the account was created (e.g. 'invitation'); NULL for seeded/legacy/unknown + #[sea_orm(column_type = "String(StringLen::N(32))", nullable)] + pub registered_via: Option, } // TODO: Add in related columns: diff --git a/pixles-api/library/src/schema/user/queries.rs b/pixles-api/library/src/schema/user/queries.rs index 08a6a6e..daa7664 100644 --- a/pixles-api/library/src/schema/user/queries.rs +++ b/pixles-api/library/src/schema/user/queries.rs @@ -20,6 +20,7 @@ impl UserQuery { created_at: Utc::now(), modified_at: Utc::now(), deleted_at: None, + registered_via: None, }) } diff --git a/pixles-api/library/src/schema/user/types.rs b/pixles-api/library/src/schema/user/types.rs index 9bece66..4738168 100644 --- a/pixles-api/library/src/schema/user/types.rs +++ b/pixles-api/library/src/schema/user/types.rs @@ -16,6 +16,8 @@ pub struct User { pub created_at: DateTime, pub modified_at: DateTime, pub deleted_at: Option>, + /// How the account was created (e.g. 'invitation'); null for seeded/legacy/unknown + pub registered_via: Option, } impl From for User { @@ -31,6 +33,7 @@ impl From for User { created_at: user.created_at, modified_at: user.modified_at, deleted_at: user.deleted_at, + registered_via: user.registered_via, } } } diff --git a/pixles-api/migration/src/lib.rs b/pixles-api/migration/src/lib.rs index b996195..3482df1 100644 --- a/pixles-api/migration/src/lib.rs +++ b/pixles-api/migration/src/lib.rs @@ -1,12 +1,16 @@ pub use sea_orm_migration::prelude::*; mod m20250210_000000_initial_schema; +mod m20250302_000000_add_registered_via; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20250210_000000_initial_schema::Migration)] + vec![ + Box::new(m20250210_000000_initial_schema::Migration), + Box::new(m20250302_000000_add_registered_via::Migration), + ] } } diff --git a/pixles-api/migration/src/m20250302_000000_add_registered_via.rs b/pixles-api/migration/src/m20250302_000000_add_registered_via.rs new file mode 100644 index 0000000..6e73ebc --- /dev/null +++ b/pixles-api/migration/src/m20250302_000000_add_registered_via.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Users::Table) + .add_column(string_len_null(Users::RegisteredVia, 32)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Users::Table) + .drop_column(Users::RegisteredVia) + .to_owned(), + ) + .await + } +} + +use sea_orm_migration::schema::string_len_null; + +#[derive(DeriveIden)] +enum Users { + Table, + RegisteredVia, +} diff --git a/pixles-api/model/src/user.rs b/pixles-api/model/src/user.rs index 0e656df..386e5dd 100644 --- a/pixles-api/model/src/user.rs +++ b/pixles-api/model/src/user.rs @@ -51,6 +51,9 @@ pub struct User { /// Timestamp of when the user was soft-deleted. If None, the user is active. pub deleted_at: Option>, + + /// How the account was created (e.g. 'invitation'); None for seeded/legacy/unknown. + pub registered_via: Option, } impl From for User { @@ -75,6 +78,7 @@ impl From for User { created_at, modified_at, deleted_at, + registered_via, } = model; User { @@ -88,6 +92,7 @@ impl From for User { created_at, modified_at, deleted_at, + registered_via, } } } diff --git a/pixles-api/service/src/user/mod.rs b/pixles-api/service/src/user/mod.rs index dc30122..caa1fb2 100644 --- a/pixles-api/service/src/user/mod.rs +++ b/pixles-api/service/src/user/mod.rs @@ -13,6 +13,7 @@ pub struct CreateUserArgs { pub name: String, pub email: String, pub password_hash: String, + pub registered_via: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/pixles-api/service/src/user/mutation.rs b/pixles-api/service/src/user/mutation.rs index 4e7fbbb..810dcda 100644 --- a/pixles-api/service/src/user/mutation.rs +++ b/pixles-api/service/src/user/mutation.rs @@ -12,6 +12,7 @@ impl Mutation { name, email, password_hash, + registered_via, } = user; user::ActiveModel { @@ -19,6 +20,7 @@ impl Mutation { name: Set(name), email: Set(email), password_hash: Set(password_hash), + registered_via: Set(registered_via), ..Default::default() } .insert(db) From 23bc2892c6788a02496d8e6ef757e1803b0f7bb0 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:59:25 -0500 Subject: [PATCH 05/17] fix(auth): handle unique constraint violation in user registration Map sea_orm UniqueConstraintViolation errors from concurrent duplicate registrations to RegisterError::UserAlreadyExists (409 Conflict) instead of propagating them as unexpected 500 errors. --- pixles-api/auth/src/service/auth.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pixles-api/auth/src/service/auth.rs b/pixles-api/auth/src/service/auth.rs index aa1c333..0f3e534 100644 --- a/pixles-api/auth/src/service/auth.rs +++ b/pixles-api/auth/src/service/auth.rs @@ -71,7 +71,6 @@ impl AuthService { let password_hash = hash_password(password.expose_secret()) .map_err(|e| RegisterError::Unexpected(eyre::eyre!(e).into()))?; - // TODO: Handle unique constraint violation from DB if race condition occurs let user = UserService::Mutation::create_user( &self.conn, service::user::CreateUserArgs { @@ -83,7 +82,13 @@ impl AuthService { }, ) .await - .map_err(|e| RegisterError::Unexpected(e.into()))?; + .map_err(|e| { + if let Some(sea_orm::error::SqlErr::UniqueConstraintViolation(_)) = e.sql_err() { + RegisterError::UserAlreadyExists + } else { + RegisterError::Unexpected(e.into()) + } + })?; self.generate_token_pair(&user.id, session_manager) .await From 5f7a9568da4988dcedf2dcebec1fa912505b7b3b Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:20:36 -0500 Subject: [PATCH 06/17] drop email service and registration verification feature Remove EmailService placeholder, account_verified login gate, and all related TODOs/stubs. Password reset token generation is preserved but no longer attempts email delivery. --- pixles-api/auth/src/errors.rs | 6 ----- pixles-api/auth/src/lib.rs | 11 +-------- pixles-api/auth/src/models/responses.rs | 6 ----- pixles-api/auth/src/routes/password.rs | 2 +- pixles-api/auth/src/service/auth.rs | 8 ------- pixles-api/auth/src/service/email.rs | 30 ------------------------- pixles-api/auth/src/service/mod.rs | 2 -- pixles-api/auth/src/service/password.rs | 20 ++++------------- pixles-api/auth/src/state.rs | 5 +---- pixles-api/auth/tests/common/mod.rs | 13 ++++++++--- pixles-api/entity/src/user.rs | 3 --- 11 files changed, 17 insertions(+), 89 deletions(-) delete mode 100644 pixles-api/auth/src/service/email.rs diff --git a/pixles-api/auth/src/errors.rs b/pixles-api/auth/src/errors.rs index 712236c..5d5e87d 100644 --- a/pixles-api/auth/src/errors.rs +++ b/pixles-api/auth/src/errors.rs @@ -58,7 +58,6 @@ impl Writer for RegisterError { #[derive(Debug)] pub enum LoginError { InvalidCredentials, - AccountNotVerified, Unexpected(InternalServerError), } @@ -66,7 +65,6 @@ impl std::fmt::Display for LoginError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidCredentials => write!(f, "User not found or invalid credentials"), - Self::AccountNotVerified => write!(f, "Account not verified"), Self::Unexpected(e) => write!(f, "Internal server error: {}", e), } } @@ -92,10 +90,6 @@ impl Writer for LoginError { res.status_code(StatusCode::UNAUTHORIZED); res.render(Text::Plain("Invalid credentials")); } - LoginError::AccountNotVerified => { - res.status_code(StatusCode::FORBIDDEN); - res.render(Text::Plain("Account not verified")); - } LoginError::Unexpected(e) => { e.write(req, depot, res).await; } diff --git a/pixles-api/auth/src/lib.rs b/pixles-api/auth/src/lib.rs index b3a49e6..665b66f 100644 --- a/pixles-api/auth/src/lib.rs +++ b/pixles-api/auth/src/lib.rs @@ -38,9 +38,6 @@ pub async fn get_router>( .await .map_err(|e| e.0)?; - // Initialize Email Service - let email_service = service::EmailService::new(); - // Initialize Passkey Service let rp_id = config.domain.clone(); let rp_origin = webauthn_rs::prelude::Url::parse(&format!("https://{}", config.domain))?; @@ -63,13 +60,7 @@ pub async fn get_router>( .allow_headers("*") .into_handler(); - let state = AppState::new( - conn, - config, - session_manager, - email_service, - passkey_service, - ); + let state = AppState::new(conn, config, session_manager, passkey_service); let router = routes::get_router(state); diff --git a/pixles-api/auth/src/models/responses.rs b/pixles-api/auth/src/models/responses.rs index 59f2b47..50368d6 100644 --- a/pixles-api/auth/src/models/responses.rs +++ b/pixles-api/auth/src/models/responses.rs @@ -114,7 +114,6 @@ pub enum LoginResponses { Success(TokenResponse), BadRequest, InvalidCredentials, - AccountNotVerified, InternalServerError(InternalServerError), } @@ -131,7 +130,6 @@ impl From for LoginResponses { fn from(e: LoginError) -> Self { match e { LoginError::InvalidCredentials => Self::InvalidCredentials, - LoginError::AccountNotVerified => Self::AccountNotVerified, LoginError::Unexpected(e) => Self::InternalServerError(e), } } @@ -153,10 +151,6 @@ impl Writer for LoginResponses { res.status_code(StatusCode::UNAUTHORIZED); res.render(Json(ApiError::new("Invalid credentials"))); } - Self::AccountNotVerified => { - res.status_code(StatusCode::FORBIDDEN); - res.render(Json(ApiError::new("Account not verified"))); - } Self::InternalServerError(e) => { e.write(_req, _depot, res).await; return; diff --git a/pixles-api/auth/src/routes/password.rs b/pixles-api/auth/src/routes/password.rs index 7e6350d..db46994 100644 --- a/pixles-api/auth/src/routes/password.rs +++ b/pixles-api/auth/src/routes/password.rs @@ -19,7 +19,7 @@ pub async fn reset_password_request( if let Err(e) = state .password_service - .request_reset(&state.conn, &state.email_service, &email) + .request_reset(&state.conn, &email) .await { return ResetPasswordRequestResponses::InternalServerError(eyre::eyre!(e).into()); diff --git a/pixles-api/auth/src/service/auth.rs b/pixles-api/auth/src/service/auth.rs index 8507022..185fcc4 100644 --- a/pixles-api/auth/src/service/auth.rs +++ b/pixles-api/auth/src/service/auth.rs @@ -102,14 +102,6 @@ impl AuthService { if let Some(user) = user { tracing::info!("User found: {}", user.id); - match UserService::Query::get_account_verification_status_by_id(&self.conn, &user.id) - .await? - { - Some(true) => {} // User is verified - Some(false) => return Err(LoginError::AccountNotVerified), // User is not verified - None => return Err(LoginError::Unexpected(eyre::eyre!("User not found").into())), // User not found - } - let password_hash = UserService::Query::get_password_hash_by_id(&self.conn, &user.id) .await? .ok_or(LoginError::Unexpected(eyre::eyre!("User not found").into()))?; diff --git a/pixles-api/auth/src/service/email.rs b/pixles-api/auth/src/service/email.rs deleted file mode 100644 index 305dc23..0000000 --- a/pixles-api/auth/src/service/email.rs +++ /dev/null @@ -1,30 +0,0 @@ -#[derive(Clone)] -pub struct EmailService; - -impl EmailService { - pub fn new() -> Self { - Self - } - - // TODO: Implement actual email service - pub async fn send_password_reset_email( - &self, - email: &str, - token: &str, - ) -> Result<(), eyre::Report> { - // Mock implementation - tracing::info!( - "Mock Email Service: Sending password reset email to {}. Token: {}", - email, - token - ); - // In real implementation, generate a link like https://pixles.com/reset-password?token=... - Ok(()) - } -} - -impl Default for EmailService { - fn default() -> Self { - Self::new() - } -} diff --git a/pixles-api/auth/src/service/mod.rs b/pixles-api/auth/src/service/mod.rs index 3910e95..53cccf5 100644 --- a/pixles-api/auth/src/service/mod.rs +++ b/pixles-api/auth/src/service/mod.rs @@ -1,12 +1,10 @@ pub mod auth; -pub mod email; pub mod passkey; pub mod password; pub mod token; pub mod totp; pub use auth::AuthService; -pub use email::EmailService; pub use passkey::PasskeyService; pub use password::PasswordService; pub use token::TokenService; diff --git a/pixles-api/auth/src/service/password.rs b/pixles-api/auth/src/service/password.rs index 56297d8..52a53b3 100644 --- a/pixles-api/auth/src/service/password.rs +++ b/pixles-api/auth/src/service/password.rs @@ -1,4 +1,3 @@ -use super::email::EmailService; use chrono::{Duration, Utc}; use model::errors::InternalServerError; use sea_orm::DatabaseConnection; @@ -19,7 +18,6 @@ impl PasswordService { pub async fn request_reset( &self, conn: &DatabaseConnection, - email_service: &EmailService, email: &str, ) -> Result<(), InternalServerError> { let start = Instant::now(); @@ -34,20 +32,10 @@ impl PasswordService { let expires_at = Utc::now() + Duration::hours(1); // Update user with token - match UserService::Mutation::update_password_reset_token( - conn, &user.id, &token, expires_at, - ) - .await - { - Ok(_) => { - // Send email - email_service - .send_password_reset_email(&user.email, &token) - .await - .map_err(InternalServerError::from) - } - Err(e) => Err(InternalServerError::from(e)), - } + UserService::Mutation::update_password_reset_token(conn, &user.id, &token, expires_at) + .await + .map(|_| ()) + .map_err(InternalServerError::from) } Ok(None) => { // User not found - pretend success diff --git a/pixles-api/auth/src/state.rs b/pixles-api/auth/src/state.rs index c42179b..dd3fe2b 100644 --- a/pixles-api/auth/src/state.rs +++ b/pixles-api/auth/src/state.rs @@ -4,7 +4,7 @@ use environment::constants::TOTP_ISSUER; use sea_orm::DatabaseConnection; use crate::config::AuthConfig; -use crate::service::{AuthService, EmailService, PasskeyService, PasswordService, TotpService}; +use crate::service::{AuthService, PasskeyService, PasswordService, TotpService}; use crate::session::SessionManager; #[derive(Clone)] @@ -16,7 +16,6 @@ pub struct AppStateInner { pub conn: DatabaseConnection, pub config: AuthConfig, pub session_manager: SessionManager, - pub email_service: EmailService, pub auth_service: AuthService, pub password_service: PasswordService, pub totp_service: TotpService, @@ -28,7 +27,6 @@ impl AppState { conn: DatabaseConnection, config: AuthConfig, session_manager: SessionManager, - email_service: crate::service::EmailService, passkey_service: PasskeyService, ) -> Self { let auth_service = AuthService::new(conn.clone(), config.clone()); @@ -40,7 +38,6 @@ impl AppState { conn, config, session_manager, - email_service, auth_service, password_service, totp_service, diff --git a/pixles-api/auth/tests/common/mod.rs b/pixles-api/auth/tests/common/mod.rs index df8d283..01f1ac2 100644 --- a/pixles-api/auth/tests/common/mod.rs +++ b/pixles-api/auth/tests/common/mod.rs @@ -119,9 +119,16 @@ pub async fn setup() -> TestContext { .await .expect("Failed to create session manager"); - let email_service = auth::service::EmailService::new(); - - let app_state = AppState::new(db.clone(), config, session_manager, email_service); + let rp_origin = webauthn_rs::prelude::Url::parse("https://localhost").expect("valid URL"); + let webauthn = std::sync::Arc::new( + webauthn_rs::prelude::WebauthnBuilder::new("localhost", &rp_origin) + .expect("valid builder") + .build() + .expect("valid webauthn"), + ); + let passkey_service = auth::service::PasskeyService::new(db.clone(), webauthn); + + let app_state = AppState::new(db.clone(), config, session_manager, passkey_service); TestContext { post: postgres_container, diff --git a/pixles-api/entity/src/user.rs b/pixles-api/entity/src/user.rs index 9677da0..d9da0f6 100644 --- a/pixles-api/entity/src/user.rs +++ b/pixles-api/entity/src/user.rs @@ -55,9 +55,6 @@ pub struct Model { pub deleted_at: Option>, } -// TODO: Add in related columns: -// - verification_token - impl Model { pub fn profile_image_url(&self) -> Option { self.profile_image_url.clone() // TODO: Need to process this properly to ensure access for public From fe1eaebba53a3637642ff82ec1c46db77d10aa3c Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:19:34 -0500 Subject: [PATCH 07/17] feat(auth): add rate limiting and account lockout to auth endpoints - Add Redis/in-memory rate limit storage with fixed-window counter - Apply per-IP rate limiting: login (10/min), register (10/min) - Apply per-email + per-IP rate limiting: password-reset-request (5/min) - Return 429 with Retry-After header when limit exceeded - Check failed_login_attempts and lock account after 10 failures (423) - Add MAX_FAILED_LOGIN_ATTEMPTS and RATE_LIMIT_* constants --- pixles-api/auth/src/errors.rs | 16 ++++++ pixles-api/auth/src/models/responses.rs | 35 +++++++++++- pixles-api/auth/src/routes/auth.rs | 64 ++++++++++++++++++++- pixles-api/auth/src/routes/password.rs | 63 +++++++++++++++++++- pixles-api/auth/src/service/auth.rs | 10 ++++ pixles-api/auth/src/session/mod.rs | 13 ++++- pixles-api/auth/src/session/storage.rs | 76 ++++++++++++++++++++++++- pixles-api/environment/src/constants.rs | 14 +++++ pixles-api/service/src/user/query.rs | 14 +++++ 9 files changed, 298 insertions(+), 7 deletions(-) diff --git a/pixles-api/auth/src/errors.rs b/pixles-api/auth/src/errors.rs index 5d5e87d..6acebe5 100644 --- a/pixles-api/auth/src/errors.rs +++ b/pixles-api/auth/src/errors.rs @@ -58,6 +58,8 @@ impl Writer for RegisterError { #[derive(Debug)] pub enum LoginError { InvalidCredentials, + AccountLocked, + RateLimited(u64), Unexpected(InternalServerError), } @@ -65,6 +67,8 @@ impl std::fmt::Display for LoginError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidCredentials => write!(f, "User not found or invalid credentials"), + Self::AccountLocked => write!(f, "Account temporarily locked due to too many failed login attempts"), + Self::RateLimited(retry_after) => write!(f, "Too many requests. Retry after {} seconds", retry_after), Self::Unexpected(e) => write!(f, "Internal server error: {}", e), } } @@ -90,6 +94,18 @@ impl Writer for LoginError { res.status_code(StatusCode::UNAUTHORIZED); res.render(Text::Plain("Invalid credentials")); } + LoginError::AccountLocked => { + res.status_code(StatusCode::LOCKED); + res.render(Text::Plain("Account locked due to too many failed login attempts")); + } + LoginError::RateLimited(retry_after) => { + res.status_code(StatusCode::TOO_MANY_REQUESTS); + res.headers_mut().insert( + salvo::http::header::RETRY_AFTER, + retry_after.to_string().parse().unwrap(), + ); + res.render(Text::Plain("Too many requests")); + } LoginError::Unexpected(e) => { e.write(req, depot, res).await; } diff --git a/pixles-api/auth/src/models/responses.rs b/pixles-api/auth/src/models/responses.rs index 94525a0..84db984 100644 --- a/pixles-api/auth/src/models/responses.rs +++ b/pixles-api/auth/src/models/responses.rs @@ -42,6 +42,7 @@ pub enum RegisterUserResponses { Success(TokenResponse), BadRequest(BadRegisterUserRequestError), UserAlreadyExists, + RateLimited(u64), InternalServerError(InternalServerError), } @@ -80,6 +81,14 @@ impl Writer for RegisterUserResponses { res.status_code(StatusCode::CONFLICT); res.render(Json(ApiError::new("User already exists"))); } + Self::RateLimited(retry_after) => { + res.status_code(StatusCode::TOO_MANY_REQUESTS); + res.headers_mut().insert( + salvo::http::header::RETRY_AFTER, + retry_after.to_string().parse().unwrap(), + ); + res.render(Json(ApiError::new("Too many requests"))); + } Self::InternalServerError(e) => e.write(req, depot, res).await, } } @@ -115,6 +124,8 @@ pub enum LoginResponses { Success(TokenResponse), BadRequest, InvalidCredentials, + AccountLocked, + RateLimited(u64), InternalServerError(InternalServerError), } @@ -131,6 +142,8 @@ impl From for LoginResponses { fn from(e: LoginError) -> Self { match e { LoginError::InvalidCredentials => Self::InvalidCredentials, + LoginError::AccountLocked => Self::AccountLocked, + LoginError::RateLimited(r) => Self::RateLimited(r), LoginError::Unexpected(e) => Self::InternalServerError(e), } } @@ -152,9 +165,20 @@ impl Writer for LoginResponses { res.status_code(StatusCode::UNAUTHORIZED); res.render(Json(ApiError::new("Invalid credentials"))); } + Self::AccountLocked => { + res.status_code(StatusCode::LOCKED); + res.render(Json(ApiError::new("Account locked due to too many failed login attempts"))); + } + Self::RateLimited(retry_after) => { + res.status_code(StatusCode::TOO_MANY_REQUESTS); + res.headers_mut().insert( + salvo::http::header::RETRY_AFTER, + retry_after.to_string().parse().unwrap(), + ); + res.render(Json(ApiError::new("Too many requests"))); + } Self::InternalServerError(e) => { e.write(_req, _depot, res).await; - return; } } } @@ -294,6 +318,7 @@ impl EndpointOutRegister for ValidateTokenResponses { pub enum ResetPasswordRequestResponses { Success, BadRequest, + RateLimited(u64), InternalServerError(InternalServerError), } @@ -309,6 +334,14 @@ impl Writer for ResetPasswordRequestResponses { res.status_code(StatusCode::BAD_REQUEST); res.render(Json(ApiError::new("Invalid request"))); } + Self::RateLimited(retry_after) => { + res.status_code(StatusCode::TOO_MANY_REQUESTS); + res.headers_mut().insert( + salvo::http::header::RETRY_AFTER, + retry_after.to_string().parse().unwrap(), + ); + res.render(Json(ApiError::new("Too many requests"))); + } Self::InternalServerError(e) => e.write(req, depot, res).await, } } diff --git a/pixles-api/auth/src/routes/auth.rs b/pixles-api/auth/src/routes/auth.rs index 0ee0349..5020d5c 100644 --- a/pixles-api/auth/src/routes/auth.rs +++ b/pixles-api/auth/src/routes/auth.rs @@ -1,3 +1,7 @@ +use environment::constants::{ + RATE_LIMIT_LOGIN_MAX, RATE_LIMIT_LOGIN_WINDOW_SECS, RATE_LIMIT_REGISTER_MAX, + RATE_LIMIT_REGISTER_WINDOW_SECS, +}; use salvo::oapi::extract::JsonBody; use salvo::prelude::*; use secrecy::ExposeSecret; @@ -12,15 +16,48 @@ use crate::models::responses::{ use crate::state::AppState; use crate::utils::headers::get_token_from_headers; +fn get_client_ip(req: &Request) -> String { + req.headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_string()) + .or_else(|| { + req.headers() + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + /// Register a new user #[endpoint(operation_id = "register_user", tags("auth"))] pub async fn register_user( + req: &mut Request, depot: &mut Depot, body: JsonBody, ) -> RegisterUserResponses { let state = depot.obtain::().unwrap(); - let request = body.into_inner(); + // Per-IP rate limit + let ip = get_client_ip(req); + let rl_key = format!("register:{}", ip); + match state + .session_manager + .check_rate_limit(&rl_key, RATE_LIMIT_REGISTER_MAX, RATE_LIMIT_REGISTER_WINDOW_SECS) + .await + { + Ok(result) if result.count > RATE_LIMIT_REGISTER_MAX => { + return RegisterUserResponses::RateLimited(result.window_ttl_secs); + } + Err(e) => { + tracing::warn!("Rate limit check failed: {}", e); + } + _ => {} + } + + let request = body.into_inner(); state .auth_service .register_user(&state.session_manager, request) @@ -30,10 +67,31 @@ pub async fn register_user( /// Login a user #[endpoint(operation_id = "login_user", tags("auth"))] -pub async fn login_user(depot: &mut Depot, body: JsonBody) -> LoginResponses { +pub async fn login_user( + req: &mut Request, + depot: &mut Depot, + body: JsonBody, +) -> LoginResponses { let state = depot.obtain::().unwrap(); - let LoginRequest { email, password } = body.into_inner(); + // Per-IP rate limit + let ip = get_client_ip(req); + let rl_key = format!("login:{}", ip); + match state + .session_manager + .check_rate_limit(&rl_key, RATE_LIMIT_LOGIN_MAX, RATE_LIMIT_LOGIN_WINDOW_SECS) + .await + { + Ok(result) if result.count > RATE_LIMIT_LOGIN_MAX => { + return LoginResponses::RateLimited(result.window_ttl_secs); + } + Err(e) => { + tracing::warn!("Rate limit check failed: {}", e); + } + _ => {} + } + + let LoginRequest { email, password } = body.into_inner(); state .auth_service .authenticate_user(&state.session_manager, &email, &password) diff --git a/pixles-api/auth/src/routes/password.rs b/pixles-api/auth/src/routes/password.rs index db46994..9863577 100644 --- a/pixles-api/auth/src/routes/password.rs +++ b/pixles-api/auth/src/routes/password.rs @@ -1,3 +1,6 @@ +use environment::constants::{ + RATE_LIMIT_PASSWORD_RESET_MAX, RATE_LIMIT_PASSWORD_RESET_WINDOW_SECS, +}; use salvo::oapi::extract::JsonBody; use salvo::prelude::*; use service::user as UserService; @@ -8,14 +11,72 @@ use crate::models::{ResetPasswordPayload, ResetPasswordRequestPayload}; use crate::state::AppState; use crate::utils::hash::hash_password; +fn get_client_ip(req: &Request) -> String { + req.headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_string()) + .or_else(|| { + req.headers() + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + /// Request password reset #[endpoint(operation_id = "reset_password_request", tags("auth"))] pub async fn reset_password_request( + req: &mut Request, depot: &mut Depot, body: JsonBody, ) -> ResetPasswordRequestResponses { let state = depot.obtain::().unwrap(); - let email = body.into_inner().email; + let payload = body.into_inner(); + let email = payload.email; + + // Per-email rate limit + let rl_key = format!("pwd_reset:{}", email.to_lowercase()); + match state + .session_manager + .check_rate_limit( + &rl_key, + RATE_LIMIT_PASSWORD_RESET_MAX, + RATE_LIMIT_PASSWORD_RESET_WINDOW_SECS, + ) + .await + { + Ok(result) if result.count > RATE_LIMIT_PASSWORD_RESET_MAX => { + return ResetPasswordRequestResponses::RateLimited(result.window_ttl_secs); + } + Err(e) => { + tracing::warn!("Rate limit check failed: {}", e); + } + _ => {} + } + + // Per-IP rate limit as secondary guard + let ip = get_client_ip(req); + let ip_rl_key = format!("pwd_reset_ip:{}", ip); + match state + .session_manager + .check_rate_limit( + &ip_rl_key, + RATE_LIMIT_PASSWORD_RESET_MAX, + RATE_LIMIT_PASSWORD_RESET_WINDOW_SECS, + ) + .await + { + Ok(result) if result.count > RATE_LIMIT_PASSWORD_RESET_MAX => { + return ResetPasswordRequestResponses::RateLimited(result.window_ttl_secs); + } + Err(e) => { + tracing::warn!("Rate limit check failed: {}", e); + } + _ => {} + } if let Err(e) = state .password_service diff --git a/pixles-api/auth/src/service/auth.rs b/pixles-api/auth/src/service/auth.rs index f43e9ee..13e771e 100644 --- a/pixles-api/auth/src/service/auth.rs +++ b/pixles-api/auth/src/service/auth.rs @@ -1,4 +1,5 @@ // use crate::models::{CreateUser, UpdateUser}; +use environment::constants::MAX_FAILED_LOGIN_ATTEMPTS; use sea_orm::DatabaseConnection; use service::user as UserService; @@ -108,6 +109,15 @@ impl AuthService { if let Some(user) = user { tracing::info!("User found: {}", user.id); + // Check account lockout + let failed_attempts = UserService::Query::get_failed_login_attempts(&self.conn, &user.id) + .await + .map_err(|e| LoginError::Unexpected(e.into()))? + .unwrap_or(0); + if failed_attempts >= MAX_FAILED_LOGIN_ATTEMPTS { + return Err(LoginError::AccountLocked); + } + let password_hash = UserService::Query::get_password_hash_by_id(&self.conn, &user.id) .await? .ok_or(LoginError::Unexpected(eyre::eyre!("User not found").into()))?; diff --git a/pixles-api/auth/src/session/mod.rs b/pixles-api/auth/src/session/mod.rs index df51ce7..6476303 100644 --- a/pixles-api/auth/src/session/mod.rs +++ b/pixles-api/auth/src/session/mod.rs @@ -7,7 +7,7 @@ use std::time::Duration; use model::errors::InternalServerError; pub mod storage; -pub use self::storage::{InMemorySessionStorage, RedisSessionStorage, SessionStorage}; +pub use self::storage::{InMemorySessionStorage, RateLimitResult, RedisSessionStorage, SessionStorage}; #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Session { @@ -137,6 +137,17 @@ impl SessionManager { self.storage.clear_mfa_attempts(mfa_token_jti).await } + // Rate limiting + pub async fn check_rate_limit( + &self, + key: &str, + max_requests: i64, + window_secs: u64, + ) -> Result { + let result = self.storage.increment_rate_limit(key, window_secs).await?; + Ok(result) + } + // Ephemeral data (Passkeys, etc) pub async fn save_temp_data( &self, diff --git a/pixles-api/auth/src/session/storage.rs b/pixles-api/auth/src/session/storage.rs index dddcaac..886c23a 100644 --- a/pixles-api/auth/src/session/storage.rs +++ b/pixles-api/auth/src/session/storage.rs @@ -4,11 +4,17 @@ use bb8_redis::bb8::Pool; use redis::AsyncCommands; use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use model::errors::InternalServerError; +/// Result of a rate limit check: count of requests in current window and remaining TTL (secs). +pub struct RateLimitResult { + pub count: i64, + pub window_ttl_secs: u64, +} + #[async_trait::async_trait] pub trait SessionStorage: Send + Sync { /// Saves a session to storage with the given session ID and data. @@ -115,6 +121,14 @@ pub trait SessionStorage: Send + Sync { /// Returns `Ok(())` on success, or an `InternalServerError` if the operation fails. async fn clear_mfa_attempts(&self, mfa_token_jti: &str) -> Result<(), InternalServerError>; + /// Increments a rate-limit counter for `key` and returns current count + window TTL. + /// Uses a fixed window of `window_secs`. On first request in the window, sets expiry. + async fn increment_rate_limit( + &self, + key: &str, + window_secs: u64, + ) -> Result; + // Ephemeral/Temporary data storage (for flows like Passkey) async fn save_temp_data( &self, @@ -288,6 +302,37 @@ impl SessionStorage for RedisSessionStorage { Ok(()) } + async fn increment_rate_limit( + &self, + key: &str, + window_secs: u64, + ) -> Result { + let mut con = self + .client + .get() + .await + .map_err(|e| InternalServerError::from(eyre::eyre!(e)))?; + let redis_key = format!("pixles:rate_limit:{}", key); + let count: i64 = con + .incr(&redis_key, 1) + .await + .map_err(|e| InternalServerError::from(eyre::eyre!(e)))?; + if count == 1 { + let _: () = con + .expire(&redis_key, window_secs as i64) + .await + .map_err(|e| InternalServerError::from(eyre::eyre!(e)))?; + } + let ttl: i64 = con + .ttl(&redis_key) + .await + .map_err(|e| InternalServerError::from(eyre::eyre!(e)))?; + Ok(RateLimitResult { + count, + window_ttl_secs: ttl.max(0) as u64, + }) + } + async fn save_temp_data( &self, key: &str, @@ -336,11 +381,15 @@ impl SessionStorage for RedisSessionStorage { } } +/// (count, reset_at_unix_secs) +type RateLimitEntry = (i64, u64); + pub struct InMemorySessionStorage { sessions: Arc>>, user_sessions: Arc>>>, mfa_attempts: Arc>>, temp_data: Arc>>, + rate_limits: Arc>>, } impl Default for InMemorySessionStorage { @@ -357,6 +406,7 @@ impl InMemorySessionStorage { user_sessions: Arc::new(RwLock::new(HashMap::new())), mfa_attempts: Arc::new(RwLock::new(HashMap::new())), temp_data: Arc::new(RwLock::new(HashMap::new())), + rate_limits: Arc::new(RwLock::new(HashMap::new())), } } } @@ -435,6 +485,30 @@ impl SessionStorage for InMemorySessionStorage { Ok(()) } + async fn increment_rate_limit( + &self, + key: &str, + window_secs: u64, + ) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut map = self.rate_limits.write().await; + let entry = map.entry(key.to_string()).or_insert((0, now + window_secs)); + if now >= entry.1 { + // Window expired — reset + *entry = (1, now + window_secs); + } else { + entry.0 += 1; + } + let (count, reset_at) = *entry; + Ok(RateLimitResult { + count, + window_ttl_secs: reset_at.saturating_sub(now), + }) + } + async fn save_temp_data( &self, key: &str, diff --git a/pixles-api/environment/src/constants.rs b/pixles-api/environment/src/constants.rs index 78e95af..176d520 100644 --- a/pixles-api/environment/src/constants.rs +++ b/pixles-api/environment/src/constants.rs @@ -6,6 +6,20 @@ pub const REFRESH_TOKEN_EXPIRY: u64 = 60 * 60 * 24 * 7; // 7 days pub const TOTP_ISSUER: &str = "Pixles"; #[cfg(feature = "auth")] pub const MAX_PASSKEYS_PER_USER: usize = 10; +#[cfg(feature = "auth")] +pub const MAX_FAILED_LOGIN_ATTEMPTS: i32 = 10; +#[cfg(feature = "auth")] +pub const RATE_LIMIT_LOGIN_MAX: i64 = 10; +#[cfg(feature = "auth")] +pub const RATE_LIMIT_LOGIN_WINDOW_SECS: u64 = 60; +#[cfg(feature = "auth")] +pub const RATE_LIMIT_REGISTER_MAX: i64 = 10; +#[cfg(feature = "auth")] +pub const RATE_LIMIT_REGISTER_WINDOW_SECS: u64 = 60; +#[cfg(feature = "auth")] +pub const RATE_LIMIT_PASSWORD_RESET_MAX: i64 = 5; +#[cfg(feature = "auth")] +pub const RATE_LIMIT_PASSWORD_RESET_WINDOW_SECS: u64 = 60; #[cfg(feature = "upload")] pub const MAX_FILE_SIZE: usize = 32 * 1024 * 1024 * 1024; // 32 GiB diff --git a/pixles-api/service/src/user/query.rs b/pixles-api/service/src/user/query.rs index 051172c..09dfd8a 100644 --- a/pixles-api/service/src/user/query.rs +++ b/pixles-api/service/src/user/query.rs @@ -121,4 +121,18 @@ impl Query { .one(db) .await } + + /// Returns failed login attempts count for user + #[cfg(feature = "auth")] + pub async fn get_failed_login_attempts( + db: &DbConn, + id: &str, + ) -> Result, DbErr> { + let user = User::find_by_id(id) + .select_only() + .column(user::Column::FailedLoginAttempts) + .one(db) + .await?; + Ok(user.map(|u| u.failed_login_attempts)) + } } From d0099e73288c821f2d1a1df5f9c680a6db52a029 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:24:52 -0500 Subject: [PATCH 08/17] feat(auth): implement integration test suite using salvo test infrastructure - Rewrite all integration tests from axum to salvo TestClient API - Fix common/mod.rs setup to use proper Valkey containers - Add get_router_with_state() to lib.rs for test convenience - Tests cover: register, login, token lifecycle, profile, password reset - Fix missing Scope import in token service unit tests - Add get_password_reset_token_by_email service query for test use --- pixles-api/auth/src/lib.rs | 6 + pixles-api/auth/src/service/token.rs | 2 +- pixles-api/auth/tests/api/login.rs | 159 +++++-------- pixles-api/auth/tests/api/password.rs | 175 +++++++------- pixles-api/auth/tests/api/profile.rs | 178 ++++----------- pixles-api/auth/tests/api/registration.rs | 160 ++++++------- pixles-api/auth/tests/api/tokens.rs | 252 ++++++++++----------- pixles-api/auth/tests/common/mod.rs | 53 ++--- pixles-api/auth/tests/integration_tests.rs | 1 - pixles-api/service/src/user/query.rs | 15 ++ 10 files changed, 412 insertions(+), 589 deletions(-) diff --git a/pixles-api/auth/src/lib.rs b/pixles-api/auth/src/lib.rs index 665b66f..fe02d4c 100644 --- a/pixles-api/auth/src/lib.rs +++ b/pixles-api/auth/src/lib.rs @@ -24,6 +24,12 @@ pub mod utils; #[cfg(feature = "server")] pub mod validation; +/// Build a router from an existing `AppState` (useful for testing). +#[cfg(feature = "server")] +pub fn get_router_with_state(state: AppState) -> Router { + routes::get_router(state) +} + #[cfg(feature = "server")] pub async fn get_router>( conn: DatabaseConnection, diff --git a/pixles-api/auth/src/service/token.rs b/pixles-api/auth/src/service/token.rs index 271e318..0137bbe 100644 --- a/pixles-api/auth/src/service/token.rs +++ b/pixles-api/auth/src/service/token.rs @@ -39,7 +39,7 @@ impl TokenService { #[cfg(test)] mod tests { use super::*; - use crate::claims::Claims; + use crate::claims::{Claims, Scope}; use base64::Engine; use jsonwebtoken::DecodingKey; use ring::signature::Ed25519KeyPair; diff --git a/pixles-api/auth/tests/api/login.rs b/pixles-api/auth/tests/api/login.rs index 1a9f93a..83a8d7b 100644 --- a/pixles-api/auth/tests/api/login.rs +++ b/pixles-api/auth/tests/api/login.rs @@ -1,121 +1,72 @@ -use crate::common::setup; - -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; +use crate::common::{build_service, setup}; +use auth::models::responses::TokenResponse; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; use secrecy::ExposeSecret; -use tower::ServiceExt; + +async fn register_test_user(service: &salvo::Service) -> TokenResponse { + let mut res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "testuser", + "name": "Test User", + "email": "test@example.com", + "password": "password123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse tokens") +} #[tokio::test] async fn login_user_success() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); - - // Register user - let _ = app - .clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "testuser", - "name": "Test User", - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let ctx = setup().await; + let service = build_service(&ctx); + register_test_user(&service).await; - // Login - let response = app - .oneshot( - Request::builder() - .uri("/login") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let mut res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "test@example.com", + "password": "password123" + })) + .send(&service) + .await; - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(res.status_code, Some(StatusCode::OK)); - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let tokens: auth::models::responses::TokenResponse = - serde_json::from_slice(&body_bytes).unwrap(); + let tokens: TokenResponse = res.take_json().await.expect("Failed to parse token response"); assert!(!tokens.access_token.expose_secret().is_empty()); assert!(!tokens.refresh_token.expose_secret().is_empty()); } #[tokio::test] async fn login_user_wrong_password() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); + let ctx = setup().await; + let service = build_service(&ctx); + register_test_user(&service).await; - // Register user - let _ = app - .clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "testuser", - "name": "Test User", - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "test@example.com", + "password": "wrongpassword" + })) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} + +#[tokio::test] +async fn login_nonexistent_user() { + let ctx = setup().await; + let service = build_service(&ctx); - // Login wrong password - let response = app - .oneshot( - Request::builder() - .uri("/login") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "email": "test@example.com", - "password": "wrongpassword" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "nobody@example.com", + "password": "password123" + })) + .send(&service) + .await; - // Expect 401 Unauthorized - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); } diff --git a/pixles-api/auth/tests/api/password.rs b/pixles-api/auth/tests/api/password.rs index a29a1ad..7884f4f 100644 --- a/pixles-api/auth/tests/api/password.rs +++ b/pixles-api/auth/tests/api/password.rs @@ -1,106 +1,95 @@ -use crate::common::setup; - -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; -use tower::ServiceExt; +use crate::common::{build_service, setup}; +use salvo::http::StatusCode; +use salvo::test::TestClient; #[tokio::test] async fn password_reset_flow() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); - - // Register - let _ = app.clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&serde_json::json!({ - "username": "resetuser", "name": "Reset User", "email": "reset@example.com", "password": "oldpassword" - })).unwrap())) - .unwrap(), - ) - .await.unwrap(); + let ctx = setup().await; + let service = build_service(&ctx); - // 1. Request Password Reset - let response = app - .clone() - .oneshot( - Request::builder() - .uri("/password-reset-request") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "email": "reset@example.com" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + // Register user + let _ = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "resetuser", + "name": "Reset User", + "email": "reset@example.com", + "password": "oldpassword123" + })) + .send(&service) + .await; - assert_eq!(response.status(), StatusCode::OK); + // Request password reset + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "reset@example.com"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); // Retrieve token from DB - // We need to use `pixles_api_service` which might not be exposed as helper in `context` but we have `context.db`. - // I need `pixles_api_entity` to query. `pixles-api-auth` depends on it. - // I can use `pixles_api_service::UserService`. - let user = service::user::Query::find_user_by_email(&context.db, "reset@example.com") - .await - .expect("Failed to query user") - .expect("User should exist"); + let reset_token = service::user::Query::get_password_reset_token_by_email( + &ctx.db, + "reset@example.com", + ) + .await + .expect("DB query failed") + .flatten() + .expect("Should have reset token"); + + // Reset password + let res = TestClient::post("http://localhost/password-reset") + .json(&serde_json::json!({ + "token": reset_token, + "new_password": "newpassword456" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // Login with new password + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "reset@example.com", + "password": "newpassword456" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); - let reset_token = user.password_reset_token.expect("Should have reset token"); + // Old password should no longer work + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "reset@example.com", + "password": "oldpassword123" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} - // 2. Reset Password - let response = app - .clone() - .oneshot( - Request::builder() - .uri("/password-reset") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "token": reset_token, - "new_password": "newpassword123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); +#[tokio::test] +async fn password_reset_request_nonexistent_email() { + let ctx = setup().await; + let service = build_service(&ctx); - assert_eq!(response.status(), StatusCode::OK); + // Should still return OK (to avoid user enumeration) + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "nobody@example.com"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); +} - // 3. Login with New Password - let response = app - .clone() - .oneshot( - Request::builder() - .uri("/login") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "email": "reset@example.com", - "password": "newpassword123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); +#[tokio::test] +async fn password_reset_invalid_token() { + let ctx = setup().await; + let service = build_service(&ctx); - assert_eq!(response.status(), StatusCode::OK); + let res = TestClient::post("http://localhost/password-reset") + .json(&serde_json::json!({ + "token": "invalid-token-xyz", + "new_password": "newpassword456" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::BAD_REQUEST)); } diff --git a/pixles-api/auth/tests/api/profile.rs b/pixles-api/auth/tests/api/profile.rs index 593c062..753b62a 100644 --- a/pixles-api/auth/tests/api/profile.rs +++ b/pixles-api/auth/tests/api/profile.rs @@ -1,151 +1,73 @@ -use crate::common::setup; +use crate::common::{build_service, setup}; +use auth::models::responses::TokenResponse; use auth::models::UserProfile; - -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; use secrecy::ExposeSecret; -use tower::ServiceExt; + +async fn register_and_get_token(service: &salvo::Service) -> TokenResponse { + let mut res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "testuser", + "name": "Test User", + "email": "test@example.com", + "password": "password123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse tokens") +} #[tokio::test] async fn get_user_profile_success() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); - - // Register - let _ = app - .clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "testuser", - "name": "Test User", - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); - - // Login - let response = app - .clone() - .oneshot( - Request::builder() - .uri("/login") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); - - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let tokens: auth::models::responses::TokenResponse = - serde_json::from_slice(&body_bytes).unwrap(); - let token = tokens.access_token; + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_get_token(&service).await; - // Get Profile - let response = app - .oneshot( - Request::builder() - .uri("/profile") - .method("GET") - .header("Authorization", format!("Bearer {}", token.expose_secret())) - .body(Body::empty()) - .unwrap(), + let mut res = TestClient::get("http://localhost/profile") + .add_header( + "Authorization", + format!("Bearer {}", tokens.access_token.expose_secret()), + true, ) - .await - .unwrap(); + .send(&service) + .await; - assert_eq!(response.status(), StatusCode::OK); - - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let profile: UserProfile = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(res.status_code, Some(StatusCode::OK)); + let profile: UserProfile = res.take_json().await.expect("Failed to parse profile"); assert_eq!(profile.username, "testuser"); assert_eq!(profile.email, "test@example.com"); } #[tokio::test] -async fn update_user_profile_success() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); +async fn get_profile_without_auth_fails() { + let ctx = setup().await; + let service = build_service(&ctx); - // Register & Login setup (simplified for brevity, should use helper if possible but inline is fine) - // Register - let _ = app.clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&serde_json::json!({ - "username": "testuser", "name": "Test User", "email": "test@example.com", "password": "password123" - })).unwrap())) - .unwrap(), - ) - .await.unwrap(); + let res = TestClient::get("http://localhost/profile").send(&service).await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} - // Login - let resp = app.clone().oneshot( - Request::builder().uri("/login").method("POST").header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&serde_json::json!({"email": "test@example.com", "password": "password123"})).unwrap())).unwrap() - ).await.unwrap(); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let tokens: auth::models::responses::TokenResponse = serde_json::from_slice(&body).unwrap(); - let token = tokens.access_token; +#[tokio::test] +async fn update_user_profile_success() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_get_token(&service).await; - // Update Profile - let response = app - .oneshot( - Request::builder() - .uri("/profile") - .method("POST") - .header("Authorization", format!("Bearer {}", token.expose_secret())) - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "newusername" - })) - .unwrap(), - )) - .unwrap(), + let res = TestClient::post("http://localhost/profile") + .add_header( + "Authorization", + format!("Bearer {}", tokens.access_token.expose_secret()), + true, ) - .await - .unwrap(); + .json(&serde_json::json!({"username": "newusername"})) + .send(&service) + .await; - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(res.status_code, Some(StatusCode::OK)); - // Verify DB - let user = service::user::Query::find_user_by_email(&context.db, "test@example.com") + let user = service::user::Query::find_user_by_email(&ctx.db, "test@example.com") .await .unwrap() .unwrap(); diff --git a/pixles-api/auth/tests/api/registration.rs b/pixles-api/auth/tests/api/registration.rs index bf4d43a..15c67f0 100644 --- a/pixles-api/auth/tests/api/registration.rs +++ b/pixles-api/auth/tests/api/registration.rs @@ -1,113 +1,89 @@ -use crate::common::setup; -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; +use crate::common::{build_service, setup}; +use auth::models::responses::TokenResponse; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; use secrecy::ExposeSecret; -use tower::ServiceExt; #[tokio::test] async fn register_user_success() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); + let ctx = setup().await; + let service = build_service(&ctx); - let response = app - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "testuser", - "name": "Test User", - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let mut res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "testuser", + "name": "Test User", + "email": "test@example.com", + "password": "password123" + })) + .send(&service) + .await; - assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(res.status_code, Some(StatusCode::CREATED)); - // Verify response body - let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let tokens: auth::models::responses::TokenResponse = - serde_json::from_slice(&body_bytes).unwrap(); + let tokens: TokenResponse = res.take_json().await.expect("Failed to parse token response"); assert!(!tokens.access_token.expose_secret().is_empty()); assert!(!tokens.refresh_token.expose_secret().is_empty()); - // Check DB for user - let user = service::user::Query::find_user_by_email(&context.db, "test@example.com") + let user = service::user::Query::find_user_by_email(&ctx.db, "test@example.com") .await - .expect("Failed to query user") + .expect("DB query failed") .expect("User should exist"); - assert_eq!(user.username, "testuser"); } #[tokio::test] async fn register_user_duplicate_email() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); + let ctx = setup().await; + let service = build_service(&ctx); - // First registration - let _ = app - .clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "testuser1", - "name": "Test User", - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let _ = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "testuser1", + "name": "Test User", + "email": "dupe@example.com", + "password": "password123" + })) + .send(&service) + .await; - // Second registration with same email - let response = app - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "username": "testuser2", - "name": "Test User 2", - "email": "test@example.com", - "password": "password123" - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); + let res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "testuser2", + "name": "Test User 2", + "email": "dupe@example.com", + "password": "password123" + })) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::CONFLICT)); +} + +#[tokio::test] +async fn register_user_duplicate_username() { + let ctx = setup().await; + let service = build_service(&ctx); + + let _ = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "sameuser", + "name": "Test User", + "email": "first@example.com", + "password": "password123" + })) + .send(&service) + .await; + + let res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "sameuser", + "name": "Test User 2", + "email": "second@example.com", + "password": "password123" + })) + .send(&service) + .await; - // Should fail (BadRequest or Conflict depends on implementation, likely BadRegisterUserRequestError which maps to ? 400 or 409) - // The implementation returns RegisterUserResponses::UserAlreadyExists which maps to 409 Conflict commonly, or 400. - // Let's check `errors.rs` or just assert generically. - // Actually `RegisterUserResponses` derive IntoResponse. - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(res.status_code, Some(StatusCode::CONFLICT)); } diff --git a/pixles-api/auth/tests/api/tokens.rs b/pixles-api/auth/tests/api/tokens.rs index 9694ae6..3062762 100644 --- a/pixles-api/auth/tests/api/tokens.rs +++ b/pixles-api/auth/tests/api/tokens.rs @@ -1,148 +1,128 @@ -use crate::common::setup; +use crate::common::{build_service, setup}; use auth::models::responses::TokenResponse; -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; use secrecy::ExposeSecret; -use tower::ServiceExt; + +async fn register_and_login(service: &salvo::Service) -> TokenResponse { + let _ = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "testuser", + "name": "Test User", + "email": "test@example.com", + "password": "password123" + })) + .send(service) + .await; + + let mut res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "test@example.com", + "password": "password123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse tokens") +} #[tokio::test] async fn token_lifecycle() { - let context = setup().await; - let app = auth::get_router(context.db.clone(), context.app_state.config.clone()) - .await - .expect("Failed to create router"); - let app: axum::Router = app.into(); - - // Register & Login to get tokens - let _ = app.clone() - .oneshot( - Request::builder() - .uri("/register") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&serde_json::json!({ - "username": "testuser", "name": "Test User", "email": "test@example.com", "password": "password123" - })).unwrap())) - .unwrap(), - ) - .await.unwrap(); - - let resp = app.clone().oneshot( - Request::builder().uri("/login").method("POST").header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&serde_json::json!({"email": "test@example.com", "password": "password123"})).unwrap())).unwrap() - ).await.unwrap(); - let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let tokens: TokenResponse = serde_json::from_slice(&body_bytes).unwrap(); - assert!(tokens.access_token.expose_secret().len() > 10); + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service).await; + + assert!(!tokens.access_token.expose_secret().is_empty()); assert!(!tokens.refresh_token.expose_secret().is_empty()); - // 1. Validate Token - let resp = app - .clone() - .oneshot( - Request::builder() - .uri("/validate") - .method("POST") - .header( - "Authorization", - format!("Bearer {}", tokens.access_token.expose_secret()), - ) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - // 2. Refresh Token - let resp = app - .clone() - .oneshot( - Request::builder() - .uri("/refresh") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "refresh_token": tokens.refresh_token.expose_secret() - })) - .unwrap(), - )) - .unwrap(), + // Validate token + let res = TestClient::post("http://localhost/validate") + .add_header( + "Authorization", + format!("Bearer {}", tokens.access_token.expose_secret()), + true, ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let new_tokens: TokenResponse = serde_json::from_slice(&body).unwrap(); + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // Refresh token + let mut res = TestClient::post("http://localhost/refresh") + .json(&serde_json::json!({ + "refresh_token": tokens.refresh_token.expose_secret() + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + let new_tokens: TokenResponse = res.take_json().await.expect("Failed to parse refreshed tokens"); assert!(!new_tokens.access_token.expose_secret().is_empty()); - assert!(!new_tokens.refresh_token.expose_secret().is_empty()); - - // 3. Logout - let resp = app - .clone() - .oneshot( - Request::builder() - .uri("/logout") - .method("POST") - .header( - "Authorization", - format!("Bearer {}", new_tokens.access_token.expose_secret()), - ) - .body(Body::empty()) - .unwrap(), + + // Logout + let res = TestClient::post("http://localhost/logout") + .add_header( + "Authorization", + format!("Bearer {}", new_tokens.access_token.expose_secret()), + true, ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - // 4. Validate should fail (conceptually, if logout revokes access token capability via session check or similar, - // IF validate checks session validity. `validate_token` endpoint implementation calls `auth_service.get_claims`. - // Wait, does `validate_token` check session in Redis? - // Looking at `src/routes/auth.rs`: `validate_token` calls `auth_service.get_claims(token)`. - // It doesn't seem to check session status in Redis, only signature validation. - // Logout revokes session in Redis. - // So `validate_token` might still succeed if it's stateless JWT only checking expiry/sig. - // However, `refresh_token` checks session. - // Let's verify `validate_token` behavior. If it succeeds, that's expected for JWT. - // Only `refresh` should strictly fail if session is revoked. - // Actually, integration test is to verify API behavior conform to implementation. - - // Let's check `auth_service.get_claims`. If it's stateless, logout won't affect it immediately until expiry. - // But `logout` revokes session. - // So `refresh` with old refresh token (or new one) should fail? - // Refresh token is tied to session. - - let resp = app - .clone() - .oneshot( - Request::builder() - .uri("/refresh") - .method("POST") - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&serde_json::json!({ - "refresh_token": new_tokens.refresh_token.expose_secret() - })) - .unwrap(), - )) - .unwrap(), + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // Refresh with old token should fail after logout (session revoked) + let res = TestClient::post("http://localhost/refresh") + .json(&serde_json::json!({ + "refresh_token": new_tokens.refresh_token.expose_secret() + })) + .send(&service) + .await; + assert_ne!(res.status_code, Some(StatusCode::OK)); +} + +#[tokio::test] +async fn validate_invalid_token() { + let ctx = setup().await; + let service = build_service(&ctx); + + let res = TestClient::post("http://localhost/validate") + .add_header("Authorization", "Bearer invalid.token.here", true) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} + +#[tokio::test] +async fn refresh_with_invalid_token() { + let ctx = setup().await; + let service = build_service(&ctx); + + let res = TestClient::post("http://localhost/refresh") + .json(&serde_json::json!({"refresh_token": "not.a.valid.token"})) + .send(&service) + .await; + assert_ne!(res.status_code, Some(StatusCode::OK)); +} + +#[tokio::test] +async fn get_devices_success() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service).await; + + let res = TestClient::get("http://localhost/devices") + .add_header( + "Authorization", + format!("Bearer {}", tokens.access_token.expose_secret()), + true, ) - .await - .unwrap(); - - // Should be 400 or 401. `RefreshTokenResponses::InvalidRefreshToken` or `InternalServerError`. - // Implementation: `get_session` returns `Ok(None)` -> InvalidRefreshToken. - // Which maps to BAD_REQUEST (usually) or UNAUTHORIZED. - // Let's check `RefreshTokenResponses` mapping in `models/responses.rs`. - // Likely BAD_REQUEST for "Session not found". - // I will assert it is NOT OK. - assert_ne!(resp.status(), StatusCode::OK); + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); +} + +#[tokio::test] +async fn logout_without_token_fails() { + let ctx = setup().await; + let service = build_service(&ctx); + + let res = TestClient::post("http://localhost/logout").send(&service).await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); } diff --git a/pixles-api/auth/tests/common/mod.rs b/pixles-api/auth/tests/common/mod.rs index 01f1ac2..a65aef3 100644 --- a/pixles-api/auth/tests/common/mod.rs +++ b/pixles-api/auth/tests/common/mod.rs @@ -1,4 +1,6 @@ use auth::config::AuthConfig; +use auth::service::PasskeyService; +use auth::session::SessionManager; use auth::state::AppState; use migration::Migrator; use sea_orm::{Database, DatabaseConnection}; @@ -11,8 +13,8 @@ use testcontainers_modules::postgres::Postgres; static TRACING: Once = Once::new(); pub struct TestContext { - pub post: Option>, - pub valkey: Option>, + pub _postgres: Option>, + pub _valkey: Option>, pub app_state: AppState, pub db: DatabaseConnection, } @@ -25,7 +27,6 @@ pub async fn setup() -> TestContext { .init(); }); - // Start Postgres or use external let (postgres_container, connection_string) = if let Ok(url) = std::env::var("TEST_DATABASE_URL") { (None, url) @@ -45,17 +46,14 @@ pub async fn setup() -> TestContext { ) }; - // Connect and Migrate let db = Database::connect(&connection_string) .await .expect("Failed to connect to database"); - - // Use refresh to ensure clean state for tests Migrator::refresh(&db) .await .expect("Failed to run migrations"); - // Start Valkey or use external + // Start Valkey container or use external let (valkey_container, valkey_url) = if let Ok(url) = std::env::var("TEST_VALKEY_URL") { (None, url) } else { @@ -74,25 +72,8 @@ pub async fn setup() -> TestContext { (Some(container), format!("redis://127.0.0.1:{}", port)) }; - // Create AppState - // We need keys. For tests we can generate random ones or use fixed ones. - // Since AuthConfig expects EncodingKey/DecodingKey which need keys. - // Let's generate ephemeral keys. - - // NOTE: This part requires `ring` or similar to generate keys if we want to mimic real setup - // Or we can just load dummy ones. - // AuthConfig::from(&ServerConfig) logic uses loaded config. - // We'll construct AuthConfig manually or via a helper. - - // For simplicity, let's create a minimal config. - // BUT AuthConfig structs fields are pub, so we can instantiate directly. - // Generate ephemeral keys provided by user history or just static for tests. - // Private: MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF - // Public: MCowBQYDK2VwAyEA66iVaMz1x2ogToGm5Hw34aITBLLqz0iEonbwjK57pWU= - use base64::Engine; let engine = base64::engine::general_purpose::STANDARD; - let priv_bytes = engine .decode("MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF") .expect("Failed to decode priv key"); @@ -109,15 +90,14 @@ pub async fn setup() -> TestContext { domain: "localhost".to_string(), jwt_eddsa_encoding_key: enc_key, jwt_eddsa_decoding_key: dec_key, - jwt_refresh_token_duration_seconds: 60, - jwt_access_token_duration_seconds: 10, + jwt_refresh_token_duration_seconds: 3600, + jwt_access_token_duration_seconds: 300, valkey_url: valkey_url.clone(), }; - let session_manager = - auth::session::SessionManager::new(valkey_url.clone(), std::time::Duration::from_secs(60)) - .await - .expect("Failed to create session manager"); + let session_manager = SessionManager::new(valkey_url, std::time::Duration::from_secs(3600)) + .await + .expect("Failed to create session manager"); let rp_origin = webauthn_rs::prelude::Url::parse("https://localhost").expect("valid URL"); let webauthn = std::sync::Arc::new( @@ -126,14 +106,19 @@ pub async fn setup() -> TestContext { .build() .expect("valid webauthn"), ); - let passkey_service = auth::service::PasskeyService::new(db.clone(), webauthn); - + let passkey_service = PasskeyService::new(db.clone(), webauthn); let app_state = AppState::new(db.clone(), config, session_manager, passkey_service); TestContext { - post: postgres_container, - valkey: valkey_container, + _postgres: postgres_container, + _valkey: valkey_container, app_state, db, } } + +/// Build a salvo Service from a TestContext for integration testing. +pub fn build_service(ctx: &TestContext) -> salvo::Service { + let router = auth::get_router_with_state(ctx.app_state.clone()); + salvo::Service::new(router) +} diff --git a/pixles-api/auth/tests/integration_tests.rs b/pixles-api/auth/tests/integration_tests.rs index 4dc0bbe..05566ef 100644 --- a/pixles-api/auth/tests/integration_tests.rs +++ b/pixles-api/auth/tests/integration_tests.rs @@ -1,3 +1,2 @@ mod api; mod common; -// TODO: Untested! Verify these test pass diff --git a/pixles-api/service/src/user/query.rs b/pixles-api/service/src/user/query.rs index 09dfd8a..ffb99e7 100644 --- a/pixles-api/service/src/user/query.rs +++ b/pixles-api/service/src/user/query.rs @@ -135,4 +135,19 @@ impl Query { .await?; Ok(user.map(|u| u.failed_login_attempts)) } + + /// Returns the password reset token for a user by email (used in tests / admin flows) + #[cfg(feature = "auth")] + pub async fn get_password_reset_token_by_email( + db: &DbConn, + email: &str, + ) -> Result>, DbErr> { + let user = User::find() + .filter(user::Column::Email.eq(email)) + .select_only() + .column(user::Column::PasswordResetToken) + .one(db) + .await?; + Ok(user.map(|u| u.password_reset_token)) + } } From 06f4ff34dd735d9bb0b851d134502bb4aeb72b9d Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:27:11 -0500 Subject: [PATCH 09/17] feat(auth): externalize TOTP issuer config and accept passkey name from request - Add totp_issuer to AuthConfig and ServerConfig, sourced from TOTP_ISSUER env var - Update TotpService to use issuer from config instead of hardcoded constant - Accept optional 'name' field in passkey finish_registration request body - Update get_auth_issuer() to read from PIXLES_ISSUER env var --- pixles-api/auth/src/claims/issuer.rs | 5 ++--- pixles-api/auth/src/config.rs | 3 +++ pixles-api/auth/src/routes/passkey.rs | 24 +++++++++++++++--------- pixles-api/auth/src/service/auth.rs | 1 + pixles-api/auth/src/state.rs | 3 +-- pixles-api/auth/tests/common/mod.rs | 1 + pixles-api/environment/src/lib.rs | 7 ++++++- 7 files changed, 29 insertions(+), 15 deletions(-) diff --git a/pixles-api/auth/src/claims/issuer.rs b/pixles-api/auth/src/claims/issuer.rs index 4dd915c..314c4d1 100644 --- a/pixles-api/auth/src/claims/issuer.rs +++ b/pixles-api/auth/src/claims/issuer.rs @@ -1,5 +1,4 @@ +/// Returns the JWT token issuer. Reads from PIXLES_ISSUER env var if set. pub fn get_auth_issuer() -> String { - // TODO: Fetch this from some application config instead of a constant - - "pixles-api".to_string() + std::env::var("PIXLES_ISSUER").unwrap_or_else(|_| crate::constants::ISSUER.to_string()) } diff --git a/pixles-api/auth/src/config.rs b/pixles-api/auth/src/config.rs index 2229f0e..db372f9 100644 --- a/pixles-api/auth/src/config.rs +++ b/pixles-api/auth/src/config.rs @@ -22,6 +22,8 @@ pub struct AuthConfig { /// Valkey URL pub valkey_url: String, + /// TOTP issuer string shown in authenticator apps + pub totp_issuer: String, } impl From<&ServerConfig> for AuthConfig { @@ -35,6 +37,7 @@ impl From<&ServerConfig> for AuthConfig { jwt_refresh_token_duration_seconds: config.jwt_refresh_token_duration_seconds, jwt_access_token_duration_seconds: config.jwt_access_token_duration_seconds, valkey_url: config.valkey_url.clone(), + totp_issuer: config.totp_issuer.clone(), } } } diff --git a/pixles-api/auth/src/routes/passkey.rs b/pixles-api/auth/src/routes/passkey.rs index 10a48aa..93823b4 100644 --- a/pixles-api/auth/src/routes/passkey.rs +++ b/pixles-api/auth/src/routes/passkey.rs @@ -96,20 +96,28 @@ pub async fn finish_registration( "Missing registration session".into(), ))?; - // Parse body manually - let body = req - .parse_json::() + // Parse body: credential fields + optional `name` + #[derive(serde::Deserialize)] + struct FinishRegBody { + name: Option, + #[serde(flatten)] + rest: serde_json::Value, + } + let parsed = req + .parse_json::() .await .map_err(|e| PasskeyRegistrationFinishResponses::RegistrationFailed(e.to_string()))?; - let reg: webauthn_rs::prelude::RegisterPublicKeyCredential = serde_json::from_value(body) - .map_err(|e| PasskeyRegistrationFinishResponses::RegistrationFailed(e.to_string()))?; + let passkey_name = parsed.name.unwrap_or_else(|| "My Passkey".to_string()); + let reg: webauthn_rs::prelude::RegisterPublicKeyCredential = + serde_json::from_value(parsed.rest) + .map_err(|e| PasskeyRegistrationFinishResponses::RegistrationFailed(e.to_string()))?; // Retrieve state let reg_state: webauthn_rs::prelude::PasskeyRegistration = state .session_manager .get_temp_data(&format!("passkey_reg:{}", challenge_id)) .await - .map_err(|e| PasskeyRegistrationFinishResponses::InternalServerError(e))? + .map_err(PasskeyRegistrationFinishResponses::InternalServerError)? .ok_or(PasskeyRegistrationFinishResponses::RegistrationFailed( "Registration session expired".into(), ))?; @@ -120,11 +128,9 @@ pub async fn finish_registration( .delete_temp_data(&format!("passkey_reg:{}", challenge_id)) .await; - let name = "My Passkey".to_string(); // TODO: get from request if possible - state .passkey_service - .finish_registration(user_id, reg_state, reg, name) + .finish_registration(user_id, reg_state, reg, passkey_name) .await?; Ok(PasskeyRegistrationFinishResponses::Success) diff --git a/pixles-api/auth/src/service/auth.rs b/pixles-api/auth/src/service/auth.rs index 13e771e..9488a27 100644 --- a/pixles-api/auth/src/service/auth.rs +++ b/pixles-api/auth/src/service/auth.rs @@ -220,6 +220,7 @@ mod tests { jwt_refresh_token_duration_seconds: 3600, jwt_access_token_duration_seconds: 300, valkey_url: "redis://localhost:6379".to_string(), + totp_issuer: "Pixles".to_string(), }; AuthService::new(conn, config) } diff --git a/pixles-api/auth/src/state.rs b/pixles-api/auth/src/state.rs index dd3fe2b..e2bd5a1 100644 --- a/pixles-api/auth/src/state.rs +++ b/pixles-api/auth/src/state.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use environment::constants::TOTP_ISSUER; use sea_orm::DatabaseConnection; use crate::config::AuthConfig; @@ -31,7 +30,7 @@ impl AppState { ) -> Self { let auth_service = AuthService::new(conn.clone(), config.clone()); let password_service = PasswordService::new(1000); // 1s minimum - let totp_service = TotpService::new(conn.clone(), TOTP_ISSUER); + let totp_service = TotpService::new(conn.clone(), &config.totp_issuer); Self { inner: Arc::new(AppStateInner { diff --git a/pixles-api/auth/tests/common/mod.rs b/pixles-api/auth/tests/common/mod.rs index a65aef3..f96c5b0 100644 --- a/pixles-api/auth/tests/common/mod.rs +++ b/pixles-api/auth/tests/common/mod.rs @@ -93,6 +93,7 @@ pub async fn setup() -> TestContext { jwt_refresh_token_duration_seconds: 3600, jwt_access_token_duration_seconds: 300, valkey_url: valkey_url.clone(), + totp_issuer: "Pixles-Test".to_string(), }; let session_manager = SessionManager::new(valkey_url, std::time::Duration::from_secs(3600)) diff --git a/pixles-api/environment/src/lib.rs b/pixles-api/environment/src/lib.rs index a6823d8..ecb2971 100644 --- a/pixles-api/environment/src/lib.rs +++ b/pixles-api/environment/src/lib.rs @@ -8,7 +8,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use jsonwebtoken::{DecodingKey, EncodingKey}; #[cfg(feature = "auth")] -use crate::constants::{ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY}; +use crate::constants::{ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY, TOTP_ISSUER}; #[cfg(feature = "upload")] use crate::constants::{MAX_CACHE_SIZE, MAX_FILE_SIZE}; use crate::jwt::convert_ed25519_der_to_jwt_keys; @@ -53,6 +53,9 @@ pub struct ServerConfig { #[cfg(feature = "auth")] /// JWT access token duration in seconds pub jwt_access_token_duration_seconds: u64, + #[cfg(feature = "auth")] + /// TOTP issuer string shown in authenticator apps + pub totp_issuer: String, #[cfg(any(feature = "upload", feature = "media", feature = "sync"))] /// Upload directory @@ -158,6 +161,8 @@ impl Environment { "JWT_ACCESS_TOKEN_DURATION_SECONDS", ) .unwrap_or(ACCESS_TOKEN_EXPIRY), + #[cfg(feature = "auth")] + totp_issuer: load_env("TOTP_ISSUER").unwrap_or(TOTP_ISSUER.to_string()), #[cfg(any(feature = "upload", feature = "media", feature = "sync"))] upload_dir: load_env("UPLOAD_DIR") .unwrap_or(String::from("./uploads")) From f792eeac920002f76313764302e7e2bfbfa6e07a Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:19:22 -0500 Subject: [PATCH 10/17] feat(web): implement auth context and token management - Add token storage utilities (localStorage) in auth.ts - Add typed API client with auto-refresh and 401 redirect in api.ts - Add AuthProvider React context with TanStack Query for profile fetching - Schedule proactive token refresh 60s before expiry - Wrap root route with AuthProvider and QueryClientProvider --- pixles-web/src/lib/api.ts | 305 ++++++++++++++++++++++++++++ pixles-web/src/lib/auth-context.tsx | 119 +++++++++++ pixles-web/src/lib/auth.ts | 56 +++++ pixles-web/src/routes/__root.tsx | 37 ++-- 4 files changed, 500 insertions(+), 17 deletions(-) create mode 100644 pixles-web/src/lib/api.ts create mode 100644 pixles-web/src/lib/auth-context.tsx create mode 100644 pixles-web/src/lib/auth.ts diff --git a/pixles-web/src/lib/api.ts b/pixles-web/src/lib/api.ts new file mode 100644 index 0000000..a0c7cd0 --- /dev/null +++ b/pixles-web/src/lib/api.ts @@ -0,0 +1,305 @@ +/** + * Typed API client for the Pixles auth REST API. + * Automatically injects Authorization headers and refreshes tokens. + */ + +import { + clearTokens, + getAccessToken, + getRefreshToken, + isAccessTokenValid, + saveTokens, + type TokenPair, +} from './auth'; + +const API_BASE = import.meta.env.PUBLIC_API_URL ?? 'http://localhost:3000'; +const AUTH_BASE = `${API_BASE}/v1/auth`; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +async function parseError(res: Response): Promise { + try { + const body = await res.json(); + return new ApiError(res.status, body.error ?? body.message ?? res.statusText); + } catch { + return new ApiError(res.status, res.statusText); + } +} + +/** Attempt to refresh the access token using the stored refresh token. */ +export async function refreshAccessToken(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) return false; + + try { + const res = await fetch(`${AUTH_BASE}/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + if (!res.ok) { + clearTokens(); + return false; + } + const tokens: TokenPair = await res.json(); + saveTokens(tokens); + return true; + } catch { + return false; + } +} + +/** + * Authenticated fetch wrapper. Injects Bearer token, auto-refreshes + * if needed, and redirects to /login on 401. + */ +export async function authFetch( + path: string, + init: RequestInit = {}, +): Promise { + // Ensure we have a valid access token + if (!isAccessTokenValid()) { + const refreshed = await refreshAccessToken(); + if (!refreshed) { + clearTokens(); + window.location.href = '/login'; + throw new ApiError(401, 'Session expired'); + } + } + + const token = getAccessToken()!; + const headers = new Headers(init.headers); + headers.set('Authorization', `Bearer ${token}`); + headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json'); + + const res = await fetch(`${AUTH_BASE}${path}`, { ...init, headers }); + + if (res.status === 401) { + clearTokens(); + window.location.href = '/login'; + throw new ApiError(401, 'Unauthorized'); + } + + return res; +} + +// ── Auth endpoints ────────────────────────────────────────────────────────── + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginMfaRequiredResponse { + mfa_required: true; + mfa_token: string; +} + +export async function login(body: LoginRequest): Promise { + const res = await fetch(`${AUTH_BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export interface RegisterRequest { + username: string; + name: string; + email: string; + password: string; +} + +export async function register(body: RegisterRequest): Promise { + const res = await fetch(`${AUTH_BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export async function logout(): Promise { + try { + await authFetch('/logout', { method: 'POST' }); + } finally { + clearTokens(); + } +} + +// ── TOTP endpoints ────────────────────────────────────────────────────────── + +export async function verifyTotpLogin(mfaToken: string, totpCode: string): Promise { + const res = await fetch(`${AUTH_BASE}/login/verify-totp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mfa_token: mfaToken, totp_code: totpCode }), + }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export interface TotpEnrollResponse { + provisioning_uri: string; +} + +export async function totpEnroll(): Promise { + const res = await authFetch('/totp/enroll', { method: 'POST' }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export async function totpVerifyEnrollment(totpCode: string): Promise { + const res = await authFetch('/totp/verify-enrollment', { + method: 'POST', + body: JSON.stringify({ totp_code: totpCode }), + }); + if (!res.ok) throw await parseError(res); +} + +export async function totpDisable(totpCode: string): Promise { + const res = await authFetch('/totp/disable', { + method: 'POST', + body: JSON.stringify({ totp_code: totpCode }), + }); + if (!res.ok) throw await parseError(res); +} + +// ── Passkey endpoints ─────────────────────────────────────────────────────── + +export async function passkeyLoginStart(username?: string): Promise { + const res = await fetch(`${AUTH_BASE}/passkey/login/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export async function passkeyLoginFinish(credential: unknown): Promise { + const res = await fetch(`${AUTH_BASE}/passkey/login/finish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credential), + }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export async function passkeyRegisterStart(): Promise { + const res = await authFetch('/passkey/register/start', { method: 'POST' }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export async function passkeyRegisterFinish( + credential: unknown, + name?: string, +): Promise { + const body = { ...(credential as object), name }; + const res = await authFetch('/passkey/register/finish', { + method: 'POST', + body: JSON.stringify(body), + }); + if (!res.ok) throw await parseError(res); +} + +export interface PasskeyCredential { + id: string; + name: string; + created_at: number; +} + +export async function listPasskeys(): Promise { + const res = await authFetch('/passkey/credentials'); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export async function deletePasskey(credId: string): Promise { + const res = await authFetch(`/passkey/credentials/${credId}`, { method: 'DELETE' }); + if (!res.ok) throw await parseError(res); +} + +// ── Profile endpoints ─────────────────────────────────────────────────────── + +export interface UserProfile { + id: string; + username: string; + name: string; + email: string; + profile_image_url?: string; + needs_onboarding: boolean; + is_admin: boolean; +} + +export async function getProfile(): Promise { + const res = await authFetch('/profile'); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +export interface UpdateProfileRequest { + username?: string; + email?: string; + current_password?: string; + new_password?: string; +} + +export async function updateProfile(body: UpdateProfileRequest): Promise { + const res = await authFetch('/profile', { + method: 'POST', + body: JSON.stringify(body), + }); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +// ── Devices endpoints ─────────────────────────────────────────────────────── + +export interface Device { + id: string; + created_at: number; + last_active_at: number; + user_agent?: string; + ip_address?: string; + is_current: boolean; +} + +export async function getDevices(): Promise { + const res = await authFetch('/devices'); + if (!res.ok) throw await parseError(res); + return res.json(); +} + +// ── Password reset ────────────────────────────────────────────────────────── + +export async function requestPasswordReset(email: string): Promise { + const res = await fetch(`${AUTH_BASE}/password-reset-request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!res.ok) throw await parseError(res); +} + +export async function resetPassword(token: string, newPassword: string): Promise { + const res = await fetch(`${AUTH_BASE}/password-reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, new_password: newPassword }), + }); + if (!res.ok) throw await parseError(res); +} diff --git a/pixles-web/src/lib/auth-context.tsx b/pixles-web/src/lib/auth-context.tsx new file mode 100644 index 0000000..bbd8d14 --- /dev/null +++ b/pixles-web/src/lib/auth-context.tsx @@ -0,0 +1,119 @@ +/** + * Auth context providing current user state, login/logout actions, + * and automatic token refresh via TanStack Query. + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import React, { createContext, useCallback, useContext, useEffect } from 'react'; +import { + clearTokens, + getTokenExpiry, + hasTokens, + saveTokens, + type TokenPair, +} from './auth'; +import { + getProfile, + logout as apiLogout, + refreshAccessToken as apiRefresh, + type UserProfile, +} from './api'; + +export interface AuthState { + /** The currently authenticated user, or null if not logged in. */ + user: UserProfile | null; + /** True while the auth state is being determined (initial load). */ + isLoading: boolean; + /** True if the user is authenticated. */ + isAuthenticated: boolean; + /** Store new tokens after successful login/register. */ + setTokens: (tokens: TokenPair) => void; + /** Log out and clear all tokens. */ + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + + // Fetch the current user profile. Only runs when we have tokens. + const { + data: user, + isLoading, + refetch, + } = useQuery({ + queryKey: ['auth', 'profile'], + queryFn: getProfile, + enabled: hasTokens(), + retry: false, + staleTime: 5 * 60 * 1000, // 5 min + }); + + // Proactively refresh the access token before expiry. + useEffect(() => { + if (!hasTokens()) return; + + const scheduleRefresh = () => { + const expiry = getTokenExpiry(); + if (!expiry) return; + const nowSecs = Date.now() / 1000; + // Refresh 60 seconds before expiry + const delayMs = Math.max((expiry - nowSecs - 60) * 1000, 0); + return setTimeout(async () => { + const refreshed = await apiRefresh(); + if (refreshed) { + scheduleRefresh(); + } else { + clearTokens(); + queryClient.setQueryData(['auth', 'profile'], null); + } + }, delayMs); + }; + + const timer = scheduleRefresh(); + return () => { + if (timer) clearTimeout(timer); + }; + }, [queryClient]); + + const setTokens = useCallback( + (tokens: TokenPair) => { + saveTokens(tokens); + refetch(); + }, + [refetch], + ); + + const handleLogout = useCallback(async () => { + try { + await apiLogout(); + } catch { + clearTokens(); + } + queryClient.setQueryData(['auth', 'profile'], null); + queryClient.clear(); + }, [queryClient]); + + const isAuthenticated = !!user; + + return ( + + {children} + + ); +} diff --git a/pixles-web/src/lib/auth.ts b/pixles-web/src/lib/auth.ts new file mode 100644 index 0000000..999d786 --- /dev/null +++ b/pixles-web/src/lib/auth.ts @@ -0,0 +1,56 @@ +/** + * Auth token storage and management utilities. + * Tokens are stored in localStorage. The access token is refreshed + * automatically before expiry via the auth context. + */ + +const ACCESS_TOKEN_KEY = 'pixles_access_token'; +const REFRESH_TOKEN_KEY = 'pixles_refresh_token'; +const TOKEN_EXPIRY_KEY = 'pixles_token_expiry'; + +export interface TokenPair { + access_token: string; + refresh_token: string; + token_type: string; + expires_by: number; // unix seconds +} + +export function saveTokens(tokens: TokenPair): void { + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token); + localStorage.setItem(TOKEN_EXPIRY_KEY, String(tokens.expires_by)); +} + +export function clearTokens(): void { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(TOKEN_EXPIRY_KEY); +} + +export function getAccessToken(): string | null { + return localStorage.getItem(ACCESS_TOKEN_KEY); +} + +export function getRefreshToken(): string | null { + return localStorage.getItem(REFRESH_TOKEN_KEY); +} + +export function getTokenExpiry(): number | null { + const v = localStorage.getItem(TOKEN_EXPIRY_KEY); + return v ? Number(v) : null; +} + +/** Returns true if the access token is present and not expiring within the next 30 seconds. */ +export function isAccessTokenValid(): boolean { + const token = getAccessToken(); + if (!token) return false; + const expiry = getTokenExpiry(); + if (!expiry) return false; + const nowSecs = Date.now() / 1000; + return expiry - nowSecs > 30; +} + +/** Returns true if any auth tokens exist (user is potentially logged in). */ +export function hasTokens(): boolean { + return !!getAccessToken() && !!getRefreshToken(); +} diff --git a/pixles-web/src/routes/__root.tsx b/pixles-web/src/routes/__root.tsx index a1f495d..c573ffc 100644 --- a/pixles-web/src/routes/__root.tsx +++ b/pixles-web/src/routes/__root.tsx @@ -2,6 +2,7 @@ import { Outlet, createRootRoute } from '@tanstack/react-router'; import { AppSidebar } from '@/components/app-sidebar'; import { Header } from '@/components/header'; +import { AuthProvider } from '@/lib/auth-context'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React, { Suspense } from 'react'; @@ -39,25 +40,27 @@ export const Route = createRootRoute({ return ( -
-
-
- -
- -
+ +
+
+
+ +
+ +
+
-
- - - + + + - - {showDevtools && ( - - - - )} + + {showDevtools && ( + + + + )} + ); }, From 1520c69616d59e79091cc5dbe91d112799eb69fd Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:22:43 -0500 Subject: [PATCH 11/17] feat(web): connect login page to API and add auth guard - Wire login form to POST /v1/auth/login with loading/error states - Handle MFA_required response: show inline TOTP verification step - Store received tokens via auth context and redirect to /photos - Add AuthGuard component in root layout: redirects unauthenticated users to /login and suppresses protected content during auth check - Redirect already-authenticated users away from /login to /photos --- pixles-web/src/routes/__root.tsx | 57 ++++++-- pixles-web/src/routes/login.lazy.tsx | 204 ++++++++++++++++++++++----- 2 files changed, 212 insertions(+), 49 deletions(-) diff --git a/pixles-web/src/routes/__root.tsx b/pixles-web/src/routes/__root.tsx index c573ffc..4f38b33 100644 --- a/pixles-web/src/routes/__root.tsx +++ b/pixles-web/src/routes/__root.tsx @@ -1,11 +1,11 @@ -import { Outlet, createRootRoute } from '@tanstack/react-router'; +import { Outlet, createRootRoute, useNavigate, useRouterState } from '@tanstack/react-router'; import { AppSidebar } from '@/components/app-sidebar'; import { Header } from '@/components/header'; -import { AuthProvider } from '@/lib/auth-context'; +import { AuthProvider, useAuth } from '@/lib/auth-context'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import React, { Suspense } from 'react'; +import React, { Suspense, useEffect } from 'react'; const queryClient = new QueryClient(); @@ -13,11 +13,8 @@ const TanStackRouterDevtools = process.env.NODE_ENV === 'production' ? () => null // Render nothing in production : React.lazy(() => - // Lazy load in development import('@tanstack/router-devtools').then((res) => ({ default: res.TanStackRouterDevtools, - // For Embedded Mode - // default: res.TanStackRouterDevtoolsPanel })), ); @@ -29,6 +26,36 @@ const ReactQueryDevtoolsProduction = React.lazy(() => ), ); +/** Paths that do not require authentication */ +const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/reset-password']; + +function AuthGuard({ children }: { children: React.ReactNode }) { + const { isLoading, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const { location } = useRouterState(); + const pathname = location.pathname; + + const isPublic = PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/')); + + useEffect(() => { + if (!isLoading && !isAuthenticated && !isPublic) { + navigate({ to: '/login', replace: true }); + } + }, [isLoading, isAuthenticated, isPublic, navigate]); + + // Show nothing while checking auth on protected routes (avoid content flash) + if (isLoading && !isPublic) { + return null; + } + + // Don't render protected content until authenticated + if (!isAuthenticated && !isPublic) { + return null; + } + + return <>{children}; +} + export const Route = createRootRoute({ component: () => { const [showDevtools, setShowDevtools] = React.useState(false); @@ -41,15 +68,17 @@ export const Route = createRootRoute({ return ( -
-
-
- -
- -
+ +
+
+
+ +
+ +
+
-
+ diff --git a/pixles-web/src/routes/login.lazy.tsx b/pixles-web/src/routes/login.lazy.tsx index 7fb0c15..382154d 100644 --- a/pixles-web/src/routes/login.lazy.tsx +++ b/pixles-web/src/routes/login.lazy.tsx @@ -8,54 +8,188 @@ import { CardTitle, } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import { Link, createLazyFileRoute } from '@tanstack/react-router'; +import { Label } from '@/components/ui/label'; +import { useAuth } from '@/lib/auth-context'; +import { ApiError, login, verifyTotpLogin } from '@/lib/api'; +import { Link, createLazyFileRoute, useNavigate } from '@tanstack/react-router'; import { MountainIcon } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; export const Route = createLazyFileRoute('/login')({ component: Login, }); +type LoginStep = 'credentials' | 'totp'; + function Login() { + const { setTokens, isAuthenticated, isLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect already-authenticated users away from login + useEffect(() => { + if (!isLoading && isAuthenticated) { + navigate({ to: '/photos', replace: true }); + } + }, [isLoading, isAuthenticated, navigate]); + + const [step, setStep] = useState('credentials'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [totpCode, setTotpCode] = useState(''); + const [mfaToken, setMfaToken] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleCredentialsSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const result = await login({ email, password }); + if ('mfa_required' in result && result.mfa_required) { + setMfaToken(result.mfa_token); + setStep('totp'); + } else { + setTokens(result); + navigate({ to: '/photos' }); + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + setError('An unexpected error occurred.'); + } + } finally { + setLoading(false); + } + } + + async function handleTotpSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const tokens = await verifyTotpLogin(mfaToken, totpCode); + setTokens(tokens); + navigate({ to: '/photos' }); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + setError('An unexpected error occurred.'); + } + } finally { + setLoading(false); + } + } + return (
Pixles - - - Login - - Enter your email below to login to your account. - - - -
- - -
-
- - -
-
- - - - -

- Don't have an account?{' '} - - Sign up - -

-
-
+ + {step === 'credentials' ? ( + + + Login + + Enter your email below to login to your account. + + +
+ + {error && ( +

{error}

+ )} +
+ + setEmail(e.target.value)} + disabled={loading} + /> +
+
+ + setPassword(e.target.value)} + disabled={loading} + /> +
+
+ + +

+ Don't have an account?{' '} + + Sign up + +

+

+ + Forgot password? + +

+
+
+
+ ) : ( + + + Two-Factor Auth + + Enter the 6-digit code from your authenticator app. + + +
+ + {error && ( +

{error}

+ )} +
+ + setTotpCode(e.target.value)} + disabled={loading} + autoFocus + /> +
+
+ + + + +
+
+ )}
); } From 88423a5cd83d03e281f391f05ad8a1c6e654b3d6 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:25:35 -0500 Subject: [PATCH 12/17] feat(web): implement MFA verification UIs (TOTP and passkey) - Add WebAuthn browser API helpers with base64url<->ArrayBuffer conversion - Add passkey login button to login page with full WebAuthn flow - Add TotpEnroll component: shows QR code from provisioning_uri, confirms enrollment with a 6-digit code - Add PasskeyRegister component: triggers browser credential creation and sends result to the API - Install react-qr-code for TOTP enrollment QR display --- pixles-web/bun.lock | 5 + pixles-web/package.json | 1 + .../src/components/mfa/passkey-register.tsx | 79 +++++++++++ pixles-web/src/components/mfa/totp-enroll.tsx | 132 ++++++++++++++++++ pixles-web/src/lib/webauthn.ts | 103 ++++++++++++++ pixles-web/src/routes/login.lazy.tsx | 79 ++++++++--- 6 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 pixles-web/src/components/mfa/passkey-register.tsx create mode 100644 pixles-web/src/components/mfa/totp-enroll.tsx create mode 100644 pixles-web/src/lib/webauthn.ts diff --git a/pixles-web/bun.lock b/pixles-web/bun.lock index c22b0b6..19e2918 100644 --- a/pixles-web/bun.lock +++ b/pixles-web/bun.lock @@ -34,6 +34,7 @@ "lucide-react": "^0.475.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-qr-code": "^2.0.18", "recharts": "^2.15.3", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", @@ -1761,6 +1762,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="], + "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1777,6 +1780,8 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], diff --git a/pixles-web/package.json b/pixles-web/package.json index 5294b9c..24bd9e8 100644 --- a/pixles-web/package.json +++ b/pixles-web/package.json @@ -46,6 +46,7 @@ "lucide-react": "^0.475.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-qr-code": "^2.0.18", "recharts": "^2.15.3", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", diff --git a/pixles-web/src/components/mfa/passkey-register.tsx b/pixles-web/src/components/mfa/passkey-register.tsx new file mode 100644 index 0000000..7fe9953 --- /dev/null +++ b/pixles-web/src/components/mfa/passkey-register.tsx @@ -0,0 +1,79 @@ +/** + * Passkey registration flow: + * 1. Call POST /auth/passkey/register/start to get creation options + * 2. Invoke browser navigator.credentials.create() with those options + * 3. Call POST /auth/passkey/register/finish with the credential + optional name + */ + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ApiError, passkeyRegisterFinish, passkeyRegisterStart } from '@/lib/api'; +import { registerPasskey } from '@/lib/webauthn'; +import { KeyRoundIcon } from 'lucide-react'; +import { useState } from 'react'; + +interface PasskeyRegisterProps { + onSuccess: () => void; + onCancel: () => void; +} + +export function PasskeyRegister({ onSuccess, onCancel }: PasskeyRegisterProps) { + const [name, setName] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleRegister(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const options = await passkeyRegisterStart(); + const credential = await registerPasskey(options); + await passkeyRegisterFinish(credential, name || undefined); + onSuccess(); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else if (err instanceof Error && err.name === 'NotAllowedError') { + setError('Passkey registration was cancelled.'); + } else if (err instanceof Error && err.name === 'InvalidStateError') { + setError('A passkey for this device is already registered.'); + } else { + setError('Passkey registration failed.'); + } + } finally { + setLoading(false); + } + } + + return ( +
+

+ Passkeys use your device's biometrics or PIN to sign in securely without a + password. +

+ {error &&

{error}

} +
+ + setName(e.target.value)} + disabled={loading} + /> +
+
+ + +
+
+ ); +} diff --git a/pixles-web/src/components/mfa/totp-enroll.tsx b/pixles-web/src/components/mfa/totp-enroll.tsx new file mode 100644 index 0000000..cbbd04f --- /dev/null +++ b/pixles-web/src/components/mfa/totp-enroll.tsx @@ -0,0 +1,132 @@ +/** + * TOTP enrollment flow: + * 1. Call POST /auth/totp/enroll to get provisioning_uri + * 2. Show QR code and provisioning URI + * 3. User scans with their authenticator app + * 4. User enters a code to confirm enrollment + * 5. Call POST /auth/totp/verify-enrollment with the code + */ + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ApiError, totpEnroll, totpVerifyEnrollment } from '@/lib/api'; +import { useState } from 'react'; +import QRCode from 'react-qr-code'; + +interface TotpEnrollProps { + onSuccess: () => void; + onCancel: () => void; +} + +type EnrollStep = 'start' | 'scan' | 'verify'; + +export function TotpEnroll({ onSuccess, onCancel }: TotpEnrollProps) { + const [step, setStep] = useState('start'); + const [provisioningUri, setProvisioningUri] = useState(''); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleStart() { + setError(null); + setLoading(true); + try { + const { provisioning_uri } = await totpEnroll(); + setProvisioningUri(provisioning_uri); + setStep('scan'); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to start enrollment.'); + } finally { + setLoading(false); + } + } + + async function handleVerify(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await totpVerifyEnrollment(code); + onSuccess(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Invalid code. Please try again.'); + } finally { + setLoading(false); + } + } + + if (step === 'start') { + return ( +
+

+ Use an authenticator app (e.g. Google Authenticator, Authy) to scan a QR code + and generate one-time codes. +

+
+ + +
+
+ ); + } + + if (step === 'scan') { + return ( +
+

+ Scan this QR code with your authenticator app, then enter the 6-digit code to + confirm. +

+
+ +
+
+ + Can't scan? Show setup key + +

{provisioningUri}

+
+ +
+ ); + } + + return ( +
+

+ Enter the 6-digit code from your authenticator app to complete setup. +

+ {error &&

{error}

} +
+ + setCode(e.target.value)} + disabled={loading} + autoFocus + /> +
+
+ + +
+
+ ); +} diff --git a/pixles-web/src/lib/webauthn.ts b/pixles-web/src/lib/webauthn.ts new file mode 100644 index 0000000..426f725 --- /dev/null +++ b/pixles-web/src/lib/webauthn.ts @@ -0,0 +1,103 @@ +/** + * WebAuthn browser API helpers for passkey login and registration. + * Handles base64url <-> ArrayBuffer conversion needed by the browser API. + */ + +function base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const binary = atob(padded); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer.buffer; +} + +function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +/** Transform server-side creation options (base64url strings) to browser-expected formats */ +function prepareCreationOptions(options: Record): PublicKeyCredentialCreationOptions { + const pk = options.publicKey as Record; + return { + ...pk, + challenge: base64urlToBuffer(pk.challenge as string), + user: { + ...(pk.user as Record), + id: base64urlToBuffer((pk.user as Record).id as string), + }, + excludeCredentials: ((pk.excludeCredentials as Array>) ?? []).map( + (cred) => ({ + ...cred, + id: base64urlToBuffer(cred.id as string), + }), + ), + } as unknown as PublicKeyCredentialCreationOptions; +} + +/** Transform server-side request options (base64url strings) to browser-expected formats */ +function prepareRequestOptions(options: Record): PublicKeyCredentialRequestOptions { + const pk = options.publicKey as Record; + return { + ...pk, + challenge: base64urlToBuffer(pk.challenge as string), + allowCredentials: ((pk.allowCredentials as Array>) ?? []).map( + (cred) => ({ + ...cred, + id: base64urlToBuffer(cred.id as string), + }), + ), + } as unknown as PublicKeyCredentialRequestOptions; +} + +/** Serialize a PublicKeyCredential to a plain JSON-serializable object */ +function serializeCredential(cred: PublicKeyCredential): unknown { + const response = cred.response; + if (response instanceof AuthenticatorAttestationResponse) { + return { + id: cred.id, + rawId: bufferToBase64url(cred.rawId), + type: cred.type, + response: { + attestationObject: bufferToBase64url(response.attestationObject), + clientDataJSON: bufferToBase64url(response.clientDataJSON), + }, + }; + } else if (response instanceof AuthenticatorAssertionResponse) { + return { + id: cred.id, + rawId: bufferToBase64url(cred.rawId), + type: cred.type, + response: { + authenticatorData: bufferToBase64url(response.authenticatorData), + clientDataJSON: bufferToBase64url(response.clientDataJSON), + signature: bufferToBase64url(response.signature), + userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + }, + }; + } + throw new Error('Unknown credential response type'); +} + +/** Trigger passkey authentication and return serialized credential */ +export async function authenticateWithPasskey(requestOptions: unknown): Promise { + const options = prepareRequestOptions(requestOptions as Record); + const credential = await navigator.credentials.get({ publicKey: options }); + if (!credential) throw new Error('No credential returned'); + return serializeCredential(credential as PublicKeyCredential); +} + +/** Trigger passkey registration and return serialized credential */ +export async function registerPasskey(creationOptions: unknown): Promise { + const options = prepareCreationOptions(creationOptions as Record); + const credential = await navigator.credentials.create({ publicKey: options }); + if (!credential) throw new Error('No credential returned'); + return serializeCredential(credential as PublicKeyCredential); +} diff --git a/pixles-web/src/routes/login.lazy.tsx b/pixles-web/src/routes/login.lazy.tsx index 382154d..4a8dd79 100644 --- a/pixles-web/src/routes/login.lazy.tsx +++ b/pixles-web/src/routes/login.lazy.tsx @@ -10,9 +10,16 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useAuth } from '@/lib/auth-context'; -import { ApiError, login, verifyTotpLogin } from '@/lib/api'; +import { + ApiError, + login, + passkeyLoginFinish, + passkeyLoginStart, + verifyTotpLogin, +} from '@/lib/api'; +import { authenticateWithPasskey } from '@/lib/webauthn'; import { Link, createLazyFileRoute, useNavigate } from '@tanstack/react-router'; -import { MountainIcon } from 'lucide-react'; +import { KeyRoundIcon, MountainIcon } from 'lucide-react'; import React, { useEffect, useState } from 'react'; export const Route = createLazyFileRoute('/login')({ @@ -25,13 +32,6 @@ function Login() { const { setTokens, isAuthenticated, isLoading } = useAuth(); const navigate = useNavigate(); - // Redirect already-authenticated users away from login - useEffect(() => { - if (!isLoading && isAuthenticated) { - navigate({ to: '/photos', replace: true }); - } - }, [isLoading, isAuthenticated, navigate]); - const [step, setStep] = useState('credentials'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -40,6 +40,13 @@ function Login() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + // Redirect already-authenticated users away from login + useEffect(() => { + if (!isLoading && isAuthenticated) { + navigate({ to: '/photos', replace: true }); + } + }, [isLoading, isAuthenticated, navigate]); + async function handleCredentialsSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -54,11 +61,7 @@ function Login() { navigate({ to: '/photos' }); } } catch (err) { - if (err instanceof ApiError) { - setError(err.message); - } else { - setError('An unexpected error occurred.'); - } + setError(err instanceof ApiError ? err.message : 'An unexpected error occurred.'); } finally { setLoading(false); } @@ -72,11 +75,29 @@ function Login() { const tokens = await verifyTotpLogin(mfaToken, totpCode); setTokens(tokens); navigate({ to: '/photos' }); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'An unexpected error occurred.'); + } finally { + setLoading(false); + } + } + + async function handlePasskeyLogin() { + setError(null); + setLoading(true); + try { + const options = await passkeyLoginStart(email || undefined); + const credential = await authenticateWithPasskey(options); + const tokens = await passkeyLoginFinish(credential); + setTokens(tokens); + navigate({ to: '/photos' }); } catch (err) { if (err instanceof ApiError) { setError(err.message); + } else if (err instanceof Error && err.name === 'NotAllowedError') { + setError('Passkey authentication was cancelled.'); } else { - setError('An unexpected error occurred.'); + setError('Passkey authentication failed.'); } } finally { setLoading(false); @@ -127,10 +148,28 @@ function Login() { />
- + +
+
+ +
+
+ or +
+
+

Don't have an account?{' '} @@ -174,7 +213,7 @@ function Login() { />

- + @@ -182,7 +221,11 @@ function Login() { variant="ghost" className="w-full" type="button" - onClick={() => { setStep('credentials'); setError(null); setTotpCode(''); }} + onClick={() => { + setStep('credentials'); + setError(null); + setTotpCode(''); + }} > Back From ccd01e8beadc9f9c83eeb367fd9e0bf3a3717bdf Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:28:58 -0500 Subject: [PATCH 13/17] feat(web): implement password reset flows - Add /forgot-password page: email form calls password-reset-request, shows confirmation without revealing if email exists - Add /reset-password page: reads token from ?token= search param, validates and submits new password, shows success with login link - Register both routes in routeTree.gen.ts - Both routes are public (no auth required) per AuthGuard PUBLIC_PATHS --- pixles-web/src/routeTree.gen.ts | 52 +++++++ .../src/routes/forgot-password.lazy.tsx | 101 +++++++++++++ pixles-web/src/routes/reset-password.tsx | 139 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 pixles-web/src/routes/forgot-password.lazy.tsx create mode 100644 pixles-web/src/routes/reset-password.tsx diff --git a/pixles-web/src/routeTree.gen.ts b/pixles-web/src/routeTree.gen.ts index 975677e..ef81511 100644 --- a/pixles-web/src/routeTree.gen.ts +++ b/pixles-web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { createFileRoute } from '@tanstack/react-router' // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as ResetPasswordImport } from './routes/reset-password' import { Route as AlbumsIndexImport } from './routes/albums/index' import { Route as AlbumsIdImport } from './routes/albums/$id' @@ -22,6 +23,7 @@ const StorageLazyImport = createFileRoute('/storage')() const SharingLazyImport = createFileRoute('/sharing')() const PhotosLazyImport = createFileRoute('/photos')() const LoginLazyImport = createFileRoute('/login')() +const ForgotPasswordLazyImport = createFileRoute('/forgot-password')() const ExploreLazyImport = createFileRoute('/explore')() const DashboardLazyImport = createFileRoute('/dashboard')() const IndexLazyImport = createFileRoute('/')() @@ -55,6 +57,12 @@ const LoginLazyRoute = LoginLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/login.lazy').then((d) => d.Route)) +const ForgotPasswordLazyRoute = ForgotPasswordLazyImport.update({ + id: '/forgot-password', + path: '/forgot-password', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/forgot-password.lazy').then((d) => d.Route)) + const ExploreLazyRoute = ExploreLazyImport.update({ id: '/explore', path: '/explore', @@ -73,6 +81,12 @@ const IndexLazyRoute = IndexLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) +const ResetPasswordRoute = ResetPasswordImport.update({ + id: '/reset-password', + path: '/reset-password', + getParentRoute: () => rootRoute, +} as any) + const AlbumsIndexRoute = AlbumsIndexImport.update({ id: '/albums/', path: '/albums/', @@ -132,6 +146,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ExploreLazyImport parentRoute: typeof rootRoute } + '/forgot-password': { + id: '/forgot-password' + path: '/forgot-password' + fullPath: '/forgot-password' + preLoaderRoute: typeof ForgotPasswordLazyImport + parentRoute: typeof rootRoute + } '/login': { id: '/login' path: '/login' @@ -146,6 +167,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PhotosLazyImport parentRoute: typeof rootRoute } + '/reset-password': { + id: '/reset-password' + path: '/reset-password' + fullPath: '/reset-password' + preLoaderRoute: typeof ResetPasswordImport + parentRoute: typeof rootRoute + } '/sharing': { id: '/sharing' path: '/sharing' @@ -204,8 +232,10 @@ export interface FileRoutesByFullPath { '/': typeof IndexLazyRoute '/dashboard': typeof DashboardLazyRoute '/explore': typeof ExploreLazyRoute + '/forgot-password': typeof ForgotPasswordLazyRoute '/login': typeof LoginLazyRoute '/photos': typeof PhotosLazyRoute + '/reset-password': typeof ResetPasswordRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -219,8 +249,10 @@ export interface FileRoutesByTo { '/': typeof IndexLazyRoute '/dashboard': typeof DashboardLazyRoute '/explore': typeof ExploreLazyRoute + '/forgot-password': typeof ForgotPasswordLazyRoute '/login': typeof LoginLazyRoute '/photos': typeof PhotosLazyRoute + '/reset-password': typeof ResetPasswordRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -235,8 +267,10 @@ export interface FileRoutesById { '/': typeof IndexLazyRoute '/dashboard': typeof DashboardLazyRoute '/explore': typeof ExploreLazyRoute + '/forgot-password': typeof ForgotPasswordLazyRoute '/login': typeof LoginLazyRoute '/photos': typeof PhotosLazyRoute + '/reset-password': typeof ResetPasswordRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -252,8 +286,10 @@ export interface FileRouteTypes { | '/' | '/dashboard' | '/explore' + | '/forgot-password' | '/login' | '/photos' + | '/reset-password' | '/sharing' | '/storage' | '/albums/$id' @@ -266,8 +302,10 @@ export interface FileRouteTypes { | '/' | '/dashboard' | '/explore' + | '/forgot-password' | '/login' | '/photos' + | '/reset-password' | '/sharing' | '/storage' | '/albums/$id' @@ -280,8 +318,10 @@ export interface FileRouteTypes { | '/' | '/dashboard' | '/explore' + | '/forgot-password' | '/login' | '/photos' + | '/reset-password' | '/sharing' | '/storage' | '/albums/$id' @@ -296,8 +336,10 @@ export interface RootRouteChildren { IndexLazyRoute: typeof IndexLazyRoute DashboardLazyRoute: typeof DashboardLazyRoute ExploreLazyRoute: typeof ExploreLazyRoute + ForgotPasswordLazyRoute: typeof ForgotPasswordLazyRoute LoginLazyRoute: typeof LoginLazyRoute PhotosLazyRoute: typeof PhotosLazyRoute + ResetPasswordRoute: typeof ResetPasswordRoute SharingLazyRoute: typeof SharingLazyRoute StorageLazyRoute: typeof StorageLazyRoute AlbumsIdRoute: typeof AlbumsIdRoute @@ -311,8 +353,10 @@ const rootRouteChildren: RootRouteChildren = { IndexLazyRoute: IndexLazyRoute, DashboardLazyRoute: DashboardLazyRoute, ExploreLazyRoute: ExploreLazyRoute, + ForgotPasswordLazyRoute: ForgotPasswordLazyRoute, LoginLazyRoute: LoginLazyRoute, PhotosLazyRoute: PhotosLazyRoute, + ResetPasswordRoute: ResetPasswordRoute, SharingLazyRoute: SharingLazyRoute, StorageLazyRoute: StorageLazyRoute, AlbumsIdRoute: AlbumsIdRoute, @@ -335,8 +379,10 @@ export const routeTree = rootRoute "/", "/dashboard", "/explore", + "/forgot-password", "/login", "/photos", + "/reset-password", "/sharing", "/storage", "/albums/$id", @@ -355,12 +401,18 @@ export const routeTree = rootRoute "/explore": { "filePath": "explore.lazy.tsx" }, + "/forgot-password": { + "filePath": "forgot-password.lazy.tsx" + }, "/login": { "filePath": "login.lazy.tsx" }, "/photos": { "filePath": "photos.lazy.tsx" }, + "/reset-password": { + "filePath": "reset-password.tsx" + }, "/sharing": { "filePath": "sharing.lazy.tsx" }, diff --git a/pixles-web/src/routes/forgot-password.lazy.tsx b/pixles-web/src/routes/forgot-password.lazy.tsx new file mode 100644 index 0000000..ab55b7f --- /dev/null +++ b/pixles-web/src/routes/forgot-password.lazy.tsx @@ -0,0 +1,101 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ApiError, requestPasswordReset } from '@/lib/api'; +import { Link, createLazyFileRoute } from '@tanstack/react-router'; +import { MountainIcon } from 'lucide-react'; +import React, { useState } from 'react'; + +export const Route = createLazyFileRoute('/forgot-password')({ + component: ForgotPassword, +}); + +function ForgotPassword() { + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [submitted, setSubmitted] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await requestPasswordReset(email); + setSubmitted(true); + } catch (err) { + // Always show success to prevent email enumeration + if (err instanceof ApiError && err.status !== 200) { + setSubmitted(true); + } else { + setError('Something went wrong. Please try again.'); + } + } finally { + setLoading(false); + } + } + + return ( +
+ + + Pixles + + + + Forgot Password + + {submitted + ? 'Check your email for instructions.' + : "Enter your email and we'll send you a reset link."} + + + {!submitted ? ( +
+ + {error &&

{error}

} +
+ + setEmail(e.target.value)} + disabled={loading} + /> +
+
+ + + + Back to login + + +
+ ) : ( + +

+ If an account with that email exists, you'll receive a reset link + shortly. +

+ + Back to login + +
+ )} +
+
+ ); +} diff --git a/pixles-web/src/routes/reset-password.tsx b/pixles-web/src/routes/reset-password.tsx new file mode 100644 index 0000000..8832b25 --- /dev/null +++ b/pixles-web/src/routes/reset-password.tsx @@ -0,0 +1,139 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ApiError, resetPassword } from '@/lib/api'; +import { Link, createFileRoute } from '@tanstack/react-router'; +import { MountainIcon } from 'lucide-react'; +import React, { useState } from 'react'; +import { z } from 'zod'; + +const resetPasswordSearchSchema = z.object({ + token: z.string().optional(), +}); + +export const Route = createFileRoute('/reset-password')({ + validateSearch: resetPasswordSearchSchema, + component: ResetPassword, +}); + +function ResetPassword() { + const { token } = Route.useSearch(); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + if (!token) { + return ( +
+ + + Invalid Link + + This password reset link is invalid or has expired. + + + + + Request a new reset link + + + +
+ ); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!token) return; + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + if (newPassword.length < 8) { + setError('Password must be at least 8 characters.'); + return; + } + setError(null); + setLoading(true); + try { + await resetPassword(token, newPassword); + setSuccess(true); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + setError('Something went wrong. Please try again.'); + } + } finally { + setLoading(false); + } + } + + return ( +
+ + + Pixles + + + + Reset Password + + {success ? 'Your password has been reset.' : 'Enter your new password.'} + + + {!success ? ( +
+ + {error &&

{error}

} +
+ + setNewPassword(e.target.value)} + disabled={loading} + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={loading} + /> +
+
+ + + +
+ ) : ( + + + + + + )} +
+
+ ); +} From 321affbbd64471ad2401d09d7ead668aa02b40d1 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:33:36 -0500 Subject: [PATCH 14/17] feat(web): add profile/security settings pages and interactive header - Update Header: show user avatar with real name/email, working logout, links to /settings and /settings/security; Sign in button when logged out - Add /settings page: edit username/email and change password via API - Add /settings/security page: - Active sessions list from GET /auth/devices - TOTP enrollment and disable (with code verification) - Passkey management: list, delete, and add new passkeys - Register new routes in routeTree.gen.ts --- pixles-web/src/components/header.tsx | 141 ++++++---- pixles-web/src/routeTree.gen.ts | 52 ++++ pixles-web/src/routes/settings.lazy.tsx | 179 ++++++++++++ .../src/routes/settings/security.lazy.tsx | 260 ++++++++++++++++++ 4 files changed, 583 insertions(+), 49 deletions(-) create mode 100644 pixles-web/src/routes/settings.lazy.tsx create mode 100644 pixles-web/src/routes/settings/security.lazy.tsx diff --git a/pixles-web/src/components/header.tsx b/pixles-web/src/components/header.tsx index a134de7..d489737 100644 --- a/pixles-web/src/components/header.tsx +++ b/pixles-web/src/components/header.tsx @@ -10,59 +10,102 @@ import { import { Input } from '@/components/ui/input'; import { APP_NAME } from '@/lib/constant'; -import { Link } from '@tanstack/react-router'; +import { useAuth } from '@/lib/auth-context'; +import { Link, useNavigate } from '@tanstack/react-router'; import { BellIcon, MountainIcon, UploadIcon } from 'lucide-react'; import { ModeToggle } from './ui/mode-toggle'; import { UploadDialog } from './upload-dialog'; -// TODO: Implement interactivity -export const Header = () => ( -
-
- - - - {APP_NAME} - - -
- -
-
- +export const Header = () => { + const { user, isAuthenticated, logout } = useAuth(); + const navigate = useNavigate(); + + const initials = user + ? (user.name || user.username) + .split(' ') + .map((w) => w[0]) + .join('') + .toUpperCase() + .slice(0, 2) + : '?'; + + async function handleLogout() { + await logout(); + navigate({ to: '/login' }); + } + + return ( +
+
+ + + + {APP_NAME} + + +
+ +
+
+ + + - - - -
- - - - - JP - - - - My Account - Settings - - Logout - - + +
+ {isAuthenticated ? ( + + + + {user?.profile_image_url && ( + + )} + {initials} + + + + {user && ( + <> +
{user.name}
+
{user.email}
+ + + )} + + Profile + + + Security + + + + Logout + +
+
+ ) : ( + + + + )} +
-
-
-); +
+ ); +}; diff --git a/pixles-web/src/routeTree.gen.ts b/pixles-web/src/routeTree.gen.ts index ef81511..d325c37 100644 --- a/pixles-web/src/routeTree.gen.ts +++ b/pixles-web/src/routeTree.gen.ts @@ -21,6 +21,8 @@ import { Route as AlbumsIdImport } from './routes/albums/$id' const StorageLazyImport = createFileRoute('/storage')() const SharingLazyImport = createFileRoute('/sharing')() +const SettingsSecurityLazyImport = createFileRoute('/settings/security')() +const SettingsLazyImport = createFileRoute('/settings')() const PhotosLazyImport = createFileRoute('/photos')() const LoginLazyImport = createFileRoute('/login')() const ForgotPasswordLazyImport = createFileRoute('/forgot-password')() @@ -45,6 +47,18 @@ const SharingLazyRoute = SharingLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/sharing.lazy').then((d) => d.Route)) +const SettingsSecurityLazyRoute = SettingsSecurityLazyImport.update({ + id: '/settings/security', + path: '/settings/security', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/settings/security.lazy').then((d) => d.Route)) + +const SettingsLazyRoute = SettingsLazyImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/settings.lazy').then((d) => d.Route)) + const PhotosLazyRoute = PhotosLazyImport.update({ id: '/photos', path: '/photos', @@ -174,6 +188,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResetPasswordImport parentRoute: typeof rootRoute } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsLazyImport + parentRoute: typeof rootRoute + } + '/settings/security': { + id: '/settings/security' + path: '/settings/security' + fullPath: '/settings/security' + preLoaderRoute: typeof SettingsSecurityLazyImport + parentRoute: typeof rootRoute + } '/sharing': { id: '/sharing' path: '/sharing' @@ -236,6 +264,8 @@ export interface FileRoutesByFullPath { '/login': typeof LoginLazyRoute '/photos': typeof PhotosLazyRoute '/reset-password': typeof ResetPasswordRoute + '/settings': typeof SettingsLazyRoute + '/settings/security': typeof SettingsSecurityLazyRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -253,6 +283,8 @@ export interface FileRoutesByTo { '/login': typeof LoginLazyRoute '/photos': typeof PhotosLazyRoute '/reset-password': typeof ResetPasswordRoute + '/settings': typeof SettingsLazyRoute + '/settings/security': typeof SettingsSecurityLazyRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -271,6 +303,8 @@ export interface FileRoutesById { '/login': typeof LoginLazyRoute '/photos': typeof PhotosLazyRoute '/reset-password': typeof ResetPasswordRoute + '/settings': typeof SettingsLazyRoute + '/settings/security': typeof SettingsSecurityLazyRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -290,6 +324,8 @@ export interface FileRouteTypes { | '/login' | '/photos' | '/reset-password' + | '/settings' + | '/settings/security' | '/sharing' | '/storage' | '/albums/$id' @@ -306,6 +342,8 @@ export interface FileRouteTypes { | '/login' | '/photos' | '/reset-password' + | '/settings' + | '/settings/security' | '/sharing' | '/storage' | '/albums/$id' @@ -322,6 +360,8 @@ export interface FileRouteTypes { | '/login' | '/photos' | '/reset-password' + | '/settings' + | '/settings/security' | '/sharing' | '/storage' | '/albums/$id' @@ -340,6 +380,8 @@ export interface RootRouteChildren { LoginLazyRoute: typeof LoginLazyRoute PhotosLazyRoute: typeof PhotosLazyRoute ResetPasswordRoute: typeof ResetPasswordRoute + SettingsLazyRoute: typeof SettingsLazyRoute + SettingsSecurityLazyRoute: typeof SettingsSecurityLazyRoute SharingLazyRoute: typeof SharingLazyRoute StorageLazyRoute: typeof StorageLazyRoute AlbumsIdRoute: typeof AlbumsIdRoute @@ -357,6 +399,8 @@ const rootRouteChildren: RootRouteChildren = { LoginLazyRoute: LoginLazyRoute, PhotosLazyRoute: PhotosLazyRoute, ResetPasswordRoute: ResetPasswordRoute, + SettingsLazyRoute: SettingsLazyRoute, + SettingsSecurityLazyRoute: SettingsSecurityLazyRoute, SharingLazyRoute: SharingLazyRoute, StorageLazyRoute: StorageLazyRoute, AlbumsIdRoute: AlbumsIdRoute, @@ -383,6 +427,8 @@ export const routeTree = rootRoute "/login", "/photos", "/reset-password", + "/settings", + "/settings/security", "/sharing", "/storage", "/albums/$id", @@ -413,6 +459,12 @@ export const routeTree = rootRoute "/reset-password": { "filePath": "reset-password.tsx" }, + "/settings": { + "filePath": "settings.lazy.tsx" + }, + "/settings/security": { + "filePath": "settings/security.lazy.tsx" + }, "/sharing": { "filePath": "sharing.lazy.tsx" }, diff --git a/pixles-web/src/routes/settings.lazy.tsx b/pixles-web/src/routes/settings.lazy.tsx new file mode 100644 index 0000000..b6e6629 --- /dev/null +++ b/pixles-web/src/routes/settings.lazy.tsx @@ -0,0 +1,179 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuth } from '@/lib/auth-context'; +import { ApiError, updateProfile } from '@/lib/api'; +import { useQueryClient } from '@tanstack/react-query'; +import { Link, createLazyFileRoute } from '@tanstack/react-router'; +import React, { useEffect, useState } from 'react'; + +export const Route = createLazyFileRoute('/settings')({ + component: Settings, +}); + +function Settings() { + const { user } = useAuth(); + const queryClient = useQueryClient(); + + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (user) { + setUsername(user.username); + setEmail(user.email); + } + }, [user]); + + async function handleProfileSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(null); + setLoading(true); + try { + const updated = await updateProfile({ username, email }); + queryClient.setQueryData(['auth', 'profile'], updated); + setSuccess('Profile updated.'); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to update profile.'); + } finally { + setLoading(false); + } + } + + async function handlePasswordSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(null); + if (newPassword !== confirmPassword) { + setError('New passwords do not match.'); + return; + } + if (newPassword.length < 8) { + setError('Password must be at least 8 characters.'); + return; + } + setLoading(true); + try { + await updateProfile({ + current_password: currentPassword, + new_password: newPassword, + }); + setSuccess('Password updated.'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to update password.'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Profile Settings

+ + Security settings → + +
+ + + + Profile Information + Update your username and email address. + + +
+ {error &&

{error}

} + {success &&

{success}

} +
+ + setUsername(e.target.value)} + disabled={loading} + /> +
+
+ + setEmail(e.target.value)} + disabled={loading} + /> +
+ +
+
+
+ + + + Change Password + Enter your current password to set a new one. + + +
+
+ + setCurrentPassword(e.target.value)} + disabled={loading} + /> +
+
+ + setNewPassword(e.target.value)} + disabled={loading} + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={loading} + /> +
+ +
+
+
+
+ ); +} diff --git a/pixles-web/src/routes/settings/security.lazy.tsx b/pixles-web/src/routes/settings/security.lazy.tsx new file mode 100644 index 0000000..858a201 --- /dev/null +++ b/pixles-web/src/routes/settings/security.lazy.tsx @@ -0,0 +1,260 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { TotpEnroll } from '@/components/mfa/totp-enroll'; +import { PasskeyRegister } from '@/components/mfa/passkey-register'; +import { ApiError, getDevices, listPasskeys, deletePasskey, totpDisable, type Device, type PasskeyCredential } from '@/lib/api'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, createLazyFileRoute } from '@tanstack/react-router'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import React, { useState } from 'react'; + +export const Route = createLazyFileRoute('/settings/security')({ + component: SecuritySettings, +}); + +function formatDate(unixSecs: number) { + return new Date(unixSecs * 1000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function DeviceCard({ device }: { device: Device }) { + return ( +
+
+
+ {device.user_agent ?? 'Unknown device'} + {device.is_current && ( + (This device) + )} +
+ {device.ip_address && ( +
{device.ip_address}
+ )} +
+ Last active: {formatDate(device.last_active_at)} +
+
+
+ ); +} + +function PasskeyRow({ passkey, onDeleted }: { passkey: PasskeyCredential; onDeleted: () => void }) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleDelete() { + if (!confirm(`Delete passkey "${passkey.name}"?`)) return; + setLoading(true); + setError(null); + try { + await deletePasskey(passkey.id); + onDeleted(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to delete passkey.'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
{passkey.name}
+
+ Added: {formatDate(passkey.created_at)} +
+ {error &&
{error}
} +
+ +
+ ); +} + +function SecuritySettings() { + const queryClient = useQueryClient(); + + const { data: devices, isLoading: devicesLoading } = useQuery({ + queryKey: ['auth', 'devices'], + queryFn: getDevices, + }); + + const { data: passkeys, isLoading: passkeysLoading } = useQuery({ + queryKey: ['auth', 'passkeys'], + queryFn: listPasskeys, + }); + + const [showTotpEnroll, setShowTotpEnroll] = useState(false); + const [showPasskeyRegister, setShowPasskeyRegister] = useState(false); + const [totpDisableCode, setTotpDisableCode] = useState(''); + const [totpDisableError, setTotpDisableError] = useState(null); + const [totpDisableLoading, setTotpDisableLoading] = useState(false); + const [totpSuccess, setTotpSuccess] = useState(null); + + async function handleTotpDisable(e: React.FormEvent) { + e.preventDefault(); + setTotpDisableError(null); + setTotpDisableLoading(true); + try { + await totpDisable(totpDisableCode); + setTotpSuccess('TOTP disabled.'); + setTotpDisableCode(''); + setShowTotpEnroll(false); + } catch (err) { + setTotpDisableError(err instanceof ApiError ? err.message : 'Failed to disable TOTP.'); + } finally { + setTotpDisableLoading(false); + } + } + + return ( +
+
+

Security Settings

+ + ← Profile settings + +
+ + {/* Active Sessions */} + + + Active Sessions + + Devices currently logged in to your account. + + + + {devicesLoading && ( +

Loading sessions…

+ )} + {devices?.map((device) => ( + + ))} + {!devicesLoading && (!devices || devices.length === 0) && ( +

No active sessions found.

+ )} +
+
+ + {/* TOTP */} + + + Authenticator App (TOTP) + + Use an authenticator app to generate one-time codes for login. + + + + {totpSuccess &&

{totpSuccess}

} + {showTotpEnroll ? ( + { + setShowTotpEnroll(false); + setTotpSuccess('Authenticator app enabled.'); + }} + onCancel={() => setShowTotpEnroll(false)} + /> + ) : ( +
+ +
+

+ If TOTP is currently enabled, enter a code to disable it: +

+
+
+ + setTotpDisableCode(e.target.value)} + disabled={totpDisableLoading} + /> +
+ +
+ {totpDisableError && ( +

{totpDisableError}

+ )} +
+
+ )} +
+
+ + {/* Passkeys */} + + + Passkeys + + Sign in using your device's biometrics or PIN. + + + + {passkeysLoading && ( +

Loading passkeys…

+ )} + {passkeys?.map((passkey) => ( + queryClient.invalidateQueries({ queryKey: ['auth', 'passkeys'] })} + /> + ))} + {!passkeysLoading && (!passkeys || passkeys.length === 0) && ( +

No passkeys registered.

+ )} + {showPasskeyRegister ? ( + { + setShowPasskeyRegister(false); + queryClient.invalidateQueries({ queryKey: ['auth', 'passkeys'] }); + }} + onCancel={() => setShowPasskeyRegister(false)} + /> + ) : ( + + )} +
+
+
+ ); +} From 98862239ef0a208aabd369f1d360ffac8c1194ff Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Wed, 4 Mar 2026 02:38:39 -0500 Subject: [PATCH 15/17] chore(auth): restrict CORS to configurable origins via ALLOWED_ORIGINS env var Replace wildcard allow_origin("*") in auth and upload servers with configurable origins loaded from ALLOWED_ORIGINS (comma-separated). Defaults to ["*"] in debug builds and [] (deny all cross-origin) in release builds. Both AuthConfig and UploadServerConfig now carry allowed_origins from ServerConfig/environment. --- pixles-api/auth/src/config.rs | 3 +++ pixles-api/auth/src/lib.rs | 11 ++++++++--- pixles-api/auth/src/service/auth.rs | 1 + pixles-api/auth/tests/common/mod.rs | 1 + pixles-api/environment/src/lib.rs | 14 ++++++++++++++ pixles-api/upload/src/config.rs | 3 +++ pixles-api/upload/src/lib.rs | 9 +++++++-- 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/pixles-api/auth/src/config.rs b/pixles-api/auth/src/config.rs index db372f9..79aa496 100644 --- a/pixles-api/auth/src/config.rs +++ b/pixles-api/auth/src/config.rs @@ -24,6 +24,8 @@ pub struct AuthConfig { pub valkey_url: String, /// TOTP issuer string shown in authenticator apps pub totp_issuer: String, + /// Allowed CORS origins. Use `["*"]` to allow all origins (development only). + pub allowed_origins: Vec, } impl From<&ServerConfig> for AuthConfig { @@ -38,6 +40,7 @@ impl From<&ServerConfig> for AuthConfig { jwt_access_token_duration_seconds: config.jwt_access_token_duration_seconds, valkey_url: config.valkey_url.clone(), totp_issuer: config.totp_issuer.clone(), + allowed_origins: config.allowed_origins.clone(), } } } diff --git a/pixles-api/auth/src/lib.rs b/pixles-api/auth/src/lib.rs index fe02d4c..8f37b9d 100644 --- a/pixles-api/auth/src/lib.rs +++ b/pixles-api/auth/src/lib.rs @@ -1,5 +1,5 @@ use config::AuthConfig; -use salvo::cors::Cors; +use salvo::cors::{AllowOrigin, Cors}; use salvo::http::Method; use salvo::prelude::*; use sea_orm::DatabaseConnection; @@ -53,9 +53,14 @@ pub async fn get_router>( let webauthn = std::sync::Arc::new(builder.build().map_err(|e| eyre::eyre!(e))?); let passkey_service = service::PasskeyService::new(conn.clone(), webauthn); - // CORS configuration - TODO: Restrict later + // CORS configuration + let allow_origin = if config.allowed_origins.iter().any(|o| o == "*") { + AllowOrigin::any() + } else { + AllowOrigin::from(&config.allowed_origins) + }; let cors = Cors::new() - .allow_origin("*") + .allow_origin(allow_origin) .allow_methods(vec![ Method::GET, Method::POST, diff --git a/pixles-api/auth/src/service/auth.rs b/pixles-api/auth/src/service/auth.rs index 9488a27..91c97de 100644 --- a/pixles-api/auth/src/service/auth.rs +++ b/pixles-api/auth/src/service/auth.rs @@ -221,6 +221,7 @@ mod tests { jwt_access_token_duration_seconds: 300, valkey_url: "redis://localhost:6379".to_string(), totp_issuer: "Pixles".to_string(), + allowed_origins: vec!["*".to_string()], }; AuthService::new(conn, config) } diff --git a/pixles-api/auth/tests/common/mod.rs b/pixles-api/auth/tests/common/mod.rs index f96c5b0..5e9c363 100644 --- a/pixles-api/auth/tests/common/mod.rs +++ b/pixles-api/auth/tests/common/mod.rs @@ -94,6 +94,7 @@ pub async fn setup() -> TestContext { jwt_access_token_duration_seconds: 300, valkey_url: valkey_url.clone(), totp_issuer: "Pixles-Test".to_string(), + allowed_origins: vec!["*".to_string()], }; let session_manager = SessionManager::new(valkey_url, std::time::Duration::from_secs(3600)) diff --git a/pixles-api/environment/src/lib.rs b/pixles-api/environment/src/lib.rs index ecb2971..2b7bc2b 100644 --- a/pixles-api/environment/src/lib.rs +++ b/pixles-api/environment/src/lib.rs @@ -73,6 +73,10 @@ pub struct ServerConfig { #[cfg(any(feature = "auth", feature = "upload"))] /// Valkey URL (e.g. "redis://127.0.0.1:6379") pub valkey_url: String, + + #[cfg(any(feature = "auth", feature = "upload"))] + /// Allowed CORS origins. Use `["*"]` to allow all origins (development only). + pub allowed_origins: Vec, } // TODO: Separate out these configs into environment variables struct ^^ @@ -177,6 +181,16 @@ impl Environment { .into(), // TODO: If this is still used #[cfg(any(feature = "auth", feature = "upload"))] valkey_url: load_env("VALKEY_URL")?, + #[cfg(any(feature = "auth", feature = "upload"))] + allowed_origins: load_env("ALLOWED_ORIGINS") + .map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()) + .unwrap_or_else(|_| { + if cfg!(debug_assertions) { + vec!["*".to_string()] + } else { + vec![] + } + }), }, log_level: load_log_level("LOG_LEVEL").unwrap_or(if cfg!(debug_assertions) { LevelFilter::TRACE diff --git a/pixles-api/upload/src/config.rs b/pixles-api/upload/src/config.rs index 0b4d143..35c04ec 100644 --- a/pixles-api/upload/src/config.rs +++ b/pixles-api/upload/src/config.rs @@ -20,6 +20,8 @@ pub struct UploadServerConfig { pub valkey_url: String, /// JWT Decoding Key pub jwt_eddsa_decoding_key: SecretKeyWrapper, + /// Allowed CORS origins. Use `["*"]` to allow all origins (development only). + pub allowed_origins: Vec, } impl From<&ServerConfig> for UploadServerConfig { @@ -33,6 +35,7 @@ impl From<&ServerConfig> for UploadServerConfig { max_cache_size: config.max_cache_size, valkey_url: config.valkey_url.clone(), jwt_eddsa_decoding_key: config.jwt_eddsa_decoding_key.clone(), + allowed_origins: config.allowed_origins.clone(), } } } diff --git a/pixles-api/upload/src/lib.rs b/pixles-api/upload/src/lib.rs index 9f2c2fd..f213867 100644 --- a/pixles-api/upload/src/lib.rs +++ b/pixles-api/upload/src/lib.rs @@ -2,7 +2,7 @@ use config::UploadServerConfig; use eyre::Result; use sea_orm::DatabaseConnection; -use salvo::cors::Cors; +use salvo::cors::{AllowOrigin, Cors}; use salvo::http::Method; use salvo::prelude::*; use tracing::info; @@ -48,8 +48,13 @@ pub async fn get_router>( conn.clone(), ); + let allow_origin = if config.allowed_origins.iter().any(|o| o == "*") { + AllowOrigin::any() + } else { + AllowOrigin::from(&config.allowed_origins) + }; let cors = Cors::new() - .allow_origin("*") // TODO: restricting origins via config + .allow_origin(allow_origin) .allow_methods(vec![ Method::GET, Method::POST, From 3884b7de2032fe32e81c2da02ee0905bd02815db Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:29:08 -0500 Subject: [PATCH 16/17] test(auth): add integration test suite for rate limiting, account lockout, TOTP, devices, and password-reset session revocation - Add rate_limiting.rs: login/register (10 req/min) and password-reset (5 req/min per email+IP) with Retry-After header assertion - Add account_lockout.rs: 10 DB-injected failures trigger 423 Locked; correct password still rejected; 9 failures do not lock; successful login resets counter - Add totp.rs: full enrollment/verify-enrollment/disable/verify-login flows including max-attempt lockout (429) and provisioning-URI issuer check - Add devices.rs: is_current flag correctness for single and multi-session scenarios; unauthenticated 401 - Add password_reset_sessions.rs: password reset revokes existing sessions; new login with reset password succeeds - Fix totp-rs 5.7.0 API: TOTP::new now requires account_name and issuer arguments --- Cargo.lock | 8 + pixles-api/auth/Cargo.toml | 1 + pixles-api/auth/src/utils/totp.rs | 2 + .../auth/tests/integration/account_lockout.rs | 158 +++++++ pixles-api/auth/tests/integration/devices.rs | 99 +++++ pixles-api/auth/tests/integration/mod.rs | 5 + .../integration/password_reset_sessions.rs | 119 ++++++ .../auth/tests/integration/rate_limiting.rs | 188 +++++++++ pixles-api/auth/tests/integration/totp.rs | 399 ++++++++++++++++++ pixles-api/auth/tests/integration_tests.rs | 1 + 10 files changed, 980 insertions(+) create mode 100644 pixles-api/auth/tests/integration/account_lockout.rs create mode 100644 pixles-api/auth/tests/integration/devices.rs create mode 100644 pixles-api/auth/tests/integration/mod.rs create mode 100644 pixles-api/auth/tests/integration/password_reset_sessions.rs create mode 100644 pixles-api/auth/tests/integration/rate_limiting.rs create mode 100644 pixles-api/auth/tests/integration/totp.rs diff --git a/Cargo.lock b/Cargo.lock index 54bef1c..51fe495 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6207,6 +6207,8 @@ dependencies = [ "rand 0.9.2", "sha1", "sha2", + "url", + "urlencoding", ] [[package]] @@ -6562,6 +6564,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/pixles-api/auth/Cargo.toml b/pixles-api/auth/Cargo.toml index 8c00faa..cac2cd9 100644 --- a/pixles-api/auth/Cargo.toml +++ b/pixles-api/auth/Cargo.toml @@ -66,3 +66,4 @@ tracing-subscriber = { workspace = true } base64 = "0.22.1" sea-orm-migration = { workspace = true } salvo = { workspace = true, features = ["test"] } +totp-rs = { version = "5.7.0", features = ["gen_secret", "otpauth"] } diff --git a/pixles-api/auth/src/utils/totp.rs b/pixles-api/auth/src/utils/totp.rs index 9b3698f..d0c4dfd 100644 --- a/pixles-api/auth/src/utils/totp.rs +++ b/pixles-api/auth/src/utils/totp.rs @@ -16,6 +16,8 @@ pub fn get_totp_generator(secret: &str) -> eyre::Result { Secret::Encoded(secret.to_string()) .to_bytes() .wrap_err("Failed to parse secret")?, + None, + String::new(), ) .wrap_err("Failed to create TOTP") } diff --git a/pixles-api/auth/tests/integration/account_lockout.rs b/pixles-api/auth/tests/integration/account_lockout.rs new file mode 100644 index 0000000..9dc57ab --- /dev/null +++ b/pixles-api/auth/tests/integration/account_lockout.rs @@ -0,0 +1,158 @@ +use crate::common::{build_service, setup}; +use salvo::http::StatusCode; +use salvo::test::TestClient; + +// ── helpers ──────────────────────────────────────────────────────────────── + +async fn register(service: &salvo::Service, email: &str, username: &str) { + TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": username, + "name": "Lockout Test User", + "email": email, + "password": "password123" + })) + .send(service) + .await; +} + +/// Accumulate `n` failed login attempts directly in the DB, bypassing the +/// API rate limiter so the lockout logic can be tested in isolation. +async fn fail_login_n_times(ctx: &crate::common::TestContext, user_id: &str, n: u32) { + for _ in 0..n { + service::user::Mutation::track_login_failure(&ctx.db, user_id) + .await + .expect("track_login_failure failed"); + } +} + +// ── tests ────────────────────────────────────────────────────────────────── + +/// Exactly 10 failed attempts recorded in the DB triggers the lockout on +/// the next login attempt (wrong password → 423, not 401). +#[tokio::test] +async fn account_locked_after_ten_failed_attempts() { + let ctx = setup().await; + let service = build_service(&ctx); + + register(&service, "lockout@example.com", "lockoutuser").await; + + let user = service::user::Query::find_user_by_email(&ctx.db, "lockout@example.com") + .await + .expect("DB query failed") + .expect("user should exist after registration"); + + fail_login_n_times(&ctx, &user.id, 10).await; + + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "lockout@example.com", + "password": "wrongpassword" + })) + .send(&service) + .await; + + // 423 Locked — not 401 Unauthorized + assert_eq!(res.status_code, Some(StatusCode::LOCKED)); +} + +/// After lockout the correct password is also rejected — the lockout check +/// runs before password verification. +#[tokio::test] +async fn locked_account_rejects_correct_password() { + let ctx = setup().await; + let service = build_service(&ctx); + + register(&service, "lockout2@example.com", "lockoutuser2").await; + + let user = service::user::Query::find_user_by_email(&ctx.db, "lockout2@example.com") + .await + .expect("DB query failed") + .expect("user should exist"); + + fail_login_n_times(&ctx, &user.id, 10).await; + + // "password123" is the correct password registered above. + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "lockout2@example.com", + "password": "password123" + })) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::LOCKED)); +} + +/// Fewer than 10 failed attempts must NOT trigger the lockout. +#[tokio::test] +async fn account_not_locked_below_threshold() { + let ctx = setup().await; + let service = build_service(&ctx); + + register(&service, "notlocked@example.com", "notlockeduser").await; + + let user = service::user::Query::find_user_by_email(&ctx.db, "notlocked@example.com") + .await + .expect("DB query failed") + .expect("user should exist"); + + // 9 failures — one below the threshold of 10. + fail_login_n_times(&ctx, &user.id, 9).await; + + // Correct password should still work. + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "notlocked@example.com", + "password": "password123" + })) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::OK)); +} + +/// A successful login resets the failed-attempt counter, so previously +/// failed attempts do not persist toward a future lockout. +#[tokio::test] +async fn successful_login_resets_failure_counter() { + let ctx = setup().await; + let service = build_service(&ctx); + + register(&service, "resetcount@example.com", "resetcountuser").await; + + let user = service::user::Query::find_user_by_email(&ctx.db, "resetcount@example.com") + .await + .expect("DB query failed") + .expect("user should exist"); + + // 9 failures — one below threshold. + fail_login_n_times(&ctx, &user.id, 9).await; + + // One successful login resets the counter. + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "resetcount@example.com", + "password": "password123" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // The counter should now be 0. Inject 9 more failures at the DB level; + // the account must still accept the correct password (not locked yet). + fail_login_n_times(&ctx, &user.id, 9).await; + + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "resetcount@example.com", + "password": "password123" + })) + .send(&service) + .await; + assert_eq!( + res.status_code, + Some(StatusCode::OK), + "9 failures after a counter reset should not lock the account" + ); +} diff --git a/pixles-api/auth/tests/integration/devices.rs b/pixles-api/auth/tests/integration/devices.rs new file mode 100644 index 0000000..c3d93de --- /dev/null +++ b/pixles-api/auth/tests/integration/devices.rs @@ -0,0 +1,99 @@ +use crate::common::{build_service, setup}; +use auth::models::responses::{Device, TokenResponse}; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; +use secrecy::ExposeSecret; + +// ── helpers ──────────────────────────────────────────────────────────────── + +async fn register(service: &salvo::Service, email: &str, username: &str) -> TokenResponse { + let mut res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": username, + "name": "Devices Test User", + "email": email, + "password": "password123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse token response") +} + +async fn login(service: &salvo::Service, email: &str) -> TokenResponse { + let mut res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": email, + "password": "password123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse token response") +} + +// ── tests ────────────────────────────────────────────────────────────────── + +/// GET /devices without auth must return 401. +#[tokio::test] +async fn devices_requires_auth() { + let ctx = setup().await; + let service = build_service(&ctx); + + let res = TestClient::get("http://localhost/devices") + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} + +/// After registering (which creates a session), GET /devices must return +/// exactly one device with `is_current: true`. +#[tokio::test] +async fn devices_shows_current_session() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register(&service, "dev_single@example.com", "devsingle").await; + let access = tokens.access_token.expose_secret().to_string(); + + let mut res = TestClient::get("http://localhost/devices") + .add_header("Authorization", format!("Bearer {}", access), true) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::OK)); + let devices: Vec = res.take_json().await.expect("Failed to parse devices"); + assert_eq!(devices.len(), 1, "Should have exactly one active session"); + assert!(devices[0].is_current, "The only device should be marked as current"); +} + +/// After registering (session 1) and logging in again (session 2), GET +/// /devices with the login access token must list both sessions and mark +/// only the login session as current. +#[tokio::test] +async fn devices_multiple_sessions_current_flagged_correctly() { + let ctx = setup().await; + let service = build_service(&ctx); + + // Session 1: registration + register(&service, "dev_multi@example.com", "devmulti").await; + + // Session 2: explicit login + let login_tokens = login(&service, "dev_multi@example.com").await; + let login_access = login_tokens.access_token.expose_secret().to_string(); + + let mut res = TestClient::get("http://localhost/devices") + .add_header("Authorization", format!("Bearer {}", login_access), true) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::OK)); + let devices: Vec = res.take_json().await.expect("Failed to parse devices"); + + assert_eq!(devices.len(), 2, "Both sessions should be listed"); + + let current_count = devices.iter().filter(|d| d.is_current).count(); + assert_eq!(current_count, 1, "Exactly one device should be marked current"); + + // All devices must have non-empty IDs. + for d in &devices { + assert!(!d.id.is_empty(), "Device ID must not be empty"); + } +} diff --git a/pixles-api/auth/tests/integration/mod.rs b/pixles-api/auth/tests/integration/mod.rs new file mode 100644 index 0000000..9c024d1 --- /dev/null +++ b/pixles-api/auth/tests/integration/mod.rs @@ -0,0 +1,5 @@ +mod account_lockout; +mod devices; +mod password_reset_sessions; +mod rate_limiting; +mod totp; diff --git a/pixles-api/auth/tests/integration/password_reset_sessions.rs b/pixles-api/auth/tests/integration/password_reset_sessions.rs new file mode 100644 index 0000000..0450170 --- /dev/null +++ b/pixles-api/auth/tests/integration/password_reset_sessions.rs @@ -0,0 +1,119 @@ +use crate::common::{build_service, setup}; +use auth::models::responses::TokenResponse; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; +use secrecy::ExposeSecret; + +// ── helpers ──────────────────────────────────────────────────────────────── + +async fn register(service: &salvo::Service, email: &str, username: &str) -> TokenResponse { + let mut res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": username, + "name": "Session Test User", + "email": email, + "password": "oldpassword123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse token response") +} + +// ── tests ────────────────────────────────────────────────────────────────── + +/// Completing a password reset revokes all existing sessions. An old +/// refresh token obtained before the reset must therefore be rejected. +#[tokio::test] +async fn password_reset_revokes_existing_sessions() { + let ctx = setup().await; + let service = build_service(&ctx); + + let tokens = register(&service, "session_revoke@example.com", "sessionrevoke").await; + let old_refresh = tokens.refresh_token.expose_secret().to_string(); + + // Request a password reset (always returns 200 regardless of email existence). + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "session_revoke@example.com"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // Retrieve the reset token directly from the DB (no email service in tests). + let reset_token = service::user::Query::get_password_reset_token_by_email( + &ctx.db, + "session_revoke@example.com", + ) + .await + .expect("DB query failed") + .flatten() + .expect("Reset token must be present after reset request"); + + // Confirm the reset with a new password. + let res = TestClient::post("http://localhost/password-reset") + .json(&serde_json::json!({ + "token": reset_token, + "new_password": "newpassword456" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // The old refresh token must now be rejected — all sessions were revoked. + let res = TestClient::post("http://localhost/refresh") + .json(&serde_json::json!({"refresh_token": old_refresh})) + .send(&service) + .await; + assert_ne!( + res.status_code, + Some(StatusCode::OK), + "Old refresh token should be rejected after password reset" + ); +} + +/// A second login after a password reset works with the new password and +/// produces valid tokens. +#[tokio::test] +async fn login_succeeds_with_new_password_after_reset() { + let ctx = setup().await; + let service = build_service(&ctx); + + register(&service, "session_newpw@example.com", "sessionnewpw").await; + + // Request and confirm a password reset. + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "session_newpw@example.com"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + let reset_token = service::user::Query::get_password_reset_token_by_email( + &ctx.db, + "session_newpw@example.com", + ) + .await + .expect("DB query failed") + .flatten() + .expect("Reset token must be present"); + + let res = TestClient::post("http://localhost/password-reset") + .json(&serde_json::json!({ + "token": reset_token, + "new_password": "newpassword789" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // Login with the new password must succeed and return a full token pair. + let mut res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "session_newpw@example.com", + "password": "newpassword789" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + let new_tokens: TokenResponse = res.take_json().await.expect("Failed to parse tokens"); + assert!(!new_tokens.access_token.expose_secret().is_empty()); + assert!(!new_tokens.refresh_token.expose_secret().is_empty()); +} diff --git a/pixles-api/auth/tests/integration/rate_limiting.rs b/pixles-api/auth/tests/integration/rate_limiting.rs new file mode 100644 index 0000000..416bc60 --- /dev/null +++ b/pixles-api/auth/tests/integration/rate_limiting.rs @@ -0,0 +1,188 @@ +use crate::common::{build_service, setup}; +use salvo::http::StatusCode; +use salvo::http::header::RETRY_AFTER; +use salvo::test::TestClient; + +// ── helpers ──────────────────────────────────────────────────────────────── + +async fn register(service: &salvo::Service, email: &str, username: &str) { + TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": username, + "name": "Rate Limit Test User", + "email": email, + "password": "password123" + })) + .send(service) + .await; +} + +// ── login rate limiting (10 requests/min per IP) ─────────────────────────── + +/// The first 10 login requests from the same IP should not be rate-limited. +/// The 11th must return 429. +#[tokio::test] +async fn login_rate_limit_enforced() { + let ctx = setup().await; + let service = build_service(&ctx); + + register(&service, "rl_login@example.com", "rlluser").await; + + for i in 0..10 { + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "rl_login@example.com", + "password": "wrongpass" + })) + .send(&service) + .await; + assert_ne!( + res.status_code, + Some(StatusCode::TOO_MANY_REQUESTS), + "attempt {} should not be rate-limited", + i + 1 + ); + } + + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "rl_login@example.com", + "password": "wrongpass" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::TOO_MANY_REQUESTS)); +} + +/// A 429 response from the login endpoint must carry a Retry-After header. +#[tokio::test] +async fn login_rate_limit_retry_after_header() { + let ctx = setup().await; + let service = build_service(&ctx); + + // Exhaust the 10-request window. + for _ in 0..11 { + TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "rl_hdr@example.com", + "password": "wrongpass" + })) + .send(&service) + .await; + } + + // Now we're definitely rate-limited; confirm the header is present. + let res = TestClient::post("http://localhost/login") + .json(&serde_json::json!({ + "email": "rl_hdr@example.com", + "password": "wrongpass" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::TOO_MANY_REQUESTS)); + assert!( + res.headers().contains_key(RETRY_AFTER), + "429 login response must include Retry-After header" + ); +} + +// ── register rate limiting (10 requests/min per IP) ─────────────────────── + +/// The 11th registration attempt from the same IP must return 429. +/// Requests 1-10 are allowed through (the first succeeds; the rest return +/// 409 Conflict because they use the same email, but they still consume +/// the rate-limit budget since the check runs before deduplication). +#[tokio::test] +async fn register_rate_limit_enforced() { + let ctx = setup().await; + let service = build_service(&ctx); + + for i in 0..10 { + let res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": format!("rlreg{}", i), + "name": "RL User", + "email": "rl_reg@example.com", + "password": "password123" + })) + .send(&service) + .await; + assert_ne!( + res.status_code, + Some(StatusCode::TOO_MANY_REQUESTS), + "attempt {} should not be rate-limited", + i + 1 + ); + } + + let res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": "rlreg10", + "name": "RL User", + "email": "rl_reg@example.com", + "password": "password123" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::TOO_MANY_REQUESTS)); +} + +// ── password-reset-request rate limiting (5 requests/min per email+IP) ──── + +/// After 5 password-reset-request calls for the same email the 6th must +/// return 429 (per-email rate limit bucket). +#[tokio::test] +async fn password_reset_email_rate_limit_enforced() { + let ctx = setup().await; + let service = build_service(&ctx); + + for i in 0..5 { + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "rl_reset@example.com"})) + .send(&service) + .await; + assert_ne!( + res.status_code, + Some(StatusCode::TOO_MANY_REQUESTS), + "attempt {} should not be rate-limited", + i + 1 + ); + } + + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "rl_reset@example.com"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::TOO_MANY_REQUESTS)); +} + +/// The per-IP bucket for password-reset-request is independent of the +/// per-email bucket. After 5 calls using *different* emails from the same +/// IP, the 6th call (any email) must return 429. +#[tokio::test] +async fn password_reset_ip_rate_limit_enforced() { + let ctx = setup().await; + let service = build_service(&ctx); + + // All requests share the "unknown" IP because TestClient sends no + // X-Forwarded-For header. Use distinct emails so only the IP bucket + // accumulates. + for i in 0..5 { + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": format!("rl_ip_{}@example.com", i)})) + .send(&service) + .await; + assert_ne!( + res.status_code, + Some(StatusCode::TOO_MANY_REQUESTS), + "attempt {} should not be rate-limited", + i + 1 + ); + } + + let res = TestClient::post("http://localhost/password-reset-request") + .json(&serde_json::json!({"email": "rl_ip_new@example.com"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::TOO_MANY_REQUESTS)); +} diff --git a/pixles-api/auth/tests/integration/totp.rs b/pixles-api/auth/tests/integration/totp.rs new file mode 100644 index 0000000..82923e5 --- /dev/null +++ b/pixles-api/auth/tests/integration/totp.rs @@ -0,0 +1,399 @@ +use crate::common::{build_service, setup}; +use auth::models::responses::TokenResponse; +use salvo::http::StatusCode; +use salvo::test::{ResponseExt, TestClient}; +use secrecy::ExposeSecret; +use totp_rs::{Algorithm, Secret, TOTP}; + +// ── helpers ──────────────────────────────────────────────────────────────── + +/// Register a user and return the access token from the response. +async fn register_and_login( + service: &salvo::Service, + email: &str, + username: &str, +) -> TokenResponse { + let mut res = TestClient::post("http://localhost/register") + .json(&serde_json::json!({ + "username": username, + "name": "TOTP Test User", + "email": email, + "password": "password123" + })) + .send(service) + .await; + res.take_json().await.expect("Failed to parse token response") +} + +/// Call POST /totp/enroll, assert 200, and return the provisioning URI. +async fn enroll_totp(service: &salvo::Service, access_token: &str) -> String { + let mut res = TestClient::post("http://localhost/totp/enroll") + .add_header("Authorization", format!("Bearer {}", access_token), true) + .send(service) + .await; + assert_eq!( + res.status_code, + Some(StatusCode::OK), + "TOTP enroll should succeed" + ); + let body: serde_json::Value = res.take_json().await.expect("Failed to parse enroll body"); + body["provisioning_uri"] + .as_str() + .expect("missing provisioning_uri") + .to_string() +} + +/// Extract the Base32 secret embedded in a provisioning URI. +/// +/// URI format: `otpauth://totp/{issuer}:{email}?secret={SECRET}&issuer={issuer}` +fn secret_from_uri(uri: &str) -> String { + uri.split("secret=") + .nth(1) + .expect("secret param not found in URI") + .split('&') + .next() + .expect("malformed URI") + .to_string() +} + +/// Generate the current 6-digit TOTP code for a Base32-encoded secret. +fn current_totp_code(secret: &str) -> String { + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + Secret::Encoded(secret.to_string()) + .to_bytes() + .expect("invalid Base32 secret"), + None, + String::new(), + ) + .expect("failed to build TOTP"); + totp.generate_current().expect("failed to generate code") +} + +/// Enroll TOTP and verify enrollment so the secret is marked as active. +/// Returns the Base32 secret. +async fn enroll_and_verify(service: &salvo::Service, access_token: &str) -> String { + let uri = enroll_totp(service, access_token).await; + let secret = secret_from_uri(&uri); + let code = current_totp_code(&secret); + + let res = TestClient::post("http://localhost/totp/verify-enrollment") + .add_header("Authorization", format!("Bearer {}", access_token), true) + .json(&serde_json::json!({"totp_code": code})) + .send(service) + .await; + assert_eq!( + res.status_code, + Some(StatusCode::OK), + "TOTP verify-enrollment should succeed with a valid code" + ); + secret +} + +// ── enrollment ───────────────────────────────────────────────────────────── + +#[tokio::test] +async fn totp_enroll_returns_provisioning_uri() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service, "totp_enroll@example.com", "totpenroll").await; + + let mut res = TestClient::post("http://localhost/totp/enroll") + .add_header( + "Authorization", + format!("Bearer {}", tokens.access_token.expose_secret()), + true, + ) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::OK)); + let body: serde_json::Value = res.take_json().await.unwrap(); + let uri = body["provisioning_uri"].as_str().expect("missing provisioning_uri"); + assert!(uri.starts_with("otpauth://totp/"), "URI should be an otpauth URI"); + assert!(uri.contains("secret="), "URI must contain a secret"); + assert!(uri.contains("Pixles-Test"), "URI must contain the configured issuer"); +} + +#[tokio::test] +async fn totp_enroll_requires_auth() { + let ctx = setup().await; + let service = build_service(&ctx); + + let res = TestClient::post("http://localhost/totp/enroll") + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} + +#[tokio::test] +async fn totp_enroll_twice_returns_conflict() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service, "totp_dupe@example.com", "totpdupe").await; + let access = tokens.access_token.expose_secret().to_string(); + + enroll_totp(&service, &access).await; + + // Second enroll attempt on the same account must be rejected. + let res = TestClient::post("http://localhost/totp/enroll") + .add_header("Authorization", format!("Bearer {}", access), true) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::CONFLICT)); +} + +// ── verify-enrollment ────────────────────────────────────────────────────── + +#[tokio::test] +async fn totp_verify_enrollment_valid_code_succeeds() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = + register_and_login(&service, "totp_verify_ok@example.com", "totpverifyok").await; + let access = tokens.access_token.expose_secret().to_string(); + + let uri = enroll_totp(&service, &access).await; + let secret = secret_from_uri(&uri); + let code = current_totp_code(&secret); + + let res = TestClient::post("http://localhost/totp/verify-enrollment") + .add_header("Authorization", format!("Bearer {}", access), true) + .json(&serde_json::json!({"totp_code": code})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); +} + +#[tokio::test] +async fn totp_verify_enrollment_invalid_code_returns_bad_request() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = + register_and_login(&service, "totp_verify_bad@example.com", "totpverifybad").await; + let access = tokens.access_token.expose_secret().to_string(); + + enroll_totp(&service, &access).await; + + // "000000" is an invalid TOTP code with overwhelming probability. + let res = TestClient::post("http://localhost/totp/verify-enrollment") + .add_header("Authorization", format!("Bearer {}", access), true) + .json(&serde_json::json!({"totp_code": "000000"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::BAD_REQUEST)); +} + +#[tokio::test] +async fn totp_verify_enrollment_without_enrolling_returns_bad_request() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = + register_and_login(&service, "totp_no_enroll@example.com", "totpnoenroll").await; + let access = tokens.access_token.expose_secret().to_string(); + + // No /totp/enroll call — there is no secret to verify against. + let res = TestClient::post("http://localhost/totp/verify-enrollment") + .add_header("Authorization", format!("Bearer {}", access), true) + .json(&serde_json::json!({"totp_code": "123456"})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::BAD_REQUEST)); +} + +// ── disable ──────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn totp_disable_with_valid_code_succeeds() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service, "totp_dis@example.com", "totpdis").await; + let access = tokens.access_token.expose_secret().to_string(); + + let secret = enroll_and_verify(&service, &access).await; + let code = current_totp_code(&secret); + + let res = TestClient::post("http://localhost/totp/disable") + .add_header("Authorization", format!("Bearer {}", access), true) + .json(&serde_json::json!({"totp_code": code})) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); + + // After disabling, re-enrolling must succeed (not 409). + let res = TestClient::post("http://localhost/totp/enroll") + .add_header("Authorization", format!("Bearer {}", access), true) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::OK)); +} + +#[tokio::test] +async fn totp_disable_with_invalid_code_fails() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service, "totp_dis_bad@example.com", "totpdisbad").await; + let access = tokens.access_token.expose_secret().to_string(); + + enroll_and_verify(&service, &access).await; + + let res = TestClient::post("http://localhost/totp/disable") + .add_header("Authorization", format!("Bearer {}", access), true) + .json(&serde_json::json!({"totp_code": "000000"})) + .send(&service) + .await; + // Invalid code is a 4xx — exact code depends on the TotpDisableResponses writer. + assert!( + res.status_code.map(|s| s.is_client_error()).unwrap_or(false), + "disable with wrong code should be a 4xx" + ); +} + +// ── verify-totp login ────────────────────────────────────────────────────── + +/// A garbage string as mfa_token must return 401 (not 500 or 200). +#[tokio::test] +async fn totp_verify_login_invalid_mfa_token() { + let ctx = setup().await; + let service = build_service(&ctx); + + let res = TestClient::post("http://localhost/login/verify-totp") + .json(&serde_json::json!({ + "mfa_token": "this.is.not.a.valid.jwt", + "totp_code": "123456" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::UNAUTHORIZED)); +} + +/// A legitimate MFA token combined with a wrong TOTP code returns 403. +#[tokio::test] +async fn totp_verify_login_wrong_code_returns_forbidden() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = + register_and_login(&service, "totp_wrong@example.com", "totpwrong").await; + let access = tokens.access_token.expose_secret().to_string(); + + // Enroll and verify TOTP so the user has an active secret. + enroll_and_verify(&service, &access).await; + + // Retrieve the user's ID from the DB to generate the MFA token directly. + let user = service::user::Query::find_user_by_email(&ctx.db, "totp_wrong@example.com") + .await + .expect("DB query failed") + .expect("user should exist"); + + let mfa_token = ctx + .app_state + .auth_service + .generate_mfa_token(&user.id) + .expect("failed to generate MFA token"); + + let res = TestClient::post("http://localhost/login/verify-totp") + .json(&serde_json::json!({ + "mfa_token": mfa_token, + "totp_code": "000000" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::FORBIDDEN)); +} + +/// After 3 wrong TOTP codes the 4th attempt returns 429 (max attempts +/// exceeded), regardless of whether the code is correct. +#[tokio::test] +async fn totp_verify_login_max_attempts_exceeded() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = + register_and_login(&service, "totp_maxattempts@example.com", "totpmax").await; + let access = tokens.access_token.expose_secret().to_string(); + + enroll_and_verify(&service, &access).await; + + let user = service::user::Query::find_user_by_email(&ctx.db, "totp_maxattempts@example.com") + .await + .expect("DB query failed") + .expect("user should exist"); + + // All 4 requests use the same MFA token so the attempt counter + // accumulates on the same JTI. + let mfa_token = ctx + .app_state + .auth_service + .generate_mfa_token(&user.id) + .expect("failed to generate MFA token"); + + // 3 wrong codes — each increments the attempt counter (0→1, 1→2, 2→3). + for attempt in 1..=3 { + let res = TestClient::post("http://localhost/login/verify-totp") + .json(&serde_json::json!({ + "mfa_token": mfa_token, + "totp_code": "000000" + })) + .send(&service) + .await; + assert_eq!( + res.status_code, + Some(StatusCode::FORBIDDEN), + "attempt {} should be FORBIDDEN (wrong code, not yet locked)", + attempt + ); + } + + // 4th attempt — counter is now 3 which satisfies `>= 3`, so locked out. + let res = TestClient::post("http://localhost/login/verify-totp") + .json(&serde_json::json!({ + "mfa_token": mfa_token, + "totp_code": "000000" + })) + .send(&service) + .await; + assert_eq!(res.status_code, Some(StatusCode::TOO_MANY_REQUESTS)); +} + +/// A valid MFA token + correct TOTP code must return 200 with a full token +/// pair (access + refresh). +#[tokio::test] +async fn totp_verify_login_success() { + let ctx = setup().await; + let service = build_service(&ctx); + let tokens = register_and_login(&service, "totp_ok@example.com", "totpok").await; + let access = tokens.access_token.expose_secret().to_string(); + + let secret = enroll_and_verify(&service, &access).await; + + let user = service::user::Query::find_user_by_email(&ctx.db, "totp_ok@example.com") + .await + .expect("DB query failed") + .expect("user should exist"); + + let mfa_token = ctx + .app_state + .auth_service + .generate_mfa_token(&user.id) + .expect("failed to generate MFA token"); + + let code = current_totp_code(&secret); + + let mut res = TestClient::post("http://localhost/login/verify-totp") + .json(&serde_json::json!({ + "mfa_token": mfa_token, + "totp_code": code + })) + .send(&service) + .await; + + assert_eq!(res.status_code, Some(StatusCode::OK)); + let full_tokens: TokenResponse = res + .take_json() + .await + .expect("Failed to parse token response"); + assert!(!full_tokens.access_token.expose_secret().is_empty()); + assert!(!full_tokens.refresh_token.expose_secret().is_empty()); +} diff --git a/pixles-api/auth/tests/integration_tests.rs b/pixles-api/auth/tests/integration_tests.rs index 05566ef..cc96535 100644 --- a/pixles-api/auth/tests/integration_tests.rs +++ b/pixles-api/auth/tests/integration_tests.rs @@ -1,2 +1,3 @@ mod api; mod common; +mod integration; From c6e200a6a440bc4f1bb6a0ec9de66eaf1f4256d2 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:29:34 -0500 Subject: [PATCH 17/17] Lint web --- pixles-web/src/components/header.tsx | 14 +- pixles-web/src/components/lazy-image.tsx | 2 +- .../src/components/mfa/passkey-register.tsx | 15 +- pixles-web/src/components/mfa/totp-enroll.tsx | 33 +++-- pixles-web/src/lib/api.ts | 41 ++++-- pixles-web/src/lib/auth-context.tsx | 17 +-- pixles-web/src/lib/webauthn.ts | 81 +++++++---- pixles-web/src/routes/__root.tsx | 18 ++- .../src/routes/forgot-password.lazy.tsx | 29 +++- pixles-web/src/routes/login.lazy.tsx | 58 ++++++-- pixles-web/src/routes/reset-password.tsx | 40 ++++-- pixles-web/src/routes/settings.lazy.tsx | 54 +++++-- .../src/routes/settings/security.lazy.tsx | 136 +++++++++++++----- 13 files changed, 399 insertions(+), 139 deletions(-) diff --git a/pixles-web/src/components/header.tsx b/pixles-web/src/components/header.tsx index d489737..d3e4a4a 100644 --- a/pixles-web/src/components/header.tsx +++ b/pixles-web/src/components/header.tsx @@ -9,8 +9,8 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; -import { APP_NAME } from '@/lib/constant'; import { useAuth } from '@/lib/auth-context'; +import { APP_NAME } from '@/lib/constant'; import { Link, useNavigate } from '@tanstack/react-router'; import { BellIcon, MountainIcon, UploadIcon } from 'lucide-react'; import { ModeToggle } from './ui/mode-toggle'; @@ -79,8 +79,12 @@ export const Header = () => { {user && ( <> -
{user.name}
-
{user.email}
+
+ {user.name} +
+
+ {user.email} +
)} @@ -88,7 +92,9 @@ export const Header = () => { Profile - Security + + Security + {alt} {!isLoaded && ( diff --git a/pixles-web/src/components/mfa/passkey-register.tsx b/pixles-web/src/components/mfa/passkey-register.tsx index 7fe9953..dc00af6 100644 --- a/pixles-web/src/components/mfa/passkey-register.tsx +++ b/pixles-web/src/components/mfa/passkey-register.tsx @@ -8,7 +8,11 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { ApiError, passkeyRegisterFinish, passkeyRegisterStart } from '@/lib/api'; +import { + ApiError, + passkeyRegisterFinish, + passkeyRegisterStart, +} from '@/lib/api'; import { registerPasskey } from '@/lib/webauthn'; import { KeyRoundIcon } from 'lucide-react'; import { useState } from 'react'; @@ -37,7 +41,10 @@ export function PasskeyRegister({ onSuccess, onCancel }: PasskeyRegisterProps) { setError(err.message); } else if (err instanceof Error && err.name === 'NotAllowedError') { setError('Passkey registration was cancelled.'); - } else if (err instanceof Error && err.name === 'InvalidStateError') { + } else if ( + err instanceof Error && + err.name === 'InvalidStateError' + ) { setError('A passkey for this device is already registered.'); } else { setError('Passkey registration failed.'); @@ -50,8 +57,8 @@ export function PasskeyRegister({ onSuccess, onCancel }: PasskeyRegisterProps) { return (

- Passkeys use your device's biometrics or PIN to sign in securely without a - password. + Passkeys use your device's biometrics or PIN to sign in securely + without a password.

{error &&

{error}

}
diff --git a/pixles-web/src/components/mfa/totp-enroll.tsx b/pixles-web/src/components/mfa/totp-enroll.tsx index cbbd04f..1e84d5b 100644 --- a/pixles-web/src/components/mfa/totp-enroll.tsx +++ b/pixles-web/src/components/mfa/totp-enroll.tsx @@ -36,7 +36,11 @@ export function TotpEnroll({ onSuccess, onCancel }: TotpEnrollProps) { setProvisioningUri(provisioning_uri); setStep('scan'); } catch (err) { - setError(err instanceof ApiError ? err.message : 'Failed to start enrollment.'); + setError( + err instanceof ApiError + ? err.message + : 'Failed to start enrollment.', + ); } finally { setLoading(false); } @@ -50,7 +54,11 @@ export function TotpEnroll({ onSuccess, onCancel }: TotpEnrollProps) { await totpVerifyEnrollment(code); onSuccess(); } catch (err) { - setError(err instanceof ApiError ? err.message : 'Invalid code. Please try again.'); + setError( + err instanceof ApiError + ? err.message + : 'Invalid code. Please try again.', + ); } finally { setLoading(false); } @@ -60,8 +68,8 @@ export function TotpEnroll({ onSuccess, onCancel }: TotpEnrollProps) { return (

- Use an authenticator app (e.g. Google Authenticator, Authy) to scan a QR code - and generate one-time codes. + Use an authenticator app (e.g. Google Authenticator, Authy) + to scan a QR code and generate one-time codes.

-
diff --git a/pixles-web/src/lib/api.ts b/pixles-web/src/lib/api.ts index a0c7cd0..a0bea98 100644 --- a/pixles-web/src/lib/api.ts +++ b/pixles-web/src/lib/api.ts @@ -4,12 +4,12 @@ */ import { + type TokenPair, clearTokens, getAccessToken, getRefreshToken, isAccessTokenValid, saveTokens, - type TokenPair, } from './auth'; const API_BASE = import.meta.env.PUBLIC_API_URL ?? 'http://localhost:3000'; @@ -28,7 +28,10 @@ export class ApiError extends Error { async function parseError(res: Response): Promise { try { const body = await res.json(); - return new ApiError(res.status, body.error ?? body.message ?? res.statusText); + return new ApiError( + res.status, + body.error ?? body.message ?? res.statusText, + ); } catch { return new ApiError(res.status, res.statusText); } @@ -75,10 +78,14 @@ export async function authFetch( } } - const token = getAccessToken()!; + const token = getAccessToken(); + if (!token) throw new ApiError(401, 'Session expired'); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); - headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json'); + headers.set( + 'Content-Type', + headers.get('Content-Type') ?? 'application/json', + ); const res = await fetch(`${AUTH_BASE}${path}`, { ...init, headers }); @@ -103,7 +110,9 @@ export interface LoginMfaRequiredResponse { mfa_token: string; } -export async function login(body: LoginRequest): Promise { +export async function login( + body: LoginRequest, +): Promise { const res = await fetch(`${AUTH_BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -140,7 +149,10 @@ export async function logout(): Promise { // ── TOTP endpoints ────────────────────────────────────────────────────────── -export async function verifyTotpLogin(mfaToken: string, totpCode: string): Promise { +export async function verifyTotpLogin( + mfaToken: string, + totpCode: string, +): Promise { const res = await fetch(`${AUTH_BASE}/login/verify-totp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -188,7 +200,9 @@ export async function passkeyLoginStart(username?: string): Promise { return res.json(); } -export async function passkeyLoginFinish(credential: unknown): Promise { +export async function passkeyLoginFinish( + credential: unknown, +): Promise { const res = await fetch(`${AUTH_BASE}/passkey/login/finish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -229,7 +243,9 @@ export async function listPasskeys(): Promise { } export async function deletePasskey(credId: string): Promise { - const res = await authFetch(`/passkey/credentials/${credId}`, { method: 'DELETE' }); + const res = await authFetch(`/passkey/credentials/${credId}`, { + method: 'DELETE', + }); if (!res.ok) throw await parseError(res); } @@ -258,7 +274,9 @@ export interface UpdateProfileRequest { new_password?: string; } -export async function updateProfile(body: UpdateProfileRequest): Promise { +export async function updateProfile( + body: UpdateProfileRequest, +): Promise { const res = await authFetch('/profile', { method: 'POST', body: JSON.stringify(body), @@ -295,7 +313,10 @@ export async function requestPasswordReset(email: string): Promise { if (!res.ok) throw await parseError(res); } -export async function resetPassword(token: string, newPassword: string): Promise { +export async function resetPassword( + token: string, + newPassword: string, +): Promise { const res = await fetch(`${AUTH_BASE}/password-reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/pixles-web/src/lib/auth-context.tsx b/pixles-web/src/lib/auth-context.tsx index bbd8d14..f111829 100644 --- a/pixles-web/src/lib/auth-context.tsx +++ b/pixles-web/src/lib/auth-context.tsx @@ -4,20 +4,21 @@ */ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import React, { createContext, useCallback, useContext, useEffect } from 'react'; +import type React from 'react'; +import { createContext, useCallback, useContext, useEffect } from 'react'; import { + type UserProfile, + logout as apiLogout, + refreshAccessToken as apiRefresh, + getProfile, +} from './api'; +import { + type TokenPair, clearTokens, getTokenExpiry, hasTokens, saveTokens, - type TokenPair, } from './auth'; -import { - getProfile, - logout as apiLogout, - refreshAccessToken as apiRefresh, - type UserProfile, -} from './api'; export interface AuthState { /** The currently authenticated user, or null if not logged in. */ diff --git a/pixles-web/src/lib/webauthn.ts b/pixles-web/src/lib/webauthn.ts index 426f725..239d801 100644 --- a/pixles-web/src/lib/webauthn.ts +++ b/pixles-web/src/lib/webauthn.ts @@ -5,7 +5,10 @@ function base64urlToBuffer(base64url: string): ArrayBuffer { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); - const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + '=', + ); const binary = atob(padded); const buffer = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { @@ -20,40 +23,49 @@ function bufferToBase64url(buffer: ArrayBuffer): string { for (const byte of bytes) { binary += String.fromCharCode(byte); } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); } /** Transform server-side creation options (base64url strings) to browser-expected formats */ -function prepareCreationOptions(options: Record): PublicKeyCredentialCreationOptions { +function prepareCreationOptions( + options: Record, +): PublicKeyCredentialCreationOptions { const pk = options.publicKey as Record; return { ...pk, challenge: base64urlToBuffer(pk.challenge as string), user: { ...(pk.user as Record), - id: base64urlToBuffer((pk.user as Record).id as string), + id: base64urlToBuffer( + (pk.user as Record).id as string, + ), }, - excludeCredentials: ((pk.excludeCredentials as Array>) ?? []).map( - (cred) => ({ - ...cred, - id: base64urlToBuffer(cred.id as string), - }), - ), + excludeCredentials: ( + (pk.excludeCredentials as Array>) ?? [] + ).map((cred) => ({ + ...cred, + id: base64urlToBuffer(cred.id as string), + })), } as unknown as PublicKeyCredentialCreationOptions; } /** Transform server-side request options (base64url strings) to browser-expected formats */ -function prepareRequestOptions(options: Record): PublicKeyCredentialRequestOptions { +function prepareRequestOptions( + options: Record, +): PublicKeyCredentialRequestOptions { const pk = options.publicKey as Record; return { ...pk, challenge: base64urlToBuffer(pk.challenge as string), - allowCredentials: ((pk.allowCredentials as Array>) ?? []).map( - (cred) => ({ - ...cred, - id: base64urlToBuffer(cred.id as string), - }), - ), + allowCredentials: ( + (pk.allowCredentials as Array>) ?? [] + ).map((cred) => ({ + ...cred, + id: base64urlToBuffer(cred.id as string), + })), } as unknown as PublicKeyCredentialRequestOptions; } @@ -66,20 +78,27 @@ function serializeCredential(cred: PublicKeyCredential): unknown { rawId: bufferToBase64url(cred.rawId), type: cred.type, response: { - attestationObject: bufferToBase64url(response.attestationObject), + attestationObject: bufferToBase64url( + response.attestationObject, + ), clientDataJSON: bufferToBase64url(response.clientDataJSON), }, }; - } else if (response instanceof AuthenticatorAssertionResponse) { + } + if (response instanceof AuthenticatorAssertionResponse) { return { id: cred.id, rawId: bufferToBase64url(cred.rawId), type: cred.type, response: { - authenticatorData: bufferToBase64url(response.authenticatorData), + authenticatorData: bufferToBase64url( + response.authenticatorData, + ), clientDataJSON: bufferToBase64url(response.clientDataJSON), signature: bufferToBase64url(response.signature), - userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + userHandle: response.userHandle + ? bufferToBase64url(response.userHandle) + : null, }, }; } @@ -87,17 +106,27 @@ function serializeCredential(cred: PublicKeyCredential): unknown { } /** Trigger passkey authentication and return serialized credential */ -export async function authenticateWithPasskey(requestOptions: unknown): Promise { - const options = prepareRequestOptions(requestOptions as Record); +export async function authenticateWithPasskey( + requestOptions: unknown, +): Promise { + const options = prepareRequestOptions( + requestOptions as Record, + ); const credential = await navigator.credentials.get({ publicKey: options }); if (!credential) throw new Error('No credential returned'); return serializeCredential(credential as PublicKeyCredential); } /** Trigger passkey registration and return serialized credential */ -export async function registerPasskey(creationOptions: unknown): Promise { - const options = prepareCreationOptions(creationOptions as Record); - const credential = await navigator.credentials.create({ publicKey: options }); +export async function registerPasskey( + creationOptions: unknown, +): Promise { + const options = prepareCreationOptions( + creationOptions as Record, + ); + const credential = await navigator.credentials.create({ + publicKey: options, + }); if (!credential) throw new Error('No credential returned'); return serializeCredential(credential as PublicKeyCredential); } diff --git a/pixles-web/src/routes/__root.tsx b/pixles-web/src/routes/__root.tsx index 4f38b33..127ef3e 100644 --- a/pixles-web/src/routes/__root.tsx +++ b/pixles-web/src/routes/__root.tsx @@ -1,4 +1,9 @@ -import { Outlet, createRootRoute, useNavigate, useRouterState } from '@tanstack/react-router'; +import { + Outlet, + createRootRoute, + useNavigate, + useRouterState, +} from '@tanstack/react-router'; import { AppSidebar } from '@/components/app-sidebar'; import { Header } from '@/components/header'; @@ -27,7 +32,12 @@ const ReactQueryDevtoolsProduction = React.lazy(() => ); /** Paths that do not require authentication */ -const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/reset-password']; +const PUBLIC_PATHS = [ + '/login', + '/register', + '/forgot-password', + '/reset-password', +]; function AuthGuard({ children }: { children: React.ReactNode }) { const { isLoading, isAuthenticated } = useAuth(); @@ -35,7 +45,9 @@ function AuthGuard({ children }: { children: React.ReactNode }) { const { location } = useRouterState(); const pathname = location.pathname; - const isPublic = PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/')); + const isPublic = PUBLIC_PATHS.some( + (p) => pathname === p || pathname.startsWith(`${p}/`), + ); useEffect(() => { if (!isLoading && !isAuthenticated && !isPublic) { diff --git a/pixles-web/src/routes/forgot-password.lazy.tsx b/pixles-web/src/routes/forgot-password.lazy.tsx index ab55b7f..0019b89 100644 --- a/pixles-web/src/routes/forgot-password.lazy.tsx +++ b/pixles-web/src/routes/forgot-password.lazy.tsx @@ -12,7 +12,8 @@ import { Label } from '@/components/ui/label'; import { ApiError, requestPasswordReset } from '@/lib/api'; import { Link, createLazyFileRoute } from '@tanstack/react-router'; import { MountainIcon } from 'lucide-react'; -import React, { useState } from 'react'; +import type React from 'react'; +import { useState } from 'react'; export const Route = createLazyFileRoute('/forgot-password')({ component: ForgotPassword, @@ -61,7 +62,11 @@ function ForgotPassword() { {!submitted ? ( - {error &&

{error}

} + {error && ( +

+ {error} +

+ )}
- - + Back to login @@ -87,10 +99,13 @@ function ForgotPassword() { ) : (

- If an account with that email exists, you'll receive a reset link - shortly. + If an account with that email exists, you'll receive + a reset link shortly.

- + Back to login
diff --git a/pixles-web/src/routes/login.lazy.tsx b/pixles-web/src/routes/login.lazy.tsx index 4a8dd79..63b1543 100644 --- a/pixles-web/src/routes/login.lazy.tsx +++ b/pixles-web/src/routes/login.lazy.tsx @@ -9,7 +9,6 @@ import { } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useAuth } from '@/lib/auth-context'; import { ApiError, login, @@ -17,10 +16,12 @@ import { passkeyLoginStart, verifyTotpLogin, } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; import { authenticateWithPasskey } from '@/lib/webauthn'; import { Link, createLazyFileRoute, useNavigate } from '@tanstack/react-router'; import { KeyRoundIcon, MountainIcon } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import type React from 'react'; +import { useEffect, useState } from 'react'; export const Route = createLazyFileRoute('/login')({ component: Login, @@ -61,7 +62,11 @@ function Login() { navigate({ to: '/photos' }); } } catch (err) { - setError(err instanceof ApiError ? err.message : 'An unexpected error occurred.'); + setError( + err instanceof ApiError + ? err.message + : 'An unexpected error occurred.', + ); } finally { setLoading(false); } @@ -76,7 +81,11 @@ function Login() { setTokens(tokens); navigate({ to: '/photos' }); } catch (err) { - setError(err instanceof ApiError ? err.message : 'An unexpected error occurred.'); + setError( + err instanceof ApiError + ? err.message + : 'An unexpected error occurred.', + ); } finally { setLoading(false); } @@ -122,7 +131,9 @@ function Login() { {error && ( -

{error}

+

+ {error} +

)}
@@ -143,13 +154,19 @@ function Login() { type="password" required value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => + setPassword(e.target.value) + } disabled={loading} />
-
@@ -157,7 +174,9 @@ function Login() {
- or + + or +
diff --git a/pixles-web/src/routes/settings.lazy.tsx b/pixles-web/src/routes/settings.lazy.tsx index b6e6629..82c58db 100644 --- a/pixles-web/src/routes/settings.lazy.tsx +++ b/pixles-web/src/routes/settings.lazy.tsx @@ -8,11 +8,12 @@ import { } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useAuth } from '@/lib/auth-context'; import { ApiError, updateProfile } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; import { useQueryClient } from '@tanstack/react-query'; import { Link, createLazyFileRoute } from '@tanstack/react-router'; -import React, { useEffect, useState } from 'react'; +import type React from 'react'; +import { useEffect, useState } from 'react'; export const Route = createLazyFileRoute('/settings')({ component: Settings, @@ -48,7 +49,11 @@ function Settings() { queryClient.setQueryData(['auth', 'profile'], updated); setSuccess('Profile updated.'); } catch (err) { - setError(err instanceof ApiError ? err.message : 'Failed to update profile.'); + setError( + err instanceof ApiError + ? err.message + : 'Failed to update profile.', + ); } finally { setLoading(false); } @@ -77,7 +82,11 @@ function Settings() { setNewPassword(''); setConfirmPassword(''); } catch (err) { - setError(err instanceof ApiError ? err.message : 'Failed to update password.'); + setError( + err instanceof ApiError + ? err.message + : 'Failed to update password.', + ); } finally { setLoading(false); } @@ -87,7 +96,10 @@ function Settings() {

Profile Settings

- + Security settings →
@@ -95,12 +107,18 @@ function Settings() { Profile Information - Update your username and email address. + + Update your username and email address. + - {error &&

{error}

} - {success &&

{success}

} + {error && ( +

{error}

+ )} + {success && ( +

{success}

+ )}
Change Password - Enter your current password to set a new one. + + Enter your current password to set a new one. +
- + setCurrentPassword(e.target.value)} + onChange={(e) => + setCurrentPassword(e.target.value) + } disabled={loading} />
@@ -158,13 +182,17 @@ function Settings() { />
- + setConfirmPassword(e.target.value)} + onChange={(e) => + setConfirmPassword(e.target.value) + } disabled={loading} />
diff --git a/pixles-web/src/routes/settings/security.lazy.tsx b/pixles-web/src/routes/settings/security.lazy.tsx index 858a201..4fe85a4 100644 --- a/pixles-web/src/routes/settings/security.lazy.tsx +++ b/pixles-web/src/routes/settings/security.lazy.tsx @@ -1,3 +1,5 @@ +import { PasskeyRegister } from '@/components/mfa/passkey-register'; +import { TotpEnroll } from '@/components/mfa/totp-enroll'; import { Button } from '@/components/ui/button'; import { Card, @@ -6,14 +8,21 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; -import { TotpEnroll } from '@/components/mfa/totp-enroll'; -import { PasskeyRegister } from '@/components/mfa/passkey-register'; -import { ApiError, getDevices, listPasskeys, deletePasskey, totpDisable, type Device, type PasskeyCredential } from '@/lib/api'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, createLazyFileRoute } from '@tanstack/react-router'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import React, { useState } from 'react'; +import { + ApiError, + type Device, + type PasskeyCredential, + deletePasskey, + getDevices, + listPasskeys, + totpDisable, +} from '@/lib/api'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, createLazyFileRoute } from '@tanstack/react-router'; +import type React from 'react'; +import { useState } from 'react'; export const Route = createLazyFileRoute('/settings/security')({ component: SecuritySettings, @@ -36,11 +45,15 @@ function DeviceCard({ device }: { device: Device }) {
{device.user_agent ?? 'Unknown device'} {device.is_current && ( - (This device) + + (This device) + )}
{device.ip_address && ( -
{device.ip_address}
+
+ {device.ip_address} +
)}
Last active: {formatDate(device.last_active_at)} @@ -50,7 +63,10 @@ function DeviceCard({ device }: { device: Device }) { ); } -function PasskeyRow({ passkey, onDeleted }: { passkey: PasskeyCredential; onDeleted: () => void }) { +function PasskeyRow({ + passkey, + onDeleted, +}: { passkey: PasskeyCredential; onDeleted: () => void }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -62,7 +78,11 @@ function PasskeyRow({ passkey, onDeleted }: { passkey: PasskeyCredential; onDele await deletePasskey(passkey.id); onDeleted(); } catch (err) { - setError(err instanceof ApiError ? err.message : 'Failed to delete passkey.'); + setError( + err instanceof ApiError + ? err.message + : 'Failed to delete passkey.', + ); } finally { setLoading(false); } @@ -75,7 +95,9 @@ function PasskeyRow({ passkey, onDeleted }: { passkey: PasskeyCredential; onDele
Added: {formatDate(passkey.created_at)}
- {error &&
{error}
} + {error && ( +
{error}
+ )}

- If TOTP is currently enabled, enter a code to disable it: + If TOTP is currently enabled, enter a code + to disable it:

- +
-
{totpDisableError && ( -

{totpDisableError}

+

+ {totpDisableError} +

)}
@@ -225,23 +286,34 @@ function SecuritySettings() { {passkeysLoading && ( -

Loading passkeys…

+

+ Loading passkeys… +

)} {passkeys?.map((passkey) => ( queryClient.invalidateQueries({ queryKey: ['auth', 'passkeys'] })} + onDeleted={() => + queryClient.invalidateQueries({ + queryKey: ['auth', 'passkeys'], + }) + } /> ))} - {!passkeysLoading && (!passkeys || passkeys.length === 0) && ( -

No passkeys registered.

- )} + {!passkeysLoading && + (!passkeys || passkeys.length === 0) && ( +

+ No passkeys registered. +

+ )} {showPasskeyRegister ? ( { setShowPasskeyRegister(false); - queryClient.invalidateQueries({ queryKey: ['auth', 'passkeys'] }); + queryClient.invalidateQueries({ + queryKey: ['auth', 'passkeys'], + }); }} onCancel={() => setShowPasskeyRegister(false)} />