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,
25 pub token_ttl_secs: i64,
26 pub admin_table: String,
28 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
45pub 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
57pub(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
67pub const MFA_OK: &str = "ok";
71pub 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 #[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
97pub 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
116pub 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
135pub 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
146fn 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
163pub 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
180pub(crate) fn mfa_pending(ctx: &ReqCtx) -> bool {
183 matches!(&ctx.claims, Some(c) if c.mfa == MFA_PENDING)
184}
185
186pub 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
199pub 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
209pub 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
219const CSRF_ERROR: &str = "Your session expired. Please try again.";
225
226const THROTTLED_ERROR: &str = "Too many attempts. Please wait a few minutes and try again.";
229
230fn login_key(email: &str) -> String {
233 format!("login:{}", email.to_lowercase())
234}
235
236fn mfa_key(email: &str) -> String {
238 format!("mfa:{}", email.to_lowercase())
239}
240
241pub fn login_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
243 let mut c = ui::base_context(ctx, "Sign in");
244 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
252pub async fn handle_login(
256 ctx: &ReqCtx,
257 email: &str,
258 password: &str,
259 csrf: Option<&str>,
260) -> ApiResponse {
261 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 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 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 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
331fn 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
341fn 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
355async 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
366async 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
376fn 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
384pub 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 if mfa_is_enabled(&user) {
396 return ApiResponse::redirect(ctx.mount.clone());
397 }
398
399 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 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
435pub 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 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 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
489pub 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 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
506pub 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 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 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 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
560fn 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
568pub fn handle_logout(ctx: &ReqCtx) -> ApiResponse {
570 ApiResponse::redirect(format!("{}/login", ctx.mount))
571 .with_header("Set-Cookie", clear_cookie_value())
572}
573
574pub 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}