totp_handler.rs 367 lignes · 12791 octets
//! TOTP 2FA settings handlers
//!
//! Handlers for the /settings/security page where users can
//! enable/disable TOTP two-factor authentication.

use askama::Template;
use axum::{
    Json,
    extract::{Extension, State},
    response::IntoResponse,
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use std::sync::Arc;
use tower_sessions::Session;

use rustwarden_core::auth::jwt::Claims;
use rustwarden_core::error::AppError;
use rustwarden_core::i18n::extractor::Locale;
use rustwarden_core::i18n::service::I18nService;
use rustwarden_core::services::app_settings_service::AppSettingsService;
use rustwarden_core::services::totp_service::TotpService;
use rustwarden_core::services::user_service::UserService;

use crate::dto::navigation_dto::NavigationDTO;
use crate::dto::user_profile_dto::UserProfileDTO;
use crate::template_helpers::csrf_token_helper;

/// Simplified navigation item for templates
#[derive(Clone)]
#[allow(dead_code)]
pub(crate) struct NavItem {
    id: String,
    label: String,
    icon: String,
    url: String,
    active: bool,
    enabled: bool,
    badge: String,
    has_badge: bool,
    disabled_message: String,
    has_disabled_message: bool,
}

/// Helper to build NavItems from NavigationDTO
fn build_nav_items_from_dto(navigation: &NavigationDTO, active_id: &str) -> Vec<NavItem> {
    navigation
        .items
        .iter()
        .map(|item| NavItem {
            id: item.id.clone(),
            label: item.label.clone(),
            icon: item.icon.clone(),
            url: item.url.clone(),
            active: item.id == active_id,
            enabled: item.enabled,
            badge: item.badge.clone(),
            has_badge: item.has_badge,
            disabled_message: item.disabled_message.clone(),
            has_disabled_message: item.has_disabled_message,
        })
        .collect()
}

/// Security settings page template
#[derive(Template)]
#[template(path = "settings/security.html")]
#[allow(dead_code)]
struct SecurityTemplate {
    page_title: String,
    app_name: String,
    app_version: String,
    app_domain: String,
    app_description: String,
    analytics_opt_out: crate::analytics::AnalyticsOptOutContext,
    csrf_token: String,
    username: String,
    email: String,
    roles_text: String,
    initials: String,
    avatar_color: String,
    current_locale: String,
    nav_items: Vec<NavItem>,
    user_menu_profile: String,
    user_menu_logout: String,
    // 2FA state
    totp_enabled: bool,
    backup_codes_count: i32,
    // Translated strings
    title: String,
    subtitle: String,
    totp_title: String,
    status_enabled: String,
    status_disabled: String,
    enabled_message: String,
    disabled_message: String,
    enable_button: String,
    disable_button: String,
    scan_qr: String,
    manual_entry: String,
    enter_code: String,
    code_placeholder: String,
    verify_button: String,
    backup_codes_title: String,
    backup_codes_warning: String,
    backup_codes_remaining: String,
    regenerate_button: String,
    regenerate_confirm: String,
    confirm_disable: String,
    password_label: String,
    cancel_button: String,
    copy_button: String,
    saved_codes_button: String,
    breadcrumb_home: String,
    breadcrumb_settings: String,
    breadcrumb_security: String,
    /// True when admin has forced 2FA and the user hasn't set it up yet
    force_2fa: bool,
    force_2fa_message: String,
    force_2fa_logout: String,
}

rustwarden_core::impl_template_response!(SecurityTemplate);

/// GET /settings/security — Security settings page
pub async fn security_page(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    Extension(i18n_service): Extension<Arc<I18nService>>,
    locale: Locale,
    analytics: rustwarden_core::config::analytics::AnalyticsContext,
    session: Session,
) -> Result<impl IntoResponse, AppError> {
    let user_model = UserService::get_user_by_username(&db, &claims.username)
        .await?
        .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
    let roles = UserService::get_user_roles(&db, user_model.id).await?;
    let user = UserProfileDTO::new(user_model, roles.clone());

    let locale_str = locale.as_str();
    let csrf_token = csrf_token_helper(&session).await;

    let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await;
    let nav_items = build_nav_items_from_dto(&navigation, "settings");

    let app_domain = AppSettingsService::get_setting(&db, "app_domain")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "localhost".to_string());
    let app_description = AppSettingsService::get_setting(&db, "app_description")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "User accounts and authentication".to_string());

    // Check TOTP status
    let totp_info = TotpService::get_totp_info(&db, claims.sub).await?;
    let totp_enabled = totp_info.as_ref().is_some_and(|t| t.enabled);

    // Check if 2FA is forced globally
    let totp_force_enabled =
        AppSettingsService::get_bool_with_fallback(&db, &["totp_force_enabled"], false)
            .await
            .unwrap_or(false);
    let force_2fa = totp_force_enabled && !totp_enabled;
    let backup_codes_count = totp_info
        .as_ref()
        .map(|t| t.backup_codes_remaining)
        .unwrap_or(0);

    let layout = rustwarden_core::templates::LayoutContext::new("", "");

    let template = SecurityTemplate {
        page_title: i18n_service
            .translate("settings_security.page_title", locale_str)
            .await,
        app_name: layout.app_name.clone(),
        app_version: layout.app_version.clone(),
        app_domain,
        app_description,
        analytics_opt_out: crate::analytics::AnalyticsOptOutContext::build(
            &analytics,
            &i18n_service,
            locale.as_str(),
        )
        .await,
        csrf_token,
        username: user.username.clone(),
        email: user.email.clone(),
        roles_text: user.roles.join(", "),
        initials: user.initials.clone(),
        avatar_color: user.avatar_color.clone(),
        current_locale: locale_str.to_string(),
        nav_items,
        user_menu_profile: i18n_service
            .translate("user_menu.profile", locale_str)
            .await,
        user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await,
        totp_enabled,
        backup_codes_count,
        title: i18n_service
            .translate("settings_security.title", locale_str)
            .await,
        subtitle: i18n_service
            .translate("settings_security.subtitle", locale_str)
            .await,
        totp_title: i18n_service
            .translate("settings_security.totp_title", locale_str)
            .await,
        status_enabled: i18n_service
            .translate("settings_security.status_enabled", locale_str)
            .await,
        status_disabled: i18n_service
            .translate("settings_security.status_disabled", locale_str)
            .await,
        enabled_message: i18n_service
            .translate("settings_security.enabled_message", locale_str)
            .await,
        disabled_message: i18n_service
            .translate("settings_security.disabled_message", locale_str)
            .await,
        enable_button: i18n_service
            .translate("settings_security.enable_button", locale_str)
            .await,
        disable_button: i18n_service
            .translate("settings_security.disable_button", locale_str)
            .await,
        scan_qr: i18n_service
            .translate("settings_security.scan_qr", locale_str)
            .await,
        manual_entry: i18n_service
            .translate("settings_security.manual_entry", locale_str)
            .await,
        enter_code: i18n_service
            .translate("settings_security.enter_code", locale_str)
            .await,
        code_placeholder: "000000".to_string(),
        verify_button: i18n_service
            .translate("settings_security.verify_button", locale_str)
            .await,
        backup_codes_title: i18n_service
            .translate("settings_security.backup_codes_title", locale_str)
            .await,
        backup_codes_warning: i18n_service
            .translate("settings_security.backup_codes_warning", locale_str)
            .await,
        backup_codes_remaining: i18n_service
            .translate("settings_security.backup_codes_remaining", locale_str)
            .await,
        regenerate_button: i18n_service
            .translate("settings_security.regenerate_button", locale_str)
            .await,
        regenerate_confirm: i18n_service
            .translate("settings_security.regenerate_confirm", locale_str)
            .await,
        confirm_disable: i18n_service
            .translate("settings_security.confirm_disable", locale_str)
            .await,
        password_label: i18n_service
            .translate("settings_security.password_label", locale_str)
            .await,
        cancel_button: i18n_service.translate("common.cancel", locale_str).await,
        copy_button: i18n_service
            .translate("settings_security.copy_button", locale_str)
            .await,
        saved_codes_button: i18n_service
            .translate("settings_security.saved_codes_button", locale_str)
            .await,
        breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await,
        breadcrumb_settings: i18n_service
            .translate("breadcrumb.settings", locale_str)
            .await,
        breadcrumb_security: i18n_service
            .translate("settings_security.breadcrumb", locale_str)
            .await,
        force_2fa,
        force_2fa_message: i18n_service
            .translate("settings_security.force_2fa_message", locale_str)
            .await,
        force_2fa_logout: i18n_service
            .translate("settings_security.force_2fa_logout", locale_str)
            .await,
    };

    Ok(template)
}

/// POST /settings/security/totp/enable — Start TOTP setup
pub async fn enable_totp(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
) -> Result<impl IntoResponse, AppError> {
    let app_name = AppSettingsService::get_setting(&db, "app_name")
        .await?
        .map(|s| s.value)
        .unwrap_or_else(|| "RustWarden".to_string());

    let setup = TotpService::setup_totp(&db, claims.sub, &claims.username, &app_name).await?;

    Ok(Json(serde_json::json!({
        "secret_base32": setup.secret_base32,
        "otpauth_uri": setup.otpauth_uri,
        "qr_code_data_uri": setup.qr_code_data_uri,
    })))
}

/// Request body for verifying a TOTP code.
#[derive(Deserialize)]
pub struct VerifyTotpRequest {
    /// Six-digit TOTP code from the authenticator app.
    pub code: String,
}

/// POST /settings/security/totp/verify — Verify code and enable 2FA
pub async fn verify_totp(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    Json(payload): Json<VerifyTotpRequest>,
) -> Result<impl IntoResponse, AppError> {
    let backup_codes = TotpService::verify_and_enable(&db, claims.sub, &payload.code).await?;

    // Audit log
    tracing::info!(user_id = %claims.sub, "TOTP_ENABLED");

    Ok(Json(serde_json::json!({
        "backup_codes": backup_codes,
    })))
}

/// Request body for disabling TOTP two-factor authentication.
#[derive(Deserialize)]
pub struct DisableTotpRequest {
    /// Current account password, required to confirm the disable action.
    pub password: String,
}

/// POST /settings/security/totp/disable — Disable 2FA (requires password)
pub async fn disable_totp(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
    Json(payload): Json<DisableTotpRequest>,
) -> Result<impl IntoResponse, AppError> {
    // Verify password first
    use rustwarden_core::services::auth_service::AuthService;
    AuthService::verify_credentials(&db, &claims.username, &payload.password).await?;

    TotpService::disable_totp(&db, claims.sub).await?;

    // Audit log
    tracing::info!(user_id = %claims.sub, "TOTP_DISABLED");

    Ok(Json(serde_json::json!({ "success": true })))
}

/// POST /settings/security/backup-codes — Regenerate backup codes
pub async fn regenerate_backup_codes(
    Extension(claims): Extension<Claims>,
    State(db): State<DatabaseConnection>,
) -> Result<impl IntoResponse, AppError> {
    let codes = TotpService::regenerate_backup_codes(&db, claims.sub).await?;

    // Audit log
    tracing::info!(user_id = %claims.sub, "BACKUP_CODES_REGENERATED");

    Ok(Json(serde_json::json!({
        "backup_codes": codes,
    })))
}