use crate::error::{Result, TidewayError};
use totp_rs::{Algorithm, Secret, TOTP};
#[derive(Clone)]
pub struct TotpConfig {
pub issuer: String,
pub digits: usize,
pub step: u64,
pub algorithm: Algorithm,
}
impl Default for TotpConfig {
fn default() -> Self {
Self {
issuer: "App".to_string(),
digits: 6,
step: 30,
algorithm: Algorithm::SHA1,
}
}
}
impl TotpConfig {
pub fn new(issuer: impl Into<String>) -> Self {
Self {
issuer: issuer.into(),
..Default::default()
}
}
pub fn digits(mut self, digits: usize) -> Self {
self.digits = digits;
self
}
pub fn step(mut self, step: u64) -> Self {
self.step = step;
self
}
}
pub struct TotpSetup {
pub secret: String,
pub uri: String,
pub qr_code_base64: String,
}
#[derive(Clone)]
pub struct TotpManager {
config: TotpConfig,
}
impl TotpManager {
pub fn new(config: TotpConfig) -> Self {
Self { config }
}
pub fn generate_setup(&self, account_name: &str) -> Result<TotpSetup> {
let secret = Secret::generate_secret();
let secret_base32 = secret.to_encoded().to_string();
let totp = self.build_totp(&secret_base32, account_name)?;
let uri = totp.get_url();
let qr_code = totp
.get_qr_base64()
.map_err(|e| TidewayError::Internal(format!("Failed to generate QR code: {}", e)))?;
Ok(TotpSetup {
secret: secret_base32,
uri,
qr_code_base64: qr_code,
})
}
pub fn verify(&self, secret: &str, code: &str, account_name: &str) -> Result<bool> {
let totp = self.build_totp(secret, account_name)?;
let code = code.replace([' ', '-'], "");
match totp.check_current(&code) {
Ok(valid) => Ok(valid),
Err(e) => {
tracing::warn!(error = %e, "TOTP verification error (system time issue?)");
Ok(false)
}
}
}
pub fn verify_at(
&self,
secret: &str,
code: &str,
account_name: &str,
time: u64,
) -> Result<bool> {
let totp = self.build_totp(secret, account_name)?;
let code = code.replace([' ', '-'], "");
Ok(totp.check(&code, time))
}
#[cfg(any(test, feature = "test-auth-bypass"))]
pub fn generate_current(&self, secret: &str, account_name: &str) -> Result<String> {
let totp = self.build_totp(secret, account_name)?;
totp.generate_current()
.map_err(|e| TidewayError::Internal(format!("Failed to generate TOTP: {}", e)))
}
fn build_totp(&self, secret: &str, account_name: &str) -> Result<TOTP> {
TOTP::new(
self.config.algorithm,
self.config.digits,
1, self.config.step,
Secret::Encoded(secret.to_string())
.to_bytes()
.map_err(|e| TidewayError::Internal(format!("Invalid TOTP secret: {}", e)))?,
Some(self.config.issuer.clone()),
account_name.to_string(),
)
.map_err(|e| TidewayError::Internal(format!("Failed to create TOTP: {}", e)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_and_verify() {
let manager = TotpManager::new(TotpConfig::new("TestApp"));
let setup = manager.generate_setup("user@example.com").unwrap();
let code = manager
.generate_current(&setup.secret, "user@example.com")
.unwrap();
assert!(
manager
.verify(&setup.secret, &code, "user@example.com")
.unwrap()
);
}
#[test]
fn test_code_with_spaces() {
let manager = TotpManager::new(TotpConfig::new("TestApp"));
let setup = manager.generate_setup("user@example.com").unwrap();
let code = manager
.generate_current(&setup.secret, "user@example.com")
.unwrap();
let code_with_spaces = format!("{} {}", &code[..3], &code[3..]);
assert!(
manager
.verify(&setup.secret, &code_with_spaces, "user@example.com")
.unwrap()
);
}
#[test]
fn test_invalid_code() {
let manager = TotpManager::new(TotpConfig::new("TestApp"));
let setup = manager.generate_setup("user@example.com").unwrap();
assert!(
!manager
.verify(&setup.secret, "000000", "user@example.com")
.unwrap()
);
}
#[test]
fn test_setup_contains_qr_code() {
let manager = TotpManager::new(TotpConfig::new("TestApp"));
let setup = manager.generate_setup("user@example.com").unwrap();
assert!(!setup.secret.is_empty());
assert!(setup.uri.starts_with("otpauth://totp/"));
assert!(!setup.qr_code_base64.is_empty());
}
}