Feat: Implement basic web authentication and UI

This commit introduces the foundation for web-based authentication and
user interface. It includes:

- **Session Management**: Securely handling user sessions using
  cryptographically generated tokens, cookies with appropriate security
  flags, and server-side storage.
- **Login/Logout Functionality**: Endpoints for users to log in with
  their credentials and log out, clearing their session.
- **User Interface**: Basic templates for the login page, a list of
  cases, and a detailed view of a single case, allowing users to
  navigate and view their data.
- **Authorization**: An `AuthenticatedWebUser` extractor to ensure only
  logged-in users can access protected web routes.
- **Configuration**: Added a `cookie_secure` option to the configuration
  to control the `Secure` flag on session cookies, useful for local
  development.
- **Dependencies**: Added necessary dependencies for password hashing,
  time handling, and TOML file manipulation.
- **Helper Binary**: Included a `hash-password` binary to simplify
  generating bcrypt hashes for user passwords.
This commit is contained in:
2026-04-14 15:09:23 +02:00
parent 61b5c8e125
commit d489716c9b
24 changed files with 930 additions and 16 deletions
+72
View File
@@ -1,11 +1,22 @@
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 tracing::warn;
use crate::config::Config;
use crate::error::AppError;
use crate::web_session::SessionStore;
/// Truncate a session token to a non-sensitive prefix for logs.
fn token_prefix(token: &str) -> String {
token.chars().take(8).collect()
}
const SESSION_COOKIE: &str = "session";
/// Extracted from the X-API-Key header on every authenticated request.
pub struct AuthenticatedUser {
@@ -52,3 +63,64 @@ where
})
}
}
/// 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,
}
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);
Ok(Self {
slug: session.slug.clone(),
role: session.role.clone(),
data_dir,
})
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Generate a bcrypt hash for a plaintext password.
//! Use when adding or rotating a `web_password` entry in `users.toml`.
//!
//! Dev: `cargo run --quiet --bin hash-password -- 'my-password'`
//! Release: `./target/release/hash-password 'my-password'`
fn main() {
let password = std::env::args().nth(1).unwrap_or_else(|| {
eprintln!("Usage: hash-password '<password>'");
std::process::exit(2);
});
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
.expect("bcrypt hashing failed");
println!("{hash}");
}
+5
View File
@@ -53,6 +53,10 @@ pub struct Config {
pub llm_model: String,
pub llm_temperature: f32,
pub session_timeout_hours: u32,
/// Whether to set the `Secure` flag on the session cookie. Default `true`.
/// Set `COOKIE_SECURE=false` only for plain-HTTP local development —
/// browsers refuse `Secure` cookies on non-HTTPS origins.
pub cookie_secure: bool,
}
impl Config {
@@ -82,6 +86,7 @@ impl Config {
llm_model: optional_env("LLM_MODEL", ""),
llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0),
session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8),
cookie_secure: optional_env_parsed("COOKIE_SECURE", true),
}
}
}
+11
View File
@@ -7,15 +7,26 @@ pub enum AppError {
BadRequest(String),
NotFound(String),
Internal(String),
/// 302 redirect. Used by web auth to send unauthenticated users to login.
Redirect(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
if let Self::Redirect(url) = &self {
return Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, url)
.body(axum::body::Body::empty())
.unwrap();
}
let (status, message) = match &self {
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
Self::Redirect(_) => unreachable!(),
};
let body = axum::Json(json!({ "error": message }));
+10
View File
@@ -4,6 +4,7 @@ pub mod error;
pub mod models;
pub mod routes;
pub mod transcribe;
pub mod web_session;
use std::sync::Arc;
@@ -12,6 +13,7 @@ use axum::Router;
use config::Config;
use transcribe::TranscribeSender;
use web_session::SessionStore;
/// Shared application state. Clonable — all fields are cheap to clone
/// (`Arc` and `mpsc::Sender`).
@@ -19,6 +21,7 @@ use transcribe::TranscribeSender;
pub struct AppState {
pub config: Arc<Config>,
pub transcribe_tx: TranscribeSender,
pub session_store: SessionStore,
}
impl FromRef<AppState> for Arc<Config> {
@@ -33,6 +36,12 @@ impl FromRef<AppState> for TranscribeSender {
}
}
impl FromRef<AppState> for SessionStore {
fn from_ref(state: &AppState) -> Self {
state.session_store.clone()
}
}
/// Test/simple entrypoint: jobs pushed into the transcribe channel are dropped
/// because the receiver is not retained. Use [`create_router_with_state`] from
/// `main.rs` where a real worker owns the receiver.
@@ -41,6 +50,7 @@ pub fn create_router(config: Arc<Config>) -> Router {
create_router_with_state(AppState {
config,
transcribe_tx: tx,
session_store: web_session::new_store(),
})
}
+1
View File
@@ -62,6 +62,7 @@ async fn main() {
let state = AppState {
config: config.clone(),
transcribe_tx,
session_store: doctate_server::web_session::new_store(),
};
let addr = format!("0.0.0.0:{}", config.server_port);
+121
View File
@@ -0,0 +1,121 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use askama::Template;
use axum::extract::State;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Form;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use rand::distributions::Alphanumeric;
use rand::Rng;
use serde::Deserialize;
use tracing::{info, warn};
use crate::config::Config;
use crate::error::AppError;
use crate::web_session::{SessionStore, WebSession};
const COOKIE_NAME: &str = "session";
const TOKEN_LEN: usize = 43;
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
error: Option<&'static str>,
}
#[derive(Deserialize)]
pub struct LoginForm {
slug: String,
password: String,
}
pub async fn handle_login_page() -> Result<Html<String>, AppError> {
render_login(None)
}
pub async fn handle_login_submit(
State(config): State<Arc<Config>>,
State(store): State<SessionStore>,
jar: CookieJar,
Form(form): Form<LoginForm>,
) -> Result<Response, AppError> {
let user = config.users.iter().find(|u| u.slug == form.slug);
let valid = user
.map(|u| bcrypt::verify(&form.password, &u.web_password).unwrap_or(false))
.unwrap_or(false);
// Always evaluate bcrypt even on unknown slug to reduce timing signal.
if !valid {
if user.is_none() {
// Dummy verify so timing is similar regardless of slug existence.
let _ = bcrypt::verify(&form.password, "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii");
}
warn!(slug = %form.slug, "login failed");
return render_login(Some("Login fehlgeschlagen")).map(IntoResponse::into_response);
}
let user = user.unwrap();
let token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(TOKEN_LEN)
.map(char::from)
.collect();
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
{
let mut w = store.write().await;
w.insert(
token.clone(),
WebSession {
slug: user.slug.clone(),
role: user.role.clone(),
expires_at: Instant::now() + ttl,
},
);
}
info!(slug = %user.slug, "login ok");
let cookie = build_cookie(token, ttl, config.cookie_secure);
let jar = jar.add(cookie);
Ok((jar, Redirect::to("/web/cases")).into_response())
}
pub async fn handle_logout(
State(config): State<Arc<Config>>,
State(store): State<SessionStore>,
jar: CookieJar,
) -> Result<Response, AppError> {
if let Some(c) = jar.get(COOKIE_NAME) {
let token = c.value().to_owned();
let mut w = store.write().await;
let removed = w.remove(&token);
if let Some(s) = removed {
info!(slug = %s.slug, "logout");
}
}
// Expire the cookie by setting an empty value with max_age=0.
let mut cookie = build_cookie(String::new(), Duration::from_secs(0), config.cookie_secure);
cookie.make_removal();
let jar = jar.add(cookie);
Ok((jar, Redirect::to("/web/login")).into_response())
}
fn render_login(error: Option<&'static str>) -> Result<Html<String>, AppError> {
LoginTemplate { error }
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
fn build_cookie(value: String, max_age: Duration, secure: bool) -> Cookie<'static> {
Cookie::build((COOKIE_NAME, value))
.http_only(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/")
.max_age(time::Duration::seconds(max_age.as_secs() as i64))
.build()
}
+7 -1
View File
@@ -1,7 +1,9 @@
mod debug;
mod health;
mod login;
mod upload;
mod web;
pub(crate) mod web;
mod user_web;
use axum::routing::{get, post};
use axum::Router;
@@ -13,6 +15,10 @@ pub fn api_router() -> Router<AppState> {
.route("/api/health", get(health::handle_health))
.route("/api/debug/whoami", get(debug::handle_whoami))
.route("/api/upload", post(upload::handle_upload))
.route("/web/login", get(login::handle_login_page).post(login::handle_login_submit))
.route("/web/logout", post(login::handle_logout))
.route("/web/cases", get(user_web::handle_my_cases))
.route("/web/cases/{case_id}", get(user_web::handle_case_detail))
.route("/web/", get(web::handle_case_list))
.route(
"/web/audio/{user}/{case_id}/{filename}",
+173
View File
@@ -0,0 +1,173 @@
use std::path::Path;
use std::sync::Arc;
use askama::Template;
use axum::extract::{Path as AxumPath, State};
use axum::response::Html;
use tracing::{info, warn};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::routes::web::{scan_recordings, RecordingView};
struct UserCaseView {
case_id: String,
case_id_short: String,
status: String,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
}
#[derive(Template)]
#[template(path = "my_cases.html")]
struct MyCasesTemplate {
slug: String,
open: Vec<UserCaseView>,
done: Vec<UserCaseView>,
}
#[derive(Template)]
#[template(path = "case_detail.html")]
struct CaseDetailTemplate {
slug: String,
case_id: String,
case_id_short: String,
status: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
}
pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
) -> Result<Html<String>, AppError> {
let (open, done) = scan_user_cases(&config.data_path, &user.slug).await;
MyCasesTemplate {
slug: user.slug,
open,
done,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
pub async fn handle_case_detail(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
AxumPath(case_id): AxumPath<String>,
) -> Result<Html<String>, AppError> {
uuid::Uuid::parse_str(&case_id)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
// IDOR guard: case_dir must live under the session's user_slug.
let user_root = config.data_path.join(&user.slug);
let (case_dir, status) = match locate_case(&user_root, &case_id).await {
Some(v) => v,
None => {
warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)");
return Err(AppError::NotFound("Case not found".into()));
}
};
info!(slug = %user.slug, case_id = %case_id, status = %status, "case detail viewed");
let recordings = scan_recordings(&case_dir).await;
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let case_id_short = case_id.chars().take(8).collect();
CaseDetailTemplate {
slug: user.slug,
case_id,
case_id_short,
status,
oneliner,
recordings,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// Locate a case directory under `<user_root>/{open|done}/<case_id>`.
/// Returns the path and the status bucket it was found in.
async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> {
for status in ["open", "done"] {
let p = user_root.join(status).join(case_id);
if tokio::fs::try_exists(&p).await.unwrap_or(false) {
return Some((p, status.to_string()));
}
}
None
}
/// Scan only the given user's cases. Returns (open, done), each sorted
/// with most recent first.
async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec<UserCaseView>, Vec<UserCaseView>) {
let user_root = data_path.join(slug);
let mut open = Vec::new();
let mut done = Vec::new();
for status in ["open", "done"] {
let status_dir = user_root.join(status);
let mut entries = match tokio::fs::read_dir(&status_dir).await {
Ok(r) => r,
Err(_) => continue,
};
while let Ok(Some(case_entry)) = entries.next_entry().await {
let case_id = match case_entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
continue;
}
if !case_entry.path().is_dir() {
continue;
}
let recordings = scan_recordings(&case_entry.path()).await;
if recordings.is_empty() {
continue;
}
let most_recent = recordings
.last()
.map(|r| r.filename.clone())
.unwrap_or_default();
let case_id_short = case_id.chars().take(8).collect();
let oneliner = tokio::fs::read_to_string(case_entry.path().join("oneliner.txt"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let view = UserCaseView {
case_id,
case_id_short,
status: status.to_string(),
most_recent,
oneliner,
recordings,
};
if status == "open" {
open.push(view);
} else {
done.push(view);
}
}
}
open.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
done.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
(open, done)
}
+5 -5
View File
@@ -11,12 +11,12 @@ use tracing::warn;
use crate::config::Config;
use crate::error::AppError;
struct RecordingView {
filename: String,
transcript: Option<String>,
pub(crate) struct RecordingView {
pub(crate) filename: String,
pub(crate) transcript: Option<String>,
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
/// after a non-recoverable ffmpeg or whisper error.
failed: bool,
pub(crate) failed: bool,
}
struct CaseView {
@@ -170,7 +170,7 @@ async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
cases
}
async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
let mut recordings = Vec::new();
let mut entries = match tokio::fs::read_dir(case_dir).await {
Ok(r) => r,
+17
View File
@@ -0,0 +1,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
pub struct WebSession {
pub slug: String,
pub role: String,
pub expires_at: Instant,
}
pub type SessionStore = Arc<RwLock<HashMap<String, WebSession>>>;
pub fn new_store() -> SessionStore {
Arc::new(RwLock::new(HashMap::new()))
}