1use 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
34pub const IDENTIFIER_KEY: &str = "_auth_id";
36
37pub const INTENDED_KEY: &str = "_auth_intended";
39
40pub const DEFAULT_LOGIN_PATH: &str = "/login";
42
43pub const DEFAULT_HOME_PATH: &str = "/";
45
46pub trait Authenticatable {
53 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#[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 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
104pub struct Guard {
106 session: SessionHandle,
107}
108
109impl Guard {
110 pub fn new(session: SessionHandle) -> Self {
111 Guard { session }
112 }
113
114 pub fn login(&self, user: &impl Authenticatable) -> String {
120 self.login_using_id(user.auth_identifier())
121 }
122
123 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 pub fn logout(&self) -> String {
137 self.session.invalidate()
138 }
139
140 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 pub fn remember_intended(&self, path: impl Into<String>) {
159 self.session.put(INTENDED_KEY, path.into());
160 }
161
162 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
178pub trait AuthExt {
180 fn try_auth(&self) -> Option<Guard>;
182
183 fn auth(&self) -> Guard;
185
186 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#[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 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 request.extend(Identity::new(id));
245 next.run(request).await
246 }
247 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#[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 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 client
480 .send(Request::new(Method::Get, "/dashboard").with_header("cookie", cookie))
481 .await
482 .assert_redirect("/login");
483
484 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 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 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}