1use crate::error::CoreError;
8use totp_rs::{Algorithm, Secret, TOTP};
9
10const ISSUER: &str = "adminx";
12pub const BACKUP_CODE_COUNT: usize = 10;
14
15pub fn generate_secret() -> String {
17 Secret::generate_secret().to_encoded().to_string()
18}
19
20fn 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, 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
37pub fn provisioning_url(secret_b32: &str, account: &str) -> Result<String, CoreError> {
39 Ok(totp_for(secret_b32, account)?.get_url())
40}
41
42pub 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
50pub 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
64pub 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
80pub 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
89pub 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 assert!(consume_backup_code(&stored, "0000-0000").is_none() || !codes.contains(&"0000-0000".to_string()));
129
130 let remaining = consume_backup_code(&stored, &codes[0]).expect("first use accepted");
132 assert!(consume_backup_code(&remaining, &codes[0]).is_none());
133 }
134}