1use 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#[derive(Debug, Clone, toasty::Model)]
20pub struct AuthToken {
21 #[key]
23 pub token_hash: String,
24 pub purpose: TokenPurpose,
26 #[index]
28 pub user_id: String,
29 pub panel: String,
31 pub email: Option<String>,
33 #[index]
34 pub expires_at: Timestamp,
35 pub created_at: Timestamp,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
41#[column(type = text)]
42#[non_exhaustive]
43pub enum TokenPurpose {
44 PasswordReset,
46 EmailVerification,
48 EmailChange,
50 EmailChangeCancel,
52 TwoFactorChallenge,
54 TwoFactorEmailCode,
56}
57
58pub(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
83pub(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
124pub(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
138pub(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
156pub(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
165pub(super) const CREDENTIALS: &[TokenPurpose] = &[
168 TokenPurpose::PasswordReset,
169 TokenPurpose::TwoFactorChallenge,
170 TokenPurpose::TwoFactorEmailCode,
171];
172
173pub(super) const REVOCATION: &[TokenPurpose] = &[
176 TokenPurpose::PasswordReset,
177 TokenPurpose::TwoFactorChallenge,
178 TokenPurpose::TwoFactorEmailCode,
179 TokenPurpose::EmailChange,
180];
181
182pub(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
206pub(super) const TOKEN_SWEEP_BATCH: usize = 500;
209
210pub(super) async fn sweep_expired(cx: &Cx) -> topcoat::Result<()> {
213 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
220pub(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 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
247pub(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
257const ATTEMPTS: u32 = 5;
259
260macro_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
295pub(super) fn answering<T>(
298 body: impl Future<Output = topcoat::Result<T>>,
299) -> impl Future<Output = topcoat::Result<T>> {
300 body
301}
302
303pub(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
314pub(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
328fn 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}