1use 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
19pub const COOKIE_NAME: &str = "adminx_token";
21
22#[derive(Clone, Debug)]
23pub struct AuthConfig {
24 pub jwt_secret: String,
28 pub token_ttl_secs: i64,
33 pub admin_table: String,
35 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
52pub 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
64pub(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
74pub const MFA_OK: &str = "ok";
78pub 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 #[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
104pub 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
123pub 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
142pub 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
153fn 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
170pub 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
187pub(crate) fn mfa_pending(ctx: &ReqCtx) -> bool {
190 matches!(&ctx.claims, Some(c) if c.mfa == MFA_PENDING)
191}
192
193pub 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
206pub 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
216pub 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
226const CSRF_ERROR: &str = "Your session expired. Please try again.";
232
233const THROTTLED_ERROR: &str = "Too many attempts. Please wait a few minutes and try again.";
236
237fn login_key(email: &str) -> String {
240 format!("login:{}", email.to_lowercase())
241}
242
243fn mfa_key(email: &str) -> String {
245 format!("mfa:{}", email.to_lowercase())
246}
247
248pub fn login_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
250 let mut c = ui::base_context(ctx, "Sign in");
251 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
259pub async fn handle_login(
263 ctx: &ReqCtx,
264 email: &str,
265 password: &str,
266 csrf: Option<&str>,
267) -> ApiResponse {
268 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 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 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 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
338fn 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
348fn 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
362async 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
373async 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
383fn 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
391pub 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 if mfa_is_enabled(&user) {
403 return ApiResponse::redirect(ctx.mount.clone());
404 }
405
406 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 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
442pub 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 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 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
496pub 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 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
513pub 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 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 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 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
567fn 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
575pub fn handle_logout(ctx: &ReqCtx) -> ApiResponse {
580 ApiResponse::redirect(format!("{}/login", ctx.mount))
581 .with_header("Set-Cookie", clear_cookie_value())
582}
583
584pub 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}