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::ratelimit;
10use crate::request::{Claims, ReqCtx};
11use crate::response::ApiResponse;
12use crate::storage::storage;
13use crate::ui;
14use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
15use once_cell::sync::OnceCell;
16use serde::{Deserialize, Serialize};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19/// Cookie name holding the JWT.
20pub const COOKIE_NAME: &str = "adminx_token";
21
22#[derive(Clone, Debug)]
23pub struct AuthConfig {
24    /// HS256 signing key. **Rotating this instantly invalidates every existing
25    /// session** — the only built-in "log everyone out" / revoke-all mechanism,
26    /// since sessions are stateless (no server-side store to clear).
27    pub jwt_secret: String,
28    /// Cookie/JWT lifetime in seconds. This is also the **maximum window a leaked
29    /// or stolen token stays valid**, because tokens are self-contained and not
30    /// individually revocable — shorter is safer (e.g. `3_600` for 1h), longer is
31    /// more convenient. `86_400` (24h) is a reasonable default.
32    pub token_ttl_secs: i64,
33    /// Table/collection holding admin users.
34    pub admin_table: String,
35    /// Set the `Secure` flag on the auth cookie (disable only for local HTTP).
36    pub secure_cookie: bool,
37}
38
39impl Default for AuthConfig {
40    fn default() -> Self {
41        Self {
42            jwt_secret: String::new(),
43            token_ttl_secs: 86_400,
44            admin_table: "adminx_users".to_string(),
45            secure_cookie: true,
46        }
47    }
48}
49
50static AUTH_CONFIG: OnceCell<AuthConfig> = OnceCell::new();
51
52/// Enable authentication globally. Until this is called, auth is disabled and
53/// every resource is publicly accessible (convenient for quick starts / tests).
54pub fn configure(config: AuthConfig) {
55    if AUTH_CONFIG.set(config).is_err() {
56        tracing::warn!("adminx auth already configured; ignoring reconfigure");
57    }
58}
59
60pub fn is_configured() -> bool {
61    AUTH_CONFIG.get().is_some()
62}
63
64/// Whether cookies should carry the `Secure` flag. Defaults to `true` (the safe
65/// side) when auth isn't configured. Shared with `crate::csrf`.
66pub(crate) fn secure_cookie() -> bool {
67    config().map(|c| c.secure_cookie).unwrap_or(true)
68}
69
70fn config() -> Option<&'static AuthConfig> {
71    AUTH_CONFIG.get()
72}
73
74// ===== JWT =====
75
76/// Session is fully authenticated (second factor satisfied, or none required).
77pub const MFA_OK: &str = "ok";
78/// Password verified, but a second factor is still required before access.
79pub const MFA_PENDING: &str = "pending";
80
81fn default_mfa_ok() -> String {
82    MFA_OK.to_string()
83}
84
85#[derive(Serialize, Deserialize)]
86struct TokenClaims {
87    sub: String,
88    email: String,
89    role: String,
90    exp: i64,
91    /// MFA step. Defaulted for tokens issued before MFA existed so they keep
92    /// working (treated as fully authenticated).
93    #[serde(default = "default_mfa_ok")]
94    mfa: String,
95}
96
97fn now_secs() -> i64 {
98    SystemTime::now()
99        .duration_since(UNIX_EPOCH)
100        .map(|d| d.as_secs() as i64)
101        .unwrap_or(0)
102}
103
104/// Issue a signed JWT for a principal at the given MFA `step` (`MFA_OK` or
105/// `MFA_PENDING`). Returns `None` if auth isn't configured.
106pub fn issue_token(sub: &str, email: &str, role: &str, step: &str) -> Option<String> {
107    let cfg = config()?;
108    let claims = TokenClaims {
109        sub: sub.to_string(),
110        email: email.to_string(),
111        role: role.to_string(),
112        exp: now_secs() + cfg.token_ttl_secs,
113        mfa: step.to_string(),
114    };
115    encode(
116        &Header::default(),
117        &claims,
118        &EncodingKey::from_secret(cfg.jwt_secret.as_bytes()),
119    )
120    .ok()
121}
122
123/// Verify a JWT and return the principal, or `None` if invalid/expired/unconfigured.
124pub fn verify_token(token: &str) -> Option<Claims> {
125    let cfg = config()?;
126    let data = decode::<TokenClaims>(
127        token,
128        &DecodingKey::from_secret(cfg.jwt_secret.as_bytes()),
129        &Validation::new(Algorithm::HS256),
130    )
131    .ok()?;
132    let c = data.claims;
133    Some(Claims {
134        sub: c.sub,
135        email: c.email,
136        role: c.role.clone(),
137        roles: vec![c.role],
138        mfa: c.mfa,
139    })
140}
141
142// ===== Passwords =====
143
144pub fn hash_password(password: &str) -> Result<String, CoreError> {
145    bcrypt::hash(password, bcrypt::DEFAULT_COST)
146        .map_err(|e| CoreError::Internal(format!("password hash failed: {e}")))
147}
148
149pub fn verify_password(password: &str, hash: &str) -> bool {
150    bcrypt::verify(password, hash).unwrap_or(false)
151}
152
153// ===== Cookies (built as ApiResponse headers) =====
154
155fn set_cookie_value(token: &str) -> String {
156    let max_age = config().map(|c| c.token_ttl_secs).unwrap_or(86_400);
157    let mut v = format!(
158        "{COOKIE_NAME}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={max_age}"
159    );
160    if secure_cookie() {
161        v.push_str("; Secure");
162    }
163    v
164}
165
166fn clear_cookie_value() -> String {
167    format!("{COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0")
168}
169
170// ===== Context building & guards =====
171
172/// Build a request context, verifying the auth cookie into `claims` if present
173/// and carrying the raw CSRF cookie through for `crate::csrf` to check.
174pub fn build_ctx(mount: &str, query: &str, token: Option<&str>, csrf: Option<&str>) -> ReqCtx {
175    let mut ctx = ReqCtx::new().with_mount(mount).with_query(query);
176    if let Some(t) = token {
177        if let Some(claims) = verify_token(t) {
178            ctx = ctx.with_claims(claims);
179        }
180    }
181    if let Some(c) = csrf {
182        ctx = ctx.with_csrf(c);
183    }
184    ctx
185}
186
187/// True when the session still owes a second factor (password verified but MFA
188/// not yet satisfied). Such a session must not reach protected pages.
189pub(crate) fn mfa_pending(ctx: &ReqCtx) -> bool {
190    matches!(&ctx.claims, Some(c) if c.mfa == MFA_PENDING)
191}
192
193/// True if the principal in `ctx` holds at least one of `allowed_roles` and has
194/// cleared MFA. Always true when auth is not configured.
195pub fn is_authorized(ctx: &ReqCtx, allowed_roles: &[String]) -> bool {
196    if !is_configured() {
197        return true;
198    }
199    if mfa_pending(ctx) {
200        return false;
201    }
202    let roles = ctx.roles();
203    allowed_roles.iter().any(|r| roles.contains(r))
204}
205
206/// Redirect a blocked UI visitor to the right place: the MFA challenge when a
207/// second factor is still pending, otherwise the login page.
208pub fn login_redirect(ctx: &ReqCtx) -> ApiResponse {
209    if mfa_pending(ctx) {
210        ApiResponse::redirect(format!("{}/mfa/verify", ctx.mount))
211    } else {
212        ApiResponse::redirect(format!("{}/login", ctx.mount))
213    }
214}
215
216/// UI guard for the dashboard and other non-resource pages: `Some(redirect)`
217/// when auth is configured and the visitor is unauthenticated or MFA-pending.
218pub fn guard_ui(ctx: &ReqCtx) -> Option<ApiResponse> {
219    if is_configured() && (ctx.claims.is_none() || mfa_pending(ctx)) {
220        Some(login_redirect(ctx))
221    } else {
222        None
223    }
224}
225
226// ===== Login / logout handlers =====
227
228/// Message shown when a form post fails the CSRF check. Deliberately vague and
229/// identical for every cause: a forged post learns nothing, and the benign cause
230/// (a token that expired with the browser session) is fixed by simply retrying.
231const CSRF_ERROR: &str = "Your session expired. Please try again.";
232
233/// Shown when an account has burned through its attempts. Says nothing about
234/// whether the account exists or the password was close.
235const THROTTLED_ERROR: &str = "Too many attempts. Please wait a few minutes and try again.";
236
237/// Throttle key for password attempts. Lower-cased so `Admin@x.io` can't be used
238/// to open a fresh budget against `admin@x.io`.
239fn login_key(email: &str) -> String {
240    format!("login:{}", email.to_lowercase())
241}
242
243/// Throttle key for second-factor attempts, keyed by the session's account.
244fn mfa_key(email: &str) -> String {
245    format!("mfa:{}", email.to_lowercase())
246}
247
248/// Render the login page (optionally with an error message).
249pub fn login_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
250    let mut c = ui::base_context(ctx, "Sign in");
251    // Login has no sidebar menus; keep it minimal.
252    c.insert("menus", &Vec::<crate::menu::MenuItem>::new());
253    if let Some(e) = error {
254        c.insert("error", e);
255    }
256    ui::render_with_csrf(ctx, c, "login.html")
257}
258
259/// Validate credentials against the admin table and, on success, redirect to the
260/// dashboard with the auth cookie set. `csrf` is the submitted hidden field; it
261/// must match the CSRF cookie or the post is rejected before any lookup.
262pub async fn handle_login(
263    ctx: &ReqCtx,
264    email: &str,
265    password: &str,
266    csrf: Option<&str>,
267) -> ApiResponse {
268    // Checked first: an unauthenticated endpoint gets no `SameSite` protection,
269    // so this is the only thing standing between a forged post and a login. It
270    // also keeps forged traffic off the database.
271    if !crate::csrf::verify(ctx, csrf) {
272        let mut resp = login_page(ctx, Some(CSRF_ERROR));
273        resp.status = 403;
274        return resp;
275    }
276
277    let cfg = match config() {
278        Some(c) => c,
279        None => return CoreError::Internal("auth not configured".into()).into(),
280    };
281
282    // Throttle before the lookup, so a throttled attacker can't even use this to
283    // probe which emails exist (and doesn't get to spend a bcrypt verify).
284    let key = login_key(email);
285    if let Some(limit) = ratelimit::login_limit() {
286        if ratelimit::is_limited(&key, limit) {
287            let mut resp = login_page(ctx, Some(THROTTLED_ERROR));
288            resp.status = 429;
289            return resp;
290        }
291    }
292
293    // A miss counts against the limit exactly like a wrong password: if only real
294    // accounts were throttled, the difference would enumerate them.
295    let fail = |ctx: &ReqCtx| {
296        if let Some(limit) = ratelimit::login_limit() {
297            ratelimit::record_failure(&key, limit);
298        }
299        login_page(ctx, Some("Invalid email or password"))
300    };
301
302    let user = match storage().find_one_by(&cfg.admin_table, "email", email).await {
303        Ok(Some(u)) => u,
304        Ok(None) => return fail(ctx),
305        Err(e) => return CoreError::from(e).into(),
306    };
307
308    let hash = user
309        .get("encrypted_password")
310        .and_then(|v| v.as_str())
311        .unwrap_or("");
312    if !verify_password(password, hash) {
313        return fail(ctx);
314    }
315    ratelimit::reset(&key);
316
317    let role = user.get("role").and_then(|v| v.as_str()).unwrap_or("admin");
318    let sub = json_id(&user).unwrap_or_else(|| email.to_string());
319
320    // MFA-enabled users get a pending session and must clear the second factor
321    // before reaching the panel. Others are logged straight in, then nudged to
322    // the (skippable) setup page.
323    if mfa_is_enabled(&user) {
324        match issue_token(&sub, email, role, MFA_PENDING) {
325            Some(token) => ApiResponse::redirect(format!("{}/mfa/verify", ctx.mount))
326                .with_header("Set-Cookie", set_cookie_value(&token)),
327            None => CoreError::Internal("failed to issue token".into()).into(),
328        }
329    } else {
330        match issue_token(&sub, email, role, MFA_OK) {
331            Some(token) => ApiResponse::redirect(format!("{}/mfa/setup", ctx.mount))
332                .with_header("Set-Cookie", set_cookie_value(&token)),
333            None => CoreError::Internal("failed to issue token".into()).into(),
334        }
335    }
336}
337
338// ===== MFA (TOTP) flow =====
339
340/// Read the string `id` of a user row (unwrapping JSON strings/numbers).
341fn json_id(user: &serde_json::Value) -> Option<String> {
342    match user.get("id")? {
343        serde_json::Value::String(s) => Some(s.clone()),
344        other => Some(other.to_string()),
345    }
346}
347
348/// Loosely interpret an `mfa_enabled` column (bool, 0/1, or "true"/"1").
349fn mfa_is_enabled(user: &serde_json::Value) -> bool {
350    match user.get("mfa_enabled") {
351        Some(serde_json::Value::Bool(b)) => *b,
352        Some(serde_json::Value::Number(n)) => n.as_i64().map(|i| i != 0).unwrap_or(false),
353        Some(serde_json::Value::String(s)) => matches!(s.as_str(), "true" | "1" | "t"),
354        _ => false,
355    }
356}
357
358fn nonempty_str<'a>(user: &'a serde_json::Value, key: &str) -> Option<&'a str> {
359    user.get(key)?.as_str().filter(|s| !s.is_empty())
360}
361
362/// Look up the current session's user row by the email in its claims.
363async fn current_user(ctx: &ReqCtx) -> Option<serde_json::Value> {
364    let cfg = config()?;
365    let email = ctx.claims.as_ref()?.email.clone();
366    storage()
367        .find_one_by(&cfg.admin_table, "email", &email)
368        .await
369        .ok()
370        .flatten()
371}
372
373/// Persist a partial update to the current user's row.
374async fn update_user(id: &str, patch: serde_json::Map<String, serde_json::Value>) -> Result<(), CoreError> {
375    let cfg = config().ok_or_else(|| CoreError::Internal("auth not configured".into()))?;
376    storage()
377        .update(&cfg.admin_table, "id", id, patch)
378        .await
379        .map(|_| ())
380        .map_err(CoreError::from)
381}
382
383/// Issue a fully-authenticated token and redirect to `dest`, setting the cookie.
384fn authed_redirect(sub: &str, email: &str, role: &str, dest: String) -> ApiResponse {
385    match issue_token(sub, email, role, MFA_OK) {
386        Some(token) => ApiResponse::redirect(dest).with_header("Set-Cookie", set_cookie_value(&token)),
387        None => CoreError::Internal("failed to issue token".into()).into(),
388    }
389}
390
391/// Setup page: shows a QR + secret and asks the user to confirm a code to enable
392/// MFA. Skippable — the "Skip for now" link just goes back to the dashboard.
393pub async fn mfa_setup_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
394    if is_configured() && ctx.claims.is_none() {
395        return ApiResponse::redirect(format!("{}/login", ctx.mount));
396    }
397    let user = match current_user(ctx).await {
398        Some(u) => u,
399        None => return ApiResponse::redirect(format!("{}/login", ctx.mount)),
400    };
401    // Already enabled -> nothing to set up.
402    if mfa_is_enabled(&user) {
403        return ApiResponse::redirect(ctx.mount.clone());
404    }
405
406    // Reuse a previously-generated (but not-yet-enabled) secret across refreshes,
407    // otherwise the QR would change on every load.
408    let secret = match nonempty_str(&user, "mfa_secret") {
409        Some(s) => s.to_string(),
410        None => {
411            let s = crate::mfa::generate_secret();
412            let Some(id) = json_id(&user) else {
413                return CoreError::Internal("user row has no id".into()).into();
414            };
415            if let Err(e) = update_user(&id, patch(&[("mfa_secret", s.clone().into())])).await {
416                return e.into();
417            }
418            s
419        }
420    };
421
422    let email = ctx.claims.as_ref().map(|c| c.email.as_str()).unwrap_or("");
423    let url = match crate::mfa::provisioning_url(&secret, email) {
424        Ok(u) => u,
425        Err(e) => return e.into(),
426    };
427    let qr = match crate::mfa::qr_svg(&url) {
428        Ok(s) => s,
429        Err(e) => return e.into(),
430    };
431
432    // Rendered inside the panel shell (layout.html), so keep the real sidebar menus.
433    let mut c = ui::base_context(ctx, "Set up two-factor auth");
434    c.insert("qr_svg", &qr);
435    c.insert("secret", &secret);
436    if let Some(e) = error {
437        c.insert("error", e);
438    }
439    ui::render_with_csrf(ctx, c, "mfa_setup.html")
440}
441
442/// Confirm the code, enable MFA, generate backup codes, and show them once.
443pub async fn handle_mfa_enable(ctx: &ReqCtx, code: &str, csrf: Option<&str>) -> ApiResponse {
444    if !crate::csrf::verify(ctx, csrf) {
445        let mut resp = mfa_setup_page(ctx, Some(CSRF_ERROR)).await;
446        resp.status = 403;
447        return resp;
448    }
449    let user = match current_user(ctx).await {
450        Some(u) => u,
451        None => return ApiResponse::redirect(format!("{}/login", ctx.mount)),
452    };
453    let email = ctx.claims.as_ref().map(|c| c.email.clone()).unwrap_or_default();
454    let secret = match nonempty_str(&user, "mfa_secret") {
455        Some(s) => s.to_string(),
456        None => return mfa_setup_page(ctx, Some("Setup expired, please rescan")).await,
457    };
458    if !crate::mfa::check_code(&secret, &email, code) {
459        return mfa_setup_page(ctx, Some("That code didn't match. Try again.")).await;
460    }
461
462    let codes = crate::mfa::generate_backup_codes();
463    let hashed = match crate::mfa::hash_backup_codes(&codes) {
464        Ok(h) => h,
465        Err(e) => return e.into(),
466    };
467    let Some(id) = json_id(&user) else {
468        return CoreError::Internal("user row has no id".into()).into();
469    };
470    if let Err(e) = update_user(
471        &id,
472        patch(&[
473            ("mfa_enabled", true.into()),
474            ("mfa_backup_codes", hashed.into()),
475        ]),
476    )
477    .await
478    {
479        return e.into();
480    }
481
482    // Refresh the cookie so the step is unambiguously "ok".
483    let role = user.get("role").and_then(|v| v.as_str()).unwrap_or("admin");
484    let token = issue_token(&id, &email, role, MFA_OK);
485
486    // Rendered inside the panel shell (layout.html), so keep the real sidebar menus.
487    let mut c = ui::base_context(ctx, "Save your backup codes");
488    c.insert("backup_codes", &codes);
489    let resp = ui::render("mfa_backup.html", &c);
490    match token {
491        Some(t) => resp.with_header("Set-Cookie", set_cookie_value(&t)),
492        None => resp,
493    }
494}
495
496/// Challenge page for an MFA-enabled user mid-login.
497pub async fn mfa_verify_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
498    if is_configured() && ctx.claims.is_none() {
499        return ApiResponse::redirect(format!("{}/login", ctx.mount));
500    }
501    // Already cleared -> straight to the panel.
502    if matches!(&ctx.claims, Some(c) if c.mfa == MFA_OK) {
503        return ApiResponse::redirect(ctx.mount.clone());
504    }
505    let mut c = ui::base_context(ctx, "Two-factor verification");
506    c.insert("menus", &Vec::<crate::menu::MenuItem>::new());
507    if let Some(e) = error {
508        c.insert("error", e);
509    }
510    ui::render_with_csrf(ctx, c, "mfa_verify.html")
511}
512
513/// Verify a TOTP or one-time backup code and, on success, upgrade the session.
514pub async fn handle_mfa_verify(ctx: &ReqCtx, code: &str, csrf: Option<&str>) -> ApiResponse {
515    if !crate::csrf::verify(ctx, csrf) {
516        let mut resp = mfa_verify_page(ctx, Some(CSRF_ERROR)).await;
517        resp.status = 403;
518        return resp;
519    }
520    let user = match current_user(ctx).await {
521        Some(u) => u,
522        None => return ApiResponse::redirect(format!("{}/login", ctx.mount)),
523    };
524    let email = ctx.claims.as_ref().map(|c| c.email.clone()).unwrap_or_default();
525    let role = user.get("role").and_then(|v| v.as_str()).unwrap_or("admin");
526    let Some(id) = json_id(&user) else {
527        return CoreError::Internal("user row has no id".into()).into();
528    };
529
530    // A 6-digit code is only 10^6 possibilities, so this throttle — not the code
531    // itself — is what makes the second factor hold up.
532    let key = mfa_key(&email);
533    let limit = ratelimit::mfa_limit();
534    if let Some(l) = limit {
535        if ratelimit::is_limited(&key, l) {
536            let mut resp = mfa_verify_page(ctx, Some(THROTTLED_ERROR)).await;
537            resp.status = 429;
538            return resp;
539        }
540    }
541
542    // 1) Try the authenticator code.
543    if let Some(secret) = nonempty_str(&user, "mfa_secret") {
544        if crate::mfa::check_code(secret, &email, code) {
545            ratelimit::reset(&key);
546            return authed_redirect(&id, &email, role, ctx.mount.clone());
547        }
548    }
549
550    // 2) Fall back to a one-time backup code, consuming it on success.
551    if let Some(stored) = nonempty_str(&user, "mfa_backup_codes") {
552        if let Some(remaining) = crate::mfa::consume_backup_code(stored, code) {
553            if let Err(e) = update_user(&id, patch(&[("mfa_backup_codes", remaining.into())])).await {
554                return e.into();
555            }
556            ratelimit::reset(&key);
557            return authed_redirect(&id, &email, role, ctx.mount.clone());
558        }
559    }
560
561    if let Some(l) = limit {
562        ratelimit::record_failure(&key, l);
563    }
564    mfa_verify_page(ctx, Some("Invalid code. Try again or use a backup code.")).await
565}
566
567/// Build a small update map from `(column, value)` pairs.
568fn patch(pairs: &[(&str, serde_json::Value)]) -> serde_json::Map<String, serde_json::Value> {
569    pairs
570        .iter()
571        .map(|(k, v)| (k.to_string(), v.clone()))
572        .collect()
573}
574
575/// Clear the auth cookie and return to the login page. Logout is **client-side**:
576/// it removes the browser's cookie but cannot invalidate a token already copied
577/// elsewhere — that token remains valid until it expires (`token_ttl_secs`). To
578/// force-revoke sessions, rotate `jwt_secret` (kills all) or wait out a short TTL.
579pub fn handle_logout(ctx: &ReqCtx) -> ApiResponse {
580    ApiResponse::redirect(format!("{}/login", ctx.mount))
581        .with_header("Set-Cookie", clear_cookie_value())
582}
583
584/// Convenience for seeding: insert an admin user (bcrypt-hashing the password).
585pub async fn create_admin(email: &str, password: &str, role: &str) -> Result<(), CoreError> {
586    let cfg = config().ok_or_else(|| CoreError::Internal("auth not configured".into()))?;
587    let mut data = serde_json::Map::new();
588    data.insert("email".into(), serde_json::Value::String(email.into()));
589    data.insert(
590        "encrypted_password".into(),
591        serde_json::Value::String(hash_password(password)?),
592    );
593    data.insert("role".into(), serde_json::Value::String(role.into()));
594    storage()
595        .create(&cfg.admin_table, data)
596        .await
597        .map_err(CoreError::from)?;
598    Ok(())
599}