Skip to main content

max / makenotwork

2.8 KB · 72 lines History Blame Raw
1 //! Handlers triggered by clicking email links, plus the forms that initiate them.
2
3 mod account;
4 mod links;
5 mod password;
6
7 use axum::routing::get;
8 use tower_governor::GovernorLayer;
9
10 use crate::{
11 AppState, constants,
12 csrf::{CsrfRouter, with_csrf, with_csrf_skip},
13 helpers::rate_limiter_ms,
14 };
15
16 /// Register email action routes.
17 ///
18 /// Returns a `CsrfRouter` so each mutating route MUST declare a CSRF posture,
19 /// the group can no longer be merged into the page tree as a bare `Router` that
20 /// silently skips the envelope (the Run #16/#17 CHRONIC: `/forgot-password`
21 /// rendered a CSRF token it never validated). Postures:
22 /// - `/forgot-password`, auto-validated. It carries no signed-link token, so
23 /// the session CSRF token IS its protection.
24 /// - `/reset-password`, `/confirm-delete`, `/unsubscribe`, CSRF skip: each
25 /// carries HMAC signed-link fields (user/expires/sig) re-validated in the
26 /// handler, which an attacker cannot forge. That signature is the CSRF
27 /// defense; a session token would add nothing for a logged-out email-link
28 /// flow.
29 /// - `/verify-email`, `/login-link`, GET-only (read-method, no posture).
30 ///
31 /// Every route is unauthenticated (reached from email links or pre-login forms)
32 /// so the whole router carries one per-IP auth rate limiter. The tokens are
33 /// 256-bit CSPRNG / HMAC so this was never a brute-force risk, the cap closes
34 /// the abuse/amplification angle. Burst 5 + 500ms replenish comfortably covers
35 /// the legitimate forgot -> reset -> login click sequence (~3 requests).
36 pub(super) fn email_action_routes() -> CsrfRouter<AppState> {
37 let auth_rate_limit = rate_limiter_ms(
38 constants::AUTH_RATE_LIMIT_MS,
39 constants::AUTH_RATE_LIMIT_BURST,
40 );
41
42 CsrfRouter::new()
43 .route(
44 "/forgot-password",
45 with_csrf(get(password::forgot_password_page).post(password::forgot_password_handler)),
46 )
47 .route(
48 "/reset-password",
49 with_csrf_skip(
50 "signed link: user/expires/sig HMAC re-validated in handler",
51 get(password::reset_password_page).post(password::reset_password_handler),
52 ),
53 )
54 .route_get("/verify-email", get(links::verify_email_handler))
55 .route_get("/login-link", get(links::login_link_handler))
56 .route(
57 "/confirm-delete",
58 with_csrf_skip(
59 "signed link: user/expires/sig HMAC re-validated in handler",
60 get(account::confirm_delete_page).post(account::confirm_delete_handler),
61 ),
62 )
63 .route(
64 "/unsubscribe",
65 with_csrf_skip(
66 "signed link: sig HMAC verified in handler",
67 get(account::unsubscribe_page).post(account::unsubscribe_handler),
68 ),
69 )
70 .layer(GovernorLayer::new(auth_rate_limit))
71 }
72