Skip to main content

churust_core/
identity.rs

1//! Who the visitor is: login, logout, and the two deadlines that end a login.
2//!
3//! Sessions carry arbitrary key/value state. This module is the thin layer that
4//! turns "some state" into "signed in as someone", so an application does not
5//! reinvent the same three session keys and the same two expiry checks.
6//!
7//! Install [`Identities`] alongside [`Sessions`](crate::Sessions), then take
8//! [`Identity`] in a handler to log a visitor in or out, and
9//! [`Authenticated`] to require one.
10//!
11//! ```
12//! use churust_core::{Authenticated, Churust, Identities, Identity, Sessions, TestClient};
13//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
14//! let app = Churust::server()
15//!     .install(Sessions::cookie("change-me-in-production"))
16//!     .install(Identities::new().visit_deadline(1800))
17//!     .routing(|r| {
18//!         r.post("/login", |id: Identity| async move {
19//!             id.login("user-42");
20//!             "welcome"
21//!         });
22//!         r.get("/me", |Authenticated(who): Authenticated| async move { who });
23//!         r.post("/logout", |id: Identity| async move {
24//!             id.logout();
25//!             "bye"
26//!         });
27//!     })
28//!     .build();
29//!
30//! let client = TestClient::new(app);
31//! // Anonymous visitors are refused by `Authenticated`.
32//! assert_eq!(client.get("/me").send().await.status().as_u16(), 401);
33//! # });
34//! ```
35//!
36//! # The two deadlines
37//!
38//! They answer different questions and a serious deployment sets both.
39//!
40//! - [`login_deadline`](Identities::login_deadline) is an **absolute** lifetime:
41//!   how long a login may last no matter how active the visitor is. It bounds
42//!   the damage from a session stolen and then used continuously, which an idle
43//!   timeout alone never expires.
44//! - [`visit_deadline`](Identities::visit_deadline) is an **idle** timeout: how
45//!   long a login survives without a request. It is what protects a shared or
46//!   unattended machine.
47//!
48//! Neither is set by default, because a default here would be either too short
49//! for an internal tool or too long for a bank, and the framework cannot know
50//! which it is looking at.
51//!
52//! # Reserved session keys
53//!
54//! This layer keeps its state in three ordinary session keys:
55//!
56//! - `__churust_uid` — who the visitor is. Writing it *is* logging someone in:
57//!   [`Authenticated`] and [`Identity::id`] read nothing else.
58//! - `__churust_lin` — when they logged in, as Unix seconds.
59//! - `__churust_seen` — when they were last seen, as Unix seconds.
60//!
61//! They are ordinary keys on purpose. [`Session::set`](crate::Session::set) does
62//! not refuse them, because [`Identity::login`] writes them through that very
63//! method, and because writing `__churust_uid` by hand is the supported way to
64//! adopt this layer over sessions an application was already minting itself.
65//! The `__churust` prefix is the reservation, and it covers
66//! [`SESSION_ID_KEY`](crate::SESSION_ID_KEY) too.
67//!
68//! Nothing a visitor sends can reach these keys: a session is server-authored,
69//! and [`CookieStore`](crate::CookieStore) verifies its signature before parsing
70//! the contents. The one shape that would is an application that writes
71//! caller-supplied key *names* — `session.set(form.key, form.value)` — which is
72//! mass assignment and hands the visitor its own `role` and `tenant_id` keys as
73//! well. If you must do that, reject any key beginning with `__churust` before
74//! the write; there is no framework-side filter that could do it for you without
75//! breaking login.
76
77use crate::app::{AppBuilder, Plugin};
78use crate::call::Call;
79use crate::error::{Error, Result};
80use crate::extract::FromCallParts;
81use crate::pipeline::{Middleware, Next, Phase};
82use crate::response::Response;
83use crate::session::Session;
84use async_trait::async_trait;
85use http::header::LOCATION;
86use http::{HeaderValue, StatusCode};
87use std::sync::Arc;
88use std::time::{SystemTime, UNIX_EPOCH};
89
90/// Session key holding who the visitor is.
91const UID_KEY: &str = "__churust_uid";
92/// Session key holding when they logged in, as Unix seconds.
93const LOGIN_AT_KEY: &str = "__churust_lin";
94/// Session key holding when they were last seen, as Unix seconds.
95const SEEN_AT_KEY: &str = "__churust_seen";
96
97/// Seconds since the Unix epoch, or `None` if the clock predates it.
98fn now_secs() -> Option<i64> {
99    SystemTime::now()
100        .duration_since(UNIX_EPOCH)
101        .ok()
102        .map(|d| d.as_secs() as i64)
103}
104
105/// What the [`Identities`] plugin was configured with, put into the call so
106/// [`Authenticated`] can render the right refusal.
107#[derive(Clone, Debug, Default)]
108struct Policy {
109    /// Where to send an unauthenticated visitor, if anywhere.
110    login_url: Option<String>,
111}
112
113/// A handle to the current visitor's identity.
114///
115/// Extract it in any handler. It is always available: an anonymous visitor
116/// simply has no [`id`](Identity::id). Every method operates on the session, so
117/// the changes are persisted by the session plugin on the way out.
118#[derive(Clone, Debug)]
119pub struct Identity {
120    session: Session,
121}
122
123impl Identity {
124    /// Who the visitor is, or `None` when anonymous.
125    pub fn id(&self) -> Option<String> {
126        self.session.get(UID_KEY)
127    }
128
129    /// Whether anyone is logged in.
130    pub fn is_authenticated(&self) -> bool {
131        self.id().is_some()
132    }
133
134    /// Record `id` as the logged-in visitor.
135    ///
136    /// The session identifier is rotated first (see [`Session::rotate`]), so a
137    /// server-side store mints a fresh record rather than promoting one an
138    /// attacker may have planted. Session contents other than the identity are
139    /// preserved, which is what keeps a pre-login cart or locale across the
140    /// boundary. Call [`logout`](Identity::logout) first if you would rather
141    /// start empty.
142    pub fn login(&self, id: impl Into<String>) {
143        self.session.rotate();
144        self.session.set(UID_KEY, id.into());
145        if let Some(now) = now_secs() {
146            self.session.set(LOGIN_AT_KEY, now.to_string());
147            self.session.set(SEEN_AT_KEY, now.to_string());
148        }
149    }
150
151    /// Log the visitor out and drop the whole session.
152    ///
153    /// Everything goes, not just the identity keys: anything put in the session
154    /// while signed in was put there for a signed-in visitor.
155    ///
156    /// With a client-side store this clears the visitor's copy but cannot
157    /// revoke a copy taken beforehand, which stays valid until its signed
158    /// deadline. Revocation needs server-side state.
159    pub fn logout(&self) {
160        self.session.clear();
161    }
162
163    /// When the visitor logged in, as Unix seconds.
164    pub fn logged_in_at(&self) -> Option<i64> {
165        self.session.get(LOGIN_AT_KEY).and_then(|v| v.parse().ok())
166    }
167
168    /// When the visitor was last seen, as Unix seconds.
169    ///
170    /// Only maintained when [`Identities::visit_deadline`] is set, and only
171    /// written periodically rather than on every request. See that method for
172    /// why.
173    pub fn last_seen_at(&self) -> Option<i64> {
174        self.session.get(SEEN_AT_KEY).and_then(|v| v.parse().ok())
175    }
176}
177
178#[async_trait]
179impl FromCallParts for Identity {
180    async fn from_call_parts(call: &mut Call) -> Result<Self> {
181        // An absent session means the Sessions plugin is not installed. An
182        // empty, unpersisted identity is friendlier than an error, and matches
183        // how `Session` itself behaves.
184        Ok(Identity {
185            session: call.get::<Session>().unwrap_or_default(),
186        })
187    }
188}
189
190/// An extractor that requires a logged-in visitor, yielding their id.
191///
192/// An anonymous visitor never reaches the handler: they get `401 Unauthorized`,
193/// or a redirect when [`Identities::login_url`] is configured.
194///
195/// ```
196/// use churust_core::Authenticated;
197///
198/// async fn profile(Authenticated(user): Authenticated) -> String {
199///     format!("signed in as {user}")
200/// }
201/// ```
202#[derive(Clone, Debug)]
203pub struct Authenticated(
204    /// The logged-in visitor's id.
205    pub String,
206);
207
208#[async_trait]
209impl FromCallParts for Authenticated {
210    async fn from_call_parts(call: &mut Call) -> Result<Self> {
211        let session = call.get::<Session>().unwrap_or_default();
212        if let Some(id) = session.get(UID_KEY) {
213            return Ok(Authenticated(id));
214        }
215
216        let policy = call.get::<Policy>().unwrap_or_default();
217        match policy.login_url {
218            // 303 rather than 302: it makes the follow-up a GET regardless of
219            // what was attempted, which is what a login page wants.
220            Some(url) => {
221                let mut error = Error::new(StatusCode::SEE_OTHER, "authentication required");
222                if let Ok(value) = HeaderValue::from_str(&url) {
223                    error = error.with_response_header(LOCATION, value);
224                }
225                Err(error)
226            }
227            // No `WWW-Authenticate`, deliberately. RFC 9110 §15.5.2 asks for
228            // one, but there is no registered scheme for a cookie session, and
229            // naming `Basic` here would make a browser open its own login
230            // dialog instead of the application's.
231            None => Err(Error::new(
232                StatusCode::UNAUTHORIZED,
233                "authentication required",
234            )),
235        }
236    }
237}
238
239/// Installs the identity layer. See the [module docs](self).
240///
241/// Order does not matter: the middleware runs in [`Phase::Call`], which is
242/// inside the phase the session plugin uses, so the session is always loaded by
243/// the time the deadlines are checked.
244#[derive(Clone, Debug, Default)]
245pub struct Identities {
246    login_deadline: Option<i64>,
247    visit_deadline: Option<i64>,
248    login_url: Option<String>,
249}
250
251impl Identities {
252    /// No deadlines, and `401` for an unauthenticated visitor.
253    pub fn new() -> Self {
254        Self::default()
255    }
256
257    /// End a login `secs` after it started, however active the visitor is.
258    ///
259    /// # Panics
260    ///
261    /// If `secs` is not positive. A deadline of zero or less would log every
262    /// visitor out on the request after they signed in, which is never the
263    /// intent.
264    pub fn login_deadline(mut self, secs: i64) -> Self {
265        assert!(
266            secs > 0,
267            "login_deadline must be a positive number of seconds"
268        );
269        self.login_deadline = Some(secs);
270        self
271    }
272
273    /// End a login after `secs` with no requests.
274    ///
275    /// Enabling this makes the layer maintain a last-seen timestamp in the
276    /// session. It is refreshed at most once per tenth of the deadline, not on
277    /// every request: the session plugin only re-issues a cookie when the
278    /// session changed, and touching a timestamp on every request would rewrite
279    /// the cookie every time and quietly extend the expiry of a session nobody
280    /// is really using. The cost of that thrift is that the idle timeout is
281    /// accurate to within a tenth of itself.
282    ///
283    /// # Panics
284    ///
285    /// If `secs` is not positive.
286    pub fn visit_deadline(mut self, secs: i64) -> Self {
287        assert!(
288            secs > 0,
289            "visit_deadline must be a positive number of seconds"
290        );
291        self.visit_deadline = Some(secs);
292        self
293    }
294
295    /// Send unauthenticated visitors to `url` instead of answering `401`.
296    ///
297    /// Applies to the [`Authenticated`] extractor. An API generally wants the
298    /// `401`; a server-rendered application generally wants the redirect.
299    pub fn login_url(mut self, url: impl Into<String>) -> Self {
300        self.login_url = Some(url.into());
301        self
302    }
303}
304
305impl Plugin for Identities {
306    fn install(self: Box<Self>, app: &mut AppBuilder) {
307        app.add_middleware_in(
308            Phase::Call,
309            Arc::new(IdentityMiddleware {
310                login_deadline: self.login_deadline,
311                visit_deadline: self.visit_deadline,
312                policy: Policy {
313                    login_url: self.login_url.clone(),
314                },
315            }),
316        );
317    }
318}
319
320struct IdentityMiddleware {
321    login_deadline: Option<i64>,
322    visit_deadline: Option<i64>,
323    policy: Policy,
324}
325
326impl IdentityMiddleware {
327    /// Drop the identity keys, leaving the rest of the session alone.
328    ///
329    /// An expired login is not the same event as a logout: whatever else the
330    /// session holds was not necessarily privileged, and discarding it would
331    /// make a timeout also empty a cart.
332    fn expire(session: &Session) {
333        session.remove(UID_KEY);
334        session.remove(LOGIN_AT_KEY);
335        session.remove(SEEN_AT_KEY);
336    }
337}
338
339#[async_trait]
340impl Middleware for IdentityMiddleware {
341    async fn handle(&self, mut call: Call, next: Next) -> Response {
342        call.insert(self.policy.clone());
343
344        if let (Some(session), Some(now)) = (call.get::<Session>(), now_secs()) {
345            if session.get(UID_KEY).is_some() {
346                let started: Option<i64> = session.get(LOGIN_AT_KEY).and_then(|v| v.parse().ok());
347                let seen: Option<i64> = session.get(SEEN_AT_KEY).and_then(|v| v.parse().ok());
348
349                // A session carrying an identity but no timestamps predates
350                // this layer, or was hand-built. Treat the absent timestamp as
351                // "now" rather than as "infinitely old": expiring it would log
352                // out every existing visitor the moment the layer is deployed.
353                let expired_absolute = self
354                    .login_deadline
355                    .zip(started)
356                    .is_some_and(|(limit, at)| now.saturating_sub(at) >= limit);
357                let expired_idle = self
358                    .visit_deadline
359                    .zip(seen)
360                    .is_some_and(|(limit, at)| now.saturating_sub(at) >= limit);
361
362                if expired_absolute || expired_idle {
363                    Self::expire(&session);
364                } else if let Some(limit) = self.visit_deadline {
365                    // Refresh at most once per tenth of the deadline. See
366                    // `Identities::visit_deadline`.
367                    let granularity = (limit / 10).max(1);
368                    let stale = seen.is_none_or(|at| now.saturating_sub(at) >= granularity);
369                    if stale {
370                        session.set(SEEN_AT_KEY, now.to_string());
371                    }
372                }
373            }
374        }
375
376        next.run(call).await
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::session::Session;
384
385    fn identity() -> Identity {
386        Identity {
387            session: Session::default(),
388        }
389    }
390
391    #[test]
392    fn a_fresh_visitor_is_anonymous() {
393        let id = identity();
394        assert!(!id.is_authenticated());
395        assert_eq!(id.id(), None);
396    }
397
398    #[test]
399    fn login_records_who_and_when() {
400        let id = identity();
401        id.login("user-1");
402        assert_eq!(id.id().as_deref(), Some("user-1"));
403        assert!(id.is_authenticated());
404        assert!(id.logged_in_at().is_some());
405        assert!(id.last_seen_at().is_some());
406    }
407
408    #[test]
409    fn logout_empties_the_session() {
410        let id = identity();
411        id.session.set("cart", "3 items");
412        id.login("user-1");
413        id.logout();
414        assert!(!id.is_authenticated());
415        assert_eq!(
416            id.session.get("cart"),
417            None,
418            "logout drops everything, not only the identity"
419        );
420    }
421
422    #[test]
423    fn login_keeps_other_session_state() {
424        let id = identity();
425        id.session.set("cart", "3 items");
426        id.login("user-1");
427        assert_eq!(id.session.get("cart").as_deref(), Some("3 items"));
428    }
429
430    #[test]
431    fn expiring_leaves_the_rest_of_the_session_alone() {
432        let id = identity();
433        id.session.set("cart", "3 items");
434        id.login("user-1");
435        IdentityMiddleware::expire(&id.session);
436        assert!(!id.is_authenticated());
437        assert_eq!(id.session.get("cart").as_deref(), Some("3 items"));
438    }
439
440    #[test]
441    #[should_panic(expected = "login_deadline must be a positive")]
442    fn a_zero_login_deadline_is_refused() {
443        let _ = Identities::new().login_deadline(0);
444    }
445
446    #[test]
447    #[should_panic(expected = "visit_deadline must be a positive")]
448    fn a_negative_visit_deadline_is_refused() {
449        let _ = Identities::new().visit_deadline(-1);
450    }
451}