use std::time::Duration;
use jiff::Timestamp;
use topcoat::{context::Cx, session::Token};
use super::{infrastructure_failure, session::token_key};
use crate::{
auth::realm::{current_realm, panel_prefix},
error::TabloError,
toasty_compat::IsolationLevel,
};
#[derive(Debug, Clone, toasty::Model)]
pub struct AuthToken {
#[key]
pub token_hash: String,
pub purpose: TokenPurpose,
#[index]
pub user_id: String,
pub panel: String,
pub email: Option<String>,
#[index]
pub expires_at: Timestamp,
pub created_at: Timestamp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
#[column(type = text)]
#[non_exhaustive]
pub enum TokenPurpose {
PasswordReset,
EmailVerification,
EmailChange,
EmailChangeCancel,
TwoFactorChallenge,
TwoFactorEmailCode,
}
pub(super) async fn issue(
cx: &Cx,
ex: &mut dyn toasty::Executor,
purpose: TokenPurpose,
user_id: &str,
email: Option<String>,
lifetime: Duration,
) -> topcoat::Result<Token> {
let token = Token::random();
record(
cx,
ex,
purpose,
user_id,
email,
lifetime,
token_key(&token.hash()),
)
.await?;
Ok(token)
}
pub(super) async fn record(
cx: &Cx,
ex: &mut dyn toasty::Executor,
purpose: TokenPurpose,
user_id: &str,
email: Option<String>,
lifetime: Duration,
token_hash: String,
) -> topcoat::Result<()> {
let now = Timestamp::now();
let expires_at = Timestamp::try_from(std::time::SystemTime::now() + lifetime)
.map_err(topcoat::Error::from)?;
let panel = panel_prefix(cx);
AuthToken::filter(
AuthToken::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthToken::fields().panel().eq(panel.clone()))
.and(AuthToken::fields().purpose().eq(purpose)),
)
.delete()
.exec(&mut *ex)
.await
.map_err(infrastructure_failure)?;
toasty::create!(AuthToken {
token_hash,
purpose,
user_id: user_id.to_string(),
panel,
email,
expires_at,
created_at: now,
})
.exec(ex)
.await
.map_err(infrastructure_failure)?;
Ok(())
}
pub(super) async fn live(
cx: &Cx,
ex: &mut dyn toasty::Executor,
purpose: TokenPurpose,
encoded: &str,
) -> topcoat::Result<Option<AuthToken>> {
let Ok(token) = Token::decode(encoded) else {
return Ok(None);
};
recorded(cx, ex, purpose, token_key(&token.hash())).await
}
pub(super) async fn recorded(
cx: &Cx,
ex: &mut dyn toasty::Executor,
purpose: TokenPurpose,
token_hash: String,
) -> topcoat::Result<Option<AuthToken>> {
let row = AuthToken::filter(AuthToken::fields().token_hash().eq(token_hash))
.first()
.exec(ex)
.await
.map_err(infrastructure_failure)?;
let prefix = panel_prefix(cx);
Ok(row.filter(|row| {
row.purpose == purpose && row.panel == prefix && row.expires_at > Timestamp::now()
}))
}
pub(super) async fn spend(ex: &mut dyn toasty::Executor, row: &AuthToken) -> topcoat::Result<()> {
AuthToken::filter(AuthToken::fields().token_hash().eq(row.token_hash.clone()))
.delete()
.exec(ex)
.await
.map_err(infrastructure_failure)
}
pub(super) const CREDENTIALS: &[TokenPurpose] = &[
TokenPurpose::PasswordReset,
TokenPurpose::TwoFactorChallenge,
TokenPurpose::TwoFactorEmailCode,
];
pub(super) const REVOCATION: &[TokenPurpose] = &[
TokenPurpose::PasswordReset,
TokenPurpose::TwoFactorChallenge,
TokenPurpose::TwoFactorEmailCode,
TokenPurpose::EmailChange,
];
pub(super) async fn end_all(
ex: &mut dyn toasty::Executor,
user_id: &str,
prefixes: Option<Vec<String>>,
purposes: &[TokenPurpose],
) -> topcoat::Result<()> {
let user = AuthToken::fields()
.user_id()
.eq(user_id.to_string())
.and(AuthToken::fields().purpose().in_list(purposes.to_vec()));
match prefixes {
Some(prefixes) => {
AuthToken::filter(user.and(AuthToken::fields().panel().in_list(prefixes)))
}
None => AuthToken::filter(user),
}
.delete()
.exec(ex)
.await
.map_err(infrastructure_failure)
}
pub(super) const TOKEN_SWEEP_BATCH: usize = 500;
pub(super) async fn sweep_expired(cx: &Cx) -> topcoat::Result<()> {
if current_realm(cx).is_some_and(|panel| panel.auth.issues_tokens()) {
sweep(&mut crate::db::db(cx)?).await?;
}
Ok(())
}
pub(super) async fn sweep(db: &mut toasty::Db) -> topcoat::Result<()> {
let now = Timestamp::now();
let expired: Vec<String> = AuthToken::filter(AuthToken::fields().expires_at().le(now))
.limit(TOKEN_SWEEP_BATCH)
.exec(&mut *db)
.await
.map_err(infrastructure_failure)?
.into_iter()
.map(|row| row.token_hash)
.collect();
if expired.is_empty() {
return Ok(());
}
AuthToken::filter(
AuthToken::fields()
.token_hash()
.in_list(expired)
.and(AuthToken::fields().expires_at().le(now)),
)
.delete()
.exec(db)
.await
.map_err(infrastructure_failure)
}
pub(super) async fn serializable(db: &mut toasty::Db) -> topcoat::Result<toasty::Transaction<'_>> {
db.transaction_builder()
.isolation(IsolationLevel::Serializable)
.begin()
.await
.map_err(infrastructure_failure)
}
const ATTEMPTS: u32 = 5;
macro_rules! run_serializable {
($cx:expr, |$tx:ident| $body:expr) => {{
let mut db = crate::db::db($cx)?;
let mut attempt = 1;
loop {
let run = async {
let mut tx = crate::auth::token::serializable(&mut db).await?;
let answer = {
let $tx = &mut tx;
crate::auth::token::answering($body).await?
};
tx.commit()
.await
.map_err(crate::auth::infrastructure_failure)?;
topcoat::Result::Ok(answer)
}
.await;
let Err(error) = run else { break run };
if !crate::auth::token::waited_to_retry(&mut attempt, &error).await {
break Err(error);
}
}
}};
}
pub(super) use run_serializable;
pub(super) fn answering<T>(
body: impl Future<Output = topcoat::Result<T>>,
) -> impl Future<Output = topcoat::Result<T>> {
body
}
pub(super) async fn waited_to_retry(attempt: &mut u32, error: &topcoat::Error) -> bool {
if *attempt >= ATTEMPTS || !TabloError::is_conflict(error) {
return false;
}
tokio::time::sleep(backoff(*attempt)).await;
*attempt += 1;
true
}
pub(super) async fn issue_alone(
cx: &Cx,
purpose: TokenPurpose,
user_id: &str,
email: Option<String>,
lifetime: Duration,
) -> topcoat::Result<Token> {
run_serializable!(cx, |tx| async {
issue(cx, tx, purpose, user_id, email.clone(), lifetime).await
})
}
fn backoff(attempt: u32) -> Duration {
let ceiling = 20u64 << attempt.min(6);
let jitter = getrandom::u64().map_or(ceiling / 2, |random| random % ceiling);
Duration::from_millis(jitter)
}