1use std::sync::Arc;
15
16use tracing::{info, warn};
17
18use crate::admin::{password, recovery, totp};
19use crate::sqlite::admin_recovery_code::AdminRecoveryCode;
20use crate::sqlite::admin_session::AdminSession;
21use crate::sqlite::admin_user::AdminUser;
22use crate::sqlite::db::Database;
23use crate::sqlite::nonce::now_secs;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MfaMethod {
28 Totp,
29 RecoveryCode,
30}
31
32impl MfaMethod {
33 #[must_use]
36 pub fn as_str(self) -> &'static str {
37 match self {
38 MfaMethod::Totp => "totp",
39 MfaMethod::RecoveryCode => "recovery_code",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum MfaOutcome {
51 Accepted {
52 via: MfaMethod,
53 recovery_codes_left: i64,
56 },
57 Rejected,
59 Replayed,
63 NotEnrolled,
66}
67
68impl MfaOutcome {
69 #[must_use]
71 pub fn reason(&self) -> &'static str {
72 match self {
73 MfaOutcome::Accepted { .. } => "",
74 MfaOutcome::Rejected => "wrong_code",
75 MfaOutcome::Replayed => "replayed",
76 MfaOutcome::NotEnrolled => "no_factor",
77 }
78 }
79}
80
81pub async fn verify_second_factor(
94 user: &mut AdminUser,
95 submitted: &str,
96 database: Arc<Database>,
97) -> Result<MfaOutcome, sqlx::Error> {
98 let Some(secret) = user.totp_secret.clone() else {
99 return Ok(MfaOutcome::NotEnrolled);
100 };
101
102 let trimmed = submitted.trim();
103 if let Some(step) = totp::verify(&secret, trimmed, now_secs()) {
104 if !user.claim_totp_step(step, &database).await? {
105 return Ok(MfaOutcome::Replayed);
106 }
107 let left = AdminRecoveryCode::count_unused(&user.id, &database).await?;
108 return Ok(MfaOutcome::Accepted {
109 via: MfaMethod::Totp,
110 recovery_codes_left: left,
111 });
112 }
113
114 let candidate = recovery::normalize(trimmed);
115 if !recovery::is_well_formed(&candidate) {
116 return Ok(MfaOutcome::Rejected);
117 }
118
119 for code in AdminRecoveryCode::list_unused(&user.id, &database).await? {
120 match password::verify_password(&code.code_hash, &candidate) {
121 Ok(true) => {
122 if !AdminRecoveryCode::consume(&code.id, &database).await? {
123 return Ok(MfaOutcome::Rejected);
126 }
127 let left = AdminRecoveryCode::count_unused(&user.id, &database).await?;
128 warn!(event = "admin_mfa_recovery_code_used",
129 outcome = "success",
130 user_id = %user.id,
131 username = %user.username,
132 remaining = left);
133 return Ok(MfaOutcome::Accepted {
134 via: MfaMethod::RecoveryCode,
135 recovery_codes_left: left,
136 });
137 }
138 Ok(false) => {}
139 Err(error) => {
140 warn!(event = "admin_recovery_code_hash_unreadable",
144 outcome = "failure",
145 user_id = %user.id,
146 code_id = %code.id,
147 error = %error);
148 }
149 }
150 }
151
152 Ok(MfaOutcome::Rejected)
153}
154
155pub async fn begin_totp_enrolment(
162 user: &mut AdminUser,
163 base_url: &str,
164 database: Arc<Database>,
165) -> Result<totp::Enrolment, sqlx::Error> {
166 let account = totp::account_label(&user.username, base_url);
167 let enrolment = totp::begin_enrolment(totp::ISSUER, &account);
168 user.set_totp_pending(&enrolment.secret, &database).await?;
169 Ok(enrolment)
170}
171
172pub async fn resume_or_begin_totp_enrolment(
179 user: &mut AdminUser,
180 base_url: &str,
181 database: Arc<Database>,
182) -> Result<totp::Enrolment, sqlx::Error> {
183 let Some(secret) = user.totp_pending_secret.clone() else {
184 return begin_totp_enrolment(user, base_url, database).await;
185 };
186
187 let account = totp::account_label(&user.username, base_url);
188 let secret_base32 = totp::base32_encode(&secret);
189 let uri = totp::provisioning_uri(&secret_base32, totp::ISSUER, &account);
190 Ok(totp::Enrolment {
191 secret,
192 secret_base32,
193 uri,
194 })
195}
196
197pub async fn confirm_totp_enrolment(
207 user: &mut AdminUser,
208 code: &str,
209 keep_session: Option<&str>,
210 database: Arc<Database>,
211) -> Result<Option<Vec<String>>, sqlx::Error> {
212 let Some(pending) = user.totp_pending_secret.clone() else {
213 return Ok(None);
214 };
215
216 let Some(step) = totp::verify(&pending, code.trim(), now_secs()) else {
217 return Ok(None);
218 };
219
220 user.confirm_totp(&database).await?;
221 user.claim_totp_step(step, &database).await?;
224
225 let codes = issue_recovery_codes(user, database.clone()).await?;
226 revoke_other_sessions(user, keep_session, database).await?;
227
228 info!(event = "admin_mfa_enabled",
229 outcome = "success",
230 user_id = %user.id,
231 username = %user.username,
232 recovery_codes = codes.len());
233 Ok(Some(codes))
234}
235
236pub async fn disable_totp(
244 user: &mut AdminUser,
245 keep_session: Option<&str>,
246 database: Arc<Database>,
247) -> Result<(), sqlx::Error> {
248 user.clear_totp(&database).await?;
249 AdminRecoveryCode::delete_for_user(&user.id, &database).await?;
250 revoke_other_sessions(user, keep_session, database).await?;
251
252 info!(event = "admin_mfa_disabled", outcome = "success", user_id = %user.id, username = %user.username);
253 Ok(())
254}
255
256pub async fn regenerate_recovery_codes(
259 user: &AdminUser,
260 database: Arc<Database>,
261) -> Result<Vec<String>, sqlx::Error> {
262 let codes = issue_recovery_codes(user, database).await?;
263 info!(event = "admin_mfa_recovery_codes_regenerated",
264 outcome = "success",
265 user_id = %user.id,
266 username = %user.username,
267 minted = codes.len());
268 Ok(codes)
269}
270
271pub async fn recovery_codes_remaining(
273 user_id: &str,
274 database: Arc<Database>,
275) -> Result<i64, sqlx::Error> {
276 AdminRecoveryCode::count_unused(user_id, &database).await
277}
278
279pub async fn operators_without_a_factor(database: Arc<Database>) -> Result<usize, sqlx::Error> {
282 Ok(AdminUser::list_all(&database)
283 .await?
284 .iter()
285 .filter(|user| !user.has_totp())
286 .count())
287}
288
289async fn issue_recovery_codes(
290 user: &AdminUser,
291 database: Arc<Database>,
292) -> Result<Vec<String>, sqlx::Error> {
293 let codes = recovery::generate_codes();
294 let hashes: Vec<String> = codes
297 .iter()
298 .map(|code| password::hash_generated_secret(&recovery::normalize(code)))
299 .collect();
300 AdminRecoveryCode::replace_all(&user.id, &hashes, &database).await?;
301 Ok(codes)
302}
303
304async fn revoke_other_sessions(
305 user: &AdminUser,
306 keep_session: Option<&str>,
307 database: Arc<Database>,
308) -> Result<u64, sqlx::Error> {
309 match keep_session {
310 Some(token_hash) => {
311 AdminSession::delete_for_user_except(&user.id, token_hash, &database).await
312 }
313 None => AdminSession::delete_for_user(&user.id, &database).await,
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::admin::totp::{DIGITS, step_at, totp_at};
321 use crate::sqlite::admin_session::NewSession;
322
323 async fn db() -> Arc<Database> {
324 Arc::new(Database::connect_in_memory().await.unwrap())
325 }
326
327 async fn operator(database: Arc<Database>) -> AdminUser {
328 AdminUser::create("alice", "hash", &database).await.unwrap()
329 }
330
331 async fn enrolled(database: Arc<Database>) -> (AdminUser, Vec<u8>) {
334 let mut user = operator(database.clone()).await;
335 let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
336 .await
337 .unwrap();
338 let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
339 let codes = confirm_totp_enrolment(&mut user, &code, None, database)
340 .await
341 .unwrap()
342 .expect("a freshly generated code must confirm its own enrolment");
343 assert_eq!(codes.len(), recovery::CODE_COUNT);
344 (user, enrolment.secret)
345 }
346
347 #[tokio::test]
348 async fn an_operator_with_no_factor_is_not_enrolled() {
349 let db = db().await;
350 let mut user = operator(db.clone()).await;
351
352 assert_eq!(
353 verify_second_factor(&mut user, "123456", db).await.unwrap(),
354 MfaOutcome::NotEnrolled
355 );
356 }
357
358 #[tokio::test]
359 async fn enrolment_is_two_steps_and_a_wrong_code_finishes_neither() {
360 let db = db().await;
361 let mut user = operator(db.clone()).await;
362
363 let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
364 .await
365 .unwrap();
366 assert!(
367 !user.has_totp(),
368 "a pending enrolment is not a second factor"
369 );
370 assert!(user.has_pending_totp());
371
372 assert!(
374 confirm_totp_enrolment(&mut user, "000000", None, db.clone())
375 .await
376 .unwrap()
377 .is_none()
378 );
379 assert!(!user.has_totp());
380 assert!(user.has_pending_totp());
381 assert_eq!(
382 recovery_codes_remaining(&user.id, db.clone())
383 .await
384 .unwrap(),
385 0
386 );
387
388 let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
389 let codes = confirm_totp_enrolment(&mut user, &code, None, db.clone())
390 .await
391 .unwrap()
392 .unwrap();
393
394 assert!(user.has_totp());
395 assert!(
396 !user.has_pending_totp(),
397 "confirming must clear the pending column, not leave two secrets live"
398 );
399 assert_eq!(codes.len(), recovery::CODE_COUNT);
400 assert_eq!(
401 recovery_codes_remaining(&user.id, db).await.unwrap(),
402 recovery::CODE_COUNT as i64
403 );
404 }
405
406 #[tokio::test]
409 async fn a_correct_code_is_accepted_once_and_replayed_thereafter() {
410 let db = db().await;
411 let (mut user, secret) = enrolled(db.clone()).await;
412
413 let claimed = user.totp_last_step.expect("enrolment claims its own step");
418 let code = totp_at(&secret, claimed, DIGITS);
419 assert_eq!(
420 verify_second_factor(&mut user, &code, db.clone())
421 .await
422 .unwrap(),
423 MfaOutcome::Replayed
424 );
425
426 let next = totp_at(&secret, step_at(now_secs()) + 1, DIGITS);
428 assert_eq!(
429 verify_second_factor(&mut user, &next, db.clone())
430 .await
431 .unwrap(),
432 MfaOutcome::Accepted {
433 via: MfaMethod::Totp,
434 recovery_codes_left: recovery::CODE_COUNT as i64,
435 }
436 );
437 assert_eq!(
438 verify_second_factor(&mut user, &next, db).await.unwrap(),
439 MfaOutcome::Replayed
440 );
441 }
442
443 #[tokio::test]
444 async fn a_wrong_code_is_rejected_without_touching_the_replay_guard() {
445 let db = db().await;
446 let (mut user, secret) = enrolled(db.clone()).await;
447 let claimed = user.totp_last_step;
448
449 for wrong in ["000000", "12345", "abcdef", ""] {
450 assert_eq!(
451 verify_second_factor(&mut user, wrong, db.clone())
452 .await
453 .unwrap(),
454 MfaOutcome::Rejected,
455 "submission {wrong:?}"
456 );
457 }
458 assert_eq!(
459 user.totp_last_step, claimed,
460 "a wrong code must not advance the guard, or it would lock out the right one"
461 );
462
463 let next = totp_at(&secret, step_at(now_secs()) + 1, DIGITS);
465 assert!(matches!(
466 verify_second_factor(&mut user, &next, db).await.unwrap(),
467 MfaOutcome::Accepted { .. }
468 ));
469 }
470
471 #[tokio::test]
472 async fn a_recovery_code_is_accepted_once_and_decrements_the_count() {
473 let db = db().await;
474 let mut user = operator(db.clone()).await;
475 let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
476 .await
477 .unwrap();
478 let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
479 let codes = confirm_totp_enrolment(&mut user, &code, None, db.clone())
480 .await
481 .unwrap()
482 .unwrap();
483
484 assert_eq!(
486 verify_second_factor(&mut user, &codes[0], db.clone())
487 .await
488 .unwrap(),
489 MfaOutcome::Accepted {
490 via: MfaMethod::RecoveryCode,
491 recovery_codes_left: recovery::CODE_COUNT as i64 - 1,
492 }
493 );
494 assert_eq!(
495 verify_second_factor(&mut user, &codes[0], db.clone())
496 .await
497 .unwrap(),
498 MfaOutcome::Rejected,
499 "single-use: a spent code is worth nothing"
500 );
501
502 assert_eq!(
504 verify_second_factor(&mut user, &codes[1].to_lowercase(), db.clone())
505 .await
506 .unwrap(),
507 MfaOutcome::Accepted {
508 via: MfaMethod::RecoveryCode,
509 recovery_codes_left: recovery::CODE_COUNT as i64 - 2,
510 }
511 );
512 assert_eq!(
513 verify_second_factor(&mut user, &codes[2].replace('-', " "), db.clone())
514 .await
515 .unwrap(),
516 MfaOutcome::Accepted {
517 via: MfaMethod::RecoveryCode,
518 recovery_codes_left: recovery::CODE_COUNT as i64 - 3,
519 }
520 );
521
522 assert_eq!(
523 recovery_codes_remaining(&user.id, db).await.unwrap(),
524 recovery::CODE_COUNT as i64 - 3
525 );
526 }
527
528 #[tokio::test]
529 async fn regenerating_supersedes_the_previous_set() {
530 let db = db().await;
531 let (mut user, _) = enrolled(db.clone()).await;
532
533 let first = AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap();
534 let second = regenerate_recovery_codes(&user, db.clone()).await.unwrap();
535 assert_eq!(second.len(), recovery::CODE_COUNT);
536
537 for code in AdminRecoveryCode::list_unused(&user.id, &db).await.unwrap() {
541 assert!(first.iter().all(|old| old.id != code.id));
542 }
543 assert_eq!(
544 verify_second_factor(&mut user, &second[0], db.clone())
545 .await
546 .unwrap(),
547 MfaOutcome::Accepted {
548 via: MfaMethod::RecoveryCode,
549 recovery_codes_left: recovery::CODE_COUNT as i64 - 1,
550 }
551 );
552 }
553
554 #[tokio::test]
555 async fn disabling_clears_every_column_and_every_code() {
556 let db = db().await;
557 let (mut user, _) = enrolled(db.clone()).await;
558 assert!(user.has_totp());
559
560 disable_totp(&mut user, None, db.clone()).await.unwrap();
561
562 assert!(!user.has_totp());
563 assert!(!user.has_pending_totp());
564 assert_eq!(user.totp_last_step, None);
565 assert_eq!(
566 recovery_codes_remaining(&user.id, db.clone())
567 .await
568 .unwrap(),
569 0,
570 "a recovery code for a factor that no longer exists is a second password"
571 );
572
573 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
576 assert!(!reloaded.has_totp());
577 assert!(!reloaded.has_pending_totp());
578 assert_eq!(reloaded.totp_last_step, None);
579 }
580
581 #[tokio::test]
582 async fn a_factor_change_revokes_every_other_session() {
583 let db = db().await;
584 let mut user = operator(db.clone()).await;
585
586 let kept = AdminSession::create(
587 NewSession {
588 user_id: &user.id,
589 token_hash: "kept-hash",
590 csrf_token: "csrf",
591 created_ip: None,
592 user_agent: None,
593 },
594 std::time::Duration::from_secs(3600),
595 &db,
596 )
597 .await
598 .unwrap();
599 AdminSession::create(
600 NewSession {
601 user_id: &user.id,
602 token_hash: "other-hash",
603 csrf_token: "csrf",
604 created_ip: None,
605 user_agent: None,
606 },
607 std::time::Duration::from_secs(3600),
608 &db,
609 )
610 .await
611 .unwrap();
612
613 let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
614 .await
615 .unwrap();
616 let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
617 confirm_totp_enrolment(&mut user, &code, Some(&kept.token_hash), db.clone())
618 .await
619 .unwrap()
620 .unwrap();
621
622 let live = AdminSession::list_all(Some(&user.id), &db).await.unwrap();
623 assert_eq!(live.len(), 1, "every other browser must be signed out");
624 assert_eq!(live[0].token_hash, "kept-hash");
625
626 disable_totp(&mut user, None, db.clone()).await.unwrap();
628 assert!(
629 AdminSession::list_all(Some(&user.id), &db)
630 .await
631 .unwrap()
632 .is_empty()
633 );
634 }
635
636 #[tokio::test]
637 async fn every_outcome_names_itself_for_the_log() {
638 assert_eq!(
639 MfaOutcome::Accepted {
640 via: MfaMethod::Totp,
641 recovery_codes_left: 10
642 }
643 .reason(),
644 ""
645 );
646 assert_eq!(MfaOutcome::Rejected.reason(), "wrong_code");
647 assert_eq!(MfaOutcome::Replayed.reason(), "replayed");
648 assert_eq!(MfaOutcome::NotEnrolled.reason(), "no_factor");
649 assert_eq!(MfaMethod::Totp.as_str(), "totp");
650 assert_eq!(MfaMethod::RecoveryCode.as_str(), "recovery_code");
651 }
652
653 #[tokio::test]
654 async fn the_startup_count_sees_only_confirmed_factors() {
655 let db = db().await;
656 let mut alice = operator(db.clone()).await;
657 AdminUser::create("bob", "hash", &db).await.unwrap();
658 assert_eq!(operators_without_a_factor(db.clone()).await.unwrap(), 2);
659
660 let enrolment = begin_totp_enrolment(&mut alice, "http://localhost:3001", db.clone())
662 .await
663 .unwrap();
664 assert_eq!(operators_without_a_factor(db.clone()).await.unwrap(), 2);
665
666 let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
667 confirm_totp_enrolment(&mut alice, &code, None, db.clone())
668 .await
669 .unwrap()
670 .unwrap();
671 assert_eq!(operators_without_a_factor(db).await.unwrap(), 1);
672 }
673}