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