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