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"
        );
    }
}