Skip to main content

adminx_core/
auth.rs

1// adminx-core/src/auth.rs
2//
3// Framework-neutral authentication: a signed JWT carried in an HttpOnly cookie.
4// No server-side session store and no framework dependency — the core issues the
5// token and emits `Set-Cookie` via `ApiResponse` headers; adapters only extract
6// the cookie value from their request and hand it back here to verify.
7
8use crate::error::CoreError;
9use crate::request::{Claims, ReqCtx};
10use crate::response::ApiResponse;
11use crate::storage::storage;
12use crate::ui;
13use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
14use once_cell::sync::OnceCell;
15use serde::{Deserialize, Serialize};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18/// Cookie name holding the JWT.
19pub const COOKIE_NAME: &str = "adminx_token";
20
21#[derive(Clone, Debug)]
22pub struct AuthConfig {
23    pub jwt_secret: String,
24    pub token_ttl_secs: i64,
25    /// Table/collection holding admin users.
26    pub admin_table: String,
27    /// Set the `Secure` flag on the auth cookie (disable only for local HTTP).
28    pub secure_cookie: bool,
29}
30
31impl Default for AuthConfig {
32    fn default() -> Self {
33        Self {
34            jwt_secret: String::new(),
35            token_ttl_secs: 86_400,
36            admin_table: "adminx_users".to_string(),
37            secure_cookie: true,
38        }
39    }
40}
41
42static AUTH_CONFIG: OnceCell<AuthConfig> = OnceCell::new();
43
44/// Enable authentication globally. Until this is called, auth is disabled and
45/// every resource is publicly accessible (convenient for quick starts / tests).
46pub fn configure(config: AuthConfig) {
47    if AUTH_CONFIG.set(config).is_err() {
48        tracing::warn!("adminx auth already configured; ignoring reconfigure");
49    }
50}
51
52pub fn is_configured() -> bool {
53    AUTH_CONFIG.get().is_some()
54}
55
56fn config() -> Option<&'static AuthConfig> {
57    AUTH_CONFIG.get()
58}
59
60// ===== JWT =====
61
62/// Session is fully authenticated (second factor satisfied, or none required).
63pub const MFA_OK: &str = "ok";
64/// Password verified, but a second factor is still required before access.
65pub const MFA_PENDING: &str = "pending";
66
67fn default_mfa_ok() -> String {
68    MFA_OK.to_string()
69}
70
71#[derive(Serialize, Deserialize)]
72struct TokenClaims {
73    sub: String,
74    email: String,
75    role: String,
76    exp: i64,
77    /// MFA step. Defaulted for tokens issued before MFA existed so they keep
78    /// working (treated as fully authenticated).
79    #[serde(default = "default_mfa_ok")]
80    mfa: String,
81}
82
83fn now_secs() -> i64 {
84    SystemTime::now()
85        .duration_since(UNIX_EPOCH)
86        .map(|d| d.as_secs() as i64)
87        .unwrap_or(0)
88}
89
90/// Issue a signed JWT for a principal at the given MFA `step` (`MFA_OK` or
91/// `MFA_PENDING`). Returns `None` if auth isn't configured.
92pub fn issue_token(sub: &str, email: &str, role: &str, step: &str) -> Option<String> {
93    let cfg = config()?;
94    let claims = TokenClaims {
95        sub: sub.to_string(),
96        email: email.to_string(),
97        role: role.to_string(),
98        exp: now_secs() + cfg.token_ttl_secs,
99        mfa: step.to_string(),
100    };
101    encode(
102        &Header::default(),
103        &claims,
104        &EncodingKey::from_secret(cfg.jwt_secret.as_bytes()),
105    )
106    .ok()
107}
108
109/// Verify a JWT and return the principal, or `None` if invalid/expired/unconfigured.
110pub fn verify_token(token: &str) -> Option<Claims> {
111    let cfg = config()?;
112    let data = decode::<TokenClaims>(
113        token,
114        &DecodingKey::from_secret(cfg.jwt_secret.as_bytes()),
115        &Validation::new(Algorithm::HS256),
116    )
117    .ok()?;
118    let c = data.claims;
119    Some(Claims {
120        sub: c.sub,
121        email: c.email,
122        role: c.role.clone(),
123        roles: vec![c.role],
124        mfa: c.mfa,
125    })
126}
127
128// ===== Passwords =====
129
130pub fn hash_password(password: &str) -> Result<String, CoreError> {
131    bcrypt::hash(password, bcrypt::DEFAULT_COST)
132        .map_err(|e| CoreError::Internal(format!("password hash failed: {e}")))
133}
134
135pub fn verify_password(password: &str, hash: &str) -> bool {
136    bcrypt::verify(password, hash).unwrap_or(false)
137}
138
139// ===== Cookies (built as ApiResponse headers) =====
140
141fn set_cookie_value(token: &str) -> String {
142    let secure = config().map(|c| c.secure_cookie).unwrap_or(true);
143    let max_age = config().map(|c| c.token_ttl_secs).unwrap_or(86_400);
144    let mut v = format!(
145        "{COOKIE_NAME}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={max_age}"
146    );
147    if secure {
148        v.push_str("; Secure");
149    }
150    v
151}
152
153fn clear_cookie_value() -> String {
154    format!("{COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0")
155}
156
157// ===== Context building & guards =====
158
159/// Build a request context, verifying the cookie token into `claims` if present.
160pub fn build_ctx(mount: &str, query: &str, token: Option<&str>) -> ReqCtx {
161    let mut ctx = ReqCtx::new().with_mount(mount).with_query(query);
162    if let Some(t) = token {
163        if let Some(claims) = verify_token(t) {
164            ctx = ctx.with_claims(claims);
165        }
166    }
167    ctx
168}
169
170/// True when the session still owes a second factor (password verified but MFA
171/// not yet satisfied). Such a session must not reach protected pages.
172fn mfa_pending(ctx: &ReqCtx) -> bool {
173    matches!(&ctx.claims, Some(c) if c.mfa == MFA_PENDING)
174}
175
176/// True if the principal in `ctx` holds at least one of `allowed_roles` and has
177/// cleared MFA. Always true when auth is not configured.
178pub fn is_authorized(ctx: &ReqCtx, allowed_roles: &[String]) -> bool {
179    if !is_configured() {
180        return true;
181    }
182    if mfa_pending(ctx) {
183        return false;
184    }
185    let roles = ctx.roles();
186    allowed_roles.iter().any(|r| roles.contains(r))
187}
188
189/// Redirect a blocked UI visitor to the right place: the MFA challenge when a
190/// second factor is still pending, otherwise the login page.
191pub fn login_redirect(ctx: &ReqCtx) -> ApiResponse {
192    if mfa_pending(ctx) {
193        ApiResponse::redirect(format!("{}/mfa/verify", ctx.mount))
194    } else {
195        ApiResponse::redirect(format!("{}/login", ctx.mount))
196    }
197}
198
199/// UI guard for the dashboard and other non-resource pages: `Some(redirect)`
200/// when auth is configured and the visitor is unauthenticated or MFA-pending.
201pub fn guard_ui(ctx: &ReqCtx) -> Option<ApiResponse> {
202    if is_configured() && (ctx.claims.is_none() || mfa_pending(ctx)) {
203        Some(login_redirect(ctx))
204    } else {
205        None
206    }
207}
208
209// ===== Login / logout handlers =====
210
211/// Render the login page (optionally with an error message).
212pub fn login_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
213    let mut c = ui::base_context(ctx, "Sign in");
214    // Login has no sidebar menus; keep it minimal.
215    c.insert("menus", &Vec::<crate::menu::MenuItem>::new());
216    if let Some(e) = error {
217        c.insert("error", e);
218    }
219    ui::render("login.html", &c)
220}
221
222/// Validate credentials against the admin table and, on success, redirect to the
223/// dashboard with the auth cookie set.
224pub async fn handle_login(ctx: &ReqCtx, email: &str, password: &str) -> ApiResponse {
225    let cfg = match config() {
226        Some(c) => c,
227        None => return CoreError::Internal("auth not configured".into()).into(),
228    };
229
230    let user = match storage().find_one_by(&cfg.admin_table, "email", email).await {
231        Ok(Some(u)) => u,
232        Ok(None) => return login_page(ctx, Some("Invalid email or password")),
233        Err(e) => return CoreError::from(e).into(),
234    };
235
236    let hash = user
237        .get("encrypted_password")
238        .and_then(|v| v.as_str())
239        .unwrap_or("");
240    if !verify_password(password, hash) {
241        return login_page(ctx, Some("Invalid email or password"));
242    }
243
244    let role = user.get("role").and_then(|v| v.as_str()).unwrap_or("admin");
245    let sub = json_id(&user).unwrap_or_else(|| email.to_string());
246
247    // MFA-enabled users get a pending session and must clear the second factor
248    // before reaching the panel. Others are logged straight in, then nudged to
249    // the (skippable) setup page.
250    if mfa_is_enabled(&user) {
251        match issue_token(&sub, email, role, MFA_PENDING) {
252            Some(token) => ApiResponse::redirect(format!("{}/mfa/verify", ctx.mount))
253                .with_header("Set-Cookie", set_cookie_value(&token)),
254            None => CoreError::Internal("failed to issue token".into()).into(),
255        }
256    } else {
257        match issue_token(&sub, email, role, MFA_OK) {
258            Some(token) => ApiResponse::redirect(format!("{}/mfa/setup", ctx.mount))
259                .with_header("Set-Cookie", set_cookie_value(&token)),
260            None => CoreError::Internal("failed to issue token".into()).into(),
261        }
262    }
263}
264
265// ===== MFA (TOTP) flow =====
266
267/// Read the string `id` of a user row (unwrapping JSON strings/numbers).
268fn json_id(user: &serde_json::Value) -> Option<String> {
269    match user.get("id")? {
270        serde_json::Value::String(s) => Some(s.clone()),
271        other => Some(other.to_string()),
272    }
273}
274
275/// Loosely interpret an `mfa_enabled` column (bool, 0/1, or "true"/"1").
276fn mfa_is_enabled(user: &serde_json::Value) -> bool {
277    match user.get("mfa_enabled") {
278        Some(serde_json::Value::Bool(b)) => *b,
279        Some(serde_json::Value::Number(n)) => n.as_i64().map(|i| i != 0).unwrap_or(false),
280        Some(serde_json::Value::String(s)) => matches!(s.as_str(), "true" | "1" | "t"),
281        _ => false,
282    }
283}
284
285fn nonempty_str<'a>(user: &'a serde_json::Value, key: &str) -> Option<&'a str> {
286    user.get(key)?.as_str().filter(|s| !s.is_empty())
287}
288
289/// Look up the current session's user row by the email in its claims.
290async fn current_user(ctx: &ReqCtx) -> Option<serde_json::Value> {
291    let cfg = config()?;
292    let email = ctx.claims.as_ref()?.email.clone();
293    storage()
294        .find_one_by(&cfg.admin_table, "email", &email)
295        .await
296        .ok()
297        .flatten()
298}
299
300/// Persist a partial update to the current user's row.
301async fn update_user(id: &str, patch: serde_json::Map<String, serde_json::Value>) -> Result<(), CoreError> {
302    let cfg = config().ok_or_else(|| CoreError::Internal("auth not configured".into()))?;
303    storage()
304        .update(&cfg.admin_table, "id", id, patch)
305        .await
306        .map(|_| ())
307        .map_err(CoreError::from)
308}
309
310/// Issue a fully-authenticated token and redirect to `dest`, setting the cookie.
311fn authed_redirect(sub: &str, email: &str, role: &str, dest: String) -> ApiResponse {
312    match issue_token(sub, email, role, MFA_OK) {
313        Some(token) => ApiResponse::redirect(dest).with_header("Set-Cookie", set_cookie_value(&token)),
314        None => CoreError::Internal("failed to issue token".into()).into(),
315    }
316}
317
318/// Setup page: shows a QR + secret and asks the user to confirm a code to enable
319/// MFA. Skippable — the "Skip for now" link just goes back to the dashboard.
320pub async fn mfa_setup_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
321    if is_configured() && ctx.claims.is_none() {
322        return ApiResponse::redirect(format!("{}/login", ctx.mount));
323    }
324    let user = match current_user(ctx).await {
325        Some(u) => u,
326        None => return ApiResponse::redirect(format!("{}/login", ctx.mount)),
327    };
328    // Already enabled -> nothing to set up.
329    if mfa_is_enabled(&user) {
330        return ApiResponse::redirect(ctx.mount.clone());
331    }
332
333    // Reuse a previously-generated (but not-yet-enabled) secret across refreshes,
334    // otherwise the QR would change on every load.
335    let secret = match nonempty_str(&user, "mfa_secret") {
336        Some(s) => s.to_string(),
337        None => {
338            let s = crate::mfa::generate_secret();
339            let Some(id) = json_id(&user) else {
340                return CoreError::Internal("user row has no id".into()).into();
341            };
342            if let Err(e) = update_user(&id, patch(&[("mfa_secret", s.clone().into())])).await {
343                return e.into();
344            }
345            s
346        }
347    };
348
349    let email = ctx.claims.as_ref().map(|c| c.email.as_str()).unwrap_or("");
350    let url = match crate::mfa::provisioning_url(&secret, email) {
351        Ok(u) => u,
352        Err(e) => return e.into(),
353    };
354    let qr = match crate::mfa::qr_svg(&url) {
355        Ok(s) => s,
356        Err(e) => return e.into(),
357    };
358
359    // Rendered inside the panel shell (layout.html), so keep the real sidebar menus.
360    let mut c = ui::base_context(ctx, "Set up two-factor auth");
361    c.insert("qr_svg", &qr);
362    c.insert("secret", &secret);
363    if let Some(e) = error {
364        c.insert("error", e);
365    }
366    ui::render("mfa_setup.html", &c)
367}
368
369/// Confirm the code, enable MFA, generate backup codes, and show them once.
370pub async fn handle_mfa_enable(ctx: &ReqCtx, code: &str) -> ApiResponse {
371    let user = match current_user(ctx).await {
372        Some(u) => u,
373        None => return ApiResponse::redirect(format!("{}/login", ctx.mount)),
374    };
375    let email = ctx.claims.as_ref().map(|c| c.email.clone()).unwrap_or_default();
376    let secret = match nonempty_str(&user, "mfa_secret") {
377        Some(s) => s.to_string(),
378        None => return mfa_setup_page(ctx, Some("Setup expired, please rescan")).await,
379    };
380    if !crate::mfa::check_code(&secret, &email, code) {
381        return mfa_setup_page(ctx, Some("That code didn't match. Try again.")).await;
382    }
383
384    let codes = crate::mfa::generate_backup_codes();
385    let hashed = match crate::mfa::hash_backup_codes(&codes) {
386        Ok(h) => h,
387        Err(e) => return e.into(),
388    };
389    let Some(id) = json_id(&user) else {
390        return CoreError::Internal("user row has no id".into()).into();
391    };
392    if let Err(e) = update_user(
393        &id,
394        patch(&[
395            ("mfa_enabled", true.into()),
396            ("mfa_backup_codes", hashed.into()),
397        ]),
398    )
399    .await
400    {
401        return e.into();
402    }
403
404    // Refresh the cookie so the step is unambiguously "ok".
405    let role = user.get("role").and_then(|v| v.as_str()).unwrap_or("admin");
406    let token = issue_token(&id, &email, role, MFA_OK);
407
408    // Rendered inside the panel shell (layout.html), so keep the real sidebar menus.
409    let mut c = ui::base_context(ctx, "Save your backup codes");
410    c.insert("backup_codes", &codes);
411    let resp = ui::render("mfa_backup.html", &c);
412    match token {
413        Some(t) => resp.with_header("Set-Cookie", set_cookie_value(&t)),
414        None => resp,
415    }
416}
417
418/// Challenge page for an MFA-enabled user mid-login.
419pub async fn mfa_verify_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
420    if is_configured() && ctx.claims.is_none() {
421        return ApiResponse::redirect(format!("{}/login", ctx.mount));
422    }
423    // Already cleared -> straight to the panel.
424    if matches!(&ctx.claims, Some(c) if c.mfa == MFA_OK) {
425        return ApiResponse::redirect(ctx.mount.clone());
426    }
427    let mut c = ui::base_context(ctx, "Two-factor verification");
428    c.insert("menus", &Vec::<crate::menu::MenuItem>::new());
429    if let Some(e) = error {
430        c.insert("error", e);
431    }
432    ui::render("mfa_verify.html", &c)
433}
434
435/// Verify a TOTP or one-time backup code and, on success, upgrade the session.
436pub async fn handle_mfa_verify(ctx: &ReqCtx, code: &str) -> ApiResponse {
437    let user = match current_user(ctx).await {
438        Some(u) => u,
439        None => return ApiResponse::redirect(format!("{}/login", ctx.mount)),
440    };
441    let email = ctx.claims.as_ref().map(|c| c.email.clone()).unwrap_or_default();
442    let role = user.get("role").and_then(|v| v.as_str()).unwrap_or("admin");
443    let Some(id) = json_id(&user) else {
444        return CoreError::Internal("user row has no id".into()).into();
445    };
446
447    // 1) Try the authenticator code.
448    if let Some(secret) = nonempty_str(&user, "mfa_secret") {
449        if crate::mfa::check_code(secret, &email, code) {
450            return authed_redirect(&id, &email, role, ctx.mount.clone());
451        }
452    }
453
454    // 2) Fall back to a one-time backup code, consuming it on success.
455    if let Some(stored) = nonempty_str(&user, "mfa_backup_codes") {
456        if let Some(remaining) = crate::mfa::consume_backup_code(stored, code) {
457            if let Err(e) = update_user(&id, patch(&[("mfa_backup_codes", remaining.into())])).await {
458                return e.into();
459            }
460            return authed_redirect(&id, &email, role, ctx.mount.clone());
461        }
462    }
463
464    mfa_verify_page(ctx, Some("Invalid code. Try again or use a backup code.")).await
465}
466
467/// Build a small update map from `(column, value)` pairs.
468fn patch(pairs: &[(&str, serde_json::Value)]) -> serde_json::Map<String, serde_json::Value> {
469    pairs
470        .iter()
471        .map(|(k, v)| (k.to_string(), v.clone()))
472        .collect()
473}
474
475/// Clear the auth cookie and return to the login page.
476pub fn handle_logout(ctx: &ReqCtx) -> ApiResponse {
477    ApiResponse::redirect(format!("{}/login", ctx.mount))
478        .with_header("Set-Cookie", clear_cookie_value())
479}
480
481/// Convenience for seeding: insert an admin user (bcrypt-hashing the password).
482pub async fn create_admin(email: &str, password: &str, role: &str) -> Result<(), CoreError> {
483    let cfg = config().ok_or_else(|| CoreError::Internal("auth not configured".into()))?;
484    let mut data = serde_json::Map::new();
485    data.insert("email".into(), serde_json::Value::String(email.into()));
486    data.insert(
487        "encrypted_password".into(),
488        serde_json::Value::String(hash_password(password)?),
489    );
490    data.insert("role".into(), serde_json::Value::String(role.into()));
491    storage()
492        .create(&cfg.admin_table, data)
493        .await
494        .map_err(CoreError::from)?;
495    Ok(())
496}