use std::time::Duration;
use topcoat::{context::Cx, router::error::too_many_requests};
use super::{TwoFactor, binding, cipher, key};
use crate::auth::{
PanelUser,
realm::{current_realm, hosted, panel_prefix},
token::{self, TokenPurpose},
};
const CODE_LIFETIME: Duration = Duration::from_mins(10);
fn code() -> String {
const LIMIT: u32 = u32::MAX - u32::MAX % 1_000_000;
loop {
let mut bytes = [0u8; 4];
getrandom::fill(&mut bytes).expect("the mailed code randomness source failed");
let value = u32::from_be_bytes(bytes);
if value < LIMIT {
return format!("{:06}", value % 1_000_000);
}
}
}
fn stored(cx: &Cx, user_id: &str, code: &str) -> topcoat::Result<String> {
Ok(cipher::code_hash(
key(cx)?,
&binding(user_id, &panel_prefix(cx)),
code,
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Sent {
Mailed,
Throttled,
Unavailable,
}
impl Sent {
pub(super) fn or_too_many(self, two_factor: &TwoFactor) -> topcoat::Result<()> {
match self {
Self::Throttled => {
Err(too_many_requests(two_factor.mail_throttle.window_secs()).into())
}
Self::Mailed | Self::Unavailable => Ok(()),
}
}
}
pub(super) async fn send(
cx: &Cx,
two_factor: &TwoFactor,
user: &dyn PanelUser,
) -> topcoat::Result<Sent> {
let mail = current_realm(cx).and_then(|panel| panel.auth.auth_mail());
let (Some(address), Some(mail), true) = (user.email(), mail, two_factor.mails()) else {
return Ok(Sent::Unavailable);
};
let cx = &crate::i18n::addressed_to(cx, user.locale().as_ref());
let user_id = user.user_id();
if !two_factor.mail_throttle.attempt(&user_id, None) {
return Ok(Sent::Throttled);
}
let code = code();
let mut db = crate::db::db(cx)?;
token::record(
cx,
&mut db,
TokenPurpose::TwoFactorEmailCode,
&user_id,
None,
CODE_LIFETIME,
stored(cx, &user_id, &code)?,
)
.await?;
let brand = hosted(cx)?.brand_name(cx);
let message = mail.compose(
address,
crate::t!(cx, "tablo-two-factor-mail-subject", brand = brand.clone()),
crate::t!(
cx,
"tablo-two-factor-mail-body",
brand = brand,
code = code.clone()
),
)?;
if let Err(error) = topcoat::mail::send(cx, message).await {
tracing::error!(error = %error, "two-factor code not sent");
}
Ok(Sent::Mailed)
}
pub(super) async fn accept(
cx: &Cx,
ex: &mut dyn toasty::Executor,
user_id: &str,
code: &str,
) -> topcoat::Result<bool> {
let hash = stored(cx, user_id, code)?;
let Some(row) = token::recorded(cx, &mut *ex, TokenPurpose::TwoFactorEmailCode, hash).await?
else {
return Ok(false);
};
if row.user_id != user_id {
return Ok(false);
}
token::spend(ex, &row).await?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_code_is_six_digits() {
for _ in 0..100 {
let code = code();
assert_eq!(code.len(), 6);
assert!(code.bytes().all(|b| b.is_ascii_digit()));
}
}
}