Skip to main content

tablo_core/
auth.rs

1//! Authenticates panel users and gates panel routes.
2//!
3//! Gates panels by default; installs [`PasswordAuth`] or a custom [`Authenticator`].
4//! Reads the signed-in user through [`user`] and [`require_user`]. Stores sessions in
5//! [`AuthSession`] for every authenticator, scoped to the issuing panel.
6
7mod gate;
8mod login;
9mod password;
10mod registration;
11mod session;
12mod throttle;
13
14use std::{
15    any::{Any, TypeId},
16    future::Future,
17    sync::Arc,
18};
19
20use topcoat::{
21    context::{Cx, try_request_context},
22    router::{
23        error::{forbidden, redirect, unauthorized},
24        request::{method, original_headers, original_method, uri},
25    },
26};
27use uuid::Uuid;
28
29pub(crate) use self::{
30    gate::{PanelGate, RuntimeGate},
31    login::{
32        MAX_LOGIN_BYTES, login_page, login_post, logout_post, logout_url, safe_next, tenant_post,
33        tenant_url,
34    },
35    registration::{DynRegistrar, register_page, register_post},
36};
37pub use self::{
38    login::{LOGIN_FIELD, NEXT_FIELD, PASSWORD_FIELD, TENANT_FIELD},
39    password::{AdminUser, PasswordAuth, create_admin, hash_password, verify_password},
40    registration::{MIN_PASSWORD_CHARS, Registrar, SignUp, new_password_errors},
41    session::{AuthSession, SESSION_LIFETIME, mint_session, revoke_sessions_for_user},
42    throttle::LoginThrottle,
43};
44use crate::{
45    BoxFuture,
46    panel::{
47        panel_prefix,
48        state::{PanelState, Panels, current, panels},
49    },
50    tenancy::Membership,
51};
52
53/// A signed-in user, as the panel knows it (ADR-0013).
54///
55/// Implement it for the app's own user type and return that type from an
56/// [`Authenticator`]; the shipped [`AdminUser`] implements it too. App code
57/// reads the user back with its own type through [`user`].
58///
59/// ```rust
60/// # struct Staff {
61/// #     id: uuid::Uuid,
62/// #     name: String,
63/// #     active: bool,
64/// #     memberships: Vec<tablo_core::Membership>,
65/// # }
66/// # use tablo_core::{Membership, PanelUser};
67/// impl PanelUser for Staff {
68///     fn user_id(&self) -> String {
69///         self.id.to_string()
70///     }
71///     fn display_name(&self) -> &str {
72///         &self.name
73///     }
74///     fn can_access_panel(&self) -> bool {
75///         self.active
76///     }
77///     fn tenants(&self) -> &[Membership] {
78///         &self.memberships
79///     }
80/// }
81/// ```
82pub trait PanelUser: Any + Send + Sync {
83    /// The stable key the session stores and
84    /// [`Authenticator::find_by_id`] receives. Any string fits.
85    fn user_id(&self) -> String;
86
87    /// The name the panel's top bar shows.
88    fn display_name(&self) -> &str;
89
90    /// Whether the user may enter the panel. A user who may not answers 403,
91    /// indistinguishable from bad credentials at login. Defaults to `true`.
92    fn can_access_panel(&self) -> bool {
93        true
94    }
95
96    /// The tenants the user may act for, in the order the tenant switcher
97    /// lists them. The first is the default tenant until the user selects
98    /// another. Defaults to none: the user has no tenant.
99    fn tenants(&self) -> &[Membership] {
100        &[]
101    }
102}
103
104/// How a panel loads its users (ADR-0013).
105///
106/// The default [`PasswordAuth`] implements it against the shipped
107/// [`AdminUser`] model; an app with an existing user table implements it and
108/// passes the value to [`Panel::auth`](crate::Panel::auth) via
109/// [`Auth::custom`]. Sessions are the framework's, so an implementation only
110/// maps credentials to a user and a stored id back to one.
111///
112/// Every *credential* failure must return `Ok(None)`, never a distinguishable
113/// error: the login response is one generic message for all of them. An
114/// infrastructure failure is not a credential verdict, so an implementation
115/// that cannot reach its store returns the driver's error instead — the login
116/// handler maps it to the opaque outage page, which keeps a database outage
117/// from rendering as a rejected password. An implementation's own error keeps
118/// its own mapping.
119pub trait Authenticator: Send + Sync + 'static {
120    /// The app's user type.
121    type User: PanelUser;
122
123    /// Verify `login`/`password`, returning the user on success.
124    ///
125    /// Implementations must run comparable work for unknown accounts so
126    /// timing does not leak account existence; [`verify_password`] does, given
127    /// `None` for an unknown account.
128    fn verify(
129        &self,
130        cx: &Cx,
131        login: &str,
132        password: &str,
133    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
134
135    /// Load the session's user by [`PanelUser::user_id`], with everything the request
136    /// reads from it — its [`tenants`](PanelUser::tenants) included.
137    ///
138    /// It runs on every request, which is what makes deactivation, revocation
139    /// and a removed membership take effect immediately; return `None` when
140    /// the user no longer authenticates.
141    fn find_by_id(
142        &self,
143        cx: &Cx,
144        id: &str,
145    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
146}
147
148/// [`Authenticator`] with its user type erased, as the panel keeps it.
149pub(crate) trait DynAuthenticator: Send + Sync {
150    fn verify<'a>(
151        &'a self,
152        cx: &'a Cx,
153        login: &'a str,
154        password: &'a str,
155    ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
156    fn find_by_id<'a>(
157        &'a self,
158        cx: &'a Cx,
159        id: &'a str,
160    ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
161    /// The [`TypeId`] of [`Authenticator::User`], so mounting knows when the
162    /// shipped [`AdminUser`] must be registered.
163    fn user_type(&self) -> TypeId;
164}
165
166impl<A: Authenticator> DynAuthenticator for A {
167    fn verify<'a>(
168        &'a self,
169        cx: &'a Cx,
170        login: &'a str,
171        password: &'a str,
172    ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
173        Box::pin(async move {
174            let user = Authenticator::verify(self, cx, login, password).await?;
175            Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
176        })
177    }
178
179    fn find_by_id<'a>(
180        &'a self,
181        cx: &'a Cx,
182        id: &'a str,
183    ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
184        Box::pin(async move {
185            let user = Authenticator::find_by_id(self, cx, id).await?;
186            Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
187        })
188    }
189
190    fn user_type(&self) -> TypeId {
191        TypeId::of::<A::User>()
192    }
193}
194
195/// The panel's authentication configuration (ADR-0013).
196///
197/// The default is [`Auth::password`]; [`Auth::custom`] installs an app-owned
198/// [`Authenticator`]; [`Auth::disabled`] is the explicit fail-open opt-out.
199/// Sign-in attempts are throttled per login by default; see [`Auth::throttle`].
200/// [`Auth::registration`] adds a sign-up page.
201pub struct Auth {
202    authenticator: Option<Box<dyn DynAuthenticator>>,
203    throttle: LoginThrottle,
204    registrar: Option<Box<dyn DynRegistrar>>,
205    sign_up_throttle: LoginThrottle,
206}
207
208impl Auth {
209    /// The shipped Argon2id + [`AdminUser`] authenticator.
210    #[must_use]
211    pub fn password() -> Self {
212        Self::custom(PasswordAuth)
213    }
214
215    /// An app-owned authenticator over its own user table.
216    #[must_use]
217    pub fn custom(authenticator: impl Authenticator) -> Self {
218        Self {
219            authenticator: Some(Box::new(authenticator)),
220            throttle: LoginThrottle::default(),
221            registrar: None,
222            sign_up_throttle: LoginThrottle::default(),
223        }
224    }
225
226    /// Explicit fail-open opt-out for public demos (ADR-0013): no gate, no
227    /// login routes, sessions unused.
228    #[must_use]
229    pub fn disabled() -> Self {
230        Self {
231            authenticator: None,
232            throttle: LoginThrottle::off(),
233            registrar: None,
234            sign_up_throttle: LoginThrottle::off(),
235        }
236    }
237
238    /// How many sign-in attempts one login may make from one client address
239    /// in a window; defaults to five a minute. [`LoginThrottle::off`] counts nothing, for a proxy
240    /// that limits sign-ins itself.
241    #[must_use]
242    pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
243        self.throttle = throttle;
244        self
245    }
246
247    /// Lets visitors create their own account at `{prefix}/register`, through `registrar`; the
248    /// login page links to it.
249    ///
250    /// ```rust
251    /// use tablo_core::{Auth, PasswordAuth};
252    ///
253    /// let auth = Auth::password().registration(PasswordAuth);
254    /// # let _ = auth;
255    /// ```
256    ///
257    /// [`PasswordAuth`] registers an active [`AdminUser`] from a [`SignUp`], who signs in to the
258    /// panel at once: only open it where any visitor may hold an account. A custom
259    /// [`Registrar`] decides what a new account may do. Mounting refuses a registrar whose
260    /// [`User`](Registrar::User) is not the type the authenticator loads, and a registration
261    /// on [`Auth::disabled`].
262    #[must_use]
263    pub fn registration(mut self, registrar: impl Registrar) -> Self {
264        self.registrar = Some(Box::new(registrar));
265        self
266    }
267
268    /// How many sign-ups one client address may submit in a window; defaults to five a minute.
269    /// Every sign-up POST counts, whether or not it creates an account.
270    #[must_use]
271    pub fn sign_up_throttle(mut self, throttle: LoginThrottle) -> Self {
272        self.sign_up_throttle = throttle;
273        self
274    }
275
276    /// Whether the explicit opt-out is set.
277    #[must_use]
278    pub fn is_disabled(&self) -> bool {
279        self.authenticator.is_none()
280    }
281
282    /// The authenticator, or `None` when auth is disabled.
283    pub(crate) fn authenticator(&self) -> Option<&dyn DynAuthenticator> {
284        self.authenticator.as_deref()
285    }
286
287    /// The sign-in throttle.
288    pub(crate) fn login_throttle(&self) -> &LoginThrottle {
289        &self.throttle
290    }
291
292    /// The registrar, or `None` without a sign-up page.
293    pub(crate) fn registrar(&self) -> Option<&dyn DynRegistrar> {
294        self.authenticator.as_ref().and(self.registrar.as_deref())
295    }
296
297    /// The sign-up throttle, counted per client address.
298    pub(crate) fn throttle_sign_ups(&self) -> &LoginThrottle {
299        &self.sign_up_throttle
300    }
301}
302
303impl Default for Auth {
304    fn default() -> Self {
305        Self::password()
306    }
307}
308
309impl std::fmt::Debug for Auth {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        if self.is_disabled() {
312            f.write_str("Auth::disabled")
313        } else {
314            f.debug_struct("Auth")
315                .field("throttle", &self.throttle)
316                .field("registration", &self.registrar.is_some())
317                .finish_non_exhaustive()
318        }
319    }
320}
321
322/// The user a panel's gate resolved, as request `Cx` carries it.
323#[derive(Clone)]
324pub(crate) struct SignedIn {
325    pub(crate) user: Arc<dyn PanelUser>,
326    /// The panel whose session signed the user in: the only panel the user
327    /// is the user of.
328    pub(crate) panel: Arc<PanelState>,
329    /// The tenant the session selected, if any. [`tenant_id`](crate::tenant_id)
330    /// honors it only while it is one of the user's memberships.
331    pub(crate) tenant: Option<Uuid>,
332}
333
334/// The request's signed-in user on the request's panel, whatever their panel
335/// access. Only the logout route reads a user without access.
336pub(crate) fn resolved(cx: &Cx) -> Option<&SignedIn> {
337    let signed = try_request_context::<SignedIn>(cx)?;
338    match current(cx) {
339        Some(panel) if !Arc::ptr_eq(&signed.panel, panel) => None,
340        _ => Some(signed),
341    }
342}
343
344/// The request's signed-in user, erased: on the request's panel, with panel
345/// access.
346pub(crate) fn signed(cx: &Cx) -> Option<&SignedIn> {
347    resolved(cx).filter(|signed| signed.user.can_access_panel())
348}
349
350/// The signed-in user's membership the request acts under: the tenant the
351/// session selected while it is still one of the user's
352/// [`tenants`](PanelUser::tenants), else the first. `None` without a
353/// signed-in user, for a user with no tenant, and when a `Tenant` override
354/// names a tenant the user is not a member of.
355pub fn membership(cx: &Cx) -> Option<&Membership> {
356    let tenant = crate::tenant_id(cx)?;
357    signed(cx)?
358        .user
359        .tenants()
360        .iter()
361        .find(|m| m.tenant == tenant)
362}
363
364/// The tenant of the session's selected membership, else of the user's first: the
365/// [`TenantSource`](crate::tenancy::TenantSource) the panel installs.
366pub(crate) fn session_tenant(cx: &Cx) -> Option<Uuid> {
367    let signed = signed(cx)?;
368    let tenants = signed.user.tenants();
369    signed
370        .tenant
371        .and_then(|tenant| tenants.iter().find(|m| m.tenant == tenant))
372        .or_else(|| tenants.first())
373        .map(|membership| membership.tenant)
374}
375
376/// The signed-in user, as the app's own user type.
377///
378/// `None` when nobody is signed in to the request's panel, and when the
379/// panel's [`Authenticator`] loads another type than `U` — a helper shared by
380/// two panels with different user types answers `None` on the other one.
381///
382/// ```rust
383/// # struct Staff { admin: bool }
384/// # use tablo_core::{PanelUser, auth};
385/// # use topcoat::context::Cx;
386/// # impl PanelUser for Staff {
387/// #     fn user_id(&self) -> String { String::new() }
388/// #     fn display_name(&self) -> &str { "" }
389/// # }
390/// fn admins_only(cx: &Cx) -> bool {
391///     auth::user::<Staff>(cx).is_some_and(|staff| staff.admin)
392/// }
393/// ```
394pub fn user<U: PanelUser>(cx: &Cx) -> Option<&U> {
395    let user: &dyn Any = &*signed(cx)?.user;
396    user.downcast_ref()
397}
398
399/// Require the signed-in user, as the app's own user type.
400///
401/// # Errors
402///
403/// Answers per request kind (ADR-0013): pages redirect to the login route with
404/// a same-origin-relative `next`; runtime endpoints, non-GET requests, and
405/// page re-runs (marked POSTs the runtime layer rewrites into GETs) answer
406/// 401. A signed-in user of another type than `U` answers 403.
407pub fn require_user<U: PanelUser>(cx: &Cx) -> topcoat::Result<&U> {
408    match signed(cx) {
409        Some(_) => user(cx).ok_or_else(|| forbidden().into()),
410        None => Err(unauthenticated_error(cx)),
411    }
412}
413
414/// Whether a user is signed in to the request's panel.
415pub fn signed_in(cx: &Cx) -> bool {
416    signed(cx).is_some()
417}
418
419/// Whether the request's panel requires a signed-in user.
420///
421/// Outside any panel — an app's own shard or page on a router that mounts
422/// several — it answers whether some mounted panel does, so a check there
423/// fails closed.
424pub fn enforced(cx: &Cx) -> bool {
425    match current(cx) {
426        Some(panel) => panel.gates(),
427        None => panels(cx).is_some_and(Panels::any_gates),
428    }
429}
430
431/// Require a signed-in user when the request's panel requires sign-in.
432///
433/// Every panel handler runs it first, and a page, route or shard the app
434/// serves under a panel runs it to answer exactly as the panel's own pages do.
435/// Shards are the case that needs it: Topcoat serves them at its runtime path,
436/// where the page's checks do not run. A form route also verifies its CSRF token with
437/// [`csrf::verify`](crate::csrf::verify).
438///
439/// # Errors
440///
441/// 401, or a redirect to the login page, as [`require_user`] answers.
442pub fn guard(cx: &Cx) -> topcoat::Result<()> {
443    if enforced(cx) && !signed_in(cx) {
444        return Err(unauthenticated_error(cx));
445    }
446    Ok(())
447}
448
449/// The error an unauthenticated request answers with, by request kind.
450fn unauthenticated_error(cx: &Cx) -> topcoat::Error {
451    let path = uri(cx).path();
452    let page_method = matches!(*method(cx), http::Method::GET | http::Method::HEAD);
453    // A page re-run reaches the gate as a rewritten GET: Topcoat's runtime
454    // layer rewrites the browser's marked POST into a GET for the page's own
455    // URL. The original request tells it apart from a plain page load, so a
456    // logged-out re-run answers 401 like any other non-page request instead
457    // of redirecting into the login page.
458    let rerun = !matches!(*original_method(cx), http::Method::GET | http::Method::HEAD)
459        && original_headers(cx).get(&topcoat::runtime::RUNTIME_HEADER) == Some(&RERUN_MARKER);
460    if path.starts_with(crate::topcoat_compat::RUNTIME_PREFIX) || !page_method || rerun {
461        unauthorized().into()
462    } else {
463        redirect(login::login_url_with_next(cx)).into()
464    }
465}
466
467/// The panel root: where a completed login or tenant switch lands.
468fn panel_root(cx: &Cx) -> String {
469    panel_prefix(cx)
470}
471
472/// Map a failed auth or session operation to the error the response carries:
473/// [`crate::error::driver_failure`] with the sign-in outage copy.
474///
475/// A driver failure becomes an infrastructure error whose `Display` is
476/// [`UNAVAILABLE_ERROR`], and the driver's own text goes to the log, never to
477/// the page; anything else is app-authored (a custom [`Authenticator`]'s own
478/// error) and keeps its mapping. Every auth and session path maps through
479/// here, which is what keeps a database outage from reading as a rejected
480/// password — and what makes the two distinguishable in the logs: a rejection
481/// is `Ok(None)`, the generic 403 and no error line, while an outage logs the
482/// driver's text.
483fn infrastructure_failure(error: impl Into<topcoat::Error>) -> topcoat::Error {
484    crate::error::driver_failure(error, UNAVAILABLE_ERROR)
485}
486
487/// What a failed login attempt renders when the database behind it could not
488/// answer.
489///
490/// The credential rejection is deliberately non-specific about *why*
491/// credentials were refused, so reusing it for an outage would tell a user
492/// their password was wrong while the database was down — the one place they
493/// would retry uselessly. This copy names the machinery that is down instead,
494/// and like the generic one it never carries driver text: that goes to the
495/// log.
496const UNAVAILABLE_ERROR: &str = "Sign-in is unavailable right now. Try again shortly.";
497
498/// The runtime-header value marking a page re-run POST (`true`, per Topcoat's
499/// page-rerun protocol).
500static RERUN_MARKER: http::HeaderValue = http::HeaderValue::from_static("true");
501
502/// Fail loudly at mount when a required shipped model is missing from the
503/// app's `Db` (ADR-0013): the table is never pushed, and the first login
504/// would otherwise be a confusing runtime error.
505pub(crate) fn check_models_registered(
506    db: &toasty::Db,
507    auth: &Auth,
508) -> Result<(), crate::DeclarationErrorKind> {
509    let Some(authenticator) = auth.authenticator() else {
510        return Ok(());
511    };
512    let schema = crate::toasty_compat::model::AppSchema::of_db(db);
513    let registered = |name: &str| schema.registers(name);
514    let shipped_user = authenticator.user_type() == TypeId::of::<AdminUser>();
515    let models: Vec<&'static str> = [
516        (!registered("AuthSession")).then_some("AuthSession"),
517        (shipped_user && !registered("AdminUser")).then_some("AdminUser"),
518    ]
519    .into_iter()
520    .flatten()
521    .collect();
522    if models.is_empty() {
523        Ok(())
524    } else {
525        Err(crate::DeclarationErrorKind::MissingAuthModels { models })
526    }
527}
528
529/// The mistakes in a panel's [`Auth::registration`]: a registration without auth, a registrar
530/// whose user is not the authenticator's, and a sign-up form field the page cannot serve.
531pub(crate) fn check_registration(auth: &Auth) -> Vec<crate::DeclarationErrorKind> {
532    use crate::{DeclarationErrorKind, declaration::SignUpFault};
533
534    let Some(registrar) = auth.registrar.as_deref() else {
535        return Vec::new();
536    };
537    let Some(authenticator) = auth.authenticator() else {
538        return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
539    };
540    let mut kinds = Vec::new();
541    let (user, user_name) = registrar.user_type();
542    if user != authenticator.user_type() {
543        kinds.push(DeclarationErrorKind::SignUp(SignUpFault::UserType {
544            registrar: user_name,
545        }));
546    }
547    let schema = registrar.schema();
548    kinds.extend(schema.declaration_errors());
549    kinds.extend(schema.empty_choices());
550    kinds.extend(schema.fields().filter_map(|field| {
551        let name = field.name().to_string();
552        let fault = if registration::RESERVED_KEYS.contains(&name.as_str()) {
553            SignUpFault::ReservedField(name)
554        } else if field.is_file() {
555            SignUpFault::FileField(name)
556        } else if field
557            .as_choice()
558            .is_some_and(|choice| choice.is_relationship())
559        {
560            SignUpFault::RelationshipField(name)
561        } else {
562            return None;
563        };
564        Some(DeclarationErrorKind::SignUp(fault))
565    }));
566    kinds
567}
568
569#[cfg(test)]
570mod tests;