v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions

This commit is contained in:
Grant
2026-05-07 23:35:22 -05:00
parent 6ac118ae70
commit beedd07f07
27 changed files with 5576 additions and 134 deletions
+276
View File
@@ -0,0 +1,276 @@
//! Web-UI password authentication. Sits alongside the existing admin
//! API key path; admin endpoints accept either credential.
//!
//! Flow:
//! 1. Operator sets a password via the StartOS action "Set web UI
//! password" (which POSTs to /v1/admin/web-password using the API
//! key). Daemon argon2id-hashes the password and stores it under
//! the settings key `web_ui_password_hash`.
//! 2. SPA login form POSTs `{password}` to /admin/login. Daemon
//! verifies, mints a 32-byte random session token, persists in
//! sessions table, sets it as `keysat_session` HttpOnly +
//! SameSite=Strict cookie. Token TTL: 24h, sliding via last_seen_at
//! bump on every authenticated request.
//! 3. Subsequent admin calls present the cookie OR the API key.
//! `require_admin_or_session` accepts either.
//! 4. /admin/logout deletes the session row + clears the cookie.
//! 5. A background task in main.rs reaps expired sessions hourly.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand::rngs::OsRng;
use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, StatusCode},
response::Response,
Json,
};
use base64::Engine;
use rand::RngCore;
use serde::Deserialize;
use serde_json::{json, Value};
/// Settings key for the argon2id password hash (PHC-format string).
pub const SETTING_WEB_UI_PASSWORD_HASH: &str = "web_ui_password_hash";
/// Cookie name for the session token.
pub const SESSION_COOKIE: &str = "keysat_session";
/// Default session TTL — 24 hours from creation. Renewed on every
/// authenticated request via last_seen_at bump (sliding window).
pub const SESSION_TTL_SECS: i64 = 60 * 60 * 24;
/// Hash a plaintext password using Argon2id with the PHC-recommended
/// parameters. Returns a PHC-format string suitable for storage.
pub fn hash_password(plaintext: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(plaintext.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 hash failed: {e}")))
}
/// Verify a plaintext password against a stored PHC-format hash.
pub fn verify_password(plaintext: &str, phc_hash: &str) -> bool {
PasswordHash::new(phc_hash)
.and_then(|parsed| Argon2::default().verify_password(plaintext.as_bytes(), &parsed))
.is_ok()
}
/// Generate a cryptographically random 32-byte session token, URL-safe
/// base64-encoded (no padding). 256 bits of entropy.
fn new_session_token() -> String {
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
}
#[derive(Debug, Deserialize)]
pub struct SetPasswordReq {
/// Plaintext password. Minimum 12 chars enforced server-side.
pub password: String,
}
/// Admin-only (via API key): sets or rotates the web UI password.
/// Invalidates all existing sessions when the password changes so that
/// stale browsers re-authenticate. Audit-logged.
pub async fn set_password(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<SetPasswordReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
if req.password.len() < 12 {
return Err(AppError::BadRequest(
"password must be at least 12 characters".into(),
));
}
let hash = hash_password(&req.password)?;
repo::settings_set(&state.db, SETTING_WEB_UI_PASSWORD_HASH, Some(&hash)).await?;
// Invalidate all existing sessions on rotation so the new password
// takes effect everywhere immediately.
repo::delete_all_sessions(&state.db).await?;
let _ = repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"web_ui.set_password",
Some("settings"),
Some(SETTING_WEB_UI_PASSWORD_HASH),
ip.as_deref(),
ua.as_deref(),
&json!({ "ok": true }),
)
.await;
Ok(Json(json!({ "ok": true })))
}
#[derive(Debug, Deserialize)]
pub struct LoginReq {
pub password: String,
}
/// Public login endpoint. Verifies the password against the stored hash;
/// on success, issues a session and returns it as an HttpOnly cookie.
/// Per-IP rate limiting on bad attempts is enforced via the existing
/// rate_limit module. Returns 204 No Content on success (no body — the
/// cookie is the credential), 401 on bad password, 503 when no password
/// is configured yet.
pub async fn login(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<LoginReq>,
) -> Result<Response, AppError> {
let (ip, ua) = request_context(&headers);
// Brute-force protection: token-bucket per client IP. Capacity 5,
// refills at 1 token / 180s (so a sustained brute-forcer is throttled
// to ~20 attempts/hour after the initial burst). Backed by SQLite via
// the existing rate_limit::consume helper.
let bucket_key = ip.as_deref().unwrap_or("unknown");
let allowed = crate::rate_limit::consume(
&state.db,
"web_login",
bucket_key,
5.0,
1.0 / 180.0,
)
.await?;
if !allowed {
return Err(AppError::TooManyRequests(
"too many login attempts; try again in a few minutes".into(),
));
}
let stored = repo::settings_get(&state.db, SETTING_WEB_UI_PASSWORD_HASH).await?;
let Some(hash) = stored else {
return Err(AppError::ServiceUnavailable(
"web UI password is not configured. Set one via the StartOS \"Set web UI password\" action."
.into(),
));
};
if !verify_password(&req.password, &hash) {
// Audit failed attempt — useful for spotting brute-force.
let _ = repo::insert_audit(
&state.db,
"web_ui",
None,
"web_ui.login_failed",
Some("settings"),
Some(SETTING_WEB_UI_PASSWORD_HASH),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
return Err(AppError::Unauthorized);
}
let token = new_session_token();
let now = chrono::Utc::now();
let expires = now + chrono::Duration::seconds(SESSION_TTL_SECS);
repo::create_session(
&state.db,
&token,
&now.to_rfc3339(),
&expires.to_rfc3339(),
ip.as_deref(),
ua.as_deref(),
)
.await?;
let _ = repo::insert_audit(
&state.db,
"web_ui",
None,
"web_ui.login_ok",
Some("session"),
Some(&token),
ip.as_deref(),
ua.as_deref(),
&json!({}),
)
.await;
// HttpOnly + Secure + SameSite=Strict + path=/ keeps the cookie out
// of JS and out of cross-site contexts. Max-Age in seconds.
let cookie = format!(
"{name}={token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={ttl}",
name = SESSION_COOKIE,
token = token,
ttl = SESSION_TTL_SECS,
);
Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::SET_COOKIE, cookie)
.body(Body::empty())
.map_err(|e| AppError::Internal(anyhow::anyhow!("response build failed: {e}")))
}
/// Logs the caller out. Reads the session cookie, deletes the matching
/// session row, and emits a Set-Cookie that clears the cookie on the
/// browser side. Idempotent: returns 204 even if the cookie is missing
/// or already invalid.
pub async fn logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, AppError> {
if let Some(token) = extract_session_cookie(&headers) {
let _ = repo::delete_session(&state.db, &token).await;
}
let cleared = format!(
"{name}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0",
name = SESSION_COOKIE,
);
Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::SET_COOKIE, cleared)
.body(Body::empty())
.map_err(|e| AppError::Internal(anyhow::anyhow!("response build failed: {e}")))
}
/// Lightweight status probe used by the SPA on first load. Tells the
/// client whether a password has been configured (so it can show "Set a
/// password via StartOS Actions" if not) and whether the current session
/// cookie is valid (so it can skip the login form).
pub async fn login_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let has_password = repo::settings_get(&state.db, SETTING_WEB_UI_PASSWORD_HASH)
.await?
.is_some();
let logged_in = if let Some(token) = extract_session_cookie(&headers) {
repo::is_session_valid(&state.db, &token).await?
} else {
false
};
Ok(Json(json!({
"has_password": has_password,
"logged_in": logged_in,
})))
}
/// Read the session token out of the Cookie header, if present. Naive
/// parser — handles the typical `Cookie: a=1; keysat_session=…; b=2`
/// shape and is robust to quoted values and stray whitespace.
pub fn extract_session_cookie(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for pair in raw.split(';') {
let pair = pair.trim();
if let Some((k, v)) = pair.split_once('=') {
if k.trim() == SESSION_COOKIE {
return Some(v.trim().trim_matches('"').to_string());
}
}
}
None
}