handlers.rs
1502 lignes · 50103 octets
//! Authentication handlers for login, logout, refresh, and user info endpoints //! //! This module contains Axum handlers for: //! - POST /api/v1/auth/login - User authentication //! - POST /api/v1/auth/logout - Session termination //! - POST /api/v1/auth/refresh - Token renewal //! - GET /api/v1/auth/me - Current user information use askama::Template; use axum::{ Extension, Json, extract::{Path, State}, http::HeaderMap, response::{Html, IntoResponse, Response}, }; use axum_extra::extract::CookieJar; use chrono::{DateTime, Utc}; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use std::sync::Arc; use time::Duration; use tower_sessions::Session; use uuid::Uuid; use validator::Validate; use crate::auth::{extractors::AuthUser, jwt::JwtService, jwt_extractor}; use crate::config::auth::AuthConfig; use crate::config::email::EmailConfig; use crate::error::AppError; use crate::hooks::RustwardenHooks; use crate::services::{ auth_service::AuthService, email_validation_service::EmailValidationService, jwt_blacklist_service::JwtBlacklistService, password_reset_service::PasswordResetService, password_service::PasswordService, refresh_token_service::RefreshTokenService, role_service::RoleService, user_preferences_service::UserPreferencesService, user_service::UserService, }; // --- Auth DTOs (inlined, no separate dto module) --- /// Request body for POST /api/v1/auth/login. #[derive(Debug, Deserialize, Validate)] pub struct LoginRequest { /// Username or email address of the authenticating user. #[validate(length(min = 1))] pub username: String, /// Plaintext password (validated then compared against the stored hash). #[validate(length(min = 1))] pub password: String, /// When true, the refresh token gets an extended lifetime. /// Default: false (backward compatible). #[serde(default)] pub remember_me: bool, } /// Compact user representation returned inside login/refresh responses. #[derive(Debug, Serialize)] pub struct UserInfo { /// User's unique identifier. pub id: Uuid, /// User's login name. pub username: String, /// User's email address. pub email: String, /// List of role names assigned to the user. pub roles: Vec<String>, /// When `true`, the user must change their password before proceeding. pub must_change_password: Option<bool>, /// Indicates that the user must set up 2FA before being allowed to navigate. #[serde(skip_serializing_if = "Option::is_none")] pub must_setup_2fa: Option<bool>, } /// Response body for a successful login. #[derive(Debug, Serialize)] pub struct LoginResponse { /// JWT access token. pub access_token: String, /// Opaque refresh token (absent when the server has not issued one). pub refresh_token: Option<String>, /// Token type, always `"Bearer"`. pub token_type: String, /// Access token lifetime in seconds. pub expires_in: i64, /// Refresh token lifetime in seconds. pub refresh_expires_in: i64, /// Compact user info embedded in the login response. pub user: UserInfo, } /// Response body for GET /api/v1/auth/me. #[derive(Debug, Serialize)] pub struct CurrentUserResponse { /// User's unique identifier. pub id: Uuid, /// User's login name. pub username: String, /// User's email address. pub email: String, /// List of role names assigned to the user. pub roles: Vec<String>, /// Timestamp when the user account was created. pub created_at: DateTime<Utc>, /// Timestamp of the last update to the user account. pub updated_at: DateTime<Utc>, /// When `true`, the user must change their password before proceeding. pub must_change_password: bool, } /// Request body for POST /api/v1/auth/refresh. #[derive(Debug, Deserialize, Validate)] pub struct RefreshRequest { /// The opaque refresh token previously issued by the server. #[validate(length(min = 1))] pub refresh_token: String, } /// Response body for a successful token refresh. #[derive(Debug, Serialize)] pub struct RefreshResponse { /// New JWT access token. pub access_token: String, /// New refresh token (rotated on each use). pub refresh_token: Option<String>, /// Token type, always `"Bearer"`. pub token_type: String, /// Access token lifetime in seconds. pub expires_in: i64, /// Refresh token lifetime in seconds. pub refresh_expires_in: i64, } /// Request body for POST /api/v1/auth/forgot-password. #[derive(Debug, Deserialize, Validate)] pub struct ForgotPasswordRequest { /// Email address to send the password-reset link to. #[validate(email)] pub email: String, } /// Response body for the forgot-password endpoint. #[derive(Debug, Serialize)] pub struct ForgotPasswordResponse { /// Generic confirmation message (does not reveal whether the email exists). pub message: String, } /// Request body for POST /api/v1/auth/reset-password. #[derive(Debug, Deserialize, Validate)] pub struct ResetPasswordRequest { /// Single-use reset token received via email. #[validate(length(min = 1))] pub token: String, /// The new plaintext password to set (minimum 8 characters). #[validate(length(min = 8))] pub new_password: String, } /// Response body for the reset-password endpoint. #[derive(Debug, Serialize)] pub struct ResetPasswordResponse { /// Confirmation message returned after a successful password reset. pub message: String, } /// Request body for POST /api/v1/auth/resend-verification. #[derive(Debug, Deserialize, Validate)] pub struct ResendVerificationRequest { /// Email address to resend the verification link to. #[validate(email)] pub email: String, } /// Response body for the resend-verification endpoint. #[derive(Debug, Serialize)] pub struct ResendVerificationResponse { /// Generic confirmation message (does not reveal whether the email exists). pub message: String, } /// Request body for POST /api/v1/auth/register. #[derive(Debug, Deserialize, Validate)] pub struct RegisterRequest { /// Desired login name (3–39 characters, `[a-z0-9-]`, no reserved words). #[validate(length(min = 3, max = 39))] pub username: String, /// Email address for the new account. #[validate(email)] pub email: String, /// Plaintext password for the new account (minimum 8 characters). #[validate(length(min = 8))] pub password: String, } /// Response body for a successful registration. #[derive(Debug, Serialize)] pub struct RegisterResponse { /// Confirmation message describing next steps (e.g. email verification). pub message: String, /// UUID of the newly created user. pub user_id: Uuid, /// Login name of the newly created user. pub username: String, /// Email address of the newly created user. pub email: String, } /// Template for email verification success page #[derive(Template)] #[template(path = "email_verified.html")] struct EmailVerifiedTemplate { success: bool, message: String, } impl IntoResponse for EmailVerifiedTemplate { fn into_response(self) -> Response { match self.render() { Ok(html) => Html(html).into_response(), Err(e) => { tracing::error!("Template rendering error: {}", e); ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {}", e), ) .into_response() } } } } /// Handler for POST /api/v1/auth/login /// /// Authenticates a user with username and password, returning a JWT access token /// and optionally a refresh token. /// /// # Request Body /// /// ```json /// { /// "username": "john-doe", /// "password": "SecurePassword123!" /// } /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", /// "refresh_token": "550e8400-e29b-41d4-a716-446655440000", /// "token_type": "Bearer", /// "expires_in": 900, /// "user": { /// "id": "550e8400-e29b-41d4-a716-446655440000", /// "username": "john-doe", /// "email": "john@example.com", /// "roles": ["user"] /// } /// } /// ``` /// /// # Errors /// /// - `400 Bad Request` - Invalid request body (validation failed) /// - `401 Unauthorized` - Invalid credentials /// - `403 Forbidden` - User must change password /// - `429 Too Many Requests` - Rate limit exceeded (5 requests/minute) /// - `500 Internal Server Error` - Server error /// /// # Security /// /// - Rate limited to 5 requests per minute per IP /// - Password verified using bcrypt (constant-time comparison) /// - Generic error messages (never reveals if username or password is wrong) /// - All authentication attempts logged /// /// # Examples /// /// ```bash /// curl -X POST http://localhost:3000/api/v1/auth/login \ /// -H "Content-Type: application/json" \ /// -d '{"username": "john-doe", "password": "password123"}' /// ``` pub async fn login( State(db): State<DatabaseConnection>, _headers: HeaderMap, _user_session: Session, Extension(auth_config): Extension<Arc<AuthConfig>>, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, Json(payload): Json<LoginRequest>, ) -> Result<Response, AppError> { tracing::info!("Login request received for user: {}", payload.username); // Validate input payload.validate().map_err(|e| { tracing::warn!( "Login validation failed for user {}: {}", payload.username, e ); AppError::Validation(format!("Invalid login request: {}", e)) })?; tracing::debug!( "Login attempt for user: {} (validation passed)", payload.username ); // Authenticate user: verifies credentials, logs must_change_password and // enforces the email-verified policy (RFC 0040 — check lives in the service). let user = AuthService::authenticate_user(&db, &payload.username, &payload.password).await?; // Check if 2FA is forced globally let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( &db, &["totp_force_enabled"], false, ) .await .unwrap_or(false); // Check if user has TOTP 2FA enabled if crate::services::totp_service::TotpService::is_totp_enabled(&db, user.id).await? { // Create a 2FA challenge instead of issuing tokens let challenge_token = crate::services::totp_service::TotpService::create_challenge(&db, user.id).await?; tracing::info!("2FA challenge created for user {}", user.username); let response = serde_json::json!({ "requires_2fa": true, "challenge_token": challenge_token, }); return Ok((axum::http::StatusCode::OK, Json(response)).into_response()); } // Fetch user roles let roles = UserService::get_user_roles(&db, user.id).await?; // Fetch user permissions let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user( &db, user.id, ) .await?; tracing::debug!( "User {} has roles: {:?}, permissions: {:?}", user.username, roles, permissions ); // Generate JWT token let access_token = JwtService::generate_token( &auth_config, user.id, &user.username, roles.clone(), permissions, )?; // JWT token is already generated above // No need to store in session serveur - JWT is stateless tracing::debug!("JWT token generated for user {}", user.username); // Remember me: use extended refresh token duration let rt_expiration_days = if payload.remember_me { auth_config.remember_me_expiration_days } else { auth_config.refresh_token_expiration_days }; let created_rt = RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?; let refresh_token = Some(created_rt.plain_token); // Build response let response = LoginResponse { access_token: access_token.clone(), // Clone for cookie refresh_token, token_type: "Bearer".to_string(), expires_in: auth_config.jwt_expiration_minutes * 60, // Convert minutes to seconds refresh_expires_in: rt_expiration_days * 86400, // Convert days to seconds user: UserInfo { id: user.id, username: user.username.clone(), email: user.email.clone(), roles, must_change_password: Some(user.must_change_password), must_setup_2fa: if totp_force_enabled { Some(true) } else { None }, }, }; // Create HttpOnly cookie with JWT for web pages // SEC-007: HttpOnly, Secure in production, SameSite=Strict (configurable via COOKIE_SAME_SITE) let expires_in_seconds = auth_config.jwt_expiration_minutes * 60; // Parse SameSite from config (default: "Strict" per SEC-007) let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() { "strict" => axum_extra::extract::cookie::SameSite::Strict, "lax" => axum_extra::extract::cookie::SameSite::Lax, "none" => axum_extra::extract::cookie::SameSite::None, _ => { tracing::warn!( "Invalid COOKIE_SAME_SITE value '{}', defaulting to Strict", auth_config.cookie_same_site ); axum_extra::extract::cookie::SameSite::Strict } }; let cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token)) .http_only(true) // SEC-007: HttpOnly .same_site(same_site) // SEC-007: SameSite=Strict (default, configurable) .secure(auth_config.cookie_secure) // SEC-007: Secure in production .path("/") .max_age(Duration::seconds(expires_in_seconds)); // Build response with cookie let mut headers = HeaderMap::new(); headers.insert( axum::http::header::SET_COOKIE, cookie .to_string() .parse() .map_err(|e| AppError::Internal(format!("Failed to create cookie header: {}", e)))?, ); // SEC-M1: Regenerate session ID to prevent session fixation attacks if let Err(e) = _user_session.cycle_id().await { tracing::warn!("Failed to cycle session ID: {}", e); } tracing::info!( "User {} logged in successfully with JWT token (cookie)", user.username ); if let Some(Extension(hooks)) = hooks { if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await { tracing::warn!("Login hook error for {}: {}", user.username, e); } } // Build response with cookie header let json_response: Response = Json(response).into_response(); let (mut parts, body) = json_response.into_parts(); // Add JWT cookie parts.headers.append( axum::http::header::SET_COOKIE, cookie .to_string() .parse() .map_err(|e| AppError::Internal(format!("Failed to create cookie header: {}", e)))?, ); // Load user preferences and set theme/locale cookies if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await { let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme)) .http_only(false) .same_site(axum_extra::extract::cookie::SameSite::Lax) .path("/") .max_age(Duration::seconds(31536000)); if let Ok(hv) = theme_cookie.to_string().parse() { parts.headers.append(axum::http::header::SET_COOKIE, hv); } let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language)) .http_only(false) .same_site(axum_extra::extract::cookie::SameSite::Lax) .path("/") .max_age(Duration::seconds(31536000)); if let Ok(hv) = locale_cookie.to_string().parse() { parts.headers.append(axum::http::header::SET_COOKIE, hv); } } Ok(Response::from_parts(parts, body)) } /// Request payload for 2FA verification #[derive(Debug, Deserialize, Validate)] pub struct Verify2faRequest { /// Challenge token from the login response #[validate(length(min = 1))] pub challenge_token: String, /// TOTP code (6 digits) or backup code (8 alphanumeric chars) #[validate(length(min = 6, max = 16))] pub code: String, } /// Handler for POST /api/v1/auth/2fa/verify /// /// Verifies a TOTP code or backup code for the 2FA login flow. /// On success, issues JWT + refresh token (same as normal login). pub async fn verify_2fa( State(db): State<DatabaseConnection>, _user_session: Session, Extension(auth_config): Extension<Arc<AuthConfig>>, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, Json(payload): Json<Verify2faRequest>, ) -> Result<Response, AppError> { payload .validate() .map_err(|e| AppError::Validation(format!("Invalid 2FA request: {}", e)))?; use crate::services::totp_service::TotpService; // Validate challenge (checks expiry + max attempts) let user_id = TotpService::validate_challenge(&db, &payload.challenge_token).await?; // Try TOTP code first (6 digits), then backup code let verified = if payload.code.len() == 6 && payload.code.chars().all(|c| c.is_ascii_digit()) { TotpService::verify_code(&db, user_id, &payload.code).await? } else { TotpService::verify_backup_code(&db, user_id, &payload.code).await? }; if !verified { TotpService::record_challenge_attempt(&db, &payload.challenge_token).await?; tracing::warn!(user_id = %user_id, "TOTP_FAILED"); return Err(AppError::TotpFailed); } // 2FA verified — consume challenge and issue tokens TotpService::consume_challenge(&db, &payload.challenge_token).await?; tracing::info!(user_id = %user_id, "TOTP_VERIFIED"); // Fetch user data let user = UserService::get_user_by_id(&db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; let roles = UserService::get_user_roles(&db, user.id).await?; let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user( &db, user.id, ) .await?; let access_token = JwtService::generate_token( &auth_config, user.id, &user.username, roles.clone(), permissions, )?; let rt_expiration_days = auth_config.refresh_token_expiration_days; let created_rt = RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?; let response_body = LoginResponse { access_token: access_token.clone(), refresh_token: Some(created_rt.plain_token), token_type: "Bearer".to_string(), expires_in: auth_config.jwt_expiration_minutes * 60, refresh_expires_in: rt_expiration_days * 86400, user: UserInfo { id: user.id, username: user.username.clone(), email: user.email.clone(), roles, must_change_password: Some(user.must_change_password), must_setup_2fa: None, }, }; // Create JWT cookie let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() { "strict" => axum_extra::extract::cookie::SameSite::Strict, "lax" => axum_extra::extract::cookie::SameSite::Lax, "none" => axum_extra::extract::cookie::SameSite::None, _ => axum_extra::extract::cookie::SameSite::Strict, }; let cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token)) .http_only(true) .same_site(same_site) .secure(auth_config.cookie_secure) .path("/") .max_age(Duration::seconds(auth_config.jwt_expiration_minutes * 60)); let json_response: Response = Json(response_body).into_response(); let (mut parts, body) = json_response.into_parts(); if let Ok(hv) = cookie.to_string().parse() { parts.headers.append(axum::http::header::SET_COOKIE, hv); } // Set preference cookies if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await { let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme)) .http_only(false) .same_site(axum_extra::extract::cookie::SameSite::Lax) .path("/") .max_age(Duration::seconds(31536000)); if let Ok(hv) = theme_cookie.to_string().parse() { parts.headers.append(axum::http::header::SET_COOKIE, hv); } let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language)) .http_only(false) .same_site(axum_extra::extract::cookie::SameSite::Lax) .path("/") .max_age(Duration::seconds(31536000)); if let Ok(hv) = locale_cookie.to_string().parse() { parts.headers.append(axum::http::header::SET_COOKIE, hv); } } // Regenerate session if let Err(e) = _user_session.cycle_id().await { tracing::warn!("Failed to cycle session ID: {}", e); } if let Some(Extension(hooks)) = hooks { if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await { tracing::warn!("Login hook error for {}: {}", user.username, e); } } Ok(Response::from_parts(parts, body)) } /// Handler for GET /api/v1/auth/me /// /// Returns the current authenticated user's information. /// /// **Simplified in Phase 5 (US3)**: Now uses AuthUser extractor for automatic JWT validation. /// No manual header extraction or token validation needed. /// /// # Request Headers /// /// ```text /// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "id": "550e8400-e29b-41d4-a716-446655440000", /// "username": "john-doe", /// "email": "john@example.com", /// "roles": ["user"], /// "created_at": "2024-01-01T00:00:00Z", /// "updated_at": "2024-01-01T00:00:00Z", /// "must_change_password": false /// } /// ``` /// /// # Errors /// /// - `401 Unauthorized` - Missing or invalid token (handled by AuthUser extractor) /// - `404 Not Found` - User not found in database /// - `500 Internal Server Error` - Server error /// /// # Security /// /// - JWT validation handled automatically by AuthUser extractor /// - User information extracted from verified claims only /// - No manual token parsing needed /// /// # Examples /// /// ```sh /// # curl -X GET http://localhost:3000/api/v1/auth/me \ /// # -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." /// ``` pub async fn get_current_user( State(db): State<DatabaseConnection>, auth_user: AuthUser, ) -> Result<Json<CurrentUserResponse>, AppError> { // AuthUser extractor has already validated the JWT token // We just need to fetch the full user info from database tracing::debug!("Fetching user info for {}", auth_user.username); // Get user from database let user = UserService::get_user_by_username(&db, &auth_user.username) .await? .ok_or_else(|| { tracing::warn!( "User {} from valid JWT not found in database", auth_user.username ); AppError::NotFound("User not found".to_string()) })?; // Get user roles (fresh from database) let roles = UserService::get_user_roles(&db, user.id).await?; // Build response let response = CurrentUserResponse { id: user.id, username: user.username, email: user.email, roles, created_at: user.created_at, updated_at: user.updated_at, must_change_password: user.must_change_password, }; Ok(Json(response)) } /// Handler for POST /api/v1/auth/logout - Logout user /// /// Destroys the user's session and revokes all refresh tokens. /// /// **Phase 6 (US4)**: Server-side session invalidation for secure logout. /// **Phase 7 (US5)**: Refresh token revocation added. /// /// # Authentication /// /// Requires a valid JWT token in the Authorization header. /// /// # Response (200 OK) /// /// ```json /// { /// "message": "Logged out successfully", /// "tokens_revoked": 2 /// } /// ``` /// /// # Errors /// /// - `401 Unauthorized` - Missing or invalid token /// - `500 Internal Server Error` - Failed to destroy session or revoke tokens /// /// # Security /// /// - Session is completely destroyed server-side /// - All refresh tokens are revoked (cannot be used to get new JWTs) /// - JWT token remains valid until expiration (stateless nature, 15 min max) /// - Client should discard JWT and refresh tokens after logout /// /// # Session & Token Management /// /// - Deletes all session data from server /// - Session cookie is invalidated /// - All refresh tokens for this user are marked as revoked /// - User must log in again to access protected resources /// /// # Examples /// /// ```bash /// curl -X POST http://localhost:3000/api/v1/auth/logout \ /// -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." /// ``` pub async fn logout( State(db): State<DatabaseConnection>, headers: HeaderMap, _user_session: Session, Extension(auth_config): Extension<Arc<AuthConfig>>, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, ) -> Result<Response, AppError> { // Extract JWT from request (cookie or header) - same logic as jwt_extractor // Create CookieJar from headers let cookie_jar = CookieJar::from_headers(&headers); // Priority 1: Check Authorization header let jwt_token_opt = if let Some(auth_header) = headers.get("authorization") { if let Ok(auth_str) = auth_header.to_str() { jwt_extractor::extract_bearer_token(auth_str) } else { None } } else { None }; // Priority 2: Check cookie let jwt_token = jwt_token_opt .or_else(|| cookie_jar.get("jwt_token").map(|c| c.value().to_string())) .ok_or_else(|| { tracing::warn!("Logout called but no JWT token found in request"); AppError::InvalidToken })?; // Validate token to get user info let claims = JwtService::validate_token(&auth_config, &jwt_token)?; let user_id = claims.sub; // Blacklist the JWT token (Feature 008 - US3) JwtBlacklistService::add_to_blacklist(&db, &jwt_token, user_id).await?; // Revoke all refresh tokens for this user let tokens_revoked = RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?; // Delete cookie JWT (Set-Cookie with MaxAge=0) // SEC-007: Use same SameSite policy as login let expires_in_seconds = 0; // Expire immediately let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() { "strict" => axum_extra::extract::cookie::SameSite::Strict, "lax" => axum_extra::extract::cookie::SameSite::Lax, "none" => axum_extra::extract::cookie::SameSite::None, _ => axum_extra::extract::cookie::SameSite::Strict, }; let cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", "")) .http_only(true) .same_site(same_site) .path("/") .max_age(Duration::seconds(expires_in_seconds)); tracing::info!( "User {} logged out successfully (JWT blacklisted, {} refresh tokens revoked)", claims.username, tokens_revoked ); if let Some(Extension(hooks)) = hooks { if let Err(e) = hooks.on_user_logout(&db, user_id).await { tracing::warn!("Logout hook error for {}: {}", claims.username, e); } } // Check if this is an HTML request (from form) or API request let is_html_request = headers .get(axum::http::header::ACCEPT) .and_then(|h| h.to_str().ok()) .map(|accept| accept.contains("text/html")) .unwrap_or(false); if is_html_request { // HTML request: redirect to login page let mut response = axum::response::Redirect::to("/login").into_response(); // Add cookie deletion to response headers response.headers_mut().insert( axum::http::header::SET_COOKIE, cookie.to_string().parse().map_err(|e| { AppError::Internal(format!("Failed to create cookie header: {}", e)) })?, ); Ok(response) } else { // API request: return JSON let response = serde_json::json!({ "message": "Logged out successfully", "tokens_revoked": tokens_revoked }); let json_response: Response = Json(response).into_response(); let (mut parts, body) = json_response.into_parts(); // Add cookie deletion to response headers parts.headers.insert( axum::http::header::SET_COOKIE, cookie.to_string().parse().map_err(|e| { AppError::Internal(format!("Failed to create cookie header: {}", e)) })?, ); Ok(Response::from_parts(parts, body)) } } /// Handler for POST /api/v1/auth/refresh - Refresh access token /// /// Uses a refresh token to obtain a new access token without re-authentication. /// Implements token rotation for security: the old refresh token is invalidated /// and a new one is returned. /// /// **Phase 7 (US5)**: Complete refresh token system with rotation and reuse detection. /// /// # Request Body /// /// ```json /// { /// "refresh_token": "abc123...xyz789" /// } /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", /// "refresh_token": "new_token_xyz...", /// "token_type": "Bearer", /// "expires_in": 900 /// } /// ``` /// /// # Errors /// /// - `400 Bad Request` - Invalid input (validation failed) /// - `401 Unauthorized` - Invalid or expired refresh token /// - `401 Unauthorized` - Token reused (security alert, all tokens revoked) /// - `500 Internal Server Error` - Server error /// /// # Security - Token Rotation /// /// Each successful refresh: /// 1. Validates the old refresh token /// 2. Generates NEW access token (JWT) /// 3. Generates NEW refresh token /// 4. Marks old refresh token as "replaced by new token" /// 5. Returns both new tokens /// /// **Why rotation?** If a refresh token is stolen, it can only be used once. /// The legitimate user will get a new token, and the attacker's attempt will /// trigger a reuse alert. /// /// # Security - Reuse Detection /// /// If a refresh token that has already been used (marked as replaced) is /// presented again, this indicates a potential attack: /// 1. ALL refresh tokens for that user are immediately revoked /// 2. User must log in again /// 3. Security event is logged /// /// This protects against token theft scenarios. /// /// # Rate Limiting /// /// This endpoint is rate-limited to prevent brute-force attacks: /// - Maximum 10 requests per minute per IP (configurable via RATE_LIMIT_REFRESH_PER_MINUTE) /// /// # Examples /// /// ```bash /// curl -X POST http://localhost:3000/api/v1/auth/refresh \ /// -H "Content-Type: application/json" \ /// -d '{"refresh_token":"abc123...xyz789"}' /// ``` pub async fn refresh_token( State(db): State<DatabaseConnection>, _headers: HeaderMap, Extension(auth_config): Extension<Arc<AuthConfig>>, Json(payload): Json<RefreshRequest>, ) -> Result<Response, AppError> { // Validate input payload.validate().map_err(|e| { tracing::warn!("Refresh request validation failed: {}", e); AppError::Validation(format!("Invalid refresh request: {}", e)) })?; tracing::debug!("Refresh token request received"); // Validate refresh token let old_token = RefreshTokenService::validate_refresh_token(&db, &payload.refresh_token).await?; // Check for reuse attack (token already used) if old_token.has_been_used() { tracing::error!( "SECURITY ALERT: Refresh token reuse detected for user {}. Revoking all tokens.", old_token.user_id ); // Revoke ALL tokens for this user (security measure) RefreshTokenService::revoke_all_tokens_for_user(&db, old_token.user_id).await?; return Err(AppError::TokenReused); } // Get user from database let user = UserService::get_user_by_id(&db, old_token.user_id) .await? .ok_or_else(|| { tracing::error!( "User {} from valid refresh token not found", old_token.user_id ); AppError::NotFound("User not found".to_string()) })?; // Get user roles let roles = UserService::get_user_roles(&db, user.id).await?; // Get user permissions let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user( &db, user.id, ) .await?; // Generate new JWT access token let access_token = JwtService::generate_token( &auth_config, user.id, &user.username, roles.clone(), permissions, )?; // Preserve original refresh token duration (remember me tokens stay long-lived) let original_duration_days = (old_token.expires_at - old_token.created_at).num_days(); let rt_expiration_days = original_duration_days .min(auth_config.remember_me_expiration_days) .max(auth_config.refresh_token_expiration_days); // Generate new refresh token (rotation) let new_created_rt = RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?; // Mark old token as replaced (completes rotation) RefreshTokenService::mark_as_replaced(&db, old_token.id, new_created_rt.model.id).await?; tracing::info!( "User {} refreshed tokens successfully (rotation complete, refresh duration={}d)", user.username, rt_expiration_days ); // Build response with new tokens let response = RefreshResponse { access_token: access_token.clone(), refresh_token: Some(new_created_rt.plain_token), token_type: "Bearer".to_string(), expires_in: auth_config.jwt_expiration_minutes * 60, refresh_expires_in: rt_expiration_days * 86400, }; // Set JWT cookie (same as login) so web clients get the new access token automatically let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() { "strict" => axum_extra::extract::cookie::SameSite::Strict, "lax" => axum_extra::extract::cookie::SameSite::Lax, "none" => axum_extra::extract::cookie::SameSite::None, _ => axum_extra::extract::cookie::SameSite::Strict, }; let cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token)) .http_only(true) .same_site(same_site) .secure(auth_config.cookie_secure) .path("/") .max_age(Duration::seconds(auth_config.jwt_expiration_minutes * 60)); let json_response: Response = Json(response).into_response(); let (mut parts, body) = json_response.into_parts(); parts.headers.append( axum::http::header::SET_COOKIE, cookie .to_string() .parse() .map_err(|e| AppError::Internal(format!("Failed to create cookie header: {}", e)))?, ); Ok(Response::from_parts(parts, body)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_login_handler_exists() { // Test that handler function exists and can be referenced let _handler = login; } #[test] fn test_get_current_user_handler_exists() { // Test that handler function exists let _handler = get_current_user; } #[test] fn test_logout_handler_exists() { // Test that handler function exists let _handler = logout; } // Note: Integration tests with database are in tests/integration/ } /// Handler for POST /api/v1/auth/forgot-password /// /// Requests a password reset email for the given email address. /// Always returns 200 OK for security (don't reveal if email exists). /// /// # Request Body /// /// ```json /// { /// "email": "user@example.com" /// } /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "message": "If an account with this email exists, a password reset link has been sent." /// } /// ``` pub async fn forgot_password( State(db): State<DatabaseConnection>, _headers: HeaderMap, Json(payload): Json<ForgotPasswordRequest>, ) -> Result<impl IntoResponse, AppError> { // Validate email format payload .validate() .map_err(|e| AppError::Validation(format!("Invalid email format: {}", e)))?; // Load email config let email_config = EmailConfig::from_env() .map_err(|e| AppError::Internal(format!("Email configuration error: {}", e)))?; // Create password reset service let password_reset_service = PasswordResetService::new(email_config); // Request password reset (returns false if user doesn't exist - we don't reveal this) let _sent = password_reset_service .request_reset(&db, &payload.email) .await?; // Always return success message (security: don't reveal if email exists) Ok(Json(ForgotPasswordResponse { message: "If an account with this email exists, a password reset link has been sent." .to_string(), })) } /// Handler for POST /api/v1/auth/reset-password /// /// Resets a user's password using a valid reset token from email. /// /// # Request Body /// /// ```json /// { /// "token": "reset-token-from-email", /// "new_password": "NewSecurePass123!" /// } /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "message": "Password has been reset successfully" /// } /// ``` pub async fn reset_password( State(db): State<DatabaseConnection>, Json(payload): Json<ResetPasswordRequest>, ) -> Result<impl IntoResponse, AppError> { // Validate input payload .validate() .map_err(|e| AppError::Validation(format!("Invalid request: {}", e)))?; // SEC-H1: Validate password complexity from DB rules crate::middleware::password_complexity::validate_password_complexity( &db, &payload.new_password, ) .await?; // Load email config let email_config = EmailConfig::from_env() .map_err(|e| AppError::Internal(format!("Email configuration error: {}", e)))?; // Create password reset service let password_reset_service = PasswordResetService::new(email_config); // Reset password password_reset_service .reset_password(&db, &payload.token, &payload.new_password) .await?; Ok(Json(ResetPasswordResponse { message: "Password has been reset successfully".to_string(), })) } /// Handler for GET /api/v1/auth/verify-email/:token /// /// Verifies a user's email address using a validation token from email. /// /// # Path Parameters /// /// * `token` - Validation token from email /// /// # Response (200 OK) /// /// ```json /// { /// "message": "Email verified successfully. You can now log in.", /// "verified": true /// } /// ``` pub async fn verify_email( State(db): State<DatabaseConnection>, Extension(i18n_service): Extension<Arc<crate::i18n::service::I18nService>>, Extension(locale): Extension<crate::i18n::extractor::Locale>, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, Path(token): Path<String>, ) -> impl IntoResponse { let loc = locale.0.as_str(); let email_config = match EmailConfig::from_env() { Ok(config) => config, Err(e) => { tracing::error!("Email configuration error: {}", e); return EmailVerifiedTemplate { success: false, message: i18n_service .translate("email_verify.config_error", loc) .await, }; } }; let validation_service = EmailValidationService::new(email_config); match validation_service.verify_email(&db, &token).await { Ok(user_id) => { if let Some(Extension(hooks)) = hooks { if let Err(e) = hooks.on_email_verified(&db, user_id).await { tracing::warn!("Email verified hook error: {}", e); } } EmailVerifiedTemplate { success: true, message: i18n_service.translate("email_verify.success", loc).await, } } Err(e) => { tracing::warn!("Email verification failed: {}", e); EmailVerifiedTemplate { success: false, message: match e { AppError::Validation(msg) => msg, AppError::NotFound(msg) => msg, _ => { i18n_service .translate("email_verify.invalid_link", loc) .await } }, } } } } /// Handler for POST /api/v1/auth/resend-verification /// /// Resends a verification email to the user. /// Always returns 200 OK for security (don't reveal if email exists or is verified). /// /// # Request Body /// /// ```json /// { /// "email": "user@example.com" /// } /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "message": "If an account with this email exists and is not verified, a verification email has been sent." /// } /// ``` pub async fn resend_verification( State(db): State<DatabaseConnection>, Json(payload): Json<ResendVerificationRequest>, ) -> Result<impl IntoResponse, AppError> { // Validate email format payload .validate() .map_err(|e| AppError::Validation(format!("Invalid email format: {}", e)))?; // Load email config let email_config = EmailConfig::from_env() .map_err(|e| AppError::Internal(format!("Email configuration error: {}", e)))?; // Create email validation service let validation_service = EmailValidationService::new(email_config); // Resend verification email (returns false if user doesn't exist or is already verified - we don't reveal this) let _sent = validation_service .resend_verification(&db, &payload.email) .await?; // Always return success message (security: don't reveal if email exists or is verified) Ok(Json(ResendVerificationResponse { message: "If an account with this email exists and is not verified, a verification email has been sent.".to_string(), })) } /// Request body for resend verification by username #[derive(Debug, Deserialize, Validate)] pub struct ResendVerificationByUsernameRequest { /// Login name of the account whose verification email should be resent. #[validate(length(min = 1))] pub username: String, } /// Handler for POST /api/v1/auth/resend-verification/by-username /// /// Accepts a username (login) and sends a verification email to the associated account if it exists and is not verified. /// Always returns 200 with a generic message. pub async fn resend_verification_by_username( State(db): State<DatabaseConnection>, Json(payload): Json<ResendVerificationByUsernameRequest>, ) -> Result<impl IntoResponse, AppError> { payload .validate() .map_err(|e| AppError::Validation(format!("Invalid username: {}", e)))?; let email_config = EmailConfig::from_env() .map_err(|e| AppError::Internal(format!("Email configuration error: {}", e)))?; let validation_service = EmailValidationService::new(email_config); // Find user by username let user_opt = UserService::get_user_by_username(&db, &payload.username).await?; if let Some(user) = user_opt { // If already verified, return generic success if !user.email_verified { // Reuse existing rate-limit + flow by email let _ = validation_service .resend_verification(&db, &user.email) .await?; } } Ok(Json(ResendVerificationResponse { message: "If an account with this username exists and is not verified, a verification email has been sent.".to_string(), })) } /// Handler for POST /api/v1/auth/register /// /// Public user registration endpoint. Allows users to create an account /// with username, email, and password if registration is enabled. /// /// # Request Body /// /// ```json /// { /// "username": "john-doe", /// "email": "john@example.com", /// "password": "SecurePassword123!" /// } /// ``` /// /// # Response (200 OK) /// /// ```json /// { /// "message": "Account created successfully. Please check your email to verify your account.", /// "user_id": "550e8400-e29b-41d4-a716-446655440000", /// "username": "john-doe", /// "email": "john@example.com" /// } /// ``` /// /// # Errors /// /// - `400 Bad Request` - Invalid request body (validation failed) /// - `403 Forbidden` - Registration is disabled /// - `409 Conflict` - Username or email already exists /// - `500 Internal Server Error` - Server error /// /// # Security /// /// - Checks `allow_registration` setting before allowing registration /// - Validates username uniqueness /// - Validates email uniqueness /// - Password is hashed using bcrypt /// - Email verification is required pub async fn register( State(db): State<DatabaseConnection>, _headers: HeaderMap, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, Json(payload): Json<RegisterRequest>, ) -> Result<Json<RegisterResponse>, AppError> { tracing::info!( "Registration request received for username: {}", payload.username ); // Validate input payload.validate().map_err(|e| { tracing::warn!("Registration validation failed: {}", e); AppError::Validation(format!("Invalid registration request: {}", e)) })?; // Enforce username charset + reserved-name rules (defense in depth — // create_user is the service gate, register adds the public-boundary check). crate::types::Username::parse(&payload.username)?; // SEC-H1: Validate password complexity from DB rules crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password) .await?; let allow_registration = crate::services::app_settings_service::AppSettingsService::get_bool( &db, "allow_registration", false, ) .await .unwrap_or(false); if !allow_registration { tracing::warn!("Registration attempt blocked: registration is disabled"); return Err(AppError::Forbidden); } // SEC-C2: Check uniqueness with generic error to prevent user enumeration let username_taken = UserService::get_user_by_username(&db, &payload.username) .await? .is_some(); let email_taken = UserService::get_user_by_email(&db, &payload.email) .await? .is_some(); if username_taken || email_taken { return Err(AppError::Validation( "Inscription impossible. Vérifiez vos informations.".to_string(), )); } // Hash password let password_hash = PasswordService::hash_password(&payload.password).await?; // Create user (email_verified will be false by default) let user = UserService::create_user( &db, payload.username.clone(), payload.email.clone(), password_hash, false, // New users don't need to change password immediately ) .await?; // Assign default "user" role let user_role = match RoleService::get_role_by_name(&db, "user").await? { Some(role) => role, None => { // Create "user" role if it doesn't exist tracing::warn!("Default 'user' role not found, creating it..."); RoleService::create_role( &db, "user".to_string(), Some("Standard user with basic access".to_string()), ) .await? } }; UserService::assign_role_to_user(&db, user.id, user_role.id).await?; tracing::info!( "Assigned '{}' role to new user '{}'", user_role.name, user.username ); // Send email validation email (optional - don't fail if email is not configured) if let Ok(email_config) = EmailConfig::from_env() { let validation_service = EmailValidationService::new(email_config); if let Err(e) = validation_service.send_validation_email(&db, user.id).await { tracing::warn!( "Failed to send validation email to user '{}': {}", user.username, e ); } else { tracing::info!( "Validation email sent to user '{}' at '{}'", user.username, user.email ); } } else { tracing::warn!( "Email configuration not available - validation email not sent to user '{}'", user.username ); } tracing::info!("User '{}' registered successfully", user.username); if let Some(Extension(hooks)) = hooks { if let Err(e) = hooks.on_user_registered(&db, user.id, &user.username).await { tracing::warn!("Register hook error for {}: {}", user.username, e); } } Ok(Json(RegisterResponse { message: "Compte créé avec succès. Veuillez vérifier votre email pour activer votre compte." .to_string(), user_id: user.id, username: user.username, email: user.email, })) }
GitRust