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