Skip to main content

tablo_core/auth/
session.rs

1//! Server-side sessions: the [`AuthSession`] rows, their lookup, revocation
2//! and sweep.
3
4use std::{sync::Arc, time::Duration};
5
6use jiff::Timestamp;
7use topcoat::{
8    context::Cx,
9    session::{self, TokenHash},
10};
11use uuid::Uuid;
12
13use super::{
14    DynAuthenticator, PanelUser, SignedIn, infrastructure_failure,
15    token::{CREDENTIALS, REVOCATION, TokenPurpose},
16};
17use crate::auth::realm::{Realm, current_realm, realms};
18
19/// How long a session stays valid: seven days, fixed.
20pub const SESSION_LIFETIME: Duration = Duration::from_hours(24 * 7);
21
22/// Shipped server-side session record: the SHA-256 hash of the client token,
23/// the user it authenticates, the panel that signed the user in, and its
24/// expiry. The raw token is never stored.
25///
26/// A session lasts [`SESSION_LIFETIME`] from login, is replaced on each login and deleted on
27/// logout; each successful login also deletes up to 500 expired sessions. It belongs to the panel
28/// that signed the user in: another panel's gate treats the request as anonymous, and
29/// [`auth::user`](crate::auth::user) answers `None` there. One browser holds one session, so
30/// signing in to a second panel ends the first, and renaming a panel's prefix ends the sessions
31/// it issued. Sessions stay valid until they expire unless
32/// [`revoke_sessions_for_user`](crate::auth::revoke_sessions_for_user) ends them.
33///
34/// The row records the panel's prefix in a non-null `panel` column and the selected tenant in a
35/// nullable `tenant` column, typed as Toasty stores a `Uuid` on the database. A table created
36/// without them gains them by migration: add `panel` with the panel's prefix as its default
37/// (`ALTER TABLE auth_sessions ADD COLUMN panel TEXT NOT NULL DEFAULT '/admin'`), then drop the
38/// default once every row names its panel.
39#[derive(Debug, Clone, toasty::Model)]
40pub struct AuthSession {
41    /// Hex-encoded SHA-256 of the session token.
42    #[key]
43    pub token_hash: String,
44    /// [`PanelUser::user_id`] of the authenticated user.
45    #[index]
46    pub user_id: String,
47    /// The prefix of the panel that signed the user in (e.g. `/admin`): the
48    /// only panel the session authenticates.
49    pub panel: String,
50    /// The tenant the user selected; `None` acts for the first.
51    pub tenant: Option<Uuid>,
52    /// Indexed for the login sweep's range scan on this column.
53    #[index]
54    pub expires_at: Timestamp,
55    pub created_at: Timestamp,
56}
57
58pub(super) fn token_key(hash: &TokenHash) -> String {
59    use std::fmt::Write as _;
60
61    let mut key = String::with_capacity(64);
62    for byte in hash.iter() {
63        write!(key, "{byte:02x}").expect("writing to a String cannot fail");
64    }
65    key
66}
67
68pub(super) async fn record(
69    cx: &Cx,
70    session: &session::Session,
71    user: &dyn PanelUser,
72    panel: &Realm,
73) -> topcoat::Result<()> {
74    let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
75    insert(
76        &mut crate::db::db(cx)?,
77        &session.token_hash,
78        user.user_id(),
79        panel.prefix.clone(),
80        None,
81        expires_at,
82    )
83    .await
84    .map_err(infrastructure_failure)
85}
86
87/// The realms whose authenticator loads `user_type`, or `own` outside a router: the panels that
88/// share a user's credentials, so a credential change ends the user's sessions and reset links on
89/// each.
90pub(super) fn credential_panels(
91    cx: &Cx,
92    user_type: std::any::TypeId,
93    own: &Arc<Realm>,
94) -> Vec<Arc<Realm>> {
95    let realms = realms(cx);
96    if realms.is_empty() {
97        return vec![Arc::clone(own)];
98    }
99    realms
100        .into_iter()
101        .filter(|realm| {
102            realm
103                .auth
104                .authenticator()
105                .is_some_and(|auth| auth.user_type() == user_type)
106        })
107        .collect()
108}
109
110/// Deletes every session `user_id` holds on `panels`, and every [`AuthToken`](super::AuthToken)
111/// for one of `purposes` it holds on those that issue them, through `ex`: what a credential
112/// change ends, with [`CREDENTIALS`], or a revocation, with [`REVOCATION`].
113pub(super) async fn end_credentials(
114    ex: &mut dyn toasty::Executor,
115    user_id: &str,
116    panels: &[Arc<Realm>],
117    purposes: &[TokenPurpose],
118) -> topcoat::Result<()> {
119    let prefixes: Vec<String> = panels.iter().map(|panel| panel.prefix.clone()).collect();
120    AuthSession::filter(
121        AuthSession::fields()
122            .user_id()
123            .eq(user_id.to_string())
124            .and(AuthSession::fields().panel().in_list(prefixes)),
125    )
126    .delete()
127    .exec(&mut *ex)
128    .await
129    .map_err(infrastructure_failure)?;
130    let issuing: Vec<String> = panels
131        .iter()
132        .filter(|panel| panel.auth.issues_tokens())
133        .map(|panel| panel.prefix.clone())
134        .collect();
135    if issuing.is_empty() {
136        // A panel that issues no token may leave its model unregistered.
137        return Ok(());
138    }
139    super::token::end_all(ex, user_id, Some(issuing), purposes).await
140}
141
142/// Replaces every session `user_id` holds, on every panel whose authenticator loads `user_type`,
143/// with `session` on the panel and tenant `signed` acts under, and ends its tokens there,
144/// through `ex`: what a credential change does inside the transaction that writes it, so the new
145/// credential and the revocation commit together.
146pub(super) async fn replace_sessions(
147    cx: &Cx,
148    ex: &mut dyn toasty::Executor,
149    signed: &SignedIn,
150    user_type: std::any::TypeId,
151    session: &session::Session,
152) -> topcoat::Result<()> {
153    let panels = credential_panels(cx, user_type, &signed.realm);
154    let user_id = signed.user.user_id();
155    end_credentials(&mut *ex, &user_id, &panels, CREDENTIALS).await?;
156    let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
157    insert(
158        ex,
159        &session.token_hash,
160        user_id,
161        signed.realm.prefix.clone(),
162        signed.tenant,
163        expires_at,
164    )
165    .await
166    .map_err(infrastructure_failure)
167}
168
169/// Records a session for `user` on the panel `Panel::new(panel)` mounts, as a successful login
170/// does, and returns the token its session cookie carries. No credential is checked.
171///
172/// `tablo::testing`'s `TestClient::sign_in` is the caller: it lets a test act as a signed-in user
173/// without a password hash.
174#[doc(hidden)]
175pub async fn mint_session(
176    ex: &mut dyn toasty::Executor,
177    panel: &str,
178    user: &dyn PanelUser,
179) -> topcoat::Result<String> {
180    let token = session::Token::random();
181    let expires_at = Timestamp::try_from(std::time::SystemTime::now() + SESSION_LIFETIME)
182        .map_err(topcoat::Error::from)?;
183    insert(
184        ex,
185        &token.hash(),
186        user.user_id(),
187        crate::naming::normalize_prefix(panel),
188        None,
189        expires_at,
190    )
191    .await?;
192    Ok(token.encode())
193}
194
195async fn insert(
196    ex: &mut dyn toasty::Executor,
197    hash: &TokenHash,
198    user_id: String,
199    panel: String,
200    tenant: Option<Uuid>,
201    expires_at: Timestamp,
202) -> toasty::Result<()> {
203    toasty::create!(AuthSession {
204        token_hash: token_key(hash),
205        user_id,
206        panel,
207        tenant,
208        expires_at,
209        created_at: Timestamp::now(),
210    })
211    .exec(ex)
212    .await?;
213    Ok(())
214}
215
216pub(super) async fn select_tenant(cx: &Cx, tenant: Uuid) -> topcoat::Result<()> {
217    let Some(hash) = session::token_hash(cx).await? else {
218        return Err(topcoat::router::error::forbidden().into());
219    };
220    let mut db = crate::db::db(cx)?;
221    AuthSession::filter(AuthSession::fields().token_hash().eq(token_key(&hash)))
222        .update()
223        .tenant(Some(tenant))
224        .exec(&mut db)
225        .await
226        .map_err(infrastructure_failure)?;
227    Ok(())
228}
229
230pub(super) async fn delete_session(cx: &Cx, hash: &TokenHash) -> topcoat::Result<()> {
231    delete_session_row(cx, &token_key(hash)).await
232}
233
234/// The session `started` holds, or a new one it then holds: a transaction
235/// [`run_serializable`](super::token::run_serializable) runs again records the session the first
236/// run gave the browser, rather than one more cookie.
237pub(super) async fn start_once(
238    cx: &Cx,
239    started: &mut Option<session::Session>,
240) -> topcoat::Result<session::Session> {
241    if let Some(session) = started {
242        return Ok(session.clone());
243    }
244    let session = session::start(cx).await?;
245    *started = Some(session.clone());
246    Ok(session)
247}
248
249/// Ends the session the request presents and starts a new one, so a token presented before a
250/// sign-in cannot be replayed after it.
251///
252/// # Errors
253///
254/// The opaque sign-in outage when the database cannot answer; any other failure keeps its mapping.
255pub(super) async fn fresh_session(cx: &Cx) -> topcoat::Result<session::Session> {
256    if let Some(hash) = session::token_hash(cx)
257        .await
258        .map_err(infrastructure_failure)?
259    {
260        delete_session(cx, &hash).await?;
261    }
262    session::start(cx).await.map_err(infrastructure_failure)
263}
264
265async fn delete_session_row(cx: &Cx, key: &str) -> topcoat::Result<()> {
266    let mut db = crate::db::db(cx)?;
267    AuthSession::filter(AuthSession::fields().token_hash().eq(key.to_string()))
268        .delete()
269        .exec(&mut db)
270        .await
271        .map_err(infrastructure_failure)?;
272    Ok(())
273}
274
275/// Revokes every live session of `user_id`, its pending sign-ins, its reset links and its pending
276/// email change, on the current panel and on each panel sharing its users, whose authenticator
277/// loads the same user type, or on every panel outside any panel; call it whenever a credential
278/// changes. A link that proves an address or undoes an email change survives.
279///
280/// # Errors
281///
282/// The opaque sign-in outage when the database cannot answer.
283pub async fn revoke_sessions_for_user(cx: &Cx, user_id: &str) -> topcoat::Result<()> {
284    let mut db = crate::db::db(cx)?;
285    if let Some(panel) = current_realm(cx) {
286        let panels = match panel.auth.authenticator() {
287            Some(auth) => credential_panels(cx, auth.user_type(), panel),
288            None => vec![Arc::clone(panel)],
289        };
290        end_credentials(&mut db, user_id, &panels, REVOCATION).await?;
291        wake_bells(
292            cx,
293            user_id,
294            panels.iter().map(|panel| panel.prefix.as_str()),
295        );
296        return Ok(());
297    }
298    AuthSession::filter(AuthSession::fields().user_id().eq(user_id.to_string()))
299        .delete()
300        .exec(&mut db)
301        .await
302        .map_err(infrastructure_failure)?;
303    let realms = realms(cx);
304    if realms.iter().any(|realm| realm.auth.issues_tokens()) {
305        super::token::end_all(&mut db, user_id, None, REVOCATION).await?;
306    }
307    wake_bells(
308        cx,
309        user_id,
310        realms.iter().map(|realm| realm.prefix.as_str()),
311    );
312    Ok(())
313}
314
315/// Wakes the bells `user_id` has open on `panels`, so each checks its session again and stops.
316fn wake_bells<'a>(cx: &Cx, user_id: &str, panels: impl Iterator<Item = &'a str>) {
317    let bus = crate::bus::bus(cx);
318    for panel in panels {
319        bus.wake(panel, user_id);
320    }
321}
322
323/// How many expired session rows one sweep drops: the bound that keeps one
324/// login from deleting an unbounded number of rows.
325pub(super) const SESSION_SWEEP_BATCH: usize = 500;
326
327/// Drops up to [`SESSION_SWEEP_BATCH`] expired session rows on successful login.
328pub(super) async fn sweep_expired_sessions(cx: &Cx) -> topcoat::Result<()> {
329    let now = Timestamp::now();
330    let mut db = crate::db::db(cx)?;
331    let expired: Vec<String> = AuthSession::filter(AuthSession::fields().expires_at().le(now))
332        .limit(SESSION_SWEEP_BATCH)
333        .exec(&mut db)
334        .await
335        .map_err(infrastructure_failure)?
336        .into_iter()
337        .map(|row| row.token_hash)
338        .collect();
339    if expired.is_empty() {
340        return Ok(());
341    }
342    // The expiry is re-checked: the select and the delete are two statements.
343    AuthSession::filter(
344        AuthSession::fields()
345            .token_hash()
346            .in_list(expired)
347            .and(AuthSession::fields().expires_at().le(now)),
348    )
349    .delete()
350    .exec(&mut db)
351    .await
352    .map_err(infrastructure_failure)?;
353    Ok(())
354}
355
356/// Returns the request's live session row, purging it when expired and reading
357/// nothing when no session cookie is present.
358pub(super) async fn session_row(cx: &Cx) -> topcoat::Result<Option<AuthSession>> {
359    let Some(hash) = session::token_hash(cx).await? else {
360        return Ok(None);
361    };
362    let key = token_key(&hash);
363    let mut db = crate::db::db(cx)?;
364    let row = AuthSession::filter(AuthSession::fields().token_hash().eq(key))
365        .first()
366        .exec(&mut db)
367        .await
368        .map_err(infrastructure_failure)?;
369    let Some(row) = row else {
370        return Ok(None);
371    };
372    if row.expires_at <= Timestamp::now() {
373        delete_session_row(cx, &row.token_hash).await?;
374        return Ok(None);
375    }
376    Ok(Some(row))
377}
378
379/// Resolves `row` to its user, purging the row when the user no longer
380/// authenticates.
381pub(super) async fn session_user(
382    cx: &Cx,
383    row: AuthSession,
384    panel: &Arc<Realm>,
385    authenticator: &dyn DynAuthenticator,
386) -> topcoat::Result<Option<SignedIn>> {
387    match authenticator
388        .find_by_id(cx, &row.user_id)
389        .await
390        .map_err(infrastructure_failure)?
391    {
392        Some(user) => Ok(Some(SignedIn {
393            user,
394            realm: Arc::clone(panel),
395            tenant: row.tenant,
396        })),
397        None => {
398            delete_session_row(cx, &row.token_hash).await?;
399            Ok(None)
400        }
401    }
402}
403
404/// Resolves the request's session into `panel`'s user; a session another panel
405/// issued resolves to no one here and stays valid there.
406pub(super) async fn resolve(
407    cx: &Cx,
408    panel: &Arc<Realm>,
409    authenticator: &dyn DynAuthenticator,
410) -> topcoat::Result<Option<SignedIn>> {
411    match session_row(cx).await? {
412        Some(row) if row.panel == panel.prefix => session_user(cx, row, panel, authenticator).await,
413        _ => Ok(None),
414    }
415}