use base32::Alphabet;
use chrono::Utc;
use serde::Serialize;
use uuid::Uuid;
use foundation_auth::shared::two_factor::TOTPSecret;
use crate::core::context::KeychainContext;
use crate::core::error::{AppError, AppResult};
use crate::core::store::two_factor as store;
use crate::core::store::two_factor::TwoFactorRow;
const ALPHABET: Alphabet = Alphabet::Rfc4648 { padding: false };
const TOLERANCE: u64 = 1;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticatorResponse {
pub key: String,
pub enabled: bool,
pub object: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnableAuthenticatorResponse {
pub enabled: bool,
pub key: String,
pub recovery_code: String,
}
fn new_secret_base32() -> String {
let mut bytes = Uuid::new_v4().as_bytes().to_vec();
bytes.extend_from_slice(Uuid::new_v4().as_bytes());
base32::encode(ALPHABET, &bytes)
}
fn totp_from_base32(secret: &str) -> AppResult<TOTPSecret> {
let bytes = base32::decode(ALPHABET, secret)
.ok_or_else(|| AppError::Internal("stored TOTP secret is not valid base32".into()))?;
Ok(TOTPSecret::from_bytes(bytes))
}
pub async fn get_authenticator(
ctx: &KeychainContext,
user_uuid: &str,
) -> AppResult<AuthenticatorResponse> {
if let Some(existing) = store::get(ctx.db(), user_uuid).await? {
return Ok(AuthenticatorResponse {
key: existing.secret,
enabled: existing.enabled,
object: "twoFactorAuthenticator",
});
}
let now = Utc::now();
let row = TwoFactorRow {
user_uuid: user_uuid.to_string(),
secret: new_secret_base32(),
enabled: false,
recovery_code: None,
created_at: now,
updated_at: now,
};
store::upsert(ctx.db(), &row).await?;
Ok(AuthenticatorResponse {
key: row.secret,
enabled: false,
object: "twoFactorAuthenticator",
})
}
pub async fn enable_authenticator(
ctx: &KeychainContext,
user_uuid: &str,
token: &str,
) -> AppResult<EnableAuthenticatorResponse> {
let mut row = store::get(ctx.db(), user_uuid)
.await?
.ok_or_else(|| AppError::BadRequest("call get-authenticator first".into()))?;
if !totp_from_base32(&row.secret)?.verify(token, TOLERANCE) {
return Err(AppError::BadRequest("invalid verification code".into()));
}
let recovery_code = Uuid::new_v4().to_string().replace('-', "");
row.enabled = true;
row.recovery_code = Some(recovery_code.clone());
row.updated_at = Utc::now();
store::upsert(ctx.db(), &row).await?;
Ok(EnableAuthenticatorResponse {
enabled: true,
key: row.secret,
recovery_code,
})
}
pub async fn disable_authenticator(
ctx: &KeychainContext,
user_uuid: &str,
token: &str,
) -> AppResult<()> {
let row = store::get(ctx.db(), user_uuid)
.await?
.ok_or_else(|| AppError::BadRequest("two-factor is not configured".into()))?;
if !totp_from_base32(&row.secret)?.verify(token, TOLERANCE) {
return Err(AppError::BadRequest("invalid verification code".into()));
}
store::delete(ctx.db(), user_uuid).await
}
pub async fn is_enabled(ctx: &KeychainContext, user_uuid: &str) -> AppResult<bool> {
Ok(store::get(ctx.db(), user_uuid)
.await?
.is_some_and(|r| r.enabled))
}
pub async fn verify(ctx: &KeychainContext, user_uuid: &str, code: &str) -> AppResult<bool> {
let Some(row) = store::get(ctx.db(), user_uuid).await? else {
return Ok(false);
};
if !row.enabled {
return Ok(false);
}
if row.recovery_code.as_deref() == Some(code) {
return Ok(true);
}
Ok(totp_from_base32(&row.secret)?.verify(code, TOLERANCE))
}