Skip to main content

larust_auth/
guard.rs

1//! Session-backed guard functions - Laravel's `Auth` facade
2//! (`Auth::login()`, `Auth::logout()`, `Auth::id()`, `Auth::check()`,
3//! `Auth::user()`), adapted to `tower_sessions::Session`'s async API.
4
5use crate::Authenticatable;
6use larust_core::AppError;
7use larust_http::session::Session;
8
9const SESSION_KEY: &str = "_auth_user_id";
10
11/// The session key a given guard name stores its user id under. `"web"`
12/// (`Authenticatable::GUARD`'s default) maps to the original, pre-multi-
13/// guard [`SESSION_KEY`] verbatim - not a formatted `"_auth_web_user_id"` -
14/// so upgrading an existing single-guard app to a version of this
15/// framework with multi-guard support doesn't log every current session
16/// out. Any other guard name gets its own, distinct, formatted key.
17fn session_key(guard: &str) -> String {
18    if guard == "web" {
19        SESSION_KEY.to_string()
20    } else {
21        format!("_auth_{guard}_user_id")
22    }
23}
24
25/// Logs a user in (Laravel's `Auth::login($user)`): rotates the session ID
26/// *before* storing the authenticated user, so a session token an attacker
27/// captured pre-login (session fixation) can't be reused to inherit the
28/// now-authenticated session - then stores [`Authenticatable::auth_id`],
29/// under `U::GUARD`'s own session slot, for later requests to look the user
30/// back up via [`user`]/the [`crate::Auth`] extractor. Logging in as a
31/// second guard (e.g. `Admin` after `User`) doesn't disturb the first
32/// guard's own login state - each has its own slot.
33pub async fn login<U: Authenticatable>(session: &Session, user: &U) -> Result<(), AppError> {
34    session
35        .cycle_id()
36        .await
37        .map_err(|source| AppError::Internal(Box::new(source)))?;
38    session
39        .insert(&session_key(U::GUARD), user.auth_id())
40        .await
41        .map_err(|source| AppError::Internal(Box::new(source)))
42}
43
44/// Logs the current user out (Laravel's `Auth::logout()`). Flushes the
45/// *entire* session, not just the auth key - a clean logout should also
46/// invalidate anything else tied to that session (e.g. the CSRF token),
47/// not leave it half-authenticated. Operates on the default (`"web"`)
48/// guard; for a named second guard, use [`logout_for`] instead, which
49/// clears only that guard's own slot and leaves the rest of the session -
50/// including any other guard's login state - untouched.
51pub async fn logout(session: &Session) -> Result<(), AppError> {
52    session
53        .flush()
54        .await
55        .map_err(|source| AppError::Internal(Box::new(source)))
56}
57
58/// [`logout`]'s named-guard counterpart: removes only `U::GUARD`'s own
59/// session slot (Laravel's `Auth::guard('admin')->logout()`), leaving the
60/// rest of the session - including any other guard's login state - intact.
61pub async fn logout_for<U: Authenticatable>(session: &Session) -> Result<(), AppError> {
62    session
63        .remove::<i64>(&session_key(U::GUARD))
64        .await
65        .map(|_| ())
66        .map_err(|source| AppError::Internal(Box::new(source)))
67}
68
69/// The current session's authenticated user id, if any (Laravel's
70/// `Auth::id()`). Operates on the default (`"web"`) guard; for a named
71/// second guard, use [`id_for`] instead.
72pub async fn id(session: &Session) -> Result<Option<i64>, AppError> {
73    session
74        .get::<i64>(SESSION_KEY)
75        .await
76        .map_err(|source| AppError::Internal(Box::new(source)))
77}
78
79/// [`id`]'s named-guard counterpart (Laravel's `Auth::guard('admin')->id()`).
80pub async fn id_for<U: Authenticatable>(session: &Session) -> Result<Option<i64>, AppError> {
81    session
82        .get::<i64>(&session_key(U::GUARD))
83        .await
84        .map_err(|source| AppError::Internal(Box::new(source)))
85}
86
87/// Whether the current session is authenticated (Laravel's
88/// `Auth::check()`). Operates on the default (`"web"`) guard; for a named
89/// second guard, use [`check_for`] instead.
90pub async fn check(session: &Session) -> Result<bool, AppError> {
91    Ok(id(session).await?.is_some())
92}
93
94/// [`check`]'s named-guard counterpart (Laravel's
95/// `Auth::guard('admin')->check()`).
96pub async fn check_for<U: Authenticatable>(session: &Session) -> Result<bool, AppError> {
97    Ok(id_for::<U>(session).await?.is_some())
98}
99
100/// The current session's authenticated user, if any (Laravel's
101/// `Auth::user()`), resolved through `U::GUARD`'s own session slot - so
102/// `user::<User>(&session)` and `user::<Admin>(&session)` read independent
103/// login state even though they share one `Session`. `Ok(None)` covers
104/// both "not logged in" and "logged in as a user id that no longer exists"
105/// - both are "no current user", not an error.
106pub async fn user<U: Authenticatable>(session: &Session) -> Result<Option<U>, AppError> {
107    match id_for::<U>(session).await? {
108        Some(user_id) => U::find_for_auth(user_id).await,
109        None => Ok(None),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::sync::Arc;
117    // `tower_sessions::MemoryStore` directly, not anything re-exported from
118    // `larust_http::session` - this is pure test scaffolding (constructing
119    // a bare `Session` to unit-test login/logout/check's own logic), not
120    // an app-level session store, so it isn't the persistence footgun
121    // `larust_http::session` deliberately no longer offers.
122    use tower_sessions::MemoryStore;
123
124    #[derive(Clone)]
125    struct TestUser {
126        id: i64,
127    }
128
129    impl Authenticatable for TestUser {
130        fn auth_id(&self) -> i64 {
131            self.id
132        }
133
134        async fn find_for_auth(id: i64) -> Result<Option<Self>, AppError> {
135            // Only id `1` "exists" - everything else (including a
136            // previously-valid-but-now-deleted id) resolves to `None`.
137            Ok((id == 1).then_some(TestUser { id }))
138        }
139    }
140
141    #[derive(Clone)]
142    struct AdminUser {
143        id: i64,
144    }
145
146    impl Authenticatable for AdminUser {
147        const GUARD: &'static str = "admin";
148
149        fn auth_id(&self) -> i64 {
150            self.id
151        }
152
153        async fn find_for_auth(id: i64) -> Result<Option<Self>, AppError> {
154            Ok((id == 1).then_some(AdminUser { id }))
155        }
156    }
157
158    fn new_session() -> Session {
159        Session::new(None, Arc::new(MemoryStore::default()), None)
160    }
161
162    #[tokio::test]
163    async fn login_sets_id_and_check_reflects_it() {
164        let session = new_session();
165        assert!(!check(&session).await.unwrap());
166
167        login(&session, &TestUser { id: 1 }).await.unwrap();
168        assert_eq!(id(&session).await.unwrap(), Some(1));
169        assert!(check(&session).await.unwrap());
170    }
171
172    #[tokio::test]
173    async fn logout_clears_the_whole_session() {
174        let session = new_session();
175        login(&session, &TestUser { id: 1 }).await.unwrap();
176
177        logout(&session).await.unwrap();
178
179        assert_eq!(id(&session).await.unwrap(), None);
180        assert!(!check(&session).await.unwrap());
181    }
182
183    #[tokio::test]
184    async fn user_resolves_the_logged_in_user() {
185        let session = new_session();
186        login(&session, &TestUser { id: 1 }).await.unwrap();
187
188        let resolved = user::<TestUser>(&session).await.unwrap();
189        assert_eq!(resolved.map(|u| u.id), Some(1));
190    }
191
192    #[tokio::test]
193    async fn user_returns_none_when_logged_out() {
194        let session = new_session();
195        assert_eq!(
196            user::<TestUser>(&session).await.unwrap().map(|u| u.id),
197            None
198        );
199    }
200
201    #[tokio::test]
202    async fn user_returns_none_for_an_id_that_no_longer_resolves() {
203        // Logged in as an id that `find_for_auth` won't resolve (simulates
204        // an account deleted after the session was created) - this must
205        // come back as `Ok(None)`, not an error.
206        let session = new_session();
207        login(&session, &TestUser { id: 999 }).await.unwrap();
208
209        let resolved = user::<TestUser>(&session).await.unwrap();
210        assert!(resolved.is_none());
211    }
212
213    #[tokio::test]
214    async fn two_guards_stay_logged_in_independently_in_the_same_session() {
215        let session = new_session();
216
217        login(&session, &TestUser { id: 1 }).await.unwrap();
218        login(&session, &AdminUser { id: 1 }).await.unwrap();
219
220        assert!(check(&session).await.unwrap());
221        assert!(check_for::<AdminUser>(&session).await.unwrap());
222        assert_eq!(
223            user::<TestUser>(&session).await.unwrap().map(|u| u.id),
224            Some(1)
225        );
226        assert_eq!(
227            user::<AdminUser>(&session).await.unwrap().map(|u| u.id),
228            Some(1)
229        );
230    }
231
232    #[tokio::test]
233    async fn logout_for_only_clears_its_own_guard() {
234        let session = new_session();
235        login(&session, &TestUser { id: 1 }).await.unwrap();
236        login(&session, &AdminUser { id: 1 }).await.unwrap();
237
238        logout_for::<AdminUser>(&session).await.unwrap();
239
240        assert!(!check_for::<AdminUser>(&session).await.unwrap());
241        // The other guard, and the rest of the session, are untouched.
242        assert!(check(&session).await.unwrap());
243    }
244
245    #[tokio::test]
246    async fn a_guard_that_never_logged_in_reads_as_logged_out() {
247        let session = new_session();
248        login(&session, &TestUser { id: 1 }).await.unwrap();
249
250        assert!(!check_for::<AdminUser>(&session).await.unwrap());
251        assert_eq!(id_for::<AdminUser>(&session).await.unwrap(), None);
252    }
253}