fabrica

hanna/fabrica

5525 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Session cookies, viewer extraction, and CSRF.
6//!
7//! The session cookie carries an opaque 32-byte token; the database stores only
8//! its SHA-256, so lookup hashes the presented cookie and matches on the digest.
9//! CSRF uses the double-submit pattern: a non-`HttpOnly` `fabrica_csrf` cookie
10//! whose value must match an `X-CSRF-Token` header (injected into every htmx
11//! request by `hx-headers` on `<body>`) or a `_csrf` form field on non-GET routes.
12
13use axum::extract::FromRequestParts;
14use axum::http::HeaderMap;
15use axum::http::request::Parts;
16use axum::response::{IntoResponse, Redirect, Response};
17use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
18use model::User;
19
20use crate::AppState;
21use crate::error::AppError;
22
23/// The CSRF double-submit cookie name (non-`HttpOnly` by design).
24pub const CSRF_COOKIE: &str = "fabrica_csrf";
25/// The per-visitor theme cookie name.
26pub const THEME_COOKIE: &str = "fabrica_theme";
27
28/// The current viewer, or `None` when signed out. Never rejects.
29pub struct MaybeUser(pub Option<User>);
30
31impl FromRequestParts<AppState> for MaybeUser {
32 type Rejection = std::convert::Infallible;
33
34 async fn from_request_parts(
35 parts: &mut Parts,
36 state: &AppState,
37 ) -> Result<Self, Self::Rejection> {
38 let jar = CookieJar::from_headers(&parts.headers);
39 let Some(cookie) = jar.get(&state.config.auth.cookie_name) else {
40 return Ok(Self(None));
41 };
42 let session_id = auth::session_id(cookie.value());
43 let user = state
44 .store
45 .user_for_session(&session_id)
46 .await
47 .ok()
48 .flatten();
49 if user.is_some() {
50 // Best-effort last-seen bump; never fail the request over it.
51 let _ = state.store.touch_session(&session_id).await;
52 }
53 Ok(Self(user))
54 }
55}
56
57/// A signed-in viewer; unauthenticated requests are redirected to `/login`.
58pub struct RequireUser(pub User);
59
60impl FromRequestParts<AppState> for RequireUser {
61 type Rejection = Response;
62
63 async fn from_request_parts(
64 parts: &mut Parts,
65 state: &AppState,
66 ) -> Result<Self, Self::Rejection> {
67 let MaybeUser(user) = MaybeUser::from_request_parts(parts, state)
68 .await
69 .unwrap_or(MaybeUser(None));
70 match user {
71 Some(user) => Ok(Self(user)),
72 None => Err(Redirect::to("/login").into_response()),
73 }
74 }
75}
76
77/// A signed-in **administrator**. Non-admins get a 404 (the admin area does not
78/// acknowledge its existence to them); anonymous requests are redirected to login.
79pub struct RequireAdmin(pub User);
80
81impl FromRequestParts<AppState> for RequireAdmin {
82 type Rejection = Response;
83
84 async fn from_request_parts(
85 parts: &mut Parts,
86 state: &AppState,
87 ) -> Result<Self, Self::Rejection> {
88 let MaybeUser(user) = MaybeUser::from_request_parts(parts, state)
89 .await
90 .unwrap_or(MaybeUser(None));
91 match user {
92 Some(user) if user.is_admin => Ok(Self(user)),
93 Some(_) => Err(crate::error::AppError::NotFound.into_response()),
94 None => Err(Redirect::to("/login").into_response()),
95 }
96 }
97}
98
99/// Ensure the CSRF cookie exists, returning the (possibly updated) jar and the
100/// token to embed in forms and htmx headers. A stable token avoids invalidating
101/// forms opened in other tabs.
102pub fn ensure_csrf(jar: CookieJar, secure: bool) -> (CookieJar, String) {
103 if let Some(cookie) = jar.get(CSRF_COOKIE) {
104 return (jar.clone(), cookie.value().to_string());
105 }
106 let token = auth::new_secret();
107 let cookie = Cookie::build((CSRF_COOKIE, token.clone()))
108 .http_only(false)
109 .same_site(SameSite::Lax)
110 .secure(secure)
111 .path("/")
112 .build();
113 (jar.add(cookie), token)
114}
115
116/// Verify a non-GET request's CSRF token: the `fabrica_csrf` cookie must match the
117/// `X-CSRF-Token` header or the `form_token` (`_csrf` form field).
118///
119/// # Errors
120///
121/// Returns [`AppError::Forbidden`] if the tokens are missing or do not match.
122pub fn verify_csrf(
123 jar: &CookieJar,
124 headers: &HeaderMap,
125 form_token: Option<&str>,
126) -> Result<(), AppError> {
127 let cookie = jar.get(CSRF_COOKIE).map(|c| c.value().to_string());
128 let submitted = headers
129 .get("x-csrf-token")
130 .and_then(|v| v.to_str().ok())
131 .map(str::to_string)
132 .or_else(|| form_token.map(str::to_string));
133 match (cookie, submitted) {
134 (Some(a), Some(b)) if !a.is_empty() && a == b => Ok(()),
135 _ => Err(AppError::Forbidden),
136 }
137}
138
139/// Build the session cookie for a freshly minted token.
140#[must_use]
141pub fn session_cookie(name: &str, value: String, ttl_days: u32, secure: bool) -> Cookie<'static> {
142 Cookie::build((name.to_string(), value))
143 .http_only(true)
144 .same_site(SameSite::Lax)
145 .secure(secure)
146 .path("/")
147 .max_age(time::Duration::days(i64::from(ttl_days)))
148 .build()
149}
150
151/// Build a removal cookie for logout (empty value, immediate expiry).
152#[must_use]
153pub fn clear_cookie(name: &str) -> Cookie<'static> {
154 Cookie::build((name.to_string(), String::new()))
155 .path("/")
156 .max_age(time::Duration::ZERO)
157 .build()
158}