Skip to main content

renox_core/auth/
mod.rs

1//! Authentication and authorization: the `users` table, password hashing,
2//! session login with "remember me", route guards, policies and gates.
3//!
4//! ```
5//! # #[derive(Model, serde::Serialize, Default)]
6//! # #[model(table = "products")]
7//! # struct Product { id: i64, name: String, price: i64, category: Option<String>, user_id: i64 }
8//! # use renox::prelude::*;
9//! # impl Policy for Product { fn allows(&self, user: &User, _: &str) -> bool { self.user_id == user.id } }
10//! # let _ =
11//! App::new()
12//!     .module(Auth::new())                       // /login, /register, /logout
13//!     .gate("admin", |user| user.email.ends_with("@shop.example"))
14//!     .module(Shop)
15//! # ;
16//!
17//! struct Shop;
18//!
19//! impl Module for Shop {
20//! #   fn name(&self) -> &'static str { "shop" }
21//!     fn routes(&self) -> Routes {
22//!         Routes::new()
23//!             .get("/products/{id}/edit", edit)
24//!             .require_auth()                    // everything above needs a login
25//!     }
26//! }
27//!
28//! async fn edit(auth: AuthUser, State(db): State<Db>, Path(id): Path<i64>) -> Result<View> {
29//!     let product = Product::find_or_404(&db, id).await?;
30//!     auth.authorize("update", &product)?;       // 403 unless the policy allows it
31//!     Ok(view("products/edit.html", context! { product }))
32//! }
33//! ```
34
35pub(crate) mod account;
36mod device;
37pub mod events;
38mod external;
39mod inbox;
40pub(crate) mod module;
41pub mod notifications;
42mod passwords;
43pub mod permissions;
44pub mod second_factor;
45mod throttle;
46mod tokens;
47mod user;
48mod verification;
49
50use std::collections::HashMap;
51use std::convert::Infallible;
52use std::ops::Deref;
53use std::sync::Arc;
54
55use axum::extract::{FromRequestParts, OptionalFromRequestParts, Request};
56use axum::http::header::{ACCEPT, AUTHORIZATION};
57use axum::http::request::Parts;
58use axum::http::{Method, StatusCode};
59use axum::middleware::Next;
60use axum::response::{IntoResponse, Redirect, Response};
61
62pub(crate) use account::require_password_confirmed;
63pub(crate) use device::require as device_guard;
64pub use device::{Device, DeviceToken, NewDeviceToken};
65pub use external::{confirm_identity, register_verified, registration_open, sign_in};
66pub use module::{Auth, Registration};
67pub use notifications::{
68    Channel, DatabaseMessage, DatabaseNotification, Notification, Recipient,
69    prune_read_notifications,
70};
71pub(crate) use permissions::Grants;
72pub use permissions::Permissions;
73pub use second_factor::{PendingLogin, complete_login, pending_login};
74pub(crate) use throttle::LoginThrottle;
75pub use tokens::{AccessToken, NewToken, prune_expired_tokens};
76pub use user::{User, hash_password, needs_rehash, normalize_email, verify_password};
77pub use verification::send_verification;
78
79use crate::crypto::constant_time_eq;
80use crate::db::Db;
81use crate::{AppState, Error, Htmx, HxRedirect, Result, Session};
82
83pub(crate) const AUTH_ID: &str = "_auth_user_id";
84const AUTH_HASH: &str = "_auth_password_hash";
85/// Unix milliseconds of the login, compared with `users.sessions_revoked_at`.
86const AUTH_AT: &str = "_auth_at";
87/// A random id per login, so one device can be logged out (`revoked_sessions`).
88const AUTH_SID: &str = "_auth_session_id";
89const INTENDED: &str = "_intended";
90
91/// Decides whether a user may perform an ability on a model.
92///
93/// ```
94/// # #[derive(Model, serde::Serialize, Default)]
95/// # #[model(table = "products")]
96/// # struct Product { id: i64, name: String, price: i64, category: Option<String>, user_id: i64 }
97/// # use renox::prelude::*;
98/// impl Policy for Product {
99///     fn allows(&self, user: &User, ability: &str) -> bool {
100///         match ability {
101///             "update" | "delete" => self.user_id == user.id,
102///             _ => true,
103///         }
104///     }
105/// }
106/// ```
107pub trait Policy {
108    /// Whether `user` may perform `ability` (e.g. `"update"`) on this model.
109    fn allows(&self, user: &User, ability: &str) -> bool;
110}
111
112/// A model together with what the current user may do with it, so templates
113/// can ask the policy: `{% if can('update', product) %}`.
114///
115/// ```
116/// # use renox::prelude::*;
117/// # #[derive(Model, serde::Serialize, Default)] struct Product { id: i64, user_id: i64 }
118/// # impl Policy for Product { fn allows(&self, user: &User, _: &str) -> bool { self.user_id == user.id } }
119/// async fn index(State(db): State<Db>, user: Option<AuthUser>, Page(page): Page) -> Result<View> {
120///     let products = Product::query().paginate(&db, page, 20).await?
121///         .map(|p| Can::new(p, user.as_deref(), &["update", "delete"]));
122///     Ok(view("products/index.html", context! { products }))
123/// }
124/// ```
125///
126/// It serializes as the model's own fields plus `_can` (`{"update": true, …}`);
127/// guests get `false` for every ability.
128#[derive(Debug, Clone, serde::Serialize)]
129#[non_exhaustive]
130pub struct Can<T> {
131    /// The model, serialized as its own fields.
132    #[serde(flatten)]
133    pub item: T,
134    /// Each asked ability and whether it is allowed, serialized as `_can`.
135    #[serde(rename = "_can")]
136    pub abilities: std::collections::BTreeMap<String, bool>,
137}
138
139/// Who asks a policy, for [`Can::new`]: a [`User`], or an [`AuthUser`],
140/// which also applies `App::gate_before` (pass `user.as_ref()` for that).
141/// Sealed: only those two.
142pub trait Viewer: viewer::Sealed {
143    /// The user the policy is asked about.
144    fn as_user(&self) -> &User;
145
146    /// `App::gate_before`'s answer, if any.
147    fn before(&self, _ability: &str) -> Option<bool> {
148        None
149    }
150}
151
152mod viewer {
153    pub trait Sealed {}
154    impl Sealed for super::User {}
155    impl Sealed for super::AuthUser {}
156}
157
158impl Viewer for User {
159    fn as_user(&self) -> &User {
160        self
161    }
162}
163
164impl Viewer for AuthUser {
165    fn as_user(&self) -> &User {
166        &self.user
167    }
168
169    fn before(&self, ability: &str) -> Option<bool> {
170        AuthUser::before(self, ability)
171    }
172}
173
174impl<T: Policy> Can<T> {
175    /// Asks `item`'s policy about each of `abilities`; a guest (`None`) gets `false` for all.
176    pub fn new<V: Viewer + ?Sized>(item: T, user: Option<&V>, abilities: &[&str]) -> Self {
177        let abilities = abilities
178            .iter()
179            .map(|ability| {
180                let allowed = user.is_some_and(|user| {
181                    user.before(ability)
182                        .unwrap_or_else(|| item.allows(user.as_user(), ability))
183                });
184                ((*ability).to_owned(), allowed)
185            })
186            .collect();
187        Self { item, abilities }
188    }
189}
190
191pub(crate) type Gate = Arc<dyn Fn(&User) -> bool + Send + Sync>;
192pub(crate) type GateBefore = Arc<dyn Fn(&User, &str) -> Option<bool> + Send + Sync>;
193pub(crate) type Gates = Arc<Access>;
194
195/// The app's gates (`App::gate`), its `App::gate_before` hook, and whether
196/// the `Permissions` module is on.
197#[derive(Default)]
198pub(crate) struct Access {
199    pub gates: HashMap<String, Gate>,
200    pub before: Option<GateBefore>,
201    pub permissions: bool,
202}
203
204impl Access {
205    /// A gate or permission named `name`: `gate_before` first, then the gate,
206    /// then the user's permissions. Unknown names deny.
207    pub(crate) fn check(&self, user: &User, grants: &Grants, name: &str) -> bool {
208        if let Some(allowed) = self.before.as_ref().and_then(|before| before(user, name)) {
209            return allowed;
210        }
211        match self.gates.get(name) {
212            Some(check) => check(user),
213            None => grants.has_permission(name),
214        }
215    }
216}
217
218/// The logged-in user's grants, in [`crate::context`] for the request.
219#[derive(Clone)]
220pub(crate) struct CurrentGrants {
221    user_id: i64,
222    grants: Arc<Grants>,
223}
224
225impl User {
226    /// Whether this user has `role` (the `Permissions` module), answered
227    /// from the roles loaded for the current request, so it works in
228    /// `Policy::allows` and `App::gate_before` ("admins may do anything").
229    /// It is `false` for any other user, and outside a request (a job, a
230    /// command): use the async `user.roles(&db)` there.
231    ///
232    /// Global roles count, plus those given in the request's scope
233    /// (`permissions::set_scope`), within their dates.
234    pub fn has_role(&self, role: &str) -> bool {
235        current_grants(self.id).is_some_and(|g| g.has_role(role))
236    }
237
238    /// Like [`User::has_role`], for a permission granted by one of the
239    /// user's roles.
240    pub fn has_permission(&self, permission: &str) -> bool {
241        current_grants(self.id).is_some_and(|g| g.has_permission(permission))
242    }
243}
244
245/// The logged-in user's id in the current request, if any.
246pub(crate) fn current_user_id() -> Option<i64> {
247    crate::context::get::<CurrentGrants>().map(|current| current.user_id)
248}
249
250fn current_grants(user_id: i64) -> Option<Arc<Grants>> {
251    crate::context::get::<CurrentGrants>()
252        .filter(|current| current.user_id == user_id)
253        .map(|current| current.grants)
254}
255
256pub(crate) type AsyncGate = Arc<
257    dyn Fn(
258            User,
259            AppState,
260        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send>>
261        + Send
262        + Sync,
263>;
264
265/// The logged-in user of the current request, if any; set by Renox's auth
266/// middleware for every request.
267#[derive(Clone)]
268pub(crate) struct CurrentUser {
269    pub user: Option<Arc<User>>,
270    pub gates: Gates,
271    /// The API token of `Authorization: Bearer`, when that authenticated
272    /// the request (so CSRF doesn't apply).
273    pub token_id: Option<i64>,
274    /// That token's abilities; `None` for every ability (sessions, and
275    /// tokens made without a list).
276    pub abilities: Option<Arc<Vec<String>>>,
277    /// The device a device token authenticated (no user then).
278    pub device: Option<Arc<Device>>,
279    pub grants: Arc<Grants>,
280}
281
282/// The logged-in user. Requests without one are sent to the `login` route
283/// (HTMX requests via `HX-Redirect`) or get 401 when they want JSON. Use
284/// `Option<AuthUser>` where logging in is optional.
285#[derive(Clone)]
286pub struct AuthUser {
287    user: Arc<User>,
288    gates: Gates,
289    state: Option<AppState>,
290    token_id: Option<i64>,
291    abilities: Option<Arc<Vec<String>>>,
292    grants: Arc<Grants>,
293    /// `role_names`, worked out the first time it's asked.
294    role_names: std::sync::OnceLock<Vec<String>>,
295}
296
297impl Deref for AuthUser {
298    type Target = User;
299
300    fn deref(&self) -> &User {
301        &self.user
302    }
303}
304
305impl AuthUser {
306    /// The id of the API token this request logged in with
307    /// (`Authorization: Bearer`), or `None` for a session login. Revoke just
308    /// that token on "log out" from an app: `user.revoke_token(&db, id)`.
309    pub fn token_id(&self) -> Option<i64> {
310        self.token_id
311    }
312
313    /// Whether the API token this request logged in with may do `ability`
314    /// (`create_token_with(.., &["orders:read"], ..)`). Sessions, and tokens
315    /// made without a list, may do everything.
316    pub fn token_can(&self, ability: &str) -> bool {
317        self.abilities
318            .as_ref()
319            .is_none_or(|list| list.iter().any(|a| a == ability || a == "*"))
320    }
321
322    /// Whether the user has `role` (the `Permissions` module): a global
323    /// role, or one given in the request's scope (`permissions::set_scope`),
324    /// within its dates.
325    pub fn has_role(&self, role: &str) -> bool {
326        self.grants.has_role(role)
327    }
328
329    /// Whether one of the user's roles grants `permission` (the
330    /// `Permissions` module; the same roles as `has_role`).
331    /// `allows(permission)` also asks `App::gate_before`.
332    pub fn has_permission(&self, permission: &str) -> bool {
333        self.grants.has_permission(permission)
334    }
335
336    /// Whether the user has `role` globally or in `scope` (the record's,
337    /// not the request's), within its dates.
338    pub fn has_role_in(&self, role: &str, scope: &permissions::Scope) -> bool {
339        self.grants.has_role_in(role, Some(scope))
340    }
341
342    /// Whether a global role of the user, or one given in `scope` (the
343    /// record's, not the request's), grants `permission` now; for
344    /// policies and handlers that work on one record.
345    pub fn has_permission_in(&self, permission: &str, scope: &permissions::Scope) -> bool {
346        self.grants.has_permission_in(permission, Some(scope))
347    }
348
349    /// The records of model `M` in which the user holds `permission`:
350    /// `Scopes::All` when a global role grants it, else their keys. See
351    /// [`User::scopes_with`].
352    pub fn scopes_with<M: crate::db::Model>(
353        &self,
354        permission: &str,
355    ) -> permissions::Scopes<M::Key> {
356        self.grants.scopes_with::<M>(permission)
357    }
358
359    /// The user's roles (the `Permissions` module) in effect: the global
360    /// ones plus those in the request's scope, sorted, as they were the
361    /// first time this was asked.
362    pub fn role_names(&self) -> &[String] {
363        self.role_names.get_or_init(|| self.grants.roles())
364    }
365
366    /// Whether the policy of `target` allows `ability` (after
367    /// `App::gate_before`).
368    pub fn can(&self, ability: &str, target: &impl Policy) -> bool {
369        self.before(ability)
370            .unwrap_or_else(|| target.allows(&self.user, ability))
371    }
372
373    fn before(&self, ability: &str) -> Option<bool> {
374        self.gates
375            .before
376            .as_ref()
377            .and_then(|before| before(&self.user, ability))
378    }
379
380    /// Like `can`, but a refusal becomes a 403 response.
381    pub fn authorize(&self, ability: &str, target: &impl Policy) -> Result {
382        if self.can(ability, target) {
383            Ok(())
384        } else {
385            Err(Error::Forbidden)
386        }
387    }
388
389    /// Whether the gate named `gate` lets this user through: `gate_before`,
390    /// then the gate, then the user's permissions of that name. Unknown
391    /// names deny.
392    pub fn allows(&self, gate: &str) -> bool {
393        self.gates.check(&self.user, &self.grants, gate)
394    }
395
396    /// Like `allows`, but a refusal becomes a 403 response.
397    pub fn gate(&self, gate: &str) -> Result {
398        if self.allows(gate) {
399            Ok(())
400        } else {
401            Err(Error::Forbidden)
402        }
403    }
404
405    /// Whether the gate named `gate` lets this user through, for gates made
406    /// with `App::gate_async` (which may query the database) as well as
407    /// plain ones. Unknown gates deny.
408    pub async fn allows_async(&self, gate: &str) -> Result<bool> {
409        if let Some(allowed) = self.before(gate) {
410            return Ok(allowed);
411        }
412        if self.gates.gates.contains_key(gate) || self.grants.has_permission(gate) {
413            return Ok(self.allows(gate));
414        }
415        let Some(state) = &self.state else {
416            return Ok(false);
417        };
418        match state.async_gates.get(gate) {
419            Some(check) => check(self.user.as_ref().clone(), state.clone()).await,
420            None => Ok(false),
421        }
422    }
423
424    /// Like `allows_async`, but a refusal becomes a 403 response.
425    pub async fn gate_async(&self, gate: &str) -> Result {
426        if self.allows_async(gate).await? {
427            Ok(())
428        } else {
429            Err(Error::Forbidden)
430        }
431    }
432
433    /// The logged-in user.
434    pub fn user(&self) -> &User {
435        &self.user
436    }
437}
438
439impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
440    type Rejection = Response;
441
442    async fn from_request_parts(parts: &mut Parts, _: &S) -> std::result::Result<Self, Response> {
443        match current(&parts.extensions) {
444            Some(user) => Ok(user),
445            None => Err(unauthenticated(parts)),
446        }
447    }
448}
449
450impl<S: Send + Sync> OptionalFromRequestParts<S> for AuthUser {
451    type Rejection = Infallible;
452
453    async fn from_request_parts(
454        parts: &mut Parts,
455        _: &S,
456    ) -> std::result::Result<Option<Self>, Infallible> {
457        Ok(current(&parts.extensions))
458    }
459}
460
461fn current(extensions: &axum::http::Extensions) -> Option<AuthUser> {
462    let current = extensions.get::<CurrentUser>()?;
463    Some(AuthUser {
464        user: current.user.clone()?,
465        gates: current.gates.clone(),
466        state: extensions.get::<AppState>().cloned(),
467        token_id: current.token_id,
468        abilities: current.abilities.clone(),
469        grants: current.grants.clone(),
470        role_names: std::sync::OnceLock::new(),
471    })
472}
473
474/// Logs `user` in for this session. With `remember` (usually
475/// `Some(state.config.remember_lifetime)`, "remember me"), the session lasts
476/// that long instead of `SESSION_LIFETIME`.
477pub fn login(session: &Session, user: &User, remember: Option<std::time::Duration>) -> Result {
478    session.regenerate_token();
479    session.put(AUTH_ID, user.id)?;
480    session.put(AUTH_HASH, fingerprint(&user.password))?;
481    session.put(AUTH_AT, unix_millis())?;
482    session.put(AUTH_SID, crate::crypto::random_token())?;
483    if let Some(lifetime) = remember {
484        session.set_lifetime(lifetime);
485    }
486    Ok(())
487}
488
489/// Logs this device out: ends the session (user, data and CSRF token), and
490/// remembers its id so a copy of the cookie stops working too. The user's
491/// other devices stay logged in; see [`logout_other_devices`] and
492/// [`User::revoke_sessions`].
493pub async fn logout(db: &Db, session: &Session) -> Result {
494    match (session.get::<i64>(AUTH_ID), session.get::<String>(AUTH_SID)) {
495        (Some(_), Some(sid)) => revoke_session(db, session, &sid).await?,
496        // Logged in before sessions had ids: end them all to be safe.
497        (Some(id), None) => {
498            user::revoke_sessions(db, id).await?;
499        }
500        _ => {}
501    }
502    session.flush();
503    Ok(())
504}
505
506/// Logs the user out everywhere except this device (e.g. "log out other
507/// devices", or after a password change).
508pub async fn logout_other_devices(db: &Db, session: &Session, user: &User) -> Result {
509    let cut_off = user::revoke_sessions(db, user.id).await?;
510    // Logged in again after the cut-off (even within the same millisecond),
511    // so this session survives it.
512    let lifetime = session
513        .lifetime()
514        .map(|minutes| std::time::Duration::from_secs(minutes * 60));
515    login(session, user, lifetime)?;
516    session.put(AUTH_AT, cut_off + 1)?;
517    Ok(())
518}
519
520/// Changes the user's password and keeps this session logged in; every
521/// other session ends.
522pub async fn change_password(
523    db: &Db,
524    session: &Session,
525    user: &mut User,
526    password: &str,
527) -> Result {
528    user.set_password(db, password).await?;
529    logout_other_devices(db, session, user).await
530}
531
532/// Denylists one session id until a copy of its cookie would have expired.
533async fn revoke_session(db: &Db, session: &Session, sid: &str) -> Result {
534    let minutes = session.lifetime().unwrap_or(60 * 24 * 30);
535    let expires = crate::db::now() + chrono::Duration::minutes(minutes as i64);
536    crate::db::sql("DELETE FROM revoked_sessions WHERE expires_at < ?")
537        .bind(crate::db::now())
538        .execute(db)
539        .await?;
540    crate::db::sql(
541        "INSERT INTO revoked_sessions (id, expires_at) SELECT ?, ? \
542         WHERE NOT EXISTS (SELECT 1 FROM revoked_sessions WHERE id = ?)",
543    )
544    .bind(sid)
545    .bind(expires)
546    .bind(sid)
547    .execute(db)
548    .await?;
549    Ok(())
550}
551
552pub(crate) fn unix_millis() -> i64 {
553    crate::clock::unix_millis()
554}
555
556/// Ties a session to the password it was logged in with, so changing the
557/// password logs out every other session.
558fn fingerprint(password_hash: &str) -> String {
559    use sha2::{Digest, Sha256};
560    let digest = Sha256::digest(password_hash.as_bytes());
561    digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
562}
563
564/// Loads the session's user once per request for extractors, guards and templates.
565pub(crate) async fn middleware(
566    axum::extract::State(state): axum::extract::State<AppState>,
567    mut req: Request,
568    next: Next,
569) -> Response {
570    let bearer = req
571        .headers()
572        .get(AUTHORIZATION)
573        .and_then(|v| v.to_str().ok())
574        .and_then(|v| v.strip_prefix("Bearer "))
575        .map(str::to_owned);
576    let session = req.extensions().get::<Session>().cloned();
577    let mut device = None;
578    let (user, token) = match (&bearer, &session) {
579        (Some(bearer), _) if bearer.trim().starts_with('d') => {
580            match device::authenticate(&state.db, bearer.trim()).await {
581                Ok(found) => device = found.map(Arc::new),
582                Err(err) => tracing::error!(error = ?err, "could not check the device token"),
583            }
584            (None, None)
585        }
586        // Only a token that authenticates turns CSRF off: a wrong or unknown
587        // one leaves the request a guest's, with CSRF checked as usual.
588        (Some(bearer), _) => match tokens::authenticate(&state.db, bearer.trim()).await {
589            Ok(Some((user, token))) => (Some(user), Some(token)),
590            Ok(None) => (None, None),
591            Err(err) => {
592                tracing::error!(error = ?err, "could not check the API token");
593                (None, None)
594            }
595        },
596        (None, Some(session)) => (resolve(&state, session).await, None),
597        (None, None) => (None, None),
598    };
599    let grants = match &user {
600        Some(user) if state.gates.permissions => {
601            match permissions::grants(&state.db, user.id).await {
602                Ok(grants) => grants,
603                Err(err) => {
604                    tracing::error!(error = ?err, "could not load the user's roles");
605                    Grants::default()
606                }
607            }
608        }
609        _ => Grants::default(),
610    };
611    let grants = Arc::new(grants);
612    if let Some(user) = &user {
613        // For `User::has_role` in policies and `gate_before`, which get a
614        // plain `User`.
615        crate::context::set(CurrentGrants {
616            user_id: user.id,
617            grants: grants.clone(),
618        });
619    }
620    req.extensions_mut().insert(CurrentUser {
621        user: user.map(Arc::new),
622        gates: state.gates.clone(),
623        token_id: token.as_ref().map(|t| t.0),
624        abilities: token.and_then(|t| t.1).map(Arc::new),
625        device,
626        grants,
627    });
628    req.extensions_mut().insert(state);
629    next.run(req).await
630}
631
632async fn resolve(state: &AppState, session: &Session) -> Option<User> {
633    let id: i64 = session.get(AUTH_ID)?;
634    let hash: String = session.get(AUTH_HASH).unwrap_or_default();
635    let logged_in_at: i64 = session.get(AUTH_AT).unwrap_or(0);
636    let sid: String = session.get(AUTH_SID).unwrap_or_default();
637    match User::find_with_revocation(&state.db, id, &sid).await {
638        Ok(Some((user, revoked_at, session_revoked)))
639            if constant_time_eq(&fingerprint(&user.password), &hash)
640                && (revoked_at == 0 || logged_in_at > revoked_at)
641                && !session_revoked =>
642        {
643            Some(user)
644        }
645        Ok(_) => {
646            // Deleted user, changed password or logged out elsewhere: this
647            // session is no longer theirs.
648            session.remove(AUTH_ID);
649            session.remove(AUTH_HASH);
650            session.remove(AUTH_AT);
651            session.remove(AUTH_SID);
652            None
653        }
654        Err(err) => {
655            tracing::error!(error = ?err, "could not load the logged-in user");
656            None
657        }
658    }
659}
660
661fn wants_json(headers: &axum::http::HeaderMap) -> bool {
662    headers
663        .get(ACCEPT)
664        .and_then(|v| v.to_str().ok())
665        .is_some_and(|v| v.contains("application/json"))
666}
667
668fn path_or(state: Option<&AppState>, route: &str, fallback: &str) -> String {
669    state
670        .and_then(|s| s.url(route, &[]).ok())
671        .unwrap_or_else(|| fallback.to_owned())
672}
673
674/// Sends a guest to the login page, remembering where they were going.
675fn unauthenticated(parts: &Parts) -> Response {
676    if wants_json(&parts.headers) || parts.headers.contains_key(AUTHORIZATION) {
677        let body = serde_json::json!({ "message": "Unauthenticated." });
678        return (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response();
679    }
680    let login = path_or(parts.extensions.get::<AppState>(), "login", "/login");
681    if let (Some(session), &Method::GET) = (parts.extensions.get::<Session>(), &parts.method) {
682        let intended = parts
683            .uri
684            .path_and_query()
685            .map(|p| p.as_str().to_owned())
686            .unwrap_or_else(|| "/".into());
687        let _ = session.put(INTENDED, intended);
688    }
689    if Htmx::from_headers(&parts.headers).request {
690        return HxRedirect(login).into_response();
691    }
692    Redirect::to(&login).into_response()
693}
694
695/// Route guard: only logged-in users. See `Routes::require_auth`.
696pub(crate) async fn require_auth(req: Request, next: Next) -> Response {
697    if current(req.extensions()).is_some() {
698        return next.run(req).await;
699    }
700    let (parts, _) = req.into_parts();
701    unauthenticated(&parts)
702}
703
704/// What a route guard asks of the logged-in user (`Routes::require_gate`,
705/// `require_role`, `require_permission`, `require_ability`).
706#[derive(Clone)]
707pub(crate) enum Requirement {
708    Gate(String),
709    Role(String),
710    Permission(String),
711    Ability(String),
712}
713
714/// Route guard: a guest is sent to log in; a user who doesn't meet
715/// `requirement` gets 403.
716pub(crate) async fn require(requirement: Arc<Requirement>, req: Request, next: Next) -> Response {
717    let Some(user) = current(req.extensions()) else {
718        let (parts, _) = req.into_parts();
719        return unauthenticated(&parts);
720    };
721    let allowed = match requirement.as_ref() {
722        Requirement::Gate(gate) => match user.allows_async(gate).await {
723            Ok(allowed) => allowed,
724            Err(err) => return err.into_response(),
725        },
726        Requirement::Role(role) => user.has_role(role),
727        Requirement::Permission(permission) => user.allows(permission),
728        Requirement::Ability(ability) => user.token_can(ability),
729    };
730    if allowed {
731        next.run(req).await
732    } else {
733        Error::Forbidden.into_response()
734    }
735}
736
737/// Route guard: only users who verified their email. See `Routes::require_verified`.
738pub(crate) async fn require_verified(req: Request, next: Next) -> Response {
739    let Some(user) = current(req.extensions()) else {
740        let (parts, _) = req.into_parts();
741        return unauthenticated(&parts);
742    };
743    if user.email_verified_at.is_some() {
744        return next.run(req).await;
745    }
746    if wants_json(req.headers()) || user_via_token(req.extensions()) {
747        let body = serde_json::json!({ "message": "Your email address is not verified." });
748        return (StatusCode::FORBIDDEN, axum::Json(body)).into_response();
749    }
750    let notice = path_or(
751        req.extensions().get::<AppState>(),
752        "verification.notice",
753        "/verify-email",
754    );
755    if Htmx::from_headers(req.headers()).request {
756        return HxRedirect(notice).into_response();
757    }
758    Redirect::to(&notice).into_response()
759}
760
761pub(crate) fn user_via_token(extensions: &axum::http::Extensions) -> bool {
762    extensions
763        .get::<CurrentUser>()
764        .is_some_and(|c| c.token_id.is_some() || c.device.is_some())
765}
766
767/// Route guard: only guests; logged-in users go to the `home` route.
768pub(crate) async fn guest_only(req: Request, next: Next) -> Response {
769    if current(req.extensions()).is_none() {
770        return next.run(req).await;
771    }
772    Redirect::to(&path_or(req.extensions().get::<AppState>(), "home", "/")).into_response()
773}
774
775/// Where to go after logging in: the page that asked for a login, else `fallback`.
776pub(crate) fn intended(session: &Session, fallback: String) -> String {
777    session
778        .pull::<String>(INTENDED)
779        .filter(|path| crate::htmx::is_local_path(path))
780        .unwrap_or(fallback)
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn fingerprints_are_short_and_stable() {
789        let a = fingerprint("$argon2id$v=19$abc");
790        assert_eq!(a.len(), 32);
791        assert_eq!(a, fingerprint("$argon2id$v=19$abc"));
792        assert_ne!(a, fingerprint("$argon2id$v=19$abd"));
793    }
794}