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