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
+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)
}