1use 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
18pub 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 pub admin_table: String,
27 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
44pub 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
60pub const MFA_OK: &str = "ok";
64pub 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 #[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
90pub 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
109pub 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
128pub 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
139fn 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
157pub 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
170fn mfa_pending(ctx: &ReqCtx) -> bool {
173 matches!(&ctx.claims, Some(c) if c.mfa == MFA_PENDING)
174}
175
176pub 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
189pub 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
199pub 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
209pub fn login_page(ctx: &ReqCtx, error: Option<&str>) -> ApiResponse {
213 let mut c = ui::base_context(ctx, "Sign in");
214 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
222pub 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 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
265fn 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
275fn 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
289async 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
300async 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
310fn 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
318pub 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 if mfa_is_enabled(&user) {
330 return ApiResponse::redirect(ctx.mount.clone());
331 }
332
333 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 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
369pub 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 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 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
418pub 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 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
435pub 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 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 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
467fn 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
475pub fn handle_logout(ctx: &ReqCtx) -> ApiResponse {
477 ApiResponse::redirect(format!("{}/login", ctx.mount))
478 .with_header("Set-Cookie", clear_cookie_value())
479}
480
481pub 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}