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 new file mode 100644 index 0000000..679cd7a --- /dev/null +++ b/AGENTS.md @@ -0,0 +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. + 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 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/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..79aa496 100644 --- a/pixles-api/auth/src/config.rs +++ b/pixles-api/auth/src/config.rs @@ -22,6 +22,10 @@ pub struct AuthConfig { /// Valkey URL 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 { @@ -35,6 +39,8 @@ 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(), + allowed_origins: config.allowed_origins.clone(), } } } diff --git a/pixles-api/auth/src/errors.rs b/pixles-api/auth/src/errors.rs index 712236c..6acebe5 100644 --- a/pixles-api/auth/src/errors.rs +++ b/pixles-api/auth/src/errors.rs @@ -58,7 +58,8 @@ impl Writer for RegisterError { #[derive(Debug)] pub enum LoginError { InvalidCredentials, - AccountNotVerified, + AccountLocked, + RateLimited(u64), Unexpected(InternalServerError), } @@ -66,7 +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::AccountNotVerified => write!(f, "Account not verified"), + 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), } } @@ -92,9 +94,17 @@ 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::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/lib.rs b/pixles-api/auth/src/lib.rs index b3a49e6..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; @@ -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, @@ -38,9 +44,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))?; @@ -50,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, @@ -63,13 +71,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/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/models/responses.rs b/pixles-api/auth/src/models/responses.rs index 59f2b47..84db984 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, @@ -41,6 +42,7 @@ pub enum RegisterUserResponses { Success(TokenResponse), BadRequest(BadRegisterUserRequestError), UserAlreadyExists, + RateLimited(u64), InternalServerError(InternalServerError), } @@ -79,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, } } @@ -114,7 +124,8 @@ pub enum LoginResponses { Success(TokenResponse), BadRequest, InvalidCredentials, - AccountNotVerified, + AccountLocked, + RateLimited(u64), InternalServerError(InternalServerError), } @@ -131,7 +142,8 @@ impl From for LoginResponses { fn from(e: LoginError) -> Self { match e { LoginError::InvalidCredentials => Self::InvalidCredentials, - LoginError::AccountNotVerified => Self::AccountNotVerified, + LoginError::AccountLocked => Self::AccountLocked, + LoginError::RateLimited(r) => Self::RateLimited(r), LoginError::Unexpected(e) => Self::InternalServerError(e), } } @@ -153,13 +165,20 @@ 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::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; } } } @@ -299,6 +318,7 @@ impl EndpointOutRegister for ValidateTokenResponses { pub enum ResetPasswordRequestResponses { Success, BadRequest, + RateLimited(u64), InternalServerError(InternalServerError), } @@ -314,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 900c08a..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; @@ -6,21 +10,54 @@ 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; 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) @@ -155,5 +213,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/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/routes/password.rs b/pixles-api/auth/src/routes/password.rs index 7e6350d..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,18 +11,76 @@ 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 - .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..91c97de 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; @@ -71,7 +72,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 { @@ -79,10 +79,17 @@ impl AuthService { name, email, password_hash, + registered_via: None, }, ) .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 @@ -102,12 +109,13 @@ 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 + // 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) @@ -212,6 +220,8 @@ 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(), + allowed_origins: vec!["*".to_string()], }; AuthService::new(conn, config) } 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/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/src/session/mod.rs b/pixles-api/auth/src/session/mod.rs index 38ec4c2..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 { @@ -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?; @@ -117,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/auth/src/state.rs b/pixles-api/auth/src/state.rs index c42179b..e2bd5a1 100644 --- a/pixles-api/auth/src/state.rs +++ b/pixles-api/auth/src/state.rs @@ -1,10 +1,9 @@ use std::sync::Arc; -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 +15,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,19 +26,17 @@ 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()); 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 { conn, config, session_manager, - email_service, auth_service, password_service, totp_service, 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/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 df8d283..5e9c363 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,24 +90,37 @@ 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(), + totp_issuer: "Pixles-Test".to_string(), + allowed_origins: vec!["*".to_string()], }; - let session_manager = - auth::session::SessionManager::new(valkey_url.clone(), std::time::Duration::from_secs(60)) - .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 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( + webauthn_rs::prelude::WebauthnBuilder::new("localhost", &rp_origin) + .expect("valid builder") + .build() + .expect("valid 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/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 4dc0bbe..cc96535 100644 --- a/pixles-api/auth/tests/integration_tests.rs +++ b/pixles-api/auth/tests/integration_tests.rs @@ -1,3 +1,3 @@ mod api; mod common; -// TODO: Untested! Verify these test pass +mod integration; diff --git a/pixles-api/entity/src/user.rs b/pixles-api/entity/src/user.rs index 9677da0..f914997 100644 --- a/pixles-api/entity/src/user.rs +++ b/pixles-api/entity/src/user.rs @@ -53,11 +53,11 @@ 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: -// - 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 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/environment/src/lib.rs b/pixles-api/environment/src/lib.rs index a6823d8..2b7bc2b 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 @@ -70,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 ^^ @@ -158,6 +165,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")) @@ -172,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/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) diff --git a/pixles-api/service/src/user/query.rs b/pixles-api/service/src/user/query.rs index 051172c..ffb99e7 100644 --- a/pixles-api/service/src/user/query.rs +++ b/pixles-api/service/src/user/query.rs @@ -121,4 +121,33 @@ 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)) + } + + /// 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)) + } } 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, 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/header.tsx b/pixles-web/src/components/header.tsx index a134de7..d3e4a4a 100644 --- a/pixles-web/src/components/header.tsx +++ b/pixles-web/src/components/header.tsx @@ -9,60 +9,109 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; +import { useAuth } from '@/lib/auth-context'; import { APP_NAME } from '@/lib/constant'; -import { Link } from '@tanstack/react-router'; +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/components/lazy-image.tsx b/pixles-web/src/components/lazy-image.tsx index ea5608e..29541b0 100644 --- a/pixles-web/src/components/lazy-image.tsx +++ b/pixles-web/src/components/lazy-image.tsx @@ -78,10 +78,10 @@ export function LazyImage({ > {alt} {!isLoaded && ( 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..dc00af6 --- /dev/null +++ b/pixles-web/src/components/mfa/passkey-register.tsx @@ -0,0 +1,86 @@ +/** + * 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..1e84d5b --- /dev/null +++ b/pixles-web/src/components/mfa/totp-enroll.tsx @@ -0,0 +1,147 @@ +/** + * 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/api.ts b/pixles-web/src/lib/api.ts new file mode 100644 index 0000000..a0bea98 --- /dev/null +++ b/pixles-web/src/lib/api.ts @@ -0,0 +1,326 @@ +/** + * Typed API client for the Pixles auth REST API. + * Automatically injects Authorization headers and refreshes tokens. + */ + +import { + type TokenPair, + clearTokens, + getAccessToken, + getRefreshToken, + isAccessTokenValid, + saveTokens, +} 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(); + 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', + ); + + 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..f111829 --- /dev/null +++ b/pixles-web/src/lib/auth-context.tsx @@ -0,0 +1,120 @@ +/** + * Auth context providing current user state, login/logout actions, + * and automatic token refresh via TanStack Query. + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +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, +} from './auth'; + +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/lib/webauthn.ts b/pixles-web/src/lib/webauthn.ts new file mode 100644 index 0000000..239d801 --- /dev/null +++ b/pixles-web/src/lib/webauthn.ts @@ -0,0 +1,132 @@ +/** + * 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), + }, + }; + } + 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/routeTree.gen.ts b/pixles-web/src/routeTree.gen.ts index 975677e..d325c37 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' @@ -20,8 +21,11 @@ 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')() const ExploreLazyImport = createFileRoute('/explore')() const DashboardLazyImport = createFileRoute('/dashboard')() const IndexLazyImport = createFileRoute('/')() @@ -43,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', @@ -55,6 +71,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 +95,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 +160,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 +181,27 @@ 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 + } + '/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' @@ -204,8 +260,12 @@ 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 + '/settings': typeof SettingsLazyRoute + '/settings/security': typeof SettingsSecurityLazyRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -219,8 +279,12 @@ 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 + '/settings': typeof SettingsLazyRoute + '/settings/security': typeof SettingsSecurityLazyRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -235,8 +299,12 @@ 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 + '/settings': typeof SettingsLazyRoute + '/settings/security': typeof SettingsSecurityLazyRoute '/sharing': typeof SharingLazyRoute '/storage': typeof StorageLazyRoute '/albums/$id': typeof AlbumsIdRoute @@ -252,8 +320,12 @@ export interface FileRouteTypes { | '/' | '/dashboard' | '/explore' + | '/forgot-password' | '/login' | '/photos' + | '/reset-password' + | '/settings' + | '/settings/security' | '/sharing' | '/storage' | '/albums/$id' @@ -266,8 +338,12 @@ export interface FileRouteTypes { | '/' | '/dashboard' | '/explore' + | '/forgot-password' | '/login' | '/photos' + | '/reset-password' + | '/settings' + | '/settings/security' | '/sharing' | '/storage' | '/albums/$id' @@ -280,8 +356,12 @@ export interface FileRouteTypes { | '/' | '/dashboard' | '/explore' + | '/forgot-password' | '/login' | '/photos' + | '/reset-password' + | '/settings' + | '/settings/security' | '/sharing' | '/storage' | '/albums/$id' @@ -296,8 +376,12 @@ export interface RootRouteChildren { IndexLazyRoute: typeof IndexLazyRoute DashboardLazyRoute: typeof DashboardLazyRoute ExploreLazyRoute: typeof ExploreLazyRoute + ForgotPasswordLazyRoute: typeof ForgotPasswordLazyRoute LoginLazyRoute: typeof LoginLazyRoute PhotosLazyRoute: typeof PhotosLazyRoute + ResetPasswordRoute: typeof ResetPasswordRoute + SettingsLazyRoute: typeof SettingsLazyRoute + SettingsSecurityLazyRoute: typeof SettingsSecurityLazyRoute SharingLazyRoute: typeof SharingLazyRoute StorageLazyRoute: typeof StorageLazyRoute AlbumsIdRoute: typeof AlbumsIdRoute @@ -311,8 +395,12 @@ const rootRouteChildren: RootRouteChildren = { IndexLazyRoute: IndexLazyRoute, DashboardLazyRoute: DashboardLazyRoute, ExploreLazyRoute: ExploreLazyRoute, + ForgotPasswordLazyRoute: ForgotPasswordLazyRoute, LoginLazyRoute: LoginLazyRoute, PhotosLazyRoute: PhotosLazyRoute, + ResetPasswordRoute: ResetPasswordRoute, + SettingsLazyRoute: SettingsLazyRoute, + SettingsSecurityLazyRoute: SettingsSecurityLazyRoute, SharingLazyRoute: SharingLazyRoute, StorageLazyRoute: StorageLazyRoute, AlbumsIdRoute: AlbumsIdRoute, @@ -335,8 +423,12 @@ export const routeTree = rootRoute "/", "/dashboard", "/explore", + "/forgot-password", "/login", "/photos", + "/reset-password", + "/settings", + "/settings/security", "/sharing", "/storage", "/albums/$id", @@ -355,12 +447,24 @@ 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" + }, + "/settings": { + "filePath": "settings.lazy.tsx" + }, + "/settings/security": { + "filePath": "settings/security.lazy.tsx" + }, "/sharing": { "filePath": "sharing.lazy.tsx" }, diff --git a/pixles-web/src/routes/__root.tsx b/pixles-web/src/routes/__root.tsx index a1f495d..127ef3e 100644 --- a/pixles-web/src/routes/__root.tsx +++ b/pixles-web/src/routes/__root.tsx @@ -1,10 +1,16 @@ -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, 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(); @@ -12,11 +18,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 })), ); @@ -28,6 +31,43 @@ 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); @@ -39,25 +79,29 @@ export const Route = createRootRoute({ return ( -
-
-
- -
- -
-
-
- - - + + +
+
+
+ +
+ +
+
+
+
+ + + - - {showDevtools && ( - - - - )} + + {showDevtools && ( + + + + )} +
); }, 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..0019b89 --- /dev/null +++ b/pixles-web/src/routes/forgot-password.lazy.tsx @@ -0,0 +1,116 @@ +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 type React from 'react'; +import { 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/login.lazy.tsx b/pixles-web/src/routes/login.lazy.tsx index 7fb0c15..63b1543 100644 --- a/pixles-web/src/routes/login.lazy.tsx +++ b/pixles-web/src/routes/login.lazy.tsx @@ -8,54 +8,263 @@ import { CardTitle, } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import { Link, createLazyFileRoute } from '@tanstack/react-router'; -import { MountainIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { + ApiError, + login, + passkeyLoginFinish, + 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 type React from 'react'; +import { 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(); + + 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); + + // 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); + 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) { + setError( + err instanceof ApiError + ? err.message + : '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) { + 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('Passkey authentication failed.'); + } + } 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} + /> +
+
+ + +
+
+ +
+
+ + or + +
+
+ +

+ 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 + /> +
+
+ + + + +
+
+ )}
); } diff --git a/pixles-web/src/routes/reset-password.tsx b/pixles-web/src/routes/reset-password.tsx new file mode 100644 index 0000000..a6b13ed --- /dev/null +++ b/pixles-web/src/routes/reset-password.tsx @@ -0,0 +1,161 @@ +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 type React from 'react'; +import { 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} + /> +
+
+ + + +
+ ) : ( + + + + + + )} +
+
+ ); +} diff --git a/pixles-web/src/routes/settings.lazy.tsx b/pixles-web/src/routes/settings.lazy.tsx new file mode 100644 index 0000000..82c58db --- /dev/null +++ b/pixles-web/src/routes/settings.lazy.tsx @@ -0,0 +1,207 @@ +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 { 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 type React from 'react'; +import { 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..4fe85a4 --- /dev/null +++ b/pixles-web/src/routes/settings/security.lazy.tsx @@ -0,0 +1,332 @@ +import { PasskeyRegister } from '@/components/mfa/passkey-register'; +import { TotpEnroll } from '@/components/mfa/totp-enroll'; +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 { + 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, +}); + +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)} + /> + ) : ( + + )} +
+
+
+ ); +}