mod backoff;
mod challenge;
mod cipher;
mod email;
mod pages;
mod settings;
mod totp;
use std::time::Duration;
use jiff::Timestamp;
use subtle::ConstantTimeEq;
use topcoat::{
context::{Cx, try_context},
cookie::Key,
};
pub(crate) use self::{challenge::challenge_url, settings::settings_url};
pub(super) use self::{
challenge::{begin as begin_challenge, challenge_page, challenge_post},
settings::{settings_page, settings_post},
};
use super::{LoginThrottle, PanelUser, infrastructure_failure};
use crate::auth::realm::{Realm, panel_prefix};
#[derive(Debug)]
pub struct TwoFactor {
required: bool,
email: bool,
throttle: LoginThrottle,
mail_throttle: LoginThrottle,
}
impl TwoFactor {
#[must_use]
pub fn new() -> Self {
Self {
required: false,
email: false,
throttle: LoginThrottle::new(5, Duration::from_mins(1)),
mail_throttle: LoginThrottle::new(3, Duration::from_mins(15)),
}
}
#[must_use]
pub fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub fn email_codes(mut self) -> Self {
self.email = true;
self
}
#[must_use]
pub fn mail_throttle(mut self, throttle: LoginThrottle) -> Self {
self.mail_throttle = throttle;
self
}
#[must_use]
pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
self.throttle = throttle;
self
}
}
impl TwoFactor {
pub(in crate::auth) fn mails(&self) -> bool {
self.email
}
}
impl Default for TwoFactor {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, toasty::Model)]
pub struct AuthFactor {
#[key]
#[auto]
pub id: uuid::Uuid,
#[index]
pub user_id: String,
pub panel: String,
pub kind: FactorKind,
pub secret: String,
pub confirmed_at: Option<Timestamp>,
pub last_step: Option<i64>,
pub failures: i64,
pub retry_at: Option<Timestamp>,
pub created_at: Timestamp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
#[column(type = text)]
#[non_exhaustive]
pub enum FactorKind {
Authenticator,
RecoveryCode,
EmailCode,
}
const RECOVERY_CODES: usize = 8;
const RECOVERY_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
fn key(cx: &Cx) -> topcoat::Result<&Key> {
try_context::<Key>(cx)
.ok_or_else(|| crate::error::declaration("two-factor sign-in needs the router's `Key`"))
}
fn binding(user_id: &str, panel: &str) -> String {
format!("{user_id}\n{panel}")
}
async fn factors(
ex: &mut dyn toasty::Executor,
panel: &str,
user_id: &str,
) -> topcoat::Result<Vec<AuthFactor>> {
AuthFactor::filter(
AuthFactor::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthFactor::fields().panel().eq(panel.to_string())),
)
.exec(ex)
.await
.map_err(infrastructure_failure)
}
#[derive(Debug, Default)]
pub(super) struct Enrolment {
pub(super) confirmed: Option<AuthFactor>,
pub(super) unconfirmed: Option<AuthFactor>,
pub(super) recovery_codes: usize,
}
pub(super) async fn enrolment(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
) -> topcoat::Result<Enrolment> {
enrolment_on(ex, &panel_prefix(cx), user_id).await
}
async fn enrolment_on(
ex: &mut dyn toasty::Executor,
panel: &str,
user_id: &str,
) -> topcoat::Result<Enrolment> {
let mut enrolment = Enrolment::default();
for factor in factors(ex, panel, user_id).await? {
match (factor.kind, factor.confirmed_at.is_some()) {
(FactorKind::RecoveryCode, _) => enrolment.recovery_codes += 1,
(_, true) => enrolment.confirmed = Some(factor),
(_, false) => enrolment.unconfirmed = Some(factor),
}
}
Ok(enrolment)
}
pub(super) async fn enrolled(cx: &Cx, user: &dyn PanelUser) -> topcoat::Result<Option<FactorKind>> {
let mut db = crate::db::db(cx)?;
Ok(enrolment(cx, &mut db, &user.user_id())
.await?
.confirmed
.map(|factor| factor.kind))
}
pub(crate) fn offered(cx: &Cx) -> bool {
super::signed(cx).is_some_and(|signed| signed.realm.auth.two_factor_config().is_some())
}
pub(super) async fn setup_pending(
cx: &Cx,
panel: &Realm,
user: &dyn PanelUser,
) -> topcoat::Result<bool> {
match panel.auth.two_factor_config() {
Some(two_factor) if two_factor.required => {
let mut db = crate::db::db(cx)?;
let enrolment = enrolment_on(&mut db, &panel.prefix, &user.user_id()).await?;
Ok(enrolment.confirmed.is_none())
}
_ => Ok(false),
}
}
async fn start(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
kind: FactorKind,
secret: String,
) -> topcoat::Result<()> {
let panel = panel_prefix(cx);
AuthFactor::filter(
AuthFactor::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthFactor::fields().panel().eq(panel.clone()))
.and(AuthFactor::fields().confirmed_at().is_none()),
)
.delete()
.exec(&mut *ex)
.await
.map_err(infrastructure_failure)?;
toasty::create!(AuthFactor {
user_id: user_id.to_string(),
panel,
kind,
secret,
confirmed_at: None,
last_step: None,
failures: 0,
retry_at: None,
created_at: Timestamp::now(),
})
.exec(ex)
.await
.map_err(infrastructure_failure)?;
Ok(())
}
async fn start_authenticator(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
) -> topcoat::Result<Vec<u8>> {
let secret = totp::secret();
let sealed = cipher::seal(key(cx)?, &secret, &binding(user_id, &panel_prefix(cx)));
start(cx, ex, user_id, FactorKind::Authenticator, sealed).await?;
Ok(secret.to_vec())
}
fn secret(cx: &Cx, factor: &AuthFactor) -> topcoat::Result<Option<Vec<u8>>> {
Ok(cipher::open(
key(cx)?,
&factor.secret,
&binding(&factor.user_id, &factor.panel),
))
}
async fn accept_code(
cx: &Cx,
ex: &mut dyn toasty::Executor,
factor: &AuthFactor,
code: &str,
) -> topcoat::Result<bool> {
let Some(secret) = secret(cx, factor)? else {
tracing::error!(panel = %factor.panel, "a two-factor secret does not open under the key");
return Ok(false);
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| crate::error::declaration("the clock is before 1970"))?
.as_secs();
let used = factor.last_step.and_then(|step| u64::try_from(step).ok());
let Some(step) = totp::verify(&secret, code, totp::step(now), used) else {
return Ok(false);
};
AuthFactor::filter(AuthFactor::fields().id().eq(factor.id))
.update()
.last_step(i64::try_from(step).ok())
.exec(ex)
.await
.map_err(infrastructure_failure)?;
Ok(true)
}
fn normalize_recovery_code(code: &str) -> String {
code.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn recovery_hash(cx: &Cx, user_id: &str, panel: &str, code: &str) -> topcoat::Result<String> {
Ok(cipher::recovery_hash(
key(cx)?,
&binding(user_id, panel),
&normalize_recovery_code(code),
))
}
async fn spend_recovery_code(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
code: &str,
) -> topcoat::Result<bool> {
let panel = panel_prefix(cx);
let hash = recovery_hash(cx, user_id, &panel, code)?;
let codes = factors(&mut *ex, &panel, user_id).await?;
let Some(matched) = codes.iter().find(|factor| {
factor.kind == FactorKind::RecoveryCode
&& bool::from(factor.secret.as_bytes().ct_eq(hash.as_bytes()))
}) else {
return Ok(false);
};
AuthFactor::filter(AuthFactor::fields().id().eq(matched.id))
.delete()
.exec(ex)
.await
.map_err(infrastructure_failure)?;
Ok(true)
}
async fn accept_factor(
cx: &Cx,
ex: &mut dyn toasty::Executor,
factor: &AuthFactor,
code: &str,
) -> topcoat::Result<bool> {
match factor.kind {
FactorKind::Authenticator => accept_code(cx, ex, factor, code).await,
FactorKind::EmailCode => email::accept(cx, ex, &factor.user_id, code).await,
FactorKind::RecoveryCode => Ok(false),
}
}
fn factor_code(code: &str) -> Option<String> {
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
(code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code)
}
pub(super) async fn accept(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
code: &str,
) -> topcoat::Result<bool> {
let enrolment = enrolment(cx, &mut *ex, user_id).await?;
let Some(factor) = enrolment.confirmed else {
return Ok(false);
};
match factor_code(code) {
Some(code) => accept_factor(cx, ex, &factor, &code).await,
None => {
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
spend_recovery_code(cx, ex, user_id, &code).await
}
}
}
async fn issue_recovery_codes(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
) -> topcoat::Result<Vec<String>> {
let panel = panel_prefix(cx);
AuthFactor::filter(
AuthFactor::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthFactor::fields().panel().eq(panel.clone()))
.and(AuthFactor::fields().kind().eq(FactorKind::RecoveryCode)),
)
.delete()
.exec(&mut *ex)
.await
.map_err(infrastructure_failure)?;
let mut codes = Vec::with_capacity(RECOVERY_CODES);
let now = Timestamp::now();
for _ in 0..RECOVERY_CODES {
let code = recovery_code();
toasty::create!(AuthFactor {
user_id: user_id.to_string(),
panel: panel.clone(),
kind: FactorKind::RecoveryCode,
secret: recovery_hash(cx, user_id, &panel, &code)?,
confirmed_at: Some(now),
last_step: None,
failures: 0,
retry_at: None,
created_at: now,
})
.exec(&mut *ex)
.await
.map_err(infrastructure_failure)?;
codes.push(code);
}
Ok(codes)
}
fn recovery_code() -> String {
let mut bytes = [0u8; 10];
getrandom::fill(&mut bytes).expect("the recovery code randomness source failed");
let chars: String = bytes
.iter()
.map(|byte| char::from(RECOVERY_ALPHABET[usize::from(byte & 31)]))
.collect();
format!("{}-{}", &chars[..5], &chars[5..])
}
async fn remove_all(cx: &Cx, ex: &mut dyn toasty::Executor, user_id: &str) -> topcoat::Result<()> {
AuthFactor::filter(
AuthFactor::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthFactor::fields().panel().eq(panel_prefix(cx))),
)
.delete()
.exec(ex)
.await
.map_err(infrastructure_failure)
}
pub(super) fn declaration_errors(
auth: &super::AuthConfig,
has_key: bool,
) -> Vec<crate::DeclarationErrorKind> {
use crate::{DeclarationErrorKind, declaration::TwoFactorFault};
if auth.two_factor.is_none() {
return Vec::new();
}
if auth.authenticator().is_none() {
return vec![DeclarationErrorKind::TwoFactor(
TwoFactorFault::AuthDisabled,
)];
}
if has_key {
Vec::new()
} else {
vec![DeclarationErrorKind::TwoFactor(TwoFactorFault::MissingKey)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recovery_code_matches_however_it_is_typed() {
let code = recovery_code();
assert_eq!(code.len(), 11);
let read = normalize_recovery_code(&code);
assert_eq!(normalize_recovery_code(&code.to_uppercase()), read);
assert_eq!(normalize_recovery_code(&code.replace('-', " ")), read);
assert_ne!(normalize_recovery_code(&recovery_code()), read);
}
}