security.rs
887 lignes · 31350 octets
//! Settings - Security handlers (password change, active sessions). use askama::Template; use axum::{ Extension, Json, extract::{Query, State}, response::IntoResponse, }; use sea_orm::DatabaseConnection; use serde::Deserialize; use std::sync::Arc; use tower_sessions::Session; use super::{NavItem, build_nav_items_from_dto, filters}; use crate::dto::navigation_dto::NavigationDTO; use crate::dto::user_profile_dto::UserProfileDTO; use rustwarden_core::auth::jwt::Claims; use rustwarden_core::config::email::EmailConfig; 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::refresh_token_service::RefreshTokenService; use rustwarden_core::services::user_service::UserService; /// Inline DTO: Change password request #[derive(Debug, Deserialize)] pub struct ChangePasswordRequest { /// Current password for verification. pub old_password: String, /// Desired new password. pub new_password: String, /// Confirmation of the new password (must match `new_password`). pub confirm_password: String, } /// Inline DTO: Change password response #[derive(Debug, serde::Serialize)] pub struct ChangePasswordResponse { /// Human-readable confirmation message. pub message: String, } /// Inline DTO: Active sessions response #[derive(Debug, serde::Serialize)] pub struct ActiveSessionsResponse { /// List of currently active refresh-token sessions for the user. pub sessions: Vec<rustwarden_core::services::refresh_token_service::ActiveSession>, } /// Query parameters for password page #[derive(Deserialize)] pub struct PasswordPageQuery { must_change: Option<String>, } /// POST /settings/security/password — Change the authenticated user's password. pub async fn change_password( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, Json(request): Json<ChangePasswordRequest>, ) -> Result<impl IntoResponse, AppError> { if request.new_password != request.confirm_password { return Err(AppError::Validation( "New password and confirmation do not match".to_string(), )); } let require_email = AppSettingsService::get_bool(&db, "password_change_require_email", true).await?; if require_email { let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); let (_request_id, token) = UserService::change_password( &db, claims.sub, &request.old_password, &request.new_password, current_token_id, ) .await?; tracing::info!( "Created pending password change request for user {}", claims.sub ); let user = UserService::get_user_by_id(&db, claims.sub) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; let email_config = EmailConfig::from_env() .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?; let confirmation_url = format!( "{}/confirm-password-change?token={}", email_config.email_base_url, token ); let template = rustwarden_core::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new( Some("fr".to_string()), user.username.clone(), confirmation_url, Some( email_config .smtp_from_name .clone() .unwrap_or_else(rustwarden_core::config::app::default_app_name), ), ); let body_html = template .render_html() .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?; let body_text = template.render_text(); let subject = "Confirmation de changement de mot de passe".to_string(); let email_svc = rustwarden_core::services::email_service::EmailService::new(email_config) .map_err(|e| { AppError::Internal(format!("Failed to create email service: {}", e)) })?; email_svc .send_email( &user.email, Some(&user.username), &subject, &body_html, &body_text, ) .await .map_err(|e| { tracing::error!( "Failed to send password change confirmation email for user {}: {}", user.username, e ); AppError::Internal( "Failed to send confirmation email. Please try again later.".to_string(), ) })?; tracing::info!( "Password change confirmation email sent for user {}", user.username ); Ok(Json(ChangePasswordResponse { message: "email_confirmation_sent".to_string(), })) } else { use rustwarden_core::services::password_service::PasswordService; let user = UserService::get_user_by_id(&db, claims.sub) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? { return Err(AppError::Validation( "Old password is incorrect".to_string(), )); } rustwarden_core::middleware::password_complexity::validate_password_complexity( &db, &request.new_password, ) .await?; let new_hash = PasswordService::hash_password(&request.new_password).await?; UserService::update_password_hash(&db, claims.sub, &new_hash).await?; RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?; tracing::info!( "Password changed directly (no email confirmation) for user {}", claims.sub ); Ok(Json(ChangePasswordResponse { message: "password_changed_directly".to_string(), })) } } /// Query parameters for password change confirmation #[derive(Deserialize)] pub struct ConfirmPasswordChangeQuery { token: String, } /// Handler for GET /confirm-password-change?token={token} - Confirm password change (PUBLIC) pub async fn confirm_password_change( State(db): State<DatabaseConnection>, Query(query): Query<ConfirmPasswordChangeQuery>, ) -> Result<impl IntoResponse, AppError> { use rustwarden_core::services::password_change_service::PasswordChangeService; let password_change_service = PasswordChangeService::new(); let _request = password_change_service .find_by_token(&db, &query.token) .await?; let user_id = password_change_service .confirm_request(&db, &query.token) .await?; RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?; tracing::info!( "Password change confirmed and applied for user {}. All sessions invalidated.", user_id ); Ok(Json(serde_json::json!({ "success": true, "message": "Password change confirmed successfully. Please log in with your new password." }))) } /// Handler for DELETE /settings/password/pending - Cancel pending password change request (PROTECTED) pub async fn cancel_pending_password_change( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, ) -> Result<impl IntoResponse, AppError> { use rustwarden_core::services::password_change_service::PasswordChangeService; let password_change_service = PasswordChangeService::new(); password_change_service .cancel_pending_request(&db, claims.sub) .await?; tracing::info!( "Canceled pending password change request for user {}", claims.sub ); Ok(Json(serde_json::json!({ "success": true, "message": "Pending password change request canceled successfully." }))) } /// Handler for GET /settings/sessions - List active sessions (PROTECTED) pub async fn list_sessions( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, ) -> Result<impl IntoResponse, AppError> { let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); let mut sessions = RefreshTokenService::list_user_sessions(&db, claims.sub).await?; if let Some(current_id) = current_token_id { for session in &mut sessions { if session.id == current_id { session.is_current = true; break; } } } Ok(Json(ActiveSessionsResponse { sessions })) } /// Handler for DELETE /settings/sessions/:id - Revoke session (PROTECTED) pub async fn revoke_session( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>, ) -> Result<impl IntoResponse, AppError> { let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); if Some(session_id) == current_token_id { return Err(AppError::Validation( "Cannot revoke current session. Please log out instead.".to_string(), )); } use rustwarden_core::models::refresh_token; use sea_orm::EntityTrait; let token = refresh_token::Entity::find_by_id(session_id) .one(&db) .await? .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?; if token.user_id != claims.sub { return Err(AppError::Validation( "You can only revoke your own sessions".to_string(), )); } RefreshTokenService::revoke_session(&db, session_id).await?; tracing::info!("Session {} revoked by user {}", session_id, claims.sub); Ok(Json(serde_json::json!({ "message": "Session revoked successfully", }))) } #[derive(Template)] #[template(path = "settings/password.html")] #[allow(dead_code)] struct PasswordTemplate { page_title: String, csrf_token: String, app_name: String, app_version: String, app_domain: String, app_description: String, analytics_opt_out: crate::analytics::AnalyticsOptOutContext, username: String, email: String, roles_text: String, initials: String, avatar_color: String, nav_items: Vec<NavItem>, user_menu_profile: String, user_menu_logout: String, must_change: bool, must_change_message: String, has_pending_request: bool, pending_request_message: String, pending_request_info: String, pending_request_cancel: String, // Translated strings (hardcoded FR) title: String, subtitle: String, new_password_label: String, current_password_label: String, current_password_placeholder: String, new_password_placeholder: String, required: String, requirements: String, strength_label: String, strength_weak: String, strength_medium: String, strength_strong: String, strength_too_short: String, strength_missing_lowercase: String, strength_missing_uppercase: String, strength_missing_digit: String, strength_missing_special: String, confirm_password_label: String, confirm_password_placeholder: String, passwords_match: String, passwords_not_match: String, change_button: String, changing: String, logout_warning_title: String, logout_warning_desc: String, saved: String, error_all_fields_required: String, error_empty_data: String, error_invalid_data: String, error_incorrect_current: String, error_reading_response: String, error_415: String, network_error: String, security_tips_title: String, security_tip_unique: String, security_tip_share: String, security_tip_regular: String, security_tip_2fa: String, security_tip_manager: String, breadcrumb_home: String, breadcrumb_settings: String, breadcrumb_password: String, cancel: String, logout_label: String, has_pending_password_change: bool, // Password complexity rules (from app_settings) pw_min_length: usize, pw_require_uppercase: bool, pw_require_lowercase: bool, pw_require_digits: bool, pw_require_special: bool, password_change_require_email: bool, subtitle_no_email: String, saved_direct: String, } rustwarden_core::impl_template_response!(PasswordTemplate); /// Session item for template #[derive(Clone, Debug)] #[allow(dead_code)] struct SessionItem { id: String, device_info: String, ip_address: String, last_activity: String, created_at: String, is_current: bool, } /// Sessions page template #[derive(Template)] #[template(path = "settings/sessions.html")] #[allow(dead_code)] struct SessionsTemplate { page_title: String, csrf_token: String, app_name: String, app_version: String, app_domain: String, app_description: String, analytics_opt_out: crate::analytics::AnalyticsOptOutContext, username: String, email: String, roles_text: String, initials: String, avatar_color: String, sessions: Vec<SessionItem>, nav_items: Vec<NavItem>, has_pending_password_change: bool, user_menu_profile: String, user_menu_logout: String, // Translated strings (hardcoded FR) title: String, subtitle: String, current_session: String, current_badge: String, unknown_device: String, ip_address: String, created_at: String, last_activity: String, session_id: String, revoke: String, revoke_confirm_template: String, revoke_success: String, revoke_error: String, no_sessions: String, no_sessions_desc: String, danger_zone: String, danger_zone_desc: String, revoke_all: String, revoke_all_confirm: String, revoke_all_success: String, revoke_all_error: String, about_title: String, about_desc: String, back_to_settings: String, breadcrumb_home: String, breadcrumb_settings: String, breadcrumb_sessions: String, } rustwarden_core::impl_template_response!(SessionsTemplate); /// Preferences page template pub async fn get_password_page( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, Query(query): Query<PasswordPageQuery>, ) -> 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.clone(), roles.clone()); let locale_str = locale.as_str(); let must_change = query.must_change.as_deref() == Some("true"); let must_change_message = if must_change { i18n_service .translate("password.must_change_message", locale_str) .await } else { String::new() }; let pw_rules = rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db).await; use rustwarden_core::services::password_change_service::PasswordChangeService; let password_change_service = PasswordChangeService::new(); let pending_request = password_change_service .find_pending_by_user_id(&db, user_model.id) .await?; let has_pending_request = pending_request.is_some(); let has_pending_password_change = has_pending_request; let pending_request_message = if has_pending_request { i18n_service .translate("password.pending_request_message", locale_str) .await } else { String::new() }; let pending_request_info = if has_pending_request { i18n_service .translate("password.pending_request_info", locale_str) .await } else { String::new() }; let pending_request_cancel = i18n_service .translate("password.pending_request_cancel", locale_str) .await; let layout = rustwarden_core::templates::LayoutContext::new("Mot de passe", ""); use crate::template_helpers::csrf_token_helper; let csrf_token = csrf_token_helper(&session).await; let mut navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; navigation.set_pending_password_change(has_pending_password_change); 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()); let page_title = i18n_service .translate("password.page_title", locale_str) .await; let template = PasswordTemplate { page_title, csrf_token, 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, username: user.username.clone(), email: user.email.clone(), roles_text: user.roles.join(", "), initials: user.initials.clone(), avatar_color: user.avatar_color.clone(), 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, must_change, must_change_message, has_pending_request, pending_request_message, pending_request_info, pending_request_cancel, title: i18n_service.translate("password.title", locale_str).await, subtitle: i18n_service .translate("password.subtitle", locale_str) .await, new_password_label: i18n_service .translate("password.new_password", locale_str) .await, current_password_label: i18n_service .translate("password.current_password", locale_str) .await, current_password_placeholder: i18n_service .translate("password.current_password_placeholder", locale_str) .await, new_password_placeholder: i18n_service .translate("password.new_password_placeholder", locale_str) .await, required: i18n_service .translate("password.required", locale_str) .await, requirements: i18n_service .translate("password.requirements", locale_str) .await, strength_label: i18n_service .translate("password.strength", locale_str) .await, strength_weak: i18n_service .translate("password.strength_weak", locale_str) .await, strength_medium: i18n_service .translate("password.strength_medium", locale_str) .await, strength_strong: i18n_service .translate("password.strength_strong", locale_str) .await, strength_too_short: i18n_service .translate("password.strength_too_short", locale_str) .await, strength_missing_lowercase: i18n_service .translate("password.strength_missing_lowercase", locale_str) .await, strength_missing_uppercase: i18n_service .translate("password.strength_missing_uppercase", locale_str) .await, strength_missing_digit: i18n_service .translate("password.strength_missing_digit", locale_str) .await, strength_missing_special: i18n_service .translate("password.strength_missing_special", locale_str) .await, confirm_password_label: i18n_service .translate("password.confirm_password", locale_str) .await, confirm_password_placeholder: i18n_service .translate("password.confirm_password_placeholder", locale_str) .await, passwords_match: i18n_service .translate("password.passwords_match", locale_str) .await, passwords_not_match: i18n_service .translate("password.passwords_not_match", locale_str) .await, change_button: i18n_service .translate("password.change_button", locale_str) .await, changing: i18n_service .translate("password.changing", locale_str) .await, logout_warning_title: i18n_service .translate("password.logout_warning_title", locale_str) .await, logout_warning_desc: i18n_service .translate("password.logout_warning_desc", locale_str) .await, saved: i18n_service.translate("password.saved", locale_str).await, error_all_fields_required: i18n_service .translate("password.error_all_fields_required", locale_str) .await, error_empty_data: i18n_service .translate("password.error_empty_data", locale_str) .await, error_invalid_data: i18n_service .translate("password.error_invalid_data", locale_str) .await, error_incorrect_current: i18n_service .translate("password.error_incorrect_current", locale_str) .await, error_reading_response: i18n_service .translate("password.error_reading_response", locale_str) .await, error_415: i18n_service .translate("password.error_415", locale_str) .await, network_error: i18n_service .translate("password.network_error", locale_str) .await, security_tips_title: i18n_service .translate("password.security_tips_title", locale_str) .await, security_tip_unique: i18n_service .translate("password.security_tip_unique", locale_str) .await, security_tip_share: i18n_service .translate("password.security_tip_share", locale_str) .await, security_tip_regular: i18n_service .translate("password.security_tip_regular", locale_str) .await, security_tip_2fa: i18n_service .translate("password.security_tip_2fa", locale_str) .await, security_tip_manager: i18n_service .translate("password.security_tip_manager", locale_str) .await, breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, breadcrumb_settings: i18n_service .translate("breadcrumb.settings", locale_str) .await, breadcrumb_password: i18n_service .translate("breadcrumb.password", locale_str) .await, cancel: i18n_service.translate("common.cancel", locale_str).await, logout_label: i18n_service.translate("user_menu.logout", locale_str).await, has_pending_password_change, pw_min_length: pw_rules.min_length, pw_require_uppercase: pw_rules.require_uppercase, pw_require_lowercase: pw_rules.require_lowercase, pw_require_digits: pw_rules.require_digits, pw_require_special: pw_rules.require_special, password_change_require_email: AppSettingsService::get_bool( &db, "password_change_require_email", true, ) .await .unwrap_or(true), subtitle_no_email: i18n_service .translate("password.subtitle_no_email", locale_str) .await, saved_direct: i18n_service .translate("password.saved_direct", locale_str) .await, }; Ok(template) } /// Handler for GET /settings/sessions - Display sessions page (PROTECTED) pub async fn get_sessions_page( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, 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 current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); let mut sessions_data = RefreshTokenService::list_user_sessions(&db, claims.sub).await?; if let Some(current_id) = current_token_id { for session_data in &mut sessions_data { if session_data.id == current_id { session_data.is_current = true; break; } } } let ip_fallback = i18n_service .translate("session.ip_unavailable", locale_str) .await; let sessions: Vec<SessionItem> = sessions_data .iter() .map(|s| SessionItem { id: s.id.to_string(), device_info: s .device_info .clone() .unwrap_or_else(|| "Unknown".to_string()), ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()), last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(), created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), is_current: s.is_current, }) .collect(); let layout = rustwarden_core::templates::LayoutContext::new("Sessions", ""); use crate::template_helpers::csrf_token_helper; 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()); let page_title = i18n_service .translate("sessions.page_title", locale_str) .await; let template = SessionsTemplate { page_title, csrf_token, 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, username: user.username.clone(), email: user.email.clone(), roles_text: user.roles.join(", "), initials: user.initials.clone(), avatar_color: user.avatar_color.clone(), sessions, nav_items, has_pending_password_change: false, user_menu_profile: i18n_service .translate("user_menu.profile", locale_str) .await, user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, title: i18n_service.translate("sessions.title", locale_str).await, subtitle: i18n_service .translate("sessions.subtitle", locale_str) .await, current_session: i18n_service .translate("sessions.current_session", locale_str) .await, current_badge: i18n_service .translate("sessions.current_badge", locale_str) .await, unknown_device: i18n_service .translate("sessions.unknown_device", locale_str) .await, ip_address: i18n_service .translate("sessions.ip_address", locale_str) .await, created_at: i18n_service .translate("sessions.created_at", locale_str) .await, last_activity: i18n_service .translate("sessions.last_activity", locale_str) .await, session_id: i18n_service .translate("sessions.session_id", locale_str) .await, revoke: i18n_service.translate("sessions.revoke", locale_str).await, revoke_confirm_template: i18n_service .translate("sessions.revoke_confirm", locale_str) .await, revoke_success: i18n_service .translate("sessions.revoke_success", locale_str) .await, revoke_error: i18n_service .translate("sessions.revoke_error", locale_str) .await, no_sessions: i18n_service .translate("sessions.no_sessions", locale_str) .await, no_sessions_desc: i18n_service .translate("sessions.no_sessions_desc", locale_str) .await, danger_zone: i18n_service .translate("sessions.danger_zone", locale_str) .await, danger_zone_desc: i18n_service .translate("sessions.danger_zone_desc", locale_str) .await, revoke_all: i18n_service .translate("sessions.revoke_all", locale_str) .await, revoke_all_confirm: i18n_service .translate("sessions.revoke_all_confirm", locale_str) .await, revoke_all_success: i18n_service .translate("sessions.revoke_all_success", locale_str) .await, revoke_all_error: i18n_service .translate("sessions.revoke_all_error", locale_str) .await, about_title: i18n_service .translate("sessions.about_title", locale_str) .await, about_desc: i18n_service .translate("sessions.about_desc", locale_str) .await, back_to_settings: i18n_service .translate("sessions.back_to_settings", locale_str) .await, breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, breadcrumb_settings: i18n_service .translate("breadcrumb.settings", locale_str) .await, breadcrumb_sessions: i18n_service .translate("breadcrumb.sessions", locale_str) .await, }; Ok(template) }
GitRust