email_change_handlers.rs
737 lignes · 28148 octets
//! HTTP handlers for the two-step email change (RFC 0041). //! //! Five endpoints, all on the machine channel (never behind the CSRF layer): //! //! | Method | Path | Auth | //! |---|---|---| //! | `POST` | `/api/v1/auth/email-change` | Bearer | //! | `GET` | `/api/v1/auth/email-change/confirm/{token}` | public, **no side effect** | //! | `POST` | `/api/v1/auth/email-change/confirm/{token}` | public, applies the change | //! | `GET` | `/api/v1/auth/email-change/report/{token}` | public, **no side effect** | //! | `POST` | `/api/v1/auth/email-change/report/{token}` | public, records the report | //! //! Both emailed links land on a `GET` that only renders a form; it is the //! human click on that form that reaches the service. Anti-phishing gateways //! and mail clients prefetch links, so a `GET` that acted would let a scanner //! silently rewrite an account's login identity. //! //! The pages are built by the private `auth::email_change_pages` module: plain //! `format!`-built HTML, so mounting these routes never requires a UI crate. use axum::{ Extension, Json, extract::{Path, State}, response::Html, }; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use std::sync::Arc; use validator::Validate; use crate::auth::email_change_pages::{EmailChangePage, render}; use crate::auth::extractors::AuthUser; use crate::config::email::EmailConfig; use crate::error::AppError; use crate::hooks::RustwardenHooks; use crate::i18n::extractor::Locale; use crate::i18n::service::I18nService; use crate::services::email_change_service::{EmailChangeLinkState, EmailChangeService}; use crate::services::email_sanitization::mask_email; /// Body of `POST /api/v1/auth/email-change`. #[derive(Debug, Deserialize, Validate)] pub struct EmailChangeRequestPayload { /// Candidate address. Nothing is written to the account until the link /// mailed to it is confirmed. #[validate(email(message = "Invalid email format"))] pub new_email: String, /// Current password: opening a change is a re-authenticated operation, so /// a stolen access token alone cannot start an account takeover. #[validate(length(min = 1, message = "Current password is required"))] pub current_password: String, } /// Response of `POST /api/v1/auth/email-change`. #[derive(Debug, Serialize)] pub struct EmailChangeRequestResponse { /// Translated confirmation message for the end user. pub message: String, /// Candidate address, **masked**: the caller already knows what it typed, /// and a response body ends up in logs and proxies. pub pending_email: String, } /// Translate `key`, falling back to `fallback` without an [`I18nService`]. async fn tr(i18n: Option<&Arc<I18nService>>, key: &str, locale: &str, fallback: &str) -> String { match i18n { Some(service) => { let value = service.translate(key, locale).await; // `I18nService::translate` echoes the key when it is unknown. if value == key { fallback.to_string() } else { value } } None => fallback.to_string(), } } /// Resolve the request locale, defaulting to the configured one. fn locale_of(locale: Option<Extension<Locale>>) -> String { locale .map(|Extension(l)| l.0) .unwrap_or_else(crate::config::tasks::default_locale) } /// Build the service, attaching i18n when the application provides it. fn service_from(i18n: Option<&Arc<I18nService>>) -> Result<EmailChangeService, AppError> { let config = EmailConfig::from_env().map_err(|e| { tracing::error!("Email configuration error: {}", e); AppError::Config(e) })?; let service = EmailChangeService::new(config); Ok(match i18n { Some(i18n) => service.with_i18n(Arc::clone(i18n)), None => service, }) } /// Render a page that only explains why a link cannot be used — no form, so /// the visitor is never invited to submit a token that would be refused. async fn unusable_link_page( i18n: Option<&Arc<I18nService>>, locale: &str, state: EmailChangeLinkState, ) -> Html<String> { let (key, fallback) = match state { EmailChangeLinkState::Expired => { ("email_change_page.error_expired", "This link has expired.") } EmailChangeLinkState::Used => ( "email_change_page.error_used", "This link has already been used.", ), EmailChangeLinkState::Unknown => { ("email_change_page.error_invalid", "This link is invalid.") } // Not reachable through the callers, but fails closed: a usable link // reported as unusable costs a retry, never a wrong write. EmailChangeLinkState::Usable => ( "email_change_page.error_generic", "This request could not be processed.", ), }; Html(render(&EmailChangePage { lang: locale.to_string(), title: tr( i18n, "email_change_page.error_title", locale, "Unusable link", ) .await, message: tr(i18n, key, locale, fallback).await, button: None, })) } /// `POST /api/v1/auth/email-change` — open a change request. /// /// Re-authenticates with `current_password`, stores a pending request and /// queues two emails: the confirmation link to the candidate address, and a /// notice carrying a report link to the current one. `users.email` is **not** /// touched here. /// /// # Errors /// /// - [`AppError::Validation`] — malformed body, address already taken, or the /// address is the account's current one. /// - [`AppError::InvalidCredentials`] — wrong `current_password`. /// - [`AppError::TooManyRequests`] — more than three requests in an hour. /// - [`AppError::Config`] — no usable SMTP configuration. pub async fn request_email_change( State(db): State<DatabaseConnection>, i18n: Option<Extension<Arc<I18nService>>>, locale: Option<Extension<Locale>>, user: AuthUser, Json(payload): Json<EmailChangeRequestPayload>, ) -> Result<Json<EmailChangeRequestResponse>, AppError> { payload.validate().map_err(|e| { tracing::warn!(user_id = %user.user_id, "Email-change request validation failed: {}", e); AppError::Validation(format!("Invalid email-change request: {e}")) })?; let i18n = i18n.map(|Extension(service)| service); let locale = locale_of(locale); let service = service_from(i18n.as_ref())?; service .request_change( &db, user.user_id, &payload.new_email, &payload.current_password, ) .await?; Ok(Json(EmailChangeRequestResponse { message: tr( i18n.as_ref(), "email_change.request_accepted", &locale, "An email has been sent to your new address. The change will be applied once you \ confirm it from that message.", ) .await, pending_email: mask_email(payload.new_email.trim()), })) } /// `GET /api/v1/auth/email-change/confirm/{token}` — interstitial page. /// /// Reads the token state and renders a form; it never writes. Always answers /// `200`, including for an unusable link: a human arriving from their mailbox /// deserves an explanation rather than a status code. pub async fn confirm_email_change_page( State(db): State<DatabaseConnection>, i18n: Option<Extension<Arc<I18nService>>>, locale: Option<Extension<Locale>>, Path(token): Path<String>, ) -> Html<String> { let i18n = i18n.map(|Extension(service)| service); let locale = locale_of(locale); match EmailChangeService::inspect_confirmation_token(&db, &token).await { Ok(EmailChangeLinkState::Usable) => Html(render(&EmailChangePage { lang: locale.clone(), title: tr( i18n.as_ref(), "email_change_page.confirm_title", &locale, "Confirm your new email address", ) .await, message: tr( i18n.as_ref(), "email_change_page.confirm_question", &locale, "You are about to replace the sign-in email address of your account. All your \ active sessions will be closed.", ) .await, button: Some( tr( i18n.as_ref(), "email_change_page.confirm_button", &locale, "Confirm the change", ) .await, ), })), Ok(state) => unusable_link_page(i18n.as_ref(), &locale, state).await, Err(e) => { tracing::error!("Email-change token lookup failed: {}", e); unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await } } } /// `POST /api/v1/auth/email-change/confirm/{token}` — apply the change. /// /// Writes the new address already verified, revokes every refresh token and /// drops any pending password-change request. Renders the outcome as HTML: /// the caller is the interstitial form, not an API client. pub async fn confirm_email_change( State(db): State<DatabaseConnection>, i18n: Option<Extension<Arc<I18nService>>>, locale: Option<Extension<Locale>>, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, Path(token): Path<String>, ) -> Html<String> { let i18n = i18n.map(|Extension(service)| service); let locale = locale_of(locale); let hooks = hooks.map(|Extension(hooks)| hooks); let service = match service_from(i18n.as_ref()) { Ok(service) => service, Err(e) => { tracing::error!("Email-change confirmation unavailable: {}", e); return unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await; } }; match service.confirm_change(&db, &token, hooks.as_ref()).await { Ok(_) => Html(render(&EmailChangePage { lang: locale.clone(), title: tr( i18n.as_ref(), "email_change_page.confirm_success_title", &locale, "Email address changed", ) .await, message: tr( i18n.as_ref(), "email_change_page.confirm_success", &locale, "Your new address is active and verified. Your sessions were closed: sign in \ again with this new address.", ) .await, button: None, })), Err(e) => { tracing::warn!("Email-change confirmation failed: {}", e); // One extra indexed read to tell "expired" from "already used" // from "unknown": this path is rare and the message matters. let state = EmailChangeService::inspect_confirmation_token(&db, &token) .await .unwrap_or(EmailChangeLinkState::Usable); unusable_link_page(i18n.as_ref(), &locale, state).await } } } /// `GET /api/v1/auth/email-change/report/{token}` — interstitial page. /// /// Read-only, exactly like the confirmation page: a prefetched report would /// wake every administrator for nothing. pub async fn report_email_change_page( State(db): State<DatabaseConnection>, i18n: Option<Extension<Arc<I18nService>>>, locale: Option<Extension<Locale>>, Path(token): Path<String>, ) -> Html<String> { let i18n = i18n.map(|Extension(service)| service); let locale = locale_of(locale); match EmailChangeService::inspect_report_token(&db, &token).await { Ok(EmailChangeLinkState::Usable) => Html(render(&EmailChangePage { lang: locale.clone(), title: tr( i18n.as_ref(), "email_change_page.report_title", &locale, "Report a suspicious request", ) .await, message: tr( i18n.as_ref(), "email_change_page.report_question", &locale, "You are about to alert the administrators about this email-change request. The \ change will not be blocked by this report.", ) .await, button: Some( tr( i18n.as_ref(), "email_change_page.report_button", &locale, "Alert the administrators", ) .await, ), })), Ok(state) => unusable_link_page(i18n.as_ref(), &locale, state).await, Err(e) => { tracing::error!("Email-change report token lookup failed: {}", e); unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await } } } /// `POST /api/v1/auth/email-change/report/{token}` — record the report. /// /// Flags the request, writes an audit entry and alerts every administrator. /// It deliberately does **not** cancel the change: the mailbox that receives /// this link can alert, not decide. pub async fn report_email_change( State(db): State<DatabaseConnection>, i18n: Option<Extension<Arc<I18nService>>>, locale: Option<Extension<Locale>>, hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, Path(token): Path<String>, ) -> Html<String> { let i18n = i18n.map(|Extension(service)| service); let locale = locale_of(locale); let hooks = hooks.map(|Extension(hooks)| hooks); let service = match service_from(i18n.as_ref()) { Ok(service) => service, Err(e) => { tracing::error!("Email-change report unavailable: {}", e); return unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await; } }; match service.report_suspicious(&db, &token, hooks.as_ref()).await { Ok(_) => Html(render(&EmailChangePage { lang: locale.clone(), title: tr( i18n.as_ref(), "email_change_page.report_success_title", &locale, "Report sent", ) .await, message: tr( i18n.as_ref(), "email_change_page.report_success", &locale, "The administrators have been notified. If you are not the origin of the request, \ change your password right away.", ) .await, button: None, })), Err(e) => { tracing::warn!("Email-change report failed: {}", e); let state = EmailChangeService::inspect_report_token(&db, &token) .await .unwrap_or(EmailChangeLinkState::Usable); unusable_link_page(i18n.as_ref(), &locale, state).await } } } #[cfg(test)] mod tests { use super::*; use crate::auth::auth_api_routes; use crate::models::{email_queue, pending_email_change, user}; use axum::Router; use axum::body::Body; use axum::http::{Request, StatusCode}; use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use tower::ServiceExt; use uuid::Uuid; const PASSWORD: &str = "TestPassword1!"; async fn test_db() -> DatabaseConnection { dotenvy::dotenv().ok(); let url = std::env::var("DATABASE_URL") .expect("DATABASE_URL required for email_change_handlers tests"); sea_orm::Database::connect(&url) .await .expect("connect to the test database") } fn email_config() -> EmailConfig { dotenvy::dotenv().ok(); EmailConfig::from_env().expect("SMTP configuration required for these tests") } /// The four link routes carry no auth layer, so the router needs nothing /// but the database state — which is exactly the headless contract. fn app(db: &DatabaseConnection) -> Router { auth_api_routes().with_state(db.clone()) } async fn body_of(response: axum::response::Response) -> String { let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .expect("read body"); String::from_utf8(bytes.to_vec()).expect("utf-8 body") } async fn verified_user(db: &DatabaseConnection) -> user::Model { let unique = Uuid::new_v4().simple().to_string(); let hash = crate::services::password_service::PasswordService::hash_password(PASSWORD) .await .expect("hash password"); let created = crate::services::user_service::UserService::create_user( db, format!("ech-{}", &unique[..9]), format!("ech-{}@example.test", &unique[..9]), hash, false, ) .await .expect("create user"); let mut active: user::ActiveModel = created.into(); active.email_verified = Set(true); active.email_verified_at = Set(Some(Utc::now())); active.update(db).await.expect("mark verified") } /// Pull a link's token back out of a queued body, the way a human reads /// the mail and clicks. async fn token_from_mail( db: &DatabaseConnection, to_email: &str, email_type: &str, kind: &str, ) -> String { let mail = email_queue::Entity::find() .filter(email_queue::Column::ToEmail.eq(to_email)) .filter(email_queue::Column::EmailType.eq(email_type)) .order_by_desc(email_queue::Column::CreatedAt) .one(db) .await .expect("query email_queue") .unwrap_or_else(|| panic!("no {email_type} queued for {to_email}")); let marker = format!( "{}/api/v1/auth/email-change/{kind}/", email_config().email_base_url ); let body = mail.body_text; let start = body .find(&marker) .unwrap_or_else(|| panic!("no {kind} link in body: {body}")) + marker.len(); body[start..] .chars() .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') .collect() } /// Open a real request through the service, then hand back the two tokens. async fn seed_request(db: &DatabaseConnection) -> (user::Model, String, String, String) { let user = verified_user(db).await; let candidate = format!("cand-{}@example.test", Uuid::new_v4().simple()); EmailChangeService::new(email_config()) .request_change(db, user.id, &candidate, PASSWORD) .await .expect("seed an email-change request"); let confirm = token_from_mail(db, &candidate, "email_change_validation", "confirm").await; let report = token_from_mail(db, &user.email, "email_change_notice", "report").await; (user, candidate, confirm, report) } async fn pending_row(db: &DatabaseConnection, user_id: Uuid) -> pending_email_change::Model { pending_email_change::Entity::find() .filter(pending_email_change::Column::UserId.eq(user_id)) .order_by_desc(pending_email_change::Column::CreatedAt) .one(db) .await .expect("query pending_email_changes") .expect("a pending row must exist") } async fn current_email(db: &DatabaseConnection, user_id: Uuid) -> String { user::Entity::find_by_id(user_id) .one(db) .await .expect("reload user") .expect("user still exists") .email } fn get(uri: &str) -> Request<Body> { Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .expect("GET request") } /// No CSRF header, no cookie: these routes live on the machine channel and /// the form the page renders carries no token either. fn post(uri: &str) -> Request<Body> { Request::builder() .method("POST") .uri(uri) .body(Body::empty()) .expect("POST request") } // ── GET has no side effect ────────────────────────────────────────────── /// RFC 0041 test 18 — anti-phishing gateways prefetch links. A `GET` that /// acted would let a scanner silently rewrite an account's identity. #[tokio::test] async fn getting_the_confirm_page_renders_a_form_and_writes_nothing() { let db = test_db().await; let (user, candidate, confirm, _) = seed_request(&db).await; // Two GETs, as a gateway and the human would both do. for _ in 0..2 { let response = app(&db) .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{confirm}"))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); let html = body_of(response).await; assert!(html.starts_with("<!DOCTYPE html>")); assert!( html.contains("method=\"post\""), "the page must offer a form, not act by itself" ); assert!( !html.contains(&confirm), "the token travels in the URL, never in the body" ); } let row = pending_row(&db, user.id).await; assert!(row.used_at.is_none(), "GET must not consume the request"); assert_eq!(row.new_email, candidate); assert_eq!( current_email(&db, user.id).await, user.email, "GET must not move the address" ); } /// RFC 0041 test 20 — same contract on the report link: a prefetch must /// not wake every administrator. #[tokio::test] async fn getting_the_report_page_renders_a_form_and_writes_nothing() { let db = test_db().await; let (user, _, _, report) = seed_request(&db).await; let response = app(&db) .oneshot(get(&format!("/api/v1/auth/email-change/report/{report}"))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); assert!(body_of(response).await.contains("method=\"post\"")); let row = pending_row(&db, user.id).await; assert!(row.reported_at.is_none(), "GET must not file the report"); assert!(row.used_at.is_none()); } // ── POST acts ─────────────────────────────────────────────────────────── /// RFC 0041 test 19 — the human click on the form is what applies the /// change, and it needs no CSRF token: machine channel, single-use secret. #[tokio::test] async fn posting_the_confirm_form_applies_the_change_without_a_csrf_token() { let db = test_db().await; let (user, candidate, confirm, _) = seed_request(&db).await; let response = app(&db) .oneshot(post(&format!( "/api/v1/auth/email-change/confirm/{confirm}" ))) .await .expect("oneshot"); assert_eq!( response.status(), StatusCode::OK, "a POST with neither CSRF header nor cookie must go through" ); let html = body_of(response).await; assert!( !html.contains("<form"), "the result page has nothing left to submit" ); assert_eq!(current_email(&db, user.id).await, candidate); assert!(pending_row(&db, user.id).await.used_at.is_some()); } /// RFC 0041 test 21 — the report is recorded and does not block anything. #[tokio::test] async fn posting_the_report_form_flags_the_request() { let db = test_db().await; let (user, candidate, confirm, report) = seed_request(&db).await; let response = app(&db) .oneshot(post(&format!("/api/v1/auth/email-change/report/{report}"))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); assert!(!body_of(response).await.contains("<form")); let row = pending_row(&db, user.id).await; assert!(row.reported_at.is_some(), "the request must be flagged"); assert!(row.used_at.is_none(), "a report must not consume it"); // And the change still goes through: alerting is not deciding. let confirmed = app(&db) .oneshot(post(&format!( "/api/v1/auth/email-change/confirm/{confirm}" ))) .await .expect("oneshot"); assert_eq!(confirmed.status(), StatusCode::OK); assert_eq!(current_email(&db, user.id).await, candidate); } // ── Unusable links ────────────────────────────────────────────────────── /// An unusable link still answers `200` with an explanation, and offers no /// form: nobody is invited to submit a token that would be refused. #[tokio::test] async fn an_unusable_link_explains_itself_and_offers_no_form() { let db = test_db().await; let (user, _, confirm, _) = seed_request(&db).await; // Unknown token. let unknown = Uuid::new_v4().to_string(); let response = app(&db) .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{unknown}"))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); let html = body_of(response).await; assert!(html.starts_with("<!DOCTYPE html>")); assert!(html.contains("<h1>"), "the visitor gets an explanation"); assert!( !html.contains("<form") && !html.contains("<button"), "an unusable link must not invite a POST" ); // Expired token. let mut active: pending_email_change::ActiveModel = pending_row(&db, user.id).await.into(); active.expires_at = Set(Utc::now() - chrono::Duration::hours(1)); active.update(&db).await.expect("expire the row"); let response = app(&db) .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{confirm}"))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); assert!(!body_of(response).await.contains("<form")); // And posting it anyway changes nothing. let response = app(&db) .oneshot(post(&format!( "/api/v1/auth/email-change/confirm/{confirm}" ))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( current_email(&db, user.id).await, user.email, "an expired link must never move the address" ); } /// The pages render with no `I18nService` extension layered: mounting /// these routes must never require a UI crate or a locale setup. #[tokio::test] async fn the_pages_render_headless_without_i18n() { let db = test_db().await; let (_, _, confirm, _) = seed_request(&db).await; let response = app(&db) .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{confirm}"))) .await .expect("oneshot"); assert_eq!(response.status(), StatusCode::OK); let html = body_of(response).await; assert!( !html.contains("{{") && !html.contains("{%"), "no template syntax may leak into the page: {html}" ); assert!( !html.contains("email_change_page."), "an untranslated key must fall back to English, not surface raw" ); } }
GitRust