// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Session cookies, viewer extraction, and CSRF. //! //! The session cookie carries an opaque 32-byte token; the database stores only //! its SHA-256, so lookup hashes the presented cookie and matches on the digest. //! CSRF uses the double-submit pattern: a non-`HttpOnly` `fabrica_csrf` cookie //! whose value must match an `X-CSRF-Token` header (injected into every htmx //! request by `hx-headers` on ``) or a `_csrf` form field on non-GET routes. use axum::extract::FromRequestParts; use axum::http::HeaderMap; use axum::http::request::Parts; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use model::User; use crate::AppState; use crate::error::AppError; /// The CSRF double-submit cookie name (non-`HttpOnly` by design). pub const CSRF_COOKIE: &str = "fabrica_csrf"; /// The per-visitor theme cookie name. pub const THEME_COOKIE: &str = "fabrica_theme"; /// The current viewer, or `None` when signed out. Never rejects. pub struct MaybeUser(pub Option); impl FromRequestParts for MaybeUser { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let jar = CookieJar::from_headers(&parts.headers); let Some(cookie) = jar.get(&state.config.auth.cookie_name) else { return Ok(Self(None)); }; let session_id = auth::session_id(cookie.value()); let user = state .store .user_for_session(&session_id) .await .ok() .flatten(); if user.is_some() { // Best-effort last-seen bump; never fail the request over it. let _ = state.store.touch_session(&session_id).await; } Ok(Self(user)) } } /// A signed-in viewer; unauthenticated requests are redirected to `/login`. pub struct RequireUser(pub User); impl FromRequestParts for RequireUser { type Rejection = Response; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let MaybeUser(user) = MaybeUser::from_request_parts(parts, state) .await .unwrap_or(MaybeUser(None)); match user { Some(user) => Ok(Self(user)), None => Err(Redirect::to("/login").into_response()), } } } /// A signed-in **administrator**. Non-admins get a 404 (the admin area does not /// acknowledge its existence to them); anonymous requests are redirected to login. pub struct RequireAdmin(pub User); impl FromRequestParts for RequireAdmin { type Rejection = Response; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let MaybeUser(user) = MaybeUser::from_request_parts(parts, state) .await .unwrap_or(MaybeUser(None)); match user { Some(user) if user.is_admin => Ok(Self(user)), Some(_) => Err(crate::error::AppError::NotFound.into_response()), None => Err(Redirect::to("/login").into_response()), } } } /// Ensure the CSRF cookie exists, returning the (possibly updated) jar and the /// token to embed in forms and htmx headers. A stable token avoids invalidating /// forms opened in other tabs. pub fn ensure_csrf(jar: CookieJar, secure: bool) -> (CookieJar, String) { if let Some(cookie) = jar.get(CSRF_COOKIE) { return (jar.clone(), cookie.value().to_string()); } let token = auth::new_secret(); let cookie = Cookie::build((CSRF_COOKIE, token.clone())) .http_only(false) .same_site(SameSite::Lax) .secure(secure) .path("/") .build(); (jar.add(cookie), token) } /// Verify a non-GET request's CSRF token: the `fabrica_csrf` cookie must match the /// `X-CSRF-Token` header or the `form_token` (`_csrf` form field). /// /// # Errors /// /// Returns [`AppError::Forbidden`] if the tokens are missing or do not match. pub fn verify_csrf( jar: &CookieJar, headers: &HeaderMap, form_token: Option<&str>, ) -> Result<(), AppError> { let cookie = jar.get(CSRF_COOKIE).map(|c| c.value().to_string()); let submitted = headers .get("x-csrf-token") .and_then(|v| v.to_str().ok()) .map(str::to_string) .or_else(|| form_token.map(str::to_string)); match (cookie, submitted) { (Some(a), Some(b)) if !a.is_empty() && a == b => Ok(()), _ => Err(AppError::Forbidden), } } /// Build the session cookie for a freshly minted token. #[must_use] pub fn session_cookie(name: &str, value: String, ttl_days: u32, secure: bool) -> Cookie<'static> { Cookie::build((name.to_string(), value)) .http_only(true) .same_site(SameSite::Lax) .secure(secure) .path("/") .max_age(time::Duration::days(i64::from(ttl_days))) .build() } /// Build a removal cookie for logout (empty value, immediate expiry). #[must_use] pub fn clear_cookie(name: &str) -> Cookie<'static> { Cookie::build((name.to_string(), String::new())) .path("/") .max_age(time::Duration::ZERO) .build() }