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:
@@ -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()
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user