Skip to main content

adminx_core/
mfa.rs

1// adminx-core/src/mfa.rs
2//
3// TOTP-based multi-factor auth primitives: secret generation, QR provisioning,
4// code verification, and one-time backup codes. Framework- and storage-neutral;
5// the auth handlers and adapters build the flow on top of these helpers.
6
7use crate::error::CoreError;
8use totp_rs::{Algorithm, Secret, TOTP};
9
10/// Issuer shown in the authenticator app.
11const ISSUER: &str = "adminx";
12/// Number of one-time backup codes generated at enable time.
13pub const BACKUP_CODE_COUNT: usize = 10;
14
15/// Generate a fresh base32 TOTP secret to persist on the user row.
16pub fn generate_secret() -> String {
17    Secret::generate_secret().to_encoded().to_string()
18}
19
20/// Build a `TOTP` from a stored base32 secret and the user's account label.
21fn totp_for(secret_b32: &str, account: &str) -> Result<TOTP, CoreError> {
22    let bytes = Secret::Encoded(secret_b32.to_string())
23        .to_bytes()
24        .map_err(|e| CoreError::Internal(format!("invalid mfa secret: {e:?}")))?;
25    TOTP::new(
26        Algorithm::SHA1,
27        6,
28        1, // allow +/- 1 step of clock skew
29        30,
30        bytes,
31        Some(ISSUER.to_string()),
32        account.to_string(),
33    )
34    .map_err(|e| CoreError::Internal(format!("totp init: {e}")))
35}
36
37/// `otpauth://` URL to encode into the setup QR code.
38pub fn provisioning_url(secret_b32: &str, account: &str) -> Result<String, CoreError> {
39    Ok(totp_for(secret_b32, account)?.get_url())
40}
41
42/// True when `code` is the valid current TOTP for this secret/account.
43pub fn check_code(secret_b32: &str, account: &str, code: &str) -> bool {
44    match totp_for(secret_b32, account) {
45        Ok(totp) => totp.check_current(code.trim()).unwrap_or(false),
46        Err(_) => false,
47    }
48}
49
50/// Render arbitrary data (an otpauth URL) as an inline SVG QR code. SVG keeps the
51/// dependency light — no raster/image encoders.
52pub fn qr_svg(data: &str) -> Result<String, CoreError> {
53    use qrcode::render::svg;
54    use qrcode::QrCode;
55    let code =
56        QrCode::new(data.as_bytes()).map_err(|e| CoreError::Internal(format!("qr encode: {e}")))?;
57    Ok(code
58        .render::<svg::Color>()
59        .min_dimensions(220, 220)
60        .quiet_zone(true)
61        .build())
62}
63
64// ===== Backup / recovery codes =====
65
66/// Generate `BACKUP_CODE_COUNT` human-friendly one-time codes (shown once, in
67/// plain text). Format: `1234-5678`.
68pub fn generate_backup_codes() -> Vec<String> {
69    use rand::Rng;
70    let mut rng = rand::thread_rng();
71    (0..BACKUP_CODE_COUNT)
72        .map(|_| {
73            let n: u32 = rng.gen_range(0..100_000_000);
74            let s = format!("{n:08}");
75            format!("{}-{}", &s[..4], &s[4..])
76        })
77        .collect()
78}
79
80/// Hash backup codes for storage. Returns a JSON array of bcrypt hashes.
81pub fn hash_backup_codes(codes: &[String]) -> Result<String, CoreError> {
82    let hashes = codes
83        .iter()
84        .map(|c| crate::auth::hash_password(c))
85        .collect::<Result<Vec<String>, _>>()?;
86    serde_json::to_string(&hashes).map_err(|e| CoreError::Internal(e.to_string()))
87}
88
89/// If `code` matches one of the stored (JSON array) hashes, consume it and return
90/// the remaining hashes as JSON. Returns `None` when nothing matched.
91pub fn consume_backup_code(stored_json: &str, code: &str) -> Option<String> {
92    let mut hashes: Vec<String> = serde_json::from_str(stored_json).ok()?;
93    let code = code.trim();
94    let pos = hashes
95        .iter()
96        .position(|h| crate::auth::verify_password(code, h))?;
97    hashes.remove(pos);
98    serde_json::to_string(&hashes).ok()
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn secret_roundtrips_to_a_working_totp() {
107        let secret = generate_secret();
108        let totp = totp_for(&secret, "user@example.com").unwrap();
109        let code = totp.generate_current().unwrap();
110        assert!(check_code(&secret, "user@example.com", &code));
111        assert!(!check_code(&secret, "user@example.com", "000000"));
112    }
113
114    #[test]
115    fn provisioning_url_is_otpauth() {
116        let secret = generate_secret();
117        let url = provisioning_url(&secret, "user@example.com").unwrap();
118        assert!(url.starts_with("otpauth://totp/"));
119    }
120
121    #[test]
122    fn backup_codes_are_one_time() {
123        let codes = generate_backup_codes();
124        assert_eq!(codes.len(), BACKUP_CODE_COUNT);
125        let stored = hash_backup_codes(&codes).unwrap();
126
127        // A wrong code consumes nothing.
128        assert!(consume_backup_code(&stored, "0000-0000").is_none() || !codes.contains(&"0000-0000".to_string()));
129
130        // A valid code is accepted once, then gone.
131        let remaining = consume_backup_code(&stored, &codes[0]).expect("first use accepted");
132        assert!(consume_backup_code(&remaining, &codes[0]).is_none());
133    }
134}