Files
doctate/server/src/auth.rs
T
Brummel 2c6062a53e refactor: drop /web/ URL prefix from browser routes
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
2026-05-04 18:36:10 +02:00

163 lines
5.3 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("/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,
/// Human-readable label rendered in the page header (browser title
/// stays neutral). Resolved from the user's `display_name` field
/// in users.toml, falling back to `slug` if absent. Pre-resolved
/// here so handlers and templates don't repeat the fallback logic.
pub display_name: String,
/// 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("/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("/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("/login".into()));
}
let r = store.read().await;
let session = r
.get(&token)
.ok_or_else(|| AppError::Redirect("/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 user_record = config.users.iter().find(|u| u.slug == session.slug);
let window_hours = user_record.map(|u| u.window_hours).unwrap_or(72);
let display_name = user_record
.map(|u| u.display_name().to_owned())
.unwrap_or_else(|| session.slug.clone());
Ok(Self {
slug: session.slug.clone(),
role: session.role.clone(),
data_dir,
window_hours,
display_name,
csrf_token: session.csrf_token.clone(),
})
}
}