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