//! Handlers triggered by clicking email links, plus the forms that initiate them. mod account; mod acknowledge; mod links; mod password; use axum::routing::get; use tower_governor::GovernorLayer; use crate::{ AppState, constants, csrf::{CsrfRouter, post_csrf_skip, with_csrf, with_csrf_skip}, helpers::rate_limiter_ms, }; /// Register email action routes. /// /// Returns a `CsrfRouter` so each mutating route MUST declare a CSRF posture, /// the group cannot be merged into the page tree as a bare `Router` that /// silently skips the envelope and renders a CSRF token it never validates. /// Postures: /// - `/forgot-password`, auto-validated. It carries no signed-link token, so /// the session CSRF token IS its protection. /// - `/reset-password`, `/confirm-delete`, `/unsubscribe`, CSRF skip: each /// carries HMAC signed-link fields (user/expires/sig) re-validated in the /// handler, which an attacker cannot forge. That signature is the CSRF /// defense; a session token would add nothing for a logged-out email-link /// flow. /// - `/acknowledge/:token`, CSRF skip: the token is an HMAC over the signing /// secret, so holding it is the authorisation, exactly as for the links /// above. The POST records only that a person read a message; there is /// nothing an attacker who already holds the link gains by forging it. /// - `/verify-email`, `/login-link`, GET-only (read-method, no posture). /// /// Every route is unauthenticated (reached from email links or pre-login forms) /// so the whole router carries one per-IP auth rate limiter. The tokens are /// 256-bit CSPRNG / HMAC so this was never a brute-force risk, the cap closes /// the abuse/amplification angle. Burst 5 + 500ms replenish comfortably covers /// the legitimate forgot -> reset -> login click sequence (~3 requests). pub(super) fn email_action_routes(limits: constants::RateLimits) -> CsrfRouter { let auth_rate_limit = rate_limiter_ms(limits.auth_ms, limits.auth_burst); CsrfRouter::new() .route( "/forgot-password", with_csrf(get(password::forgot_password_page).post(password::forgot_password_handler)), ) .route( "/reset-password", with_csrf_skip( "signed link: user/expires/sig HMAC re-validated in handler", get(password::reset_password_page).post(password::reset_password_handler), ), ) .route_get("/verify-email", get(links::verify_email_handler)) .route_get("/login-link", get(links::login_link_handler)) .route( "/confirm-delete", with_csrf_skip( "signed link: user/expires/sig HMAC re-validated in handler", get(account::confirm_delete_page).post(account::confirm_delete_handler), ), ) .route( "/unsubscribe", with_csrf_skip( "signed link: sig HMAC verified in handler", get(account::unsubscribe_page).post(account::unsubscribe_handler), ), ) // The preferences page's own actions. Sessionless like the page, and // authorised by the same signed token, which is re-verified in each // handler; a CSRF cookie would require the login this page exists to // avoid. .route( "/unsubscribe/list", post_csrf_skip( "signed link: sig HMAC verified in handler", account::preferences_toggle, ), ) .route( "/unsubscribe/all", post_csrf_skip( "signed link: sig HMAC verified in handler", account::preferences_unsubscribe_all, ), ) .route( "/acknowledge/{token}", with_csrf_skip( "signed link: the token is an HMAC over the signing secret", get(acknowledge::acknowledge_page).post(acknowledge::acknowledge_handler), ), ) .layer(GovernorLayer::new(auth_rate_limit)) }