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::{DynAuthenticator, PanelUser, SignedIn, infrastructure_failure};
14use crate::panel::state::{PanelState, current};
15
16/// How long a session stays valid: seven days, fixed (ADR-0013).
17pub const SESSION_LIFETIME: Duration = Duration::from_hours(24 * 7);
18
19/// Shipped server-side session record: the SHA-256 hash of the client token,
20/// the user it authenticates, the panel that signed the user in, and its
21/// expiry. The raw token is never stored.
22#[derive(Debug, Clone, toasty::Model)]
23pub struct AuthSession {
24    /// Hex-encoded SHA-256 of the session token.
25    #[key]
26    pub token_hash: String,
27    /// [`PanelUser::user_id`] of the authenticated user.
28    #[index]
29    pub user_id: String,
30    /// The prefix of the panel that signed the user in (e.g. `/admin`): the
31    /// only panel the session authenticates.
32    pub panel: String,
33    /// The tenant the user selected; `None` acts for the first.
34    pub tenant: Option<Uuid>,
35    /// Indexed for the login sweep's range scan on this column.
36    #[index]
37    pub expires_at: Timestamp,
38    pub created_at: Timestamp,
39}
40
41pub(super) fn token_key(hash: &TokenHash) -> String {
42    use std::fmt::Write as _;
43
44    let mut key = String::with_capacity(64);
45    for byte in hash.iter() {
46        write!(key, "{byte:02x}").expect("writing to a String cannot fail");
47    }
48    key
49}
50
51pub(super) async fn record(
52    cx: &Cx,
53    session: &session::Session,
54    user: &dyn PanelUser,
55    panel: &PanelState,
56) -> topcoat::Result<()> {
57    let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
58    insert(
59        &mut crate::db::db(cx),
60        &session.token_hash,
61        user.user_id(),
62        panel.prefix.clone(),
63        expires_at,
64    )
65    .await
66    .map_err(infrastructure_failure)
67}
68
69/// Records a session for `user` on the panel `Panel::new(panel)` mounts, as a successful login
70/// does, and returns the token its session cookie carries. No credential is checked.
71///
72/// `tablo::testing`'s `TestClient::sign_in` is the caller: it lets a test act as a signed-in user
73/// without a password hash.
74#[doc(hidden)]
75pub async fn mint_session(
76    ex: &mut dyn toasty::Executor,
77    panel: &str,
78    user: &dyn PanelUser,
79) -> topcoat::Result<String> {
80    let token = session::Token::random();
81    let expires_at = Timestamp::try_from(std::time::SystemTime::now() + SESSION_LIFETIME)
82        .map_err(topcoat::Error::from)?;
83    insert(
84        ex,
85        &token.hash(),
86        user.user_id(),
87        crate::panel::normalize_prefix(panel),
88        expires_at,
89    )
90    .await?;
91    Ok(token.encode())
92}
93
94async fn insert(
95    ex: &mut dyn toasty::Executor,
96    hash: &TokenHash,
97    user_id: String,
98    panel: String,
99    expires_at: Timestamp,
100) -> toasty::Result<()> {
101    toasty::create!(AuthSession {
102        token_hash: token_key(hash),
103        user_id,
104        panel,
105        tenant: None,
106        expires_at,
107        created_at: Timestamp::now(),
108    })
109    .exec(ex)
110    .await?;
111    Ok(())
112}
113
114pub(super) async fn select_tenant(cx: &Cx, tenant: Uuid) -> topcoat::Result<()> {
115    let Some(hash) = session::token_hash(cx).await? else {
116        return Err(topcoat::router::error::forbidden().into());
117    };
118    let mut db = crate::db::db(cx);
119    AuthSession::filter(AuthSession::fields().token_hash().eq(token_key(&hash)))
120        .update()
121        .tenant(Some(tenant))
122        .exec(&mut db)
123        .await
124        .map_err(infrastructure_failure)?;
125    Ok(())
126}
127
128pub(super) async fn delete_session(cx: &Cx, hash: &TokenHash) -> topcoat::Result<()> {
129    delete_session_row(cx, &token_key(hash)).await
130}
131
132async fn delete_session_row(cx: &Cx, key: &str) -> topcoat::Result<()> {
133    let mut db = crate::db::db(cx);
134    AuthSession::filter(AuthSession::fields().token_hash().eq(key.to_string()))
135        .delete()
136        .exec(&mut db)
137        .await
138        .map_err(infrastructure_failure)?;
139    Ok(())
140}
141
142/// Revokes every live session of `user_id` for the current panel, or on every
143/// panel outside any panel; call it whenever a credential changes.
144///
145/// # Errors
146///
147/// The opaque sign-in outage when the database cannot answer.
148pub async fn revoke_sessions_for_user(cx: &Cx, user_id: &str) -> topcoat::Result<()> {
149    let mut db = crate::db::db(cx);
150    let user = AuthSession::fields().user_id().eq(user_id.to_string());
151    let sessions = match current(cx) {
152        Some(panel) => {
153            AuthSession::filter(user.and(AuthSession::fields().panel().eq(panel.prefix.clone())))
154        }
155        None => AuthSession::filter(user),
156    };
157    sessions
158        .delete()
159        .exec(&mut db)
160        .await
161        .map_err(infrastructure_failure)?;
162    Ok(())
163}
164
165/// How many expired session rows one sweep drops: the bound that keeps one
166/// login from deleting an unbounded number of rows.
167pub(super) const SESSION_SWEEP_BATCH: usize = 500;
168
169/// Drops up to [`SESSION_SWEEP_BATCH`] expired session rows on successful login.
170pub(super) async fn sweep_expired_sessions(cx: &Cx) -> topcoat::Result<()> {
171    let now = Timestamp::now();
172    let mut db = crate::db::db(cx);
173    let expired: Vec<String> = AuthSession::filter(AuthSession::fields().expires_at().le(now))
174        .limit(SESSION_SWEEP_BATCH)
175        .exec(&mut db)
176        .await
177        .map_err(infrastructure_failure)?
178        .into_iter()
179        .map(|row| row.token_hash)
180        .collect();
181    if expired.is_empty() {
182        return Ok(());
183    }
184    // The expiry is re-checked: the select and the delete are two statements.
185    AuthSession::filter(
186        AuthSession::fields()
187            .token_hash()
188            .in_list(expired)
189            .and(AuthSession::fields().expires_at().le(now)),
190    )
191    .delete()
192    .exec(&mut db)
193    .await
194    .map_err(infrastructure_failure)?;
195    Ok(())
196}
197
198/// Returns the request's live session row, purging it when expired and reading
199/// nothing when no session cookie is present.
200pub(super) async fn session_row(cx: &Cx) -> topcoat::Result<Option<AuthSession>> {
201    let Some(hash) = session::token_hash(cx).await? else {
202        return Ok(None);
203    };
204    let key = token_key(&hash);
205    let mut db = crate::db::db(cx);
206    let row = AuthSession::filter(AuthSession::fields().token_hash().eq(key))
207        .first()
208        .exec(&mut db)
209        .await
210        .map_err(infrastructure_failure)?;
211    let Some(row) = row else {
212        return Ok(None);
213    };
214    if row.expires_at <= Timestamp::now() {
215        delete_session_row(cx, &row.token_hash).await?;
216        return Ok(None);
217    }
218    Ok(Some(row))
219}
220
221/// Resolves `row` to its user, purging the row when the user no longer
222/// authenticates.
223pub(super) async fn session_user(
224    cx: &Cx,
225    row: AuthSession,
226    panel: &Arc<PanelState>,
227    authenticator: &dyn DynAuthenticator,
228) -> topcoat::Result<Option<SignedIn>> {
229    match authenticator
230        .find_by_id(cx, &row.user_id)
231        .await
232        .map_err(infrastructure_failure)?
233    {
234        Some(user) => Ok(Some(SignedIn {
235            user,
236            panel: Arc::clone(panel),
237            tenant: row.tenant,
238        })),
239        None => {
240            delete_session_row(cx, &row.token_hash).await?;
241            Ok(None)
242        }
243    }
244}
245
246/// Resolves the request's session into `panel`'s user; a session another panel
247/// issued resolves to no one here and stays valid there.
248pub(super) async fn resolve(
249    cx: &Cx,
250    panel: &Arc<PanelState>,
251    authenticator: &dyn DynAuthenticator,
252) -> topcoat::Result<Option<SignedIn>> {
253    match session_row(cx).await? {
254        Some(row) if row.panel == panel.prefix => session_user(cx, row, panel, authenticator).await,
255        _ => Ok(None),
256    }
257}