use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use crate::{
error::{ApiError, ApiResult},
middleware::AuthUser,
models::AuditEventType,
redis::{two_factor_backup_codes_key, two_factor_setup_key, TWO_FACTOR_SETUP_TTL_SECONDS},
two_factor::{
generate_backup_codes, generate_qr_code_data_url, generate_secret, hash_backup_code,
verify_totp_code,
},
AppState,
};
#[derive(Debug, Serialize)]
pub struct Setup2FAResponse {
pub secret: String, pub qr_code_url: String, pub backup_codes: Vec<String>, }
#[derive(Debug, Deserialize)]
pub struct Verify2FASetupRequest {
pub code: String, }
#[derive(Debug, Serialize)]
pub struct Verify2FASetupResponse {
pub success: bool,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct Disable2FARequest {
pub password: String, }
#[derive(Debug, Serialize)]
pub struct Disable2FAResponse {
pub success: bool,
pub message: String,
}
pub async fn setup_2fa(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> ApiResult<Json<Setup2FAResponse>> {
let user = state
.store
.find_user_by_id(user_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("User not found".to_string()))?;
if user.two_factor_enabled {
return Err(ApiError::InvalidRequest(
"2FA is already enabled. Disable it first to set up a new device.".to_string(),
));
}
let secret = generate_secret()
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to generate 2FA secret: {}", e)))?;
let backup_codes = generate_backup_codes(10).map_err(|e| {
ApiError::Internal(anyhow::anyhow!("Failed to generate backup codes: {}", e))
})?;
let issuer = "MockForge";
let account_name = format!("{}:{}", user.email, user.username);
let qr_code_url = generate_qr_code_data_url(&secret, &account_name, issuer)
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to generate QR code: {}", e)))?;
if let Some(ref redis) = state.redis {
let secret_key = two_factor_setup_key(&user_id);
redis
.set_with_expiry(&secret_key, &secret, TWO_FACTOR_SETUP_TTL_SECONDS)
.await
.map_err(|e| {
ApiError::Internal(anyhow::anyhow!("Failed to store 2FA secret: {}", e))
})?;
let backup_codes_key = two_factor_backup_codes_key(&user_id);
let backup_codes_json = serde_json::to_string(&backup_codes).map_err(|e| {
ApiError::Internal(anyhow::anyhow!("Failed to serialize backup codes: {}", e))
})?;
redis
.set_with_expiry(&backup_codes_key, &backup_codes_json, TWO_FACTOR_SETUP_TTL_SECONDS)
.await
.map_err(|e| {
ApiError::Internal(anyhow::anyhow!("Failed to store backup codes: {}", e))
})?;
tracing::debug!("Stored 2FA setup data in Redis for user {}", user_id);
} else {
tracing::warn!("Redis not configured - 2FA setup will require secret to be passed in verification request");
}
Ok(Json(Setup2FAResponse {
secret: secret.clone(),
qr_code_url,
backup_codes: backup_codes.clone(),
}))
}
pub async fn verify_2fa_setup(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Json(request): Json<Verify2FASetupRequest>,
) -> ApiResult<Json<Verify2FASetupResponse>> {
let user = state
.store
.find_user_by_id(user_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("User not found".to_string()))?;
if user.two_factor_enabled {
return Err(ApiError::InvalidRequest("2FA is already enabled".to_string()));
}
let redis = state.redis.as_ref().ok_or_else(|| {
ApiError::Internal(anyhow::anyhow!(
"Redis not configured. Use verify_2fa_setup_with_secret endpoint instead."
))
})?;
let secret_key = two_factor_setup_key(&user_id);
let secret = redis
.get(&secret_key)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to retrieve 2FA secret: {}", e)))?
.ok_or_else(|| {
ApiError::InvalidRequest(
"2FA setup expired or not started. Please call setup_2fa first.".to_string(),
)
})?;
let backup_codes_key = two_factor_backup_codes_key(&user_id);
let backup_codes_json = redis
.get(&backup_codes_key)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to retrieve backup codes: {}", e)))?
.ok_or_else(|| {
ApiError::InvalidRequest(
"2FA setup expired or not started. Please call setup_2fa first.".to_string(),
)
})?;
let backup_codes: Vec<String> = serde_json::from_str(&backup_codes_json)
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to parse backup codes: {}", e)))?;
let valid = verify_totp_code(&secret, &request.code, Some(1))
.map_err(|e| ApiError::Internal(anyhow::anyhow!("TOTP verification error: {}", e)))?;
if !valid {
return Err(ApiError::InvalidRequest(
"Invalid verification code. Please try again.".to_string(),
));
}
let hashed_backup_codes: Vec<String> = backup_codes
.iter()
.map(|code| hash_backup_code(code))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to hash backup codes: {}", e)))?;
state.store.enable_user_2fa(user_id, &secret, &hashed_backup_codes).await?;
let _ = redis.delete(&secret_key).await;
let _ = redis.delete(&backup_codes_key).await;
let user_org_id = uuid::Uuid::nil();
state
.store
.record_audit_event(
user_org_id,
Some(user_id),
AuditEventType::TwoFactorEnabled,
"Two-factor authentication enabled".to_string(),
None,
None,
None,
)
.await;
tracing::info!("2FA enabled for user {}", user_id);
send_2fa_security_alert(&user, true).await;
Ok(Json(Verify2FASetupResponse {
success: true,
message:
"2FA has been enabled successfully. Please save your backup codes in a safe place."
.to_string(),
}))
}
async fn send_2fa_security_alert(user: &mockforge_registry_core::models::User, enabled: bool) {
if !user.security_alerts {
return;
}
let Ok(email_service) = crate::email::EmailService::from_env() else {
return;
};
let (headline, detail) = if enabled {
(
"Two-factor authentication was enabled",
"If you did not enable 2FA, contact support immediately — your account may be compromised.",
)
} else {
(
"Two-factor authentication was disabled",
"If you did not disable 2FA, reset your password immediately and contact support.",
)
};
let msg = crate::email::EmailService::generate_security_alert_email(
&user.username,
&user.email,
headline,
detail,
);
if let Err(e) = email_service.send(msg).await {
tracing::warn!("Failed to send 2FA security alert: {}", e);
}
}
pub async fn verify_2fa_setup_with_secret(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Json(request): Json<Verify2FASetupRequestWithSecret>,
) -> ApiResult<Json<Verify2FASetupResponse>> {
let user = state
.store
.find_user_by_id(user_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("User not found".to_string()))?;
if user.two_factor_enabled {
return Err(ApiError::InvalidRequest("2FA is already enabled".to_string()));
}
let valid = verify_totp_code(&request.secret, &request.code, Some(1))
.map_err(|e| ApiError::Internal(anyhow::anyhow!("TOTP verification error: {}", e)))?;
if !valid {
return Err(ApiError::InvalidRequest(
"Invalid verification code. Please try again.".to_string(),
));
}
let backup_codes = generate_backup_codes(10).map_err(|e| {
ApiError::Internal(anyhow::anyhow!("Failed to generate backup codes: {}", e))
})?;
let hashed_backup_codes: Vec<String> = backup_codes
.iter()
.map(|code| hash_backup_code(code))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Internal(anyhow::anyhow!("Failed to hash backup codes: {}", e)))?;
state
.store
.enable_user_2fa(user_id, &request.secret, &hashed_backup_codes)
.await?;
let user_org_id = uuid::Uuid::nil();
state
.store
.record_audit_event(
user_org_id,
Some(user_id),
AuditEventType::TwoFactorEnabled,
"Two-factor authentication enabled".to_string(),
None,
None,
None,
)
.await;
send_2fa_security_alert(&user, true).await;
Ok(Json(Verify2FASetupResponse {
success: true,
message:
"2FA has been enabled successfully. Please save your backup codes in a safe place."
.to_string(),
}))
}
#[derive(Debug, Deserialize)]
pub struct Verify2FASetupRequestWithSecret {
pub secret: String,
pub code: String,
pub backup_codes: Vec<String>, }
pub async fn disable_2fa(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Json(request): Json<Disable2FARequest>,
) -> ApiResult<Json<Disable2FAResponse>> {
let user = state
.store
.find_user_by_id(user_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("User not found".to_string()))?;
use crate::auth::verify_password;
let valid =
verify_password(&request.password, &user.password_hash).map_err(ApiError::Internal)?;
if !valid {
return Err(ApiError::InvalidRequest("Invalid password".to_string()));
}
if !user.two_factor_enabled {
return Err(ApiError::InvalidRequest("2FA is not enabled".to_string()));
}
state.store.disable_user_2fa(user_id).await?;
let user_org_id = uuid::Uuid::nil();
state
.store
.record_audit_event(
user_org_id,
Some(user_id),
AuditEventType::TwoFactorDisabled,
"Two-factor authentication disabled".to_string(),
None,
None,
None,
)
.await;
send_2fa_security_alert(&user, false).await;
Ok(Json(Disable2FAResponse {
success: true,
message: "2FA has been disabled successfully.".to_string(),
}))
}
pub async fn get_2fa_status(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> ApiResult<Json<serde_json::Value>> {
let user = state
.store
.find_user_by_id(user_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("User not found".to_string()))?;
Ok(Json(serde_json::json!({
"enabled": user.two_factor_enabled,
"verified_at": user.two_factor_verified_at,
"backup_codes_count": user.two_factor_backup_codes.as_ref().map(|c| c.len()).unwrap_or(0),
})))
}