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;