// 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()
}