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;