2e3b5efe86
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.
Shape:
- New askama macro partials/csrf_field.html renders the hidden
input; 3 templates import + {% call csrf::field(csrf_token) %}
inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
session once during extraction) so render handlers don't need a
second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
CaseRecordingsTemplate) gain the field; render sites pass
user.csrf_token through.
Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
a hidden input — catches anyone adding a new form without the
macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
with token → 303. Catches drift between rendered and accepted
token formats.
158 lines
4.9 KiB
Rust
158 lines
4.9 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use axum::extract::{FromRef, FromRequestParts};
|
|
use axum::http::request::Parts;
|
|
use axum_extra::extract::cookie::CookieJar;
|
|
use doctate_common::API_KEY_HEADER;
|
|
use tracing::warn;
|
|
|
|
use crate::config::Config;
|
|
use crate::error::AppError;
|
|
use crate::web_session::{SESSION_COOKIE, SessionStore};
|
|
|
|
/// Truncate a session token to a non-sensitive prefix for logs.
|
|
fn token_prefix(token: &str) -> String {
|
|
token.chars().take(8).collect()
|
|
}
|
|
|
|
/// Extracted from the X-API-Key header on every authenticated request.
|
|
pub struct AuthenticatedUser {
|
|
pub slug: String,
|
|
pub role: String,
|
|
pub data_dir: PathBuf,
|
|
/// Copied from the matching entry in `users.toml` — the single
|
|
/// source of truth for how many hours of history clients see.
|
|
pub window_hours: u32,
|
|
}
|
|
|
|
impl AuthenticatedUser {
|
|
pub fn is_admin(&self) -> bool {
|
|
self.role == "admin"
|
|
}
|
|
}
|
|
|
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
|
where
|
|
S: Send + Sync,
|
|
Arc<Config>: FromRef<S>,
|
|
{
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
let config = Arc::<Config>::from_ref(state);
|
|
|
|
let api_key = parts
|
|
.headers
|
|
.get(API_KEY_HEADER)
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or(AppError::Unauthorized)?;
|
|
|
|
let slug = config.api_keys.get(api_key).ok_or(AppError::Unauthorized)?;
|
|
|
|
let user = config
|
|
.users
|
|
.iter()
|
|
.find(|u| u.slug == *slug)
|
|
.ok_or(AppError::Unauthorized)?;
|
|
|
|
let data_dir = config.data_path.join(slug);
|
|
tokio::fs::create_dir_all(&data_dir).await?;
|
|
|
|
Ok(Self {
|
|
slug: slug.clone(),
|
|
role: user.role.clone(),
|
|
data_dir,
|
|
window_hours: user.window_hours,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Extracted from the session cookie on web UI requests.
|
|
/// On missing/expired session, returns `AppError::Redirect("/web/login")`.
|
|
pub struct AuthenticatedWebUser {
|
|
pub slug: String,
|
|
pub role: String,
|
|
pub data_dir: PathBuf,
|
|
/// Mirrors [`AuthenticatedUser::window_hours`] so the same policy
|
|
/// is available to web handlers without a second config lookup.
|
|
pub window_hours: u32,
|
|
/// Session's CSRF token. Rendered into every state-changing form
|
|
/// as a hidden field so the `CsrfForm` extractor can validate it
|
|
/// on the matching POST. Cloned out of the session record here so
|
|
/// the template handlers don't need a second store lookup.
|
|
pub csrf_token: String,
|
|
}
|
|
|
|
impl AuthenticatedWebUser {
|
|
pub fn is_admin(&self) -> bool {
|
|
self.role == "admin"
|
|
}
|
|
}
|
|
|
|
impl<S> FromRequestParts<S> for AuthenticatedWebUser
|
|
where
|
|
S: Send + Sync,
|
|
Arc<Config>: FromRef<S>,
|
|
SessionStore: FromRef<S>,
|
|
{
|
|
type Rejection = AppError;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
let config = Arc::<Config>::from_ref(state);
|
|
let store = SessionStore::from_ref(state);
|
|
|
|
let jar = CookieJar::from_headers(&parts.headers);
|
|
let token = jar
|
|
.get(SESSION_COOKIE)
|
|
.map(|c| c.value().to_owned())
|
|
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
|
|
|
// Read lock first to check validity. If expired, upgrade to write and remove.
|
|
let expired = {
|
|
let r = store.read().await;
|
|
match r.get(&token) {
|
|
Some(s) => s.expires_at <= Instant::now(),
|
|
None => {
|
|
warn!(token_prefix = %token_prefix(&token), "session lookup failed (unknown or already expired)");
|
|
return Err(AppError::Redirect("/web/login".into()));
|
|
}
|
|
}
|
|
};
|
|
|
|
if expired {
|
|
let mut w = store.write().await;
|
|
let removed = w.remove(&token);
|
|
if let Some(s) = removed {
|
|
warn!(slug = %s.slug, "session expired");
|
|
}
|
|
return Err(AppError::Redirect("/web/login".into()));
|
|
}
|
|
|
|
let r = store.read().await;
|
|
let session = r
|
|
.get(&token)
|
|
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
|
|
|
let data_dir = config.data_path.join(&session.slug);
|
|
// Look up the TOML user record for the policy fields the session
|
|
// does not carry. A missing record can only happen if users.toml
|
|
// was edited while a session was still live; fall back to the
|
|
// serde default so the request stays functional.
|
|
let window_hours = config
|
|
.users
|
|
.iter()
|
|
.find(|u| u.slug == session.slug)
|
|
.map(|u| u.window_hours)
|
|
.unwrap_or(72);
|
|
Ok(Self {
|
|
slug: session.slug.clone(),
|
|
role: session.role.clone(),
|
|
data_dir,
|
|
window_hours,
|
|
csrf_token: session.csrf_token.clone(),
|
|
})
|
|
}
|
|
}
|