Skip to main content

tablo_core/auth/
token.rs

1//! The single-use secrets auth hands out besides sessions: the [`AuthToken`] rows a mailed link
2//! or a pending sign-in carries, issued, read, used and ended here.
3
4use std::time::Duration;
5
6use jiff::Timestamp;
7use topcoat::{context::Cx, session::Token};
8
9use super::{infrastructure_failure, session::token_key};
10use crate::{
11    auth::realm::{current_realm, panel_prefix},
12    error::TabloError,
13    toasty_compat::IsolationLevel,
14};
15
16/// Shipped single-use secret record: the SHA-256 hash of the token a mailed link or a pending
17/// sign-in carries, what it is for, the account and the panel it acts on, and its expiry. The
18/// raw token is never stored.
19#[derive(Debug, Clone, toasty::Model)]
20pub struct AuthToken {
21    /// Hex-encoded SHA-256 of the token.
22    #[key]
23    pub token_hash: String,
24    /// What the token does when used.
25    pub purpose: TokenPurpose,
26    /// [`PanelUser::user_id`](super::PanelUser::user_id) of the account the token acts on.
27    #[index]
28    pub user_id: String,
29    /// The prefix of the panel that issued the token: the only panel it works on.
30    pub panel: String,
31    /// The address a link proves the account reads, for a purpose that proves one.
32    pub email: Option<String>,
33    #[index]
34    pub expires_at: Timestamp,
35    pub created_at: Timestamp,
36}
37
38/// What an [`AuthToken`] does when used. Stored as its `snake_case` name in a text column, so a
39/// purpose a later release adds needs no migration.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
41#[column(type = text)]
42#[non_exhaustive]
43pub enum TokenPurpose {
44    /// A mailed link that sets a new password.
45    PasswordReset,
46    /// A mailed link that proves the account reads the address it was mailed to.
47    EmailVerification,
48    /// A link mailed to a new address that makes it the account's.
49    EmailChange,
50    /// A link mailed to the old address that cancels a pending change.
51    EmailChangeCancel,
52    /// A sign-in whose password verified, waiting for its second factor.
53    TwoFactorChallenge,
54    /// A code mailed as a second factor, to sign in or to set the factor up.
55    TwoFactorEmailCode,
56}
57
58/// A new token for `purpose` on the request's panel, held by `user_id` until `lifetime` passes,
59/// written through `ex`. The account's earlier tokens for the purpose on that panel end; expired
60/// ones wait for [`sweep_expired`].
61pub(super) async fn issue(
62    cx: &Cx,
63    ex: &mut dyn toasty::Executor,
64    purpose: TokenPurpose,
65    user_id: &str,
66    email: Option<String>,
67    lifetime: Duration,
68) -> topcoat::Result<Token> {
69    let token = Token::random();
70    record(
71        cx,
72        ex,
73        purpose,
74        user_id,
75        email,
76        lifetime,
77        token_key(&token.hash()),
78    )
79    .await?;
80    Ok(token)
81}
82
83/// Records `token_hash` as `user_id`'s token for `purpose` on the request's panel until
84/// `lifetime` passes, in place of the account's earlier ones there and every expired token.
85pub(super) async fn record(
86    cx: &Cx,
87    ex: &mut dyn toasty::Executor,
88    purpose: TokenPurpose,
89    user_id: &str,
90    email: Option<String>,
91    lifetime: Duration,
92    token_hash: String,
93) -> topcoat::Result<()> {
94    let now = Timestamp::now();
95    let expires_at = Timestamp::try_from(std::time::SystemTime::now() + lifetime)
96        .map_err(topcoat::Error::from)?;
97    let panel = panel_prefix(cx);
98    AuthToken::filter(
99        AuthToken::fields()
100            .user_id()
101            .eq(user_id.to_string())
102            .and(AuthToken::fields().panel().eq(panel.clone()))
103            .and(AuthToken::fields().purpose().eq(purpose)),
104    )
105    .delete()
106    .exec(&mut *ex)
107    .await
108    .map_err(infrastructure_failure)?;
109    toasty::create!(AuthToken {
110        token_hash,
111        purpose,
112        user_id: user_id.to_string(),
113        panel,
114        email,
115        expires_at,
116        created_at: now,
117    })
118    .exec(ex)
119    .await
120    .map_err(infrastructure_failure)?;
121    Ok(())
122}
123
124/// The live token for `purpose` that `encoded` names on the request's panel, or `None` for an
125/// unknown, expired, malformed or other panel's one.
126pub(super) async fn live(
127    cx: &Cx,
128    ex: &mut dyn toasty::Executor,
129    purpose: TokenPurpose,
130    encoded: &str,
131) -> topcoat::Result<Option<AuthToken>> {
132    let Ok(token) = Token::decode(encoded) else {
133        return Ok(None);
134    };
135    recorded(cx, ex, purpose, token_key(&token.hash())).await
136}
137
138/// The live token for `purpose` that [`record`] stored as `token_hash` on the request's panel.
139pub(super) async fn recorded(
140    cx: &Cx,
141    ex: &mut dyn toasty::Executor,
142    purpose: TokenPurpose,
143    token_hash: String,
144) -> topcoat::Result<Option<AuthToken>> {
145    let row = AuthToken::filter(AuthToken::fields().token_hash().eq(token_hash))
146        .first()
147        .exec(ex)
148        .await
149        .map_err(infrastructure_failure)?;
150    let prefix = panel_prefix(cx);
151    Ok(row.filter(|row| {
152        row.purpose == purpose && row.panel == prefix && row.expires_at > Timestamp::now()
153    }))
154}
155
156/// Deletes `row` through `ex`: a used token works once.
157pub(super) async fn spend(ex: &mut dyn toasty::Executor, row: &AuthToken) -> topcoat::Result<()> {
158    AuthToken::filter(AuthToken::fields().token_hash().eq(row.token_hash.clone()))
159        .delete()
160        .exec(ex)
161        .await
162        .map_err(infrastructure_failure)
163}
164
165/// The purposes a credential change ends: the tokens that sign in or set a credential. A link
166/// that proves an address, makes a change the account asked for, or undoes one outlives it.
167pub(super) const CREDENTIALS: &[TokenPurpose] = &[
168    TokenPurpose::PasswordReset,
169    TokenPurpose::TwoFactorChallenge,
170    TokenPurpose::TwoFactorEmailCode,
171];
172
173/// The purposes a reset, a cancelled email change or a revocation ends: those of a credential
174/// change, and a pending email change, which whoever knew the password may have asked for.
175pub(super) const REVOCATION: &[TokenPurpose] = &[
176    TokenPurpose::PasswordReset,
177    TokenPurpose::TwoFactorChallenge,
178    TokenPurpose::TwoFactorEmailCode,
179    TokenPurpose::EmailChange,
180];
181
182/// Deletes every token for one of `purposes` that `user_id` holds on the panels `prefixes` names,
183/// or on every panel for `None`, through `ex`.
184pub(super) async fn end_all(
185    ex: &mut dyn toasty::Executor,
186    user_id: &str,
187    prefixes: Option<Vec<String>>,
188    purposes: &[TokenPurpose],
189) -> topcoat::Result<()> {
190    let user = AuthToken::fields()
191        .user_id()
192        .eq(user_id.to_string())
193        .and(AuthToken::fields().purpose().in_list(purposes.to_vec()));
194    match prefixes {
195        Some(prefixes) => {
196            AuthToken::filter(user.and(AuthToken::fields().panel().in_list(prefixes)))
197        }
198        None => AuthToken::filter(user),
199    }
200    .delete()
201    .exec(ex)
202    .await
203    .map_err(infrastructure_failure)
204}
205
206/// How many expired token rows one sweep drops: the bound that keeps one login from deleting an
207/// unbounded number of rows.
208pub(super) const TOKEN_SWEEP_BATCH: usize = 500;
209
210/// Drops up to [`TOKEN_SWEEP_BATCH`] expired token rows when a login succeeds on a panel that
211/// issues tokens, outside any transaction: inside one, every issue would contend on the same rows.
212pub(super) async fn sweep_expired(cx: &Cx) -> topcoat::Result<()> {
213    // A panel that issues no token may leave the model unregistered.
214    if current_realm(cx).is_some_and(|panel| panel.auth.issues_tokens()) {
215        sweep(&mut crate::db::db(cx)?).await?;
216    }
217    Ok(())
218}
219
220/// Drops up to [`TOKEN_SWEEP_BATCH`] expired token rows from `db`.
221pub(super) async fn sweep(db: &mut toasty::Db) -> topcoat::Result<()> {
222    let now = Timestamp::now();
223    let expired: Vec<String> = AuthToken::filter(AuthToken::fields().expires_at().le(now))
224        .limit(TOKEN_SWEEP_BATCH)
225        .exec(&mut *db)
226        .await
227        .map_err(infrastructure_failure)?
228        .into_iter()
229        .map(|row| row.token_hash)
230        .collect();
231    if expired.is_empty() {
232        return Ok(());
233    }
234    // The expiry is re-checked: the select and the delete are two statements.
235    AuthToken::filter(
236        AuthToken::fields()
237            .token_hash()
238            .in_list(expired)
239            .and(AuthToken::fields().expires_at().le(now)),
240    )
241    .delete()
242    .exec(db)
243    .await
244    .map_err(infrastructure_failure)
245}
246
247/// A serializable transaction on `db`: two requests racing on one token, or one account, then
248/// cannot both commit.
249pub(super) async fn serializable(db: &mut toasty::Db) -> topcoat::Result<toasty::Transaction<'_>> {
250    db.transaction_builder()
251        .isolation(IsolationLevel::Serializable)
252        .begin()
253        .await
254        .map_err(infrastructure_failure)
255}
256
257/// How many times [`run_serializable!`] runs its transaction before it gives up on a conflict.
258const ATTEMPTS: u32 = 5;
259
260/// Runs `$body`, an async block answering a `topcoat::Result`, in a serializable transaction it
261/// reaches as `$tx` (a `&mut toasty::Transaction`), and commits when `$body` answers `Ok`. When the
262/// database aborts the transaction for a concurrent one, both run again: PostgreSQL and MySQL abort
263/// one of two overlapping transactions even when they act on different accounts.
264///
265/// `$body` may run several times, so it does nothing the transaction does not undo: a mail, a
266/// notification or a page waits for the answer, and a session goes through
267/// [`super::session::start_once`]. A macro rather than a function taking a closure: a closure
268/// borrowing the transaction and the request's locals has no lifetime to name.
269macro_rules! run_serializable {
270    ($cx:expr, |$tx:ident| $body:expr) => {{
271        let mut db = crate::db::db($cx)?;
272        let mut attempt = 1;
273        loop {
274            let run = async {
275                let mut tx = crate::auth::token::serializable(&mut db).await?;
276                let answer = {
277                    let $tx = &mut tx;
278                    crate::auth::token::answering($body).await?
279                };
280                tx.commit()
281                    .await
282                    .map_err(crate::auth::infrastructure_failure)?;
283                topcoat::Result::Ok(answer)
284            }
285            .await;
286            let Err(error) = run else { break run };
287            if !crate::auth::token::waited_to_retry(&mut attempt, &error).await {
288                break Err(error);
289            }
290        }
291    }};
292}
293pub(super) use run_serializable;
294
295/// `body` as it is: names the error type a [`run_serializable!`] body answers, which its `?`
296/// cannot infer.
297pub(super) fn answering<T>(
298    body: impl Future<Output = topcoat::Result<T>>,
299) -> impl Future<Output = topcoat::Result<T>> {
300    body
301}
302
303/// Whether a transaction that failed with `error` on its `attempt`th run runs again: after a
304/// conflict, up to [`ATTEMPTS`] runs, once a [`backoff`] has passed.
305pub(super) async fn waited_to_retry(attempt: &mut u32, error: &topcoat::Error) -> bool {
306    if *attempt >= ATTEMPTS || !TabloError::is_conflict(error) {
307        return false;
308    }
309    tokio::time::sleep(backoff(*attempt)).await;
310    *attempt += 1;
311    true
312}
313
314/// [`issue`] in a transaction of its own, which [`run_serializable!`] runs again on a conflict: a
315/// link mailed after the response has no visitor left to try again.
316pub(super) async fn issue_alone(
317    cx: &Cx,
318    purpose: TokenPurpose,
319    user_id: &str,
320    email: Option<String>,
321    lifetime: Duration,
322) -> topcoat::Result<Token> {
323    run_serializable!(cx, |tx| async {
324        issue(cx, tx, purpose, user_id, email.clone(), lifetime).await
325    })
326}
327
328/// A random wait under 20ms doubled per failed `attempt`: transactions that aborted each other
329/// would meet again if they waited alike.
330fn backoff(attempt: u32) -> Duration {
331    let ceiling = 20u64 << attempt.min(6);
332    let jitter = getrandom::u64().map_or(ceiling / 2, |random| random % ceiling);
333    Duration::from_millis(jitter)
334}