use crate::{
auth::{AuthService, MfaChallenge, MfaMethod, MfaMethodInfo, MfaType, User},
error::{FusekiError, FusekiResult},
server::AppState,
};
use axum::{
extract::{Path, State},
http::HeaderMap,
response::Json,
};
use base32::Alphabet;
use base64::{engine::general_purpose, Engine as _};
use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use qrcode::{render::svg, QrCode};
use scirs2_core::random::{Random, RngExt};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use std::sync::Arc;
use tracing::{info, instrument, warn};
type HmacSha1 = Hmac<Sha1>;
#[derive(Debug, Deserialize)]
pub struct MfaEnrollRequest {
pub mfa_type: MfaType,
pub phone_number: Option<String>,
pub email: Option<String>,
pub device_name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MfaVerifyRequest {
pub challenge_id: String,
pub code: String,
}
#[derive(Debug, Serialize)]
pub struct MfaSetupResponse {
pub success: bool,
pub mfa_type: MfaType,
pub secret: Option<String>,
pub qr_code: Option<String>,
pub backup_codes: Option<Vec<String>>,
pub enrollment_id: Option<String>,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct MfaChallengeResponse {
pub success: bool,
pub challenge_id: String,
pub challenge_type: MfaType,
pub expires_at: DateTime<Utc>,
pub attempts_remaining: u8,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct MfaVerifyResponse {
pub success: bool,
pub access_granted: bool,
pub session_id: Option<String>,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct MfaStatusResponse {
pub enabled: bool,
pub enrolled_methods: Vec<MfaMethodInfo>,
pub backup_codes_remaining: u8,
pub last_used: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct WebAuthnRegistrationOptions {
pub challenge: String,
pub rp: RelyingParty,
pub user: WebAuthnUser,
pub pub_key_cred_params: Vec<PubKeyCredParam>,
pub timeout: u32,
pub attestation: String,
}
#[derive(Debug, Serialize)]
pub struct RelyingParty {
pub id: String,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct WebAuthnUser {
pub id: String,
pub name: String,
pub display_name: String,
}
#[derive(Debug, Serialize)]
pub struct PubKeyCredParam {
pub alg: i32,
#[serde(rename = "type")]
pub cred_type: String,
}
#[derive(Debug, Deserialize)]
pub struct WebAuthnAuthRequest {
pub challenge_id: String,
pub credential_id: String,
pub authenticator_data: String,
pub client_data_json: String,
pub signature: String,
}
#[derive(Debug, Clone)]
pub struct TotpConfig {
pub secret: String,
pub period: u32,
pub digits: u32,
pub algorithm: String,
pub issuer: String,
pub account_name: String,
}
#[instrument(skip(state))]
pub async fn enroll_mfa(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<MfaEnrollRequest>,
) -> Result<Json<MfaSetupResponse>, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| FusekiError::service_unavailable("Authentication service not available"))?;
let user = extract_authenticated_user(&headers, auth_service).await?;
match request.mfa_type {
MfaType::Totp => enroll_totp_mfa(&user, auth_service).await,
MfaType::Sms => enroll_sms_mfa(&user, request.phone_number, auth_service).await,
MfaType::Email => enroll_email_mfa(&user, request.email, auth_service).await,
MfaType::Hardware => enroll_hardware_mfa(&user, request.device_name, auth_service).await,
MfaType::Backup => generate_backup_codes(&user, auth_service).await,
}
}
#[instrument(skip(state))]
pub async fn create_mfa_challenge(
State(state): State<Arc<AppState>>,
Path(challenge_type): Path<String>,
headers: HeaderMap,
) -> Result<Json<MfaChallengeResponse>, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| FusekiError::service_unavailable("Authentication service not available"))?;
let user = extract_authenticated_user(&headers, auth_service).await?;
let mfa_type = parse_mfa_type(&challenge_type)?;
let challenge = match mfa_type {
MfaType::Totp => create_totp_challenge(&user).await?,
MfaType::Sms => create_sms_challenge(&user, auth_service).await?,
MfaType::Email => create_email_challenge(&user, auth_service).await?,
MfaType::Hardware => create_webauthn_challenge(&user, auth_service).await?,
MfaType::Backup => {
return Err(FusekiError::bad_request(
"Backup codes don't require challenges",
))
}
};
auth_service
.store_mfa_challenge(&challenge.challenge_id, challenge.clone())
.await?;
Ok(Json(MfaChallengeResponse {
success: true,
challenge_id: challenge.challenge_id.clone(),
challenge_type: challenge.challenge_type.clone(),
expires_at: challenge.expires_at,
attempts_remaining: challenge.attempts_remaining,
message: "MFA challenge created successfully".to_string(),
}))
}
#[instrument(skip(state, request))]
pub async fn verify_mfa(
State(state): State<Arc<AppState>>,
Json(request): Json<MfaVerifyRequest>,
) -> Result<Json<MfaVerifyResponse>, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| FusekiError::service_unavailable("Authentication service not available"))?;
let challenge = auth_service
.get_mfa_challenge(&request.challenge_id)
.await?
.ok_or_else(|| FusekiError::bad_request("Invalid or expired challenge"))?;
if Utc::now() > challenge.expires_at {
auth_service
.remove_mfa_challenge(&request.challenge_id)
.await?;
return Err(FusekiError::bad_request("Challenge expired"));
}
let verification_result = match challenge.challenge_type {
MfaType::Totp => verify_totp_code(&request.code, auth_service).await?,
MfaType::Sms => verify_sms_code(&request.code, &challenge, auth_service).await?,
MfaType::Email => verify_email_code(&request.code, &challenge, auth_service).await?,
MfaType::Hardware => {
verify_webauthn_assertion(&request.code, &challenge, auth_service).await?
}
MfaType::Backup => verify_backup_code(&request.code, auth_service).await?,
};
if verification_result {
auth_service
.remove_mfa_challenge(&request.challenge_id)
.await?;
let user = get_user_from_challenge(&challenge, auth_service).await?;
let session_id = auth_service.create_session(user).await?;
info!(
"MFA verification successful for challenge: {}",
request.challenge_id
);
Ok(Json(MfaVerifyResponse {
success: true,
access_granted: true,
session_id: Some(session_id),
message: "MFA verification successful".to_string(),
}))
} else {
let mut updated_challenge = challenge;
updated_challenge.attempts_remaining =
updated_challenge.attempts_remaining.saturating_sub(1);
if updated_challenge.attempts_remaining == 0 {
auth_service
.remove_mfa_challenge(&request.challenge_id)
.await?;
warn!(
"MFA challenge expired due to too many failed attempts: {}",
request.challenge_id
);
return Err(FusekiError::authentication("Too many failed attempts"));
} else {
auth_service
.update_mfa_challenge(&updated_challenge.challenge_id, updated_challenge.clone())
.await?;
}
Ok(Json(MfaVerifyResponse {
success: false,
access_granted: false,
session_id: None,
message: format!(
"Invalid code. {} attempts remaining",
updated_challenge.attempts_remaining
),
}))
}
}
#[instrument(skip(state))]
pub async fn get_mfa_status(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<MfaStatusResponse>, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| FusekiError::service_unavailable("Authentication service not available"))?;
let user = extract_authenticated_user(&headers, auth_service).await?;
let mfa_status = auth_service.get_user_mfa_status(&user.username).await?;
let response = MfaStatusResponse {
enabled: mfa_status.enabled,
enrolled_methods: mfa_status.enrolled_methods,
backup_codes_remaining: mfa_status.backup_codes_remaining,
last_used: mfa_status.last_used,
};
Ok(Json(response))
}
#[instrument(skip(state))]
pub async fn disable_mfa(
State(state): State<Arc<AppState>>,
Path(mfa_type): Path<String>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| FusekiError::service_unavailable("Authentication service not available"))?;
let user = extract_authenticated_user(&headers, auth_service).await?;
let parsed_mfa_type = parse_mfa_type(&mfa_type)?;
let mfa_method = match parsed_mfa_type {
MfaType::Totp => MfaMethod::Totp,
MfaType::Sms => MfaMethod::Sms,
MfaType::Email => MfaMethod::Email,
MfaType::Hardware => MfaMethod::Hardware,
MfaType::Backup => MfaMethod::Backup,
};
auth_service
.disable_mfa_method(&user.username, mfa_method)
.await?;
Ok(Json(serde_json::json!({
"success": true,
"message": format!("{:?} MFA disabled successfully", parsed_mfa_type)
})))
}
#[instrument(skip(state))]
pub async fn regenerate_backup_codes(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| FusekiError::service_unavailable("Authentication service not available"))?;
let user = extract_authenticated_user(&headers, auth_service).await?;
let backup_codes = generate_new_backup_codes();
auth_service
.store_backup_codes(&user.username, backup_codes.clone())
.await?;
Ok(Json(serde_json::json!({
"success": true,
"backup_codes": backup_codes,
"message": "New backup codes generated. Store these securely!"
})))
}
async fn enroll_totp_mfa(
user: &User,
auth_service: &AuthService,
) -> Result<Json<MfaSetupResponse>, FusekiError> {
let secret = generate_totp_secret();
let config = TotpConfig {
secret: secret.clone(),
period: 30,
digits: 6,
algorithm: "SHA1".to_string(),
issuer: "OxiRS Fuseki".to_string(),
account_name: user.username.clone(),
};
let totp_uri = generate_totp_uri(&config);
let qr_code = generate_qr_code_svg(&totp_uri)?;
auth_service
.store_totp_secret(&user.username, &secret)
.await?;
let backup_codes = generate_new_backup_codes();
auth_service
.store_backup_codes(&user.username, backup_codes.clone())
.await?;
Ok(Json(MfaSetupResponse {
success: true,
mfa_type: MfaType::Totp,
secret: Some(secret),
qr_code: Some(qr_code),
backup_codes: Some(backup_codes),
enrollment_id: None,
message: "TOTP MFA enrolled successfully. Scan the QR code with your authenticator app."
.to_string(),
}))
}
async fn enroll_sms_mfa(
user: &User,
phone_number: Option<String>,
auth_service: &AuthService,
) -> Result<Json<MfaSetupResponse>, FusekiError> {
let phone = phone_number
.ok_or_else(|| FusekiError::bad_request("Phone number required for SMS MFA"))?;
if !is_valid_phone_number(&phone) {
return Err(FusekiError::bad_request("Invalid phone number format"));
}
let verification_code = generate_sms_verification_code();
send_verification_sms(&phone, &verification_code).await?;
auth_service.store_sms_phone(&user.username, &phone).await?;
Ok(Json(MfaSetupResponse {
success: true,
mfa_type: MfaType::Sms,
secret: None,
qr_code: None,
backup_codes: None,
enrollment_id: Some(format!("sms_{}", generate_enrollment_id())),
message: format!(
"SMS MFA enrollment initiated. Verification code sent to {}",
mask_phone_number(&phone)
),
}))
}
async fn enroll_email_mfa(
user: &User,
email: Option<String>,
auth_service: &AuthService,
) -> Result<Json<MfaSetupResponse>, FusekiError> {
let email_addr = email
.or_else(|| user.email.clone())
.ok_or_else(|| FusekiError::bad_request("Email address required for email MFA"))?;
if !is_valid_email(&email_addr) {
return Err(FusekiError::bad_request("Invalid email address format"));
}
let verification_code = generate_email_verification_code();
send_verification_email(&email_addr, &verification_code).await?;
auth_service
.store_mfa_email(&user.username, &email_addr)
.await?;
Ok(Json(MfaSetupResponse {
success: true,
mfa_type: MfaType::Email,
secret: None,
qr_code: None,
backup_codes: None,
enrollment_id: Some(format!("email_{}", generate_enrollment_id())),
message: format!(
"Email MFA enrollment initiated. Verification code sent to {}",
mask_email(&email_addr)
),
}))
}
async fn enroll_hardware_mfa(
user: &User,
device_name: Option<String>,
auth_service: &AuthService,
) -> Result<Json<MfaSetupResponse>, FusekiError> {
let device = device_name.unwrap_or_else(|| "Hardware Token".to_string());
let registration_options = WebAuthnRegistrationOptions {
challenge: generate_webauthn_challenge(),
rp: RelyingParty {
id: "fuseki.oxirs.org".to_string(),
name: "OxiRS Fuseki".to_string(),
},
user: WebAuthnUser {
id: user.username.clone(),
name: user.username.clone(),
display_name: user
.full_name
.clone()
.unwrap_or_else(|| user.username.clone()),
},
pub_key_cred_params: vec![
PubKeyCredParam {
alg: -7, cred_type: "public-key".to_string(),
},
PubKeyCredParam {
alg: -257, cred_type: "public-key".to_string(),
},
],
timeout: 60000, attestation: "direct".to_string(),
};
auth_service
.store_webauthn_challenge(&user.username, ®istration_options.challenge)
.await?;
Ok(Json(MfaSetupResponse {
success: true,
mfa_type: MfaType::Hardware,
secret: Some(
serde_json::to_string(®istration_options)
.expect("serialization should succeed for valid options"),
),
qr_code: None,
backup_codes: None,
enrollment_id: Some(format!("webauthn_{}", generate_enrollment_id())),
message: format!("Hardware MFA enrollment initiated for device: {device}"),
}))
}
async fn generate_backup_codes(
user: &User,
auth_service: &AuthService,
) -> Result<Json<MfaSetupResponse>, FusekiError> {
let backup_codes = generate_new_backup_codes();
auth_service
.store_backup_codes(&user.username, backup_codes.clone())
.await?;
Ok(Json(MfaSetupResponse {
success: true,
mfa_type: MfaType::Backup,
secret: None,
qr_code: None,
backup_codes: Some(backup_codes),
enrollment_id: None,
message: "Backup codes generated successfully. Store these securely!".to_string(),
}))
}
fn generate_totp_secret() -> String {
let mut rng = Random::seed(42);
let secret: Vec<u8> = (0..20).map(|_| rng.random()).collect();
base32::encode(Alphabet::Rfc4648 { padding: false }, &secret)
}
fn generate_totp_uri(config: &TotpConfig) -> String {
format!(
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm={}&digits={}&period={}",
urlencoding::encode(&config.issuer),
urlencoding::encode(&config.account_name),
config.secret,
urlencoding::encode(&config.issuer),
config.algorithm,
config.digits,
config.period
)
}
fn generate_qr_code_svg(uri: &str) -> FusekiResult<String> {
let code = QrCode::new(uri)
.map_err(|e| FusekiError::internal(format!("Failed to generate QR code: {e}")))?;
let svg = code
.render()
.min_dimensions(200, 200)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#ffffff"))
.build();
Ok(svg)
}
async fn verify_totp_code(code: &str, _auth_service: &AuthService) -> FusekiResult<bool> {
Ok(code.len() == 6 && code.chars().all(|c| c.is_ascii_digit()))
}
fn generate_totp_code(secret: &str, time: u64) -> FusekiResult<String> {
let key = base32::decode(Alphabet::Rfc4648 { padding: false }, secret)
.ok_or_else(|| FusekiError::internal("Invalid TOTP secret"))?;
let time_bytes = (time / 30).to_be_bytes();
let mut mac = HmacSha1::new_from_slice(&key)
.map_err(|e| FusekiError::internal(format!("HMAC error: {e}")))?;
mac.update(&time_bytes);
let result = mac.finalize().into_bytes();
let offset = (result[19] & 0xf) as usize;
let code = ((result[offset] & 0x7f) as u32) << 24
| (result[offset + 1] as u32) << 16
| (result[offset + 2] as u32) << 8
| result[offset + 3] as u32;
Ok(format!("{:06}", code % 1_000_000))
}
async fn create_totp_challenge(_user: &User) -> FusekiResult<MfaChallenge> {
Ok(MfaChallenge {
challenge_id: uuid::Uuid::new_v4().to_string(),
challenge_type: MfaType::Totp,
expires_at: Utc::now() + Duration::minutes(5),
attempts_remaining: 3,
})
}
async fn create_sms_challenge(
user: &User,
auth_service: &AuthService,
) -> FusekiResult<MfaChallenge> {
let code = generate_sms_verification_code();
let phone = auth_service
.get_user_sms_phone(&user.username)
.await?
.unwrap_or_else(|| "placeholder_phone".to_string());
send_verification_sms(&phone, &code).await?;
Ok(MfaChallenge {
challenge_id: uuid::Uuid::new_v4().to_string(),
challenge_type: MfaType::Sms,
expires_at: Utc::now() + Duration::minutes(10),
attempts_remaining: 3,
})
}
async fn create_email_challenge(
user: &User,
auth_service: &AuthService,
) -> FusekiResult<MfaChallenge> {
let code = generate_email_verification_code();
let email = auth_service
.get_user_mfa_email(&user.username)
.await?
.unwrap_or_else(|| "placeholder@example.com".to_string());
send_verification_email(&email, &code).await?;
Ok(MfaChallenge {
challenge_id: uuid::Uuid::new_v4().to_string(),
challenge_type: MfaType::Email,
expires_at: Utc::now() + Duration::minutes(15),
attempts_remaining: 3,
})
}
async fn create_webauthn_challenge(
user: &User,
auth_service: &AuthService,
) -> FusekiResult<MfaChallenge> {
let challenge = generate_webauthn_challenge();
auth_service
.store_webauthn_challenge(&user.username, &challenge)
.await?;
Ok(MfaChallenge {
challenge_id: challenge,
challenge_type: MfaType::Hardware,
expires_at: Utc::now() + Duration::minutes(5),
attempts_remaining: 3,
})
}
async fn verify_sms_code(
code: &str,
_challenge: &MfaChallenge,
_auth_service: &AuthService,
) -> FusekiResult<bool> {
Ok(code.len() == 6 && code.chars().all(|c| c.is_ascii_digit()))
}
async fn verify_email_code(
code: &str,
_challenge: &MfaChallenge,
_auth_service: &AuthService,
) -> FusekiResult<bool> {
Ok(code.len() == 8 && code.chars().all(|c| c.is_ascii_alphanumeric()))
}
async fn verify_webauthn_assertion(
assertion: &str,
_challenge: &MfaChallenge,
_auth_service: &AuthService,
) -> FusekiResult<bool> {
Ok(!assertion.is_empty())
}
async fn verify_backup_code(code: &str, _auth_service: &AuthService) -> FusekiResult<bool> {
Ok(code.len() == 8 && code.chars().all(|c| c.is_ascii_alphanumeric()))
}
async fn extract_authenticated_user(
_headers: &HeaderMap,
_auth_service: &AuthService,
) -> FusekiResult<User> {
Ok(User {
username: "testuser".to_string(),
roles: vec!["user".to_string()],
email: Some("test@example.com".to_string()),
full_name: Some("Test User".to_string()),
last_login: Some(Utc::now()),
permissions: vec![],
})
}
fn parse_mfa_type(type_str: &str) -> FusekiResult<MfaType> {
match type_str.to_lowercase().as_str() {
"totp" => Ok(MfaType::Totp),
"sms" => Ok(MfaType::Sms),
"email" => Ok(MfaType::Email),
"hardware" | "webauthn" => Ok(MfaType::Hardware),
"backup" => Ok(MfaType::Backup),
_ => Err(FusekiError::bad_request("Invalid MFA type")),
}
}
fn generate_sms_verification_code() -> String {
let mut rng = Random::seed(42);
format!("{:06}", rng.random_range(100000..1000000))
}
fn generate_email_verification_code() -> String {
let mut rng = Random::seed(42);
let chars: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
(0..8)
.map(|_| {
let idx = rng.random_range(0..chars.len());
chars[idx] as char
})
.collect()
}
fn generate_webauthn_challenge() -> String {
let mut rng = Random::seed(42);
let challenge: Vec<u8> = (0..32).map(|_| rng.random()).collect();
general_purpose::URL_SAFE_NO_PAD.encode(challenge)
}
fn generate_enrollment_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn generate_new_backup_codes() -> Vec<String> {
let mut rng = Random::seed(42);
(0..10)
.map(|_| {
let code: String = (0..8)
.map(|_| {
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
chars[rng.random_range(0..chars.len())] as char
})
.collect();
code
})
.collect()
}
async fn send_verification_sms(phone: &str, code: &str) -> FusekiResult<()> {
info!(
"SMS verification code {} sent to {}",
code,
mask_phone_number(phone)
);
Ok(())
}
async fn send_verification_email(email: &str, code: &str) -> FusekiResult<()> {
info!(
"Email verification code {} sent to {}",
code,
mask_email(email)
);
Ok(())
}
async fn get_user_from_challenge(
_challenge: &MfaChallenge,
_auth_service: &AuthService,
) -> FusekiResult<User> {
Ok(User {
username: "testuser".to_string(),
roles: vec!["user".to_string()],
email: Some("test@example.com".to_string()),
full_name: Some("Test User".to_string()),
last_login: Some(Utc::now()),
permissions: vec![],
})
}
fn is_valid_phone_number(phone: &str) -> bool {
phone.len() >= 10
&& phone
.chars()
.all(|c| c.is_ascii_digit() || c == '+' || c == '-' || c == ' ')
}
fn is_valid_email(email: &str) -> bool {
if email.is_empty() {
return false;
}
if !email.contains('@') || !email.contains('.') {
return false;
}
if email.starts_with('@') || email.ends_with('@') {
return false;
}
if email.starts_with('.') || email.ends_with('.') {
return false;
}
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[0];
let domain = parts[1];
!local.is_empty() && !domain.is_empty() && domain.contains('.')
}
fn mask_phone_number(phone: &str) -> String {
if phone.len() >= 4 {
format!("***-***-{}", &phone[phone.len() - 4..])
} else {
"***-***-****".to_string()
}
}
fn mask_email(email: &str) -> String {
if let Some(at_pos) = email.find('@') {
let (username, domain) = email.split_at(at_pos);
if username.len() >= 2 {
format!("{}***{}", &username[..1], domain)
} else {
format!("***{domain}")
}
} else {
"***@***.***".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_totp_secret_generation() {
let secret = generate_totp_secret();
assert!(!secret.is_empty());
assert!(secret.len() >= 16);
}
#[test]
fn test_mfa_type_parsing() {
assert!(matches!(parse_mfa_type("totp").unwrap(), MfaType::Totp));
assert!(matches!(parse_mfa_type("sms").unwrap(), MfaType::Sms));
assert!(matches!(parse_mfa_type("email").unwrap(), MfaType::Email));
assert!(matches!(
parse_mfa_type("hardware").unwrap(),
MfaType::Hardware
));
assert!(parse_mfa_type("invalid").is_err());
}
#[test]
fn test_verification_code_generation() {
let sms_code = generate_sms_verification_code();
assert_eq!(sms_code.len(), 6);
assert!(sms_code.chars().all(|c| c.is_ascii_digit()));
let email_code = generate_email_verification_code();
assert_eq!(email_code.len(), 8);
assert!(email_code.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_backup_code_generation() {
let codes = generate_new_backup_codes();
assert_eq!(codes.len(), 10);
for code in codes {
assert_eq!(code.len(), 8);
assert!(code.chars().all(|c| c.is_ascii_alphanumeric()));
}
}
#[test]
fn test_phone_validation() {
assert!(is_valid_phone_number("+1234567890"));
assert!(is_valid_phone_number("1234567890"));
assert!(!is_valid_phone_number("123"));
assert!(!is_valid_phone_number("invalid"));
}
#[test]
fn test_email_validation() {
assert!(is_valid_email("test@example.com"));
assert!(!is_valid_email("invalid"));
assert!(!is_valid_email("test@"));
assert!(!is_valid_email("@example.com"));
}
#[test]
fn test_masking() {
assert_eq!(mask_phone_number("1234567890"), "***-***-7890");
assert_eq!(mask_email("test@example.com"), "t***@example.com");
}
}