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,
    }))
}