use crate::error::CoreError;
use totp_rs::{Algorithm, Secret, TOTP};
const ISSUER: &str = "adminx";
pub const BACKUP_CODE_COUNT: usize = 10;
pub fn generate_secret() -> String {
Secret::generate_secret().to_encoded().to_string()
}
fn totp_for(secret_b32: &str, account: &str) -> Result<TOTP, CoreError> {
let bytes = Secret::Encoded(secret_b32.to_string())
.to_bytes()
.map_err(|e| CoreError::Internal(format!("invalid mfa secret: {e:?}")))?;
TOTP::new(
Algorithm::SHA1,
6,
1, 30,
bytes,
Some(ISSUER.to_string()),
account.to_string(),
)
.map_err(|e| CoreError::Internal(format!("totp init: {e}")))
}
pub fn provisioning_url(secret_b32: &str, account: &str) -> Result<String, CoreError> {
Ok(totp_for(secret_b32, account)?.get_url())
}
pub fn check_code(secret_b32: &str, account: &str, code: &str) -> bool {
match totp_for(secret_b32, account) {
Ok(totp) => totp.check_current(code.trim()).unwrap_or(false),
Err(_) => false,
}
}
pub fn qr_svg(data: &str) -> Result<String, CoreError> {
use qrcode::render::svg;
use qrcode::QrCode;
let code =
QrCode::new(data.as_bytes()).map_err(|e| CoreError::Internal(format!("qr encode: {e}")))?;
Ok(code
.render::<svg::Color>()
.min_dimensions(220, 220)
.quiet_zone(true)
.build())
}
pub fn generate_backup_codes() -> Vec<String> {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..BACKUP_CODE_COUNT)
.map(|_| {
let n: u32 = rng.gen_range(0..100_000_000);
let s = format!("{n:08}");
format!("{}-{}", &s[..4], &s[4..])
})
.collect()
}
pub fn hash_backup_codes(codes: &[String]) -> Result<String, CoreError> {
let hashes = codes
.iter()
.map(|c| crate::auth::hash_password(c))
.collect::<Result<Vec<String>, _>>()?;
serde_json::to_string(&hashes).map_err(|e| CoreError::Internal(e.to_string()))
}
pub fn consume_backup_code(stored_json: &str, code: &str) -> Option<String> {
let mut hashes: Vec<String> = serde_json::from_str(stored_json).ok()?;
let code = code.trim();
let pos = hashes
.iter()
.position(|h| crate::auth::verify_password(code, h))?;
hashes.remove(pos);
serde_json::to_string(&hashes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_roundtrips_to_a_working_totp() {
let secret = generate_secret();
let totp = totp_for(&secret, "user@example.com").unwrap();
let code = totp.generate_current().unwrap();
assert!(check_code(&secret, "user@example.com", &code));
assert!(!check_code(&secret, "user@example.com", "000000"));
}
#[test]
fn provisioning_url_is_otpauth() {
let secret = generate_secret();
let url = provisioning_url(&secret, "user@example.com").unwrap();
assert!(url.starts_with("otpauth://totp/"));
}
#[test]
fn backup_codes_are_one_time() {
let codes = generate_backup_codes();
assert_eq!(codes.len(), BACKUP_CODE_COUNT);
let stored = hash_backup_codes(&codes).unwrap();
assert!(consume_backup_code(&stored, "0000-0000").is_none() || !codes.contains(&"0000-0000".to_string()));
let remaining = consume_backup_code(&stored, &codes[0]).expect("first use accepted");
assert!(consume_backup_code(&remaining, &codes[0]).is_none());
}
}