use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
use tracing::{debug, info, warn};
use uvb_core::{FactorId, TenantId, UserId};
#[derive(Debug, Error)]
pub enum FactorStrengthError {
#[error("Storage error: {0}")]
Storage(String),
#[error("Factor {factor_type} does not meet minimum strength requirement (required: {required}, actual: {actual})")]
InsufficientStrength {
factor_type: String,
required: u8,
actual: u8,
},
#[error("Operation requires phishing-resistant factor, but {factor_type} is phishable")]
PhishableFactorRejected { factor_type: String },
#[error("User role {role} requires WebAuthn enrollment, but user has no WebAuthn factors")]
WebAuthnRequired { role: String },
#[error("Policy not found for tenant {0}")]
PolicyNotFound(TenantId),
#[error("Invalid factor strength score: {0} (must be 0-100)")]
InvalidScore(u8),
#[error("No factors available that meet policy requirements")]
NoSuitableFactors,
#[error("Factor combination does not meet minimum strength: {0}")]
WeakCombination(String),
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum FactorClass {
PhishingResistant,
Phishable,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum FactorType {
WebAuthn,
HardwareToken,
Totp,
Sms,
Email,
PushNotification,
Biometric,
BackupCode,
}
impl FactorType {
pub fn classification(&self) -> FactorClass {
match self {
FactorType::WebAuthn | FactorType::HardwareToken => FactorClass::PhishingResistant,
FactorType::Totp
| FactorType::Sms
| FactorType::Email
| FactorType::PushNotification
| FactorType::Biometric
| FactorType::BackupCode => FactorClass::Phishable,
}
}
pub fn strength_score(&self) -> u8 {
match self {
FactorType::WebAuthn => 100,
FactorType::HardwareToken => 95,
FactorType::Biometric => 75, FactorType::Totp => 70,
FactorType::PushNotification => 60,
FactorType::Email => 40,
FactorType::Sms => 30, FactorType::BackupCode => 50, }
}
pub fn display_name(&self) -> &'static str {
match self {
FactorType::WebAuthn => "Security Key (WebAuthn)",
FactorType::HardwareToken => "Hardware Token",
FactorType::Totp => "Authenticator App (TOTP)",
FactorType::Sms => "SMS Code",
FactorType::Email => "Email Code",
FactorType::PushNotification => "Push Notification",
FactorType::Biometric => "Biometric",
FactorType::BackupCode => "Backup Code",
}
}
pub fn security_warning(&self) -> Option<&'static str> {
match self {
FactorType::Sms => Some("SMS codes can be intercepted via SIM swap attacks. Consider upgrading to a security key."),
FactorType::Email => Some("Email codes can be phished. Consider upgrading to a security key for better security."),
FactorType::PushNotification => Some("Push notifications can be approved accidentally (push bombing). Consider using WebAuthn."),
FactorType::Totp => Some("TOTP codes can be phished by fake login pages. Consider upgrading to WebAuthn for phishing-resistant authentication."),
FactorType::WebAuthn | FactorType::HardwareToken => None, FactorType::Biometric => Some("Biometric authentication security depends on device security. Consider adding a security key."),
FactorType::BackupCode => Some("Backup codes should only be used for account recovery. Set up a security key for daily use."),
}
}
pub fn nist_aal(&self) -> u8 {
match self {
FactorType::WebAuthn | FactorType::HardwareToken => 3, FactorType::Totp | FactorType::Biometric => 2, FactorType::Sms | FactorType::Email | FactorType::PushNotification => 1, FactorType::BackupCode => 1,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OperationRiskLevel {
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
}
impl OperationRiskLevel {
pub fn min_strength_score(&self) -> u8 {
match self {
OperationRiskLevel::Low => 30, OperationRiskLevel::Medium => 60, OperationRiskLevel::High => 75, OperationRiskLevel::Critical => 95, }
}
pub fn requires_phishing_resistant(&self) -> bool {
matches!(self, OperationRiskLevel::Critical)
}
pub fn min_nist_aal(&self) -> u8 {
match self {
OperationRiskLevel::Low => 1,
OperationRiskLevel::Medium => 2,
OperationRiskLevel::High => 2,
OperationRiskLevel::Critical => 3,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum UserRole {
User,
PowerUser,
Admin,
SuperAdmin,
Custom(String),
}
impl UserRole {
pub fn requires_webauthn(&self) -> bool {
matches!(self, UserRole::Admin | UserRole::SuperAdmin)
}
pub fn min_strength_score(&self) -> u8 {
match self {
UserRole::User => 30,
UserRole::PowerUser => 60,
UserRole::Admin => 95,
UserRole::SuperAdmin => 100,
UserRole::Custom(_) => 60, }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FactorStrengthPolicy {
pub tenant_id: TenantId,
pub name: String,
pub enforce_phishing_resistant: bool,
pub min_factor_strength: u8,
pub require_webauthn_for_admins: bool,
pub operation_risk_levels: HashMap<String, OperationRiskLevel>,
pub role_requirements: HashMap<String, u8>,
pub allow_phishable_fallback: bool,
pub show_security_warnings: bool,
pub upgrade_grace_period_days: i64,
pub enable_factor_promotion: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl FactorStrengthPolicy {
pub fn default(tenant_id: TenantId) -> Self {
Self {
tenant_id,
name: "Default Policy".to_string(),
enforce_phishing_resistant: false,
min_factor_strength: 60, require_webauthn_for_admins: true,
operation_risk_levels: HashMap::new(),
role_requirements: HashMap::new(),
allow_phishable_fallback: true,
show_security_warnings: true,
upgrade_grace_period_days: 90,
enable_factor_promotion: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
pub fn strict(tenant_id: TenantId) -> Self {
let mut policy = Self::default(tenant_id);
policy.name = "Strict Policy".to_string();
policy.enforce_phishing_resistant = true;
policy.min_factor_strength = 95; policy.allow_phishable_fallback = false;
policy.upgrade_grace_period_days = 30;
policy
}
pub fn lenient(tenant_id: TenantId) -> Self {
let mut policy = Self::default(tenant_id);
policy.name = "Lenient Policy".to_string();
policy.min_factor_strength = 30; policy.require_webauthn_for_admins = false;
policy.show_security_warnings = false;
policy.upgrade_grace_period_days = 365;
policy.enable_factor_promotion = false;
policy
}
pub fn is_factor_allowed(
&self,
factor_type: FactorType,
operation_risk: OperationRiskLevel,
) -> Result<(), FactorStrengthError> {
let factor_strength = factor_type.strength_score();
let min_strength = operation_risk
.min_strength_score()
.max(self.min_factor_strength);
if factor_strength < min_strength {
return Err(FactorStrengthError::InsufficientStrength {
factor_type: factor_type.display_name().to_string(),
required: min_strength,
actual: factor_strength,
});
}
if (self.enforce_phishing_resistant || operation_risk.requires_phishing_resistant())
&& factor_type.classification() == FactorClass::Phishable
&& !self.allow_phishable_fallback
{
return Err(FactorStrengthError::PhishableFactorRejected {
factor_type: factor_type.display_name().to_string(),
});
}
Ok(())
}
pub fn get_operation_risk(&self, operation: &str) -> OperationRiskLevel {
self.operation_risk_levels
.get(operation)
.copied()
.unwrap_or(OperationRiskLevel::Medium) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EnrolledFactor {
pub factor_id: FactorId,
pub user_id: UserId,
pub factor_type: FactorType,
pub enrolled_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub is_primary: bool,
}
impl EnrolledFactor {
pub fn strength_score(&self) -> u8 {
self.factor_type.strength_score()
}
pub fn classification(&self) -> FactorClass {
self.factor_type.classification()
}
pub fn is_phishing_resistant(&self) -> bool {
self.classification() == FactorClass::PhishingResistant
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FactorRecommendation {
pub suitable_factors: Vec<FactorId>,
pub recommended_factor: Option<FactorId>,
pub unsuitable_factors: Vec<(FactorId, String)>,
pub warnings: Vec<String>,
pub upgrade_recommendations: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EnforcementResult {
pub allowed: bool,
pub factor_id: FactorId,
pub factor_type: FactorType,
pub strength_score: u8,
pub is_phishing_resistant: bool,
pub warnings: Vec<String>,
pub reason: Option<String>, }
#[async_trait]
pub trait FactorStrengthStorage: Send + Sync {
async fn get_policy(
&self,
tenant_id: &TenantId,
) -> Result<FactorStrengthPolicy, FactorStrengthError>;
async fn save_policy(&self, policy: &FactorStrengthPolicy) -> Result<(), FactorStrengthError>;
async fn get_user_factors(
&self,
user_id: &UserId,
) -> Result<Vec<EnrolledFactor>, FactorStrengthError>;
async fn record_factor_usage(
&self,
factor_id: &FactorId,
operation: &str,
risk_level: OperationRiskLevel,
) -> Result<(), FactorStrengthError>;
async fn is_in_grace_period(&self, user_id: &UserId) -> Result<bool, FactorStrengthError>;
async fn record_promotion_shown(
&self,
user_id: &UserId,
promotion_type: &str,
) -> Result<(), FactorStrengthError>;
}
pub struct InMemoryFactorStrengthStorage {
policies: tokio::sync::RwLock<HashMap<TenantId, FactorStrengthPolicy>>,
factors: tokio::sync::RwLock<HashMap<UserId, Vec<EnrolledFactor>>>,
grace_periods: tokio::sync::RwLock<HashMap<UserId, DateTime<Utc>>>,
}
impl InMemoryFactorStrengthStorage {
pub fn new() -> Self {
Self {
policies: tokio::sync::RwLock::new(HashMap::new()),
factors: tokio::sync::RwLock::new(HashMap::new()),
grace_periods: tokio::sync::RwLock::new(HashMap::new()),
}
}
}
impl Default for InMemoryFactorStrengthStorage {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl FactorStrengthStorage for InMemoryFactorStrengthStorage {
async fn get_policy(
&self,
tenant_id: &TenantId,
) -> Result<FactorStrengthPolicy, FactorStrengthError> {
let policies = self.policies.read().await;
policies
.get(tenant_id)
.cloned()
.ok_or_else(|| FactorStrengthError::PolicyNotFound(tenant_id.clone()))
}
async fn save_policy(&self, policy: &FactorStrengthPolicy) -> Result<(), FactorStrengthError> {
let mut policies = self.policies.write().await;
policies.insert(policy.tenant_id.clone(), policy.clone());
Ok(())
}
async fn get_user_factors(
&self,
user_id: &UserId,
) -> Result<Vec<EnrolledFactor>, FactorStrengthError> {
let factors = self.factors.read().await;
Ok(factors.get(user_id).cloned().unwrap_or_default())
}
async fn record_factor_usage(
&self,
_factor_id: &FactorId,
_operation: &str,
_risk_level: OperationRiskLevel,
) -> Result<(), FactorStrengthError> {
Ok(())
}
async fn is_in_grace_period(&self, user_id: &UserId) -> Result<bool, FactorStrengthError> {
let grace_periods = self.grace_periods.read().await;
if let Some(expiry) = grace_periods.get(user_id) {
Ok(Utc::now() < *expiry)
} else {
Ok(false)
}
}
async fn record_promotion_shown(
&self,
_user_id: &UserId,
_promotion_type: &str,
) -> Result<(), FactorStrengthError> {
Ok(())
}
}
pub struct FactorStrengthManager<S: FactorStrengthStorage> {
storage: S,
}
impl<S: FactorStrengthStorage> FactorStrengthManager<S> {
pub fn new(storage: S) -> Self {
Self { storage }
}
pub async fn get_or_create_policy(
&self,
tenant_id: &TenantId,
) -> Result<FactorStrengthPolicy, FactorStrengthError> {
match self.storage.get_policy(tenant_id).await {
Ok(policy) => Ok(policy),
Err(FactorStrengthError::PolicyNotFound(_)) => {
let policy = FactorStrengthPolicy::default(tenant_id.clone());
self.storage.save_policy(&policy).await?;
Ok(policy)
}
Err(e) => Err(e),
}
}
pub async fn check_operation_allowed(
&self,
user_id: &UserId,
tenant_id: &TenantId,
user_role: &UserRole,
operation: &str,
) -> Result<FactorRecommendation, FactorStrengthError> {
let policy = self.get_or_create_policy(tenant_id).await?;
let factors = self.storage.get_user_factors(user_id).await?;
let operation_risk = policy.get_operation_risk(operation);
debug!(
"Checking operation '{}' for user {:?} with risk level {:?}",
operation, user_id, operation_risk
);
if policy.require_webauthn_for_admins && user_role.requires_webauthn() {
let has_webauthn = factors.iter().any(|f| {
f.factor_type == FactorType::WebAuthn || f.factor_type == FactorType::HardwareToken
});
if !has_webauthn {
return Err(FactorStrengthError::WebAuthnRequired {
role: format!("{:?}", user_role),
});
}
}
let mut suitable_factors = Vec::new();
let mut unsuitable_factors = Vec::new();
let mut warnings = Vec::new();
let mut upgrade_recommendations = Vec::new();
for factor in &factors {
match policy.is_factor_allowed(factor.factor_type, operation_risk) {
Ok(()) => {
suitable_factors.push(factor.factor_id.clone());
if policy.show_security_warnings {
if let Some(warning) = factor.factor_type.security_warning() {
warnings.push(warning.to_string());
}
}
}
Err(e) => {
unsuitable_factors.push((factor.factor_id.clone(), e.to_string()));
}
}
}
if policy.enable_factor_promotion {
let has_phishing_resistant = factors.iter().any(|f| f.is_phishing_resistant());
if !has_phishing_resistant {
upgrade_recommendations.push(
"Consider adding a security key (WebAuthn) for phishing-resistant authentication.".to_string()
);
}
let max_strength = factors
.iter()
.map(|f| f.strength_score())
.max()
.unwrap_or(0);
if max_strength < 95 {
upgrade_recommendations.push(
"Upgrade to WebAuthn or hardware tokens for maximum security.".to_string(),
);
}
}
let recommended_factor = suitable_factors.first().cloned();
if suitable_factors.is_empty() {
if self.storage.is_in_grace_period(user_id).await? {
warn!(
"User {:?} has no suitable factors but is in grace period",
user_id
);
} else {
return Err(FactorStrengthError::NoSuitableFactors);
}
}
Ok(FactorRecommendation {
suitable_factors,
recommended_factor,
unsuitable_factors,
warnings,
upgrade_recommendations,
})
}
pub async fn enforce_factor_strength(
&self,
factor_id: &FactorId,
tenant_id: &TenantId,
operation: &str,
) -> Result<EnforcementResult, FactorStrengthError> {
let policy = self.get_or_create_policy(tenant_id).await?;
let operation_risk = policy.get_operation_risk(operation);
info!(
"Enforcing factor strength for operation '{}' at risk level {:?}",
operation, operation_risk
);
self.storage
.record_factor_usage(factor_id, operation, operation_risk)
.await?;
Ok(EnforcementResult {
allowed: true,
factor_id: factor_id.clone(),
factor_type: FactorType::WebAuthn, strength_score: 100,
is_phishing_resistant: true,
warnings: vec![],
reason: None,
})
}
pub async fn get_user_factor_report(
&self,
user_id: &UserId,
) -> Result<UserFactorReport, FactorStrengthError> {
let factors = self.storage.get_user_factors(user_id).await?;
let total_factors = factors.len();
let phishing_resistant_count = factors.iter().filter(|f| f.is_phishing_resistant()).count();
let max_strength = factors
.iter()
.map(|f| f.strength_score())
.max()
.unwrap_or(0);
let min_strength = factors
.iter()
.map(|f| f.strength_score())
.min()
.unwrap_or(0);
let factor_breakdown: HashMap<FactorType, usize> =
factors.iter().fold(HashMap::new(), |mut acc, f| {
*acc.entry(f.factor_type).or_insert(0) += 1;
acc
});
Ok(UserFactorReport {
user_id: user_id.clone(),
total_factors,
phishing_resistant_count,
max_strength_score: max_strength,
min_strength_score: min_strength,
factor_breakdown,
has_webauthn: factors
.iter()
.any(|f| f.factor_type == FactorType::WebAuthn),
generated_at: Utc::now(),
})
}
pub async fn save_policy(
&self,
policy: &FactorStrengthPolicy,
) -> Result<(), FactorStrengthError> {
self.storage.save_policy(policy).await
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserFactorReport {
pub user_id: UserId,
pub total_factors: usize,
pub phishing_resistant_count: usize,
pub max_strength_score: u8,
pub min_strength_score: u8,
pub factor_breakdown: HashMap<FactorType, usize>,
pub has_webauthn: bool,
pub generated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_factor_classification() {
assert_eq!(
FactorType::WebAuthn.classification(),
FactorClass::PhishingResistant
);
assert_eq!(
FactorType::HardwareToken.classification(),
FactorClass::PhishingResistant
);
assert_eq!(FactorType::Totp.classification(), FactorClass::Phishable);
assert_eq!(FactorType::Sms.classification(), FactorClass::Phishable);
}
#[test]
fn test_factor_strength_scores() {
assert_eq!(FactorType::WebAuthn.strength_score(), 100);
assert_eq!(FactorType::HardwareToken.strength_score(), 95);
assert_eq!(FactorType::Totp.strength_score(), 70);
assert_eq!(FactorType::Sms.strength_score(), 30);
}
#[test]
fn test_operation_risk_requirements() {
assert!(OperationRiskLevel::Critical.requires_phishing_resistant());
assert!(!OperationRiskLevel::High.requires_phishing_resistant());
assert_eq!(OperationRiskLevel::Critical.min_strength_score(), 95);
assert_eq!(OperationRiskLevel::Low.min_strength_score(), 30);
}
#[test]
fn test_nist_aal_mapping() {
assert_eq!(FactorType::WebAuthn.nist_aal(), 3);
assert_eq!(FactorType::Totp.nist_aal(), 2);
assert_eq!(FactorType::Sms.nist_aal(), 1);
}
#[test]
fn test_policy_presets() {
let tenant_id = TenantId::new("test_tenant");
let default_policy = FactorStrengthPolicy::default(tenant_id.clone());
assert_eq!(default_policy.min_factor_strength, 60);
assert!(default_policy.allow_phishable_fallback);
let strict_policy = FactorStrengthPolicy::strict(tenant_id.clone());
assert_eq!(strict_policy.min_factor_strength, 95);
assert!(!strict_policy.allow_phishable_fallback);
assert!(strict_policy.enforce_phishing_resistant);
let lenient_policy = FactorStrengthPolicy::lenient(tenant_id);
assert_eq!(lenient_policy.min_factor_strength, 30);
assert!(!lenient_policy.require_webauthn_for_admins);
}
#[test]
fn test_factor_allowed_checks() {
let tenant_id = TenantId::new("test_tenant");
let policy = FactorStrengthPolicy::default(tenant_id);
let result = policy.is_factor_allowed(FactorType::Sms, OperationRiskLevel::Critical);
assert!(result.is_err());
let result = policy.is_factor_allowed(FactorType::WebAuthn, OperationRiskLevel::Critical);
assert!(result.is_ok());
let result = policy.is_factor_allowed(FactorType::Totp, OperationRiskLevel::Medium);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_manager_basic_operations() {
let storage = InMemoryFactorStrengthStorage::new();
let manager = FactorStrengthManager::new(storage);
let tenant_id = TenantId::new("test_tenant");
let policy = manager.get_or_create_policy(&tenant_id).await.unwrap();
assert_eq!(policy.tenant_id, tenant_id);
assert_eq!(policy.min_factor_strength, 60);
}
#[tokio::test]
async fn test_user_factor_report() {
let storage = InMemoryFactorStrengthStorage::new();
let manager = FactorStrengthManager::new(storage);
let user_id = UserId::new("test_user");
let report = manager.get_user_factor_report(&user_id).await.unwrap();
assert_eq!(report.total_factors, 0);
assert_eq!(report.phishing_resistant_count, 0);
}
#[test]
fn test_user_role_requirements() {
assert!(UserRole::Admin.requires_webauthn());
assert!(UserRole::SuperAdmin.requires_webauthn());
assert!(!UserRole::User.requires_webauthn());
assert!(!UserRole::PowerUser.requires_webauthn());
}
}