Skip to main content

max / makenotwork

4.0 KB · 99 lines History Blame Raw
1 //! Handlers triggered by clicking email links, plus the forms that initiate them.
2
3 mod account;
4 mod acknowledge;
5 mod links;
6 mod password;
7
8 use axum::routing::get;
9 use tower_governor::GovernorLayer;
10
11 use crate::{
12 AppState, constants,
13 csrf::{CsrfRouter, post_csrf_skip, with_csrf, with_csrf_skip},
14 helpers::rate_limiter_ms,
15 };
16
17 /// Register email action routes.
18 ///
19 /// Returns a `CsrfRouter` so each mutating route MUST declare a CSRF posture,
20 /// the group cannot be merged into the page tree as a bare `Router` that
21 /// silently skips the envelope and renders a CSRF token it never validates.
22 /// Postures:
23 /// - `/forgot-password`, auto-validated. It carries no signed-link token, so
24 /// the session CSRF token IS its protection.
25 /// - `/reset-password`, `/confirm-delete`, `/unsubscribe`, CSRF skip: each
26 /// carries HMAC signed-link fields (user/expires/sig) re-validated in the
27 /// handler, which an attacker cannot forge. That signature is the CSRF
28 /// defense; a session token would add nothing for a logged-out email-link
29 /// flow.
30 /// - `/acknowledge/:token`, CSRF skip: the token is an HMAC over the signing
31 /// secret, so holding it is the authorisation, exactly as for the links
32 /// above. The POST records only that a person read a message; there is
33 /// nothing an attacker who already holds the link gains by forging it.
34 /// - `/verify-email`, `/login-link`, GET-only (read-method, no posture).
35 ///
36 /// Every route is unauthenticated (reached from email links or pre-login forms)
37 /// so the whole router carries one per-IP auth rate limiter. The tokens are
38 /// 256-bit CSPRNG / HMAC so this was never a brute-force risk, the cap closes
39 /// the abuse/amplification angle. Burst 5 + 500ms replenish comfortably covers
40 /// the legitimate forgot -> reset -> login click sequence (~3 requests).
41 pub(super) fn email_action_routes(limits: constants::RateLimits) -> CsrfRouter<AppState> {
42 let auth_rate_limit = rate_limiter_ms(limits.auth_ms, limits.auth_burst);
43
44 CsrfRouter::new()
45 .route(
46 "/forgot-password",
47 with_csrf(get(password::forgot_password_page).post(password::forgot_password_handler)),
48 )
49 .route(
50 "/reset-password",
51 with_csrf_skip(
52 "signed link: user/expires/sig HMAC re-validated in handler",
53 get(password::reset_password_page).post(password::reset_password_handler),
54 ),
55 )
56 .route_get("/verify-email", get(links::verify_email_handler))
57 .route_get("/login-link", get(links::login_link_handler))
58 .route(
59 "/confirm-delete",
60 with_csrf_skip(
61 "signed link: user/expires/sig HMAC re-validated in handler",
62 get(account::confirm_delete_page).post(account::confirm_delete_handler),
63 ),
64 )
65 .route(
66 "/unsubscribe",
67 with_csrf_skip(
68 "signed link: sig HMAC verified in handler",
69 get(account::unsubscribe_page).post(account::unsubscribe_handler),
70 ),
71 )
72 // The preferences page's own actions. Sessionless like the page, and
73 // authorised by the same signed token, which is re-verified in each
74 // handler; a CSRF cookie would require the login this page exists to
75 // avoid.
76 .route(
77 "/unsubscribe/list",
78 post_csrf_skip(
79 "signed link: sig HMAC verified in handler",
80 account::preferences_toggle,
81 ),
82 )
83 .route(
84 "/unsubscribe/all",
85 post_csrf_skip(
86 "signed link: sig HMAC verified in handler",
87 account::preferences_unsubscribe_all,
88 ),
89 )
90 .route(
91 "/acknowledge/{token}",
92 with_csrf_skip(
93 "signed link: the token is an HMAC over the signing secret",
94 get(acknowledge::acknowledge_page).post(acknowledge::acknowledge_handler),
95 ),
96 )
97 .layer(GovernorLayer::new(auth_rate_limit))
98 }
99