use crate::random::random_hex;
use crate::session::Session;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{Method, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
const SESSION_KEY: &str = "_csrf_token";
pub const FIELD_NAME: &str = "_csrf_token";
pub const HEADER_NAME: &str = "X-CSRF-TOKEN";
pub async fn token(session: &Session) -> String {
if let Ok(Some(existing)) = session.get::<String>(SESSION_KEY).await {
return existing;
}
let generated = generate_token();
if let Err(error) = session.insert(SESSION_KEY, generated.clone()).await {
tracing::warn!(%error, "failed to persist CSRF token to session store");
}
generated
}
fn generate_token() -> String {
random_hex(32)
}
pub async fn verify(session: Session, request: Request, next: Next) -> Response {
let is_state_changing = matches!(
*request.method(),
Method::POST | Method::PUT | Method::PATCH | Method::DELETE
);
if !is_state_changing {
return next.run(request).await;
}
let expected = token(&session).await;
if let Some(header_value) = request
.headers()
.get(HEADER_NAME)
.and_then(|value| value.to_str().ok())
{
return if header_value == expected {
next.run(request).await
} else {
reject()
};
}
let (parts, body) = request.into_parts();
let bytes = match axum::body::to_bytes(body, 2 * 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => return (StatusCode::BAD_REQUEST, "invalid request body").into_response(),
};
let submitted = form_urlencoded::parse(&bytes)
.find(|(key, _)| key == FIELD_NAME)
.map(|(_, value)| value.into_owned());
if submitted.as_deref() != Some(expected.as_str()) {
return reject();
}
let request = Request::from_parts(parts, Body::from(bytes));
next.run(request).await
}
fn reject() -> Response {
(
StatusCode::from_u16(419).expect("419 is a valid HTTP status code"),
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>419 Page Expired</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0; min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; text-align: center;
padding: 40px 20px; gap: 12px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
color-scheme: light dark;
}
h1 { margin: 0; font-size: 1.4rem; }
p { margin: 0; max-width: 40ch; opacity: .75; }
a {
margin-top: 8px; display: inline-block; padding: 10px 20px; border-radius: 10px;
font-weight: 700; color: #fff; background: #f4513d; text-decoration: none;
}
</style>
</head>
<body>
<h1>419 · Page Expired</h1>
<p>This page took too long, or was already submitted once - your session's security token no longer matches.</p>
<a href="/">Go back home</a>
</body>
</html>"#,
)
.into_response()
}