Skip to main content

rustlavel_auth/
guard.rs

1//! Who is logged in: the guard, the `auth` middleware, and the `guest` one.
2//!
3//! The session already survives between requests, so authentication is just one
4//! more value in it — the user's identifier — plus the discipline around
5//! putting it there and taking it out.
6//!
7//! ```ignore
8//! router.middleware(SessionManager::from_config(&config, store)?);
9//!
10//! router.post("/login", |mut req: Request| async move {
11//!     let user = User::by_email(&req.input("email").unwrap_or_default()).await?;
12//!     if verify_password(&req.input("password").unwrap_or_default(), &user.password) {
13//!         req.auth().login(&user);
14//!         return Ok(Response::see_other("/dashboard"));
15//!     }
16//!     Ok(Response::see_other("/login"))
17//! });
18//!
19//! router.group("/dashboard", |r| {
20//!     r.middleware(Authenticate::from_config(&config));
21//!     r.get("", |req: Request| async move {
22//!         format!("hello, {}", req.identity().unwrap().id())
23//!     });
24//! });
25//! ```
26
27use crate::middleware::{SessionExt, SessionHandle};
28use rustlavel_core::{Config, Error, Json};
29use rustlavel_http::handler::BoxFuture;
30use rustlavel_http::middleware::{Middleware, Next};
31use rustlavel_http::response::IntoResponse;
32use rustlavel_http::{Request, Response, Status};
33
34/// Where the authenticated user's identifier is kept in the session.
35pub const IDENTIFIER_KEY: &str = "_auth_id";
36
37/// Where the `auth` middleware remembers the page the visitor was after.
38pub const INTENDED_KEY: &str = "_auth_intended";
39
40/// The login path used when nothing is configured.
41pub const DEFAULT_LOGIN_PATH: &str = "/login";
42
43/// Where `guest` sends an already-authenticated visitor.
44pub const DEFAULT_HOME_PATH: &str = "/";
45
46/// Anything that can be logged in.
47///
48/// Only an identifier is required, because that is genuinely all the session
49/// needs to hold: everything else about the user is loaded from wherever users
50/// live. Keeping a whole serialized user in a session cookie is how stale — and
51/// eventually wrong — permissions get carried around for hours.
52pub trait Authenticatable {
53    /// The value stored in the session, usually a primary key.
54    fn auth_identifier(&self) -> String;
55}
56
57impl Authenticatable for str {
58    fn auth_identifier(&self) -> String {
59        self.to_string()
60    }
61}
62
63impl Authenticatable for String {
64    fn auth_identifier(&self) -> String {
65        self.clone()
66    }
67}
68
69impl Authenticatable for i64 {
70    fn auth_identifier(&self) -> String {
71        self.to_string()
72    }
73}
74
75/// The identifier of the user this request belongs to.
76///
77/// Attached by the `auth` middleware with `Request::extend`, so a handler
78/// downstream reads it with `req.extension::<Identity>()` — or, more
79/// readably, `req.identity()`.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Identity(String);
82
83impl Identity {
84    pub fn new(id: impl Into<String>) -> Self {
85        Identity(id.into())
86    }
87
88    pub fn id(&self) -> &str {
89        &self.0
90    }
91
92    /// The identifier parsed into a type: `identity.id_as::<i64>()`.
93    pub fn id_as<T: std::str::FromStr>(&self) -> Option<T> {
94        self.0.parse().ok()
95    }
96}
97
98impl std::fmt::Display for Identity {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(&self.0)
101    }
102}
103
104/// Logging in and out, on top of one session.
105pub struct Guard {
106    session: SessionHandle,
107}
108
109impl Guard {
110    pub fn new(session: SessionHandle) -> Self {
111        Guard { session }
112    }
113
114    /// Log a user in, returning the session's new id.
115    ///
116    /// The session id is rotated first. See [`crate::Session::regenerate`]: a
117    /// session id that was known before the login must not still be valid
118    /// after it, or an attacker who planted the cookie inherits the login.
119    pub fn login(&self, user: &impl Authenticatable) -> String {
120        self.login_using_id(user.auth_identifier())
121    }
122
123    /// Log in by identifier, for a "log in as" or "remember me" flow that
124    /// already knows the key without loading the user.
125    pub fn login_using_id(&self, id: impl Into<String>) -> String {
126        let new_id = self.session.regenerate();
127        self.session.put(IDENTIFIER_KEY, id.into());
128        new_id
129    }
130
131    /// Log out: empty the session and rotate its id.
132    ///
133    /// Everything goes, not just the identifier. A cart, a half-filled form, a
134    /// flashed message — all of it belonged to the person who just left, and on
135    /// a shared machine the next person must not find any of it.
136    pub fn logout(&self) -> String {
137        self.session.invalidate()
138    }
139
140    /// The authenticated user's identifier, if there is one.
141    pub fn user_id(&self) -> Option<String> {
142        self.session.get_string(IDENTIFIER_KEY)
143    }
144
145    pub fn check(&self) -> bool {
146        self.user_id().is_some()
147    }
148
149    pub fn guest(&self) -> bool {
150        !self.check()
151    }
152
153    /// Remember where the visitor was heading before being sent to log in.
154    ///
155    /// Stored rather than flashed: the visitor sees the login form, submits it,
156    /// and only then is the destination needed — two requests later, by which
157    /// time flash data would already be gone.
158    pub fn remember_intended(&self, path: impl Into<String>) {
159        self.session.put(INTENDED_KEY, path.into());
160    }
161
162    /// Take back that destination, or `fallback` if there is none.
163    ///
164    /// Consumed on read, so a second login does not bounce the visitor to a
165    /// page they asked for hours ago.
166    pub fn intended(&self, fallback: &str) -> String {
167        self.session
168            .forget(INTENDED_KEY)
169            .and_then(|value| value.as_str().map(str::to_string))
170            .unwrap_or_else(|| fallback.to_string())
171    }
172
173    pub fn session(&self) -> &SessionHandle {
174        &self.session
175    }
176}
177
178/// `req.auth()` and `req.identity()` — reaching authentication from a handler.
179pub trait AuthExt {
180    /// The guard, or `None` when the `session` middleware is not installed.
181    fn try_auth(&self) -> Option<Guard>;
182
183    /// The guard, panicking with an actionable message if there is no session.
184    fn auth(&self) -> Guard;
185
186    /// The identity attached by the `auth` middleware.
187    fn identity(&self) -> Option<&Identity>;
188}
189
190impl AuthExt for Request {
191    fn try_auth(&self) -> Option<Guard> {
192        self.try_session().cloned().map(Guard::new)
193    }
194
195    fn auth(&self) -> Guard {
196        Guard::new(self.session().clone())
197    }
198
199    fn identity(&self) -> Option<&Identity> {
200        self.extension::<Identity>()
201    }
202}
203
204/// The `auth` middleware: no identity, no handler.
205#[derive(Debug, Clone)]
206pub struct Authenticate {
207    login_path: String,
208}
209
210impl Authenticate {
211    pub fn new() -> Self {
212        Authenticate { login_path: DEFAULT_LOGIN_PATH.to_string() }
213    }
214
215    /// Read `auth.login_path` from configuration.
216    pub fn from_config(config: &Config) -> Self {
217        Authenticate { login_path: config.string("auth.login_path", DEFAULT_LOGIN_PATH) }
218    }
219
220    pub fn login_path(mut self, path: impl Into<String>) -> Self {
221        self.login_path = path.into();
222        self
223    }
224}
225
226impl Default for Authenticate {
227    fn default() -> Self {
228        Authenticate::new()
229    }
230}
231
232impl Middleware for Authenticate {
233    fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
234        let login_path = self.login_path.clone();
235
236        Box::pin(async move {
237            let Some(guard) = request.try_auth() else {
238                return missing_session("auth").into_response();
239            };
240
241            match guard.user_id() {
242                Some(id) => {
243                    // The handler reads this with `req.identity()`.
244                    request.extend(Identity::new(id));
245                    next.run(request).await
246                }
247                // An API client gets a status it can act on; a browser gets
248                // sent somewhere it can actually log in. Redirecting an XHR
249                // would hand it the login page's HTML as the answer to its
250                // request, which is how "why is my JSON parser failing?"
251                // becomes a two-hour debugging session.
252                None if request.wants_json() => Response::new(Status::UNAUTHORIZED)
253                    .with_json(Json::object([("message", Json::from("Unauthenticated."))])),
254                None => {
255                    guard.remember_intended(request.target());
256                    Response::see_other(login_path)
257                }
258            }
259        })
260    }
261}
262
263/// The `guest` middleware: keeps an authenticated visitor off the login page.
264#[derive(Debug, Clone)]
265pub struct Guest {
266    home: String,
267}
268
269impl Guest {
270    pub fn new() -> Self {
271        Guest { home: DEFAULT_HOME_PATH.to_string() }
272    }
273
274    /// Read `auth.home` from configuration.
275    pub fn from_config(config: &Config) -> Self {
276        Guest { home: config.string("auth.home", DEFAULT_HOME_PATH) }
277    }
278
279    pub fn home(mut self, path: impl Into<String>) -> Self {
280        self.home = path.into();
281        self
282    }
283}
284
285impl Default for Guest {
286    fn default() -> Self {
287        Guest::new()
288    }
289}
290
291impl Middleware for Guest {
292    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
293        let home = self.home.clone();
294
295        Box::pin(async move {
296            let Some(guard) = request.try_auth() else {
297                return missing_session("guest").into_response();
298            };
299
300            if guard.check() { Response::see_other(home) } else { next.run(request).await }
301        })
302    }
303}
304
305fn missing_session(name: &str) -> Error {
306    Error::msg(format!(
307        "the `{name}` middleware needs a session. Register the `session` middleware before it: \
308         `router.middleware(SessionManager::from_config(&config, store)?)`."
309    ))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::key::AppKey;
316    use crate::middleware::SessionManager;
317    use crate::store::MemoryStore;
318    use rustlavel_http::{Method, Router, TestClient, TestResponse};
319
320    struct User {
321        id: i64,
322    }
323
324    impl Authenticatable for User {
325        fn auth_identifier(&self) -> String {
326            self.id.to_string()
327        }
328    }
329
330    fn router() -> Router {
331        let mut router = Router::new();
332        router.middleware(SessionManager::new(&AppKey::from_bytes([6u8; 32]), MemoryStore::new()));
333
334        router.get("/login-as-41", |request: Request| async move {
335            request.auth().login(&User { id: 41 });
336            "logged in"
337        });
338        router.get("/logout", |request: Request| async move {
339            request.auth().logout();
340            "logged out"
341        });
342
343        router.group("/dashboard", |r| {
344            r.middleware(Authenticate::new().login_path("/login"));
345            r.get("", |request: Request| async move {
346                let identity = request.identity().expect("the middleware attaches an identity");
347                format!("user {} ({:?})", identity.id(), identity.id_as::<i64>())
348            });
349        });
350
351        router.group("/api", |r| {
352            r.middleware(Authenticate::new());
353            r.get("/me", |_request: Request| async { "private" });
354        });
355
356        router.group("/login", |r| {
357            r.middleware(Guest::new().home("/dashboard"));
358            r.get("", |_request: Request| async { "the login form" });
359        });
360
361        router
362    }
363
364    fn cookie_of(response: &TestResponse) -> String {
365        response
366            .header("set-cookie")
367            .expect("the session middleware should have set a cookie")
368            .split(';')
369            .next()
370            .unwrap()
371            .to_string()
372    }
373
374    async fn logged_in(client: &TestClient) -> String {
375        cookie_of(&client.get("/login-as-41").await.assert_ok())
376    }
377
378    #[test]
379    fn a_guard_stores_reads_and_clears_the_user_id() {
380        let session = SessionHandle::new(crate::Session::new());
381        let guard = Guard::new(session.clone());
382
383        assert!(guard.guest());
384        assert_eq!(guard.user_id(), None);
385
386        guard.login(&User { id: 7 });
387
388        assert!(guard.check());
389        assert_eq!(guard.user_id().as_deref(), Some("7"));
390        assert_eq!(session.get_string(IDENTIFIER_KEY).as_deref(), Some("7"));
391
392        guard.logout();
393
394        assert!(guard.guest());
395        assert!(session.all().is_empty());
396    }
397
398    #[test]
399    fn logging_in_rotates_the_session_id() {
400        let session = SessionHandle::new(crate::Session::new());
401        let before = session.id();
402
403        let after = Guard::new(session.clone()).login(&User { id: 7 });
404
405        assert_ne!(before, after, "session fixation: the pre-login id must stop working");
406        assert_eq!(session.id(), after);
407    }
408
409    #[test]
410    fn logging_out_rotates_the_session_id_too() {
411        let session = SessionHandle::new(crate::Session::new());
412        let guard = Guard::new(session.clone());
413        let signed_in = guard.login(&User { id: 7 });
414
415        let after = guard.logout();
416
417        assert_ne!(signed_in, after);
418        assert_eq!(session.id(), after);
419    }
420
421    #[test]
422    fn the_intended_destination_is_remembered_once() {
423        let guard = Guard::new(SessionHandle::new(crate::Session::new()));
424
425        assert_eq!(guard.intended("/home"), "/home");
426
427        guard.remember_intended("/reports?year=2026");
428        assert_eq!(guard.intended("/home"), "/reports?year=2026");
429        assert_eq!(guard.intended("/home"), "/home", "it is consumed on read");
430    }
431
432    #[test]
433    fn common_identifier_types_are_authenticatable() {
434        assert_eq!("ada".auth_identifier(), "ada");
435        assert_eq!(String::from("ada").auth_identifier(), "ada");
436        assert_eq!(41i64.auth_identifier(), "41");
437    }
438
439    #[tokio::test]
440    async fn the_auth_middleware_redirects_a_browser_to_the_login_path() {
441        let response = TestClient::new(router()).get("/dashboard").await;
442
443        response.assert_redirect("/login");
444    }
445
446    #[tokio::test]
447    async fn the_auth_middleware_returns_401_to_a_json_client() {
448        let response = TestClient::new(router())
449            .send(Request::new(Method::Get, "/api/me").with_header("accept", "application/json"))
450            .await;
451
452        response.assert_status(401).assert_json("message", "Unauthenticated.");
453    }
454
455    #[tokio::test]
456    async fn an_authenticated_request_reaches_the_handler_with_its_identity() {
457        let client = TestClient::new(router());
458        let cookie = logged_in(&client).await;
459
460        client
461            .send(Request::new(Method::Get, "/dashboard").with_header("cookie", cookie))
462            .await
463            .assert_ok()
464            .assert_see("user 41")
465            .assert_see("Some(41)");
466    }
467
468    #[tokio::test]
469    async fn logging_out_closes_the_door_again() {
470        let client = TestClient::new(router());
471        let cookie = logged_in(&client).await;
472
473        let goodbye = client
474            .send(Request::new(Method::Get, "/logout").with_header("cookie", cookie.clone()))
475            .await
476            .assert_ok();
477
478        // The old cookie no longer opens the dashboard...
479        client
480            .send(Request::new(Method::Get, "/dashboard").with_header("cookie", cookie))
481            .await
482            .assert_redirect("/login");
483
484        // ...and neither does the one handed back by the logout itself.
485        client
486            .send(
487                Request::new(Method::Get, "/dashboard").with_header("cookie", cookie_of(&goodbye)),
488            )
489            .await
490            .assert_redirect("/login");
491    }
492
493    #[tokio::test]
494    async fn the_redirect_remembers_where_the_visitor_was_going() {
495        let client = TestClient::new(router());
496
497        let redirected = client.get("/dashboard").await.assert_redirect("/login");
498        let cookie = cookie_of(&redirected);
499
500        // The login form itself is where the intended path would be read.
501        let form = client
502            .send(Request::new(Method::Get, "/login").with_header("cookie", cookie.clone()))
503            .await;
504        form.assert_ok().assert_see("the login form");
505    }
506
507    #[tokio::test]
508    async fn the_guest_middleware_pushes_an_authenticated_visitor_home() {
509        let client = TestClient::new(router());
510
511        client.get("/login").await.assert_ok().assert_see("the login form");
512
513        let cookie = logged_in(&client).await;
514        client
515            .send(Request::new(Method::Get, "/login").with_header("cookie", cookie))
516            .await
517            .assert_redirect("/dashboard");
518    }
519
520    #[tokio::test]
521    async fn both_middleware_fail_loudly_without_a_session() {
522        for name in ["auth", "guest"] {
523            let mut router = Router::new();
524            match name {
525                "auth" => router.middleware(Authenticate::new()),
526                _ => router.middleware(Guest::new()),
527            };
528            router.get("/", |_request: Request| async { "ok" });
529
530            // A server error rather than a silent pass: a guard that cannot
531            // read the session must never let the request through.
532            TestClient::new(router).get("/").await.assert_status(500);
533        }
534    }
535
536    #[test]
537    fn the_missing_session_error_says_how_to_fix_it() {
538        let message = missing_session("auth").to_string();
539
540        assert!(message.contains("`auth` middleware needs a session"), "message was {message}");
541        assert!(message.contains("SessionManager::from_config"), "message was {message}");
542    }
543
544    #[test]
545    fn configuration_supplies_the_login_and_home_paths() {
546        let config = Config::new();
547        config.set("auth.login_path", "/sign-in");
548        config.set("auth.home", "/app");
549
550        assert_eq!(Authenticate::from_config(&config).login_path, "/sign-in");
551        assert_eq!(Guest::from_config(&config).home, "/app");
552
553        assert_eq!(Authenticate::from_config(&Config::new()).login_path, DEFAULT_LOGIN_PATH);
554        assert_eq!(Guest::from_config(&Config::new()).home, DEFAULT_HOME_PATH);
555    }
556}