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}