//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login //! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted //! Bearer token; the browser carries it on every subsequent request to //! `/admin/*` and `/api/*`. The middleware in `api::middleware` already //! accepts both `Authorization: Bearer …` and the cookie. use crate::api::auth::mint_token; use crate::api::middleware::{sha256_token, SESSION_COOKIE}; use crate::api::state::AppState; use crate::api::users::verify_password; use axum::extract::{Extension, Form}; use axum::http::header::{COOKIE, SET_COOKIE}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; use std::sync::Arc; #[derive(Debug, Deserialize)] pub struct LoginForm { pub username: String, pub password: String, /// 6-digit TOTP code, present on the second leg when the first leg /// returned `tfa_check`. #[serde(default)] pub tfa_code: String, /// Echo of the TOTP nonce the first-leg response set on the form. #[serde(default)] pub secret: String, } pub async fn login( Extension(state): Extension>, Form(form): Form, ) -> Response { // First leg: password verify. Same DB call paths as `/api/login` — // we re-use the existing helpers so the dashboard can't accidentally // diverge from the API's auth contract. let user = match state.db.user_find_by_username(&form.username).await { Ok(Some(u)) => u, Ok(None) => return error_fragment("Bad credentials"), Err(e) => return error_fragment(&format!("internal: {}", e)), }; let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await { Ok(b) => b, Err(e) => return error_fragment(&format!("internal: {}", e)), }; if !pw_ok { return error_fragment("Bad credentials"); } if user.status == 0 { return error_fragment("Account disabled"); } if !user.is_admin { // Only admins can use the dashboard. Non-admin users still get // tokens via `/api/login` for the desktop client; they just don't // see the management surface. return error_fragment("Admin access required"); } // Optional second leg: TOTP. If the user has a secret enrolled and the // form didn't carry a code, return a fragment that asks for one. let totp_secret = state .db .totp_get_secret(user.id) .await .ok() .flatten(); if let Some(secret_b32) = totp_secret { if form.tfa_code.is_empty() { // Shape used by the JS in login.html to switch to the second // leg: it watches for the special marker via HX-Trigger and // reveals the #tfa-section. let frag = format!( r#"Enter your 6-digit authenticator code. "# ); // We don't need a session yet — caller will resubmit with the // same username/password plus the code. (No nonce involved on // the dashboard path: the password is already in scope, so // tfa_check / tfa_code are folded into one form.) let _ = secret_b32; return Html(frag).into_response(); } // Verify the supplied code. let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) { Ok(b) => b, Err(_) => return error_fragment("Internal TOTP error"), }; if !ok { return error_fragment("Bad TOTP code"); } } // Mint + persist a token, set the cookie. let token = mint_token(); let sha = sha256_token(&token); if let Err(e) = state .db .token_insert( user.id, &sha, "", "", r#"{"source":"admin-ui"}"#, state.cfg.session_ttl_secs, ) .await { return error_fragment(&format!("internal: {}", e)); } let cookie = format!( "{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}", name = SESSION_COOKIE, token = token, ttl = state.cfg.session_ttl_secs, ); let mut headers = HeaderMap::new(); if let Ok(v) = HeaderValue::from_str(&cookie) { headers.insert(SET_COOKIE, v); } // 200 with empty body; the form's hx-on::after-request redirects on // success. (StatusCode::OK, headers, "").into_response() } pub async fn logout( Extension(state): Extension>, headers: HeaderMap, ) -> Response { // Best-effort: pull the token out of the cookie, drop the row. if let Some(tok) = cookie_token(&headers) { let sha = sha256_token(&tok); let _ = state.db.token_delete(&sha).await; } let mut out = HeaderMap::new(); let clear = format!( "{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0", name = SESSION_COOKIE ); if let Ok(v) = HeaderValue::from_str(&clear) { out.insert(SET_COOKIE, v); } (StatusCode::OK, out, "").into_response() } fn cookie_token(headers: &HeaderMap) -> Option { let s = headers.get(COOKIE)?.to_str().ok()?; for pair in s.split(';') { if let Some((name, value)) = pair.trim().split_once('=') { if name.trim() == SESSION_COOKIE { let v = value.trim(); if !v.is_empty() { return Some(v.to_string()); } } } } None } fn error_fragment(msg: &str) -> Response { let html = format!("{}", html_escape(msg)); (StatusCode::UNAUTHORIZED, Html(html)).into_response() } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") }