Skip to main content

sova_auth/
guard.rs

1//! Auth guards and request helpers.
2
3use crate::state::{
4    wants_json, FortifyState, PASSWORD_CONFIRMED_AT, PASSWORD_CONFIRM_TTL_SECS, PENDING_2FA_KEY,
5};
6use crate::store::CurrentUser;
7use crate::token::now_secs;
8use sova_core::extend::{named, MwEntry};
9use sova_core::{with_state, Error, IntoResponse, Next, Redirect, Request, Response, Result};
10use sova_passport::PassportExt;
11use sova_session::SessionExt;
12
13/// Require login: redirect HTML to configured login path, else 401.
14pub fn fortify_guard(login_path: impl Into<String>) -> MwEntry {
15    let login = login_path.into();
16    named(
17        "fortify-guard",
18        with_state(login, |login, req, next| async move {
19            if req.get::<CurrentUser>().is_some() || req.is_authenticated() {
20                if req.get::<CurrentUser>().is_none() {
21                    return reject(&req, login.as_str()).into_response();
22                }
23                return next(req).await;
24            }
25            reject(&req, login.as_str()).into_response()
26        }),
27    )
28}
29
30/// Like [`fortify_guard`], but reads `FortifyState.login_path` per request.
31pub fn fortify_guard_from_state() -> MwEntry {
32    named("fortify-guard", |req: Request, next: Next| async move {
33        let login = req
34            .try_state::<FortifyState>()
35            .map(|s| s.login_path.clone())
36            .unwrap_or_else(|| "/login".into());
37        if req.get::<CurrentUser>().is_some() || req.is_authenticated() {
38            if req.get::<CurrentUser>().is_none() {
39                return reject(&req, &login).into_response();
40            }
41            return next(req).await;
42        }
43        reject(&req, &login).into_response()
44    })
45}
46
47fn reject(req: &Request, login: &str) -> Response {
48    if wants_json(req) {
49        Error::Unauthorized.into_response()
50    } else {
51        Redirect::see_other(login).into_response()
52    }
53}
54
55/// Require verified email (redirect or 403 JSON).
56pub fn verified(verify_path: impl Into<String>) -> MwEntry {
57    let path = verify_path.into();
58    named(
59        "fortify-verified",
60        with_state(path, |path, req, next| async move {
61            match req.get::<CurrentUser>() {
62                Some(u) if u.email_verified => next(req).await,
63                Some(_) => {
64                    if wants_json(&req) {
65                        Error::custom(403, "Email not verified").into_response()
66                    } else {
67                        Redirect::see_other(path.as_str()).into_response()
68                    }
69                }
70                None => Error::Unauthorized.into_response(),
71            }
72        }),
73    )
74}
75
76pub fn verified_from_state() -> MwEntry {
77    named("fortify-verified", |req: Request, next: Next| async move {
78        let path = req
79            .try_state::<FortifyState>()
80            .map(|s| s.verify_path.clone())
81            .unwrap_or_else(|| "/email/verify".into());
82        match req.get::<CurrentUser>() {
83            Some(u) if u.email_verified => next(req).await,
84            Some(_) => {
85                if wants_json(&req) {
86                    Error::custom(403, "Email not verified").into_response()
87                } else {
88                    Redirect::see_other(path.as_str()).into_response()
89                }
90            }
91            None => Error::Unauthorized.into_response(),
92        }
93    })
94}
95
96/// Require recent password confirmation (session TTL).
97pub fn password_confirmed(confirm_path: impl Into<String>) -> MwEntry {
98    let path = confirm_path.into();
99    named(
100        "fortify-password-confirmed",
101        with_state(path, |path, req, next| async move {
102            if is_password_confirmed(&req) {
103                return next(req).await;
104            }
105            if wants_json(&req) {
106                Error::custom(423, "Password confirmation required").into_response()
107            } else {
108                Redirect::see_other(path.as_str()).into_response()
109            }
110        }),
111    )
112}
113
114pub fn password_confirmed_from_state() -> MwEntry {
115    named(
116        "fortify-password-confirmed",
117        |req: Request, next: Next| async move {
118            let path = req
119                .try_state::<FortifyState>()
120                .map(|s| s.confirm_password_path.clone())
121                .unwrap_or_else(|| "/user/confirm-password".into());
122            if is_password_confirmed(&req) {
123                return next(req).await;
124            }
125            if wants_json(&req) {
126                Error::custom(423, "Password confirmation required").into_response()
127            } else {
128                Redirect::see_other(path.as_str()).into_response()
129            }
130        },
131    )
132}
133
134pub fn is_password_confirmed(req: &Request) -> bool {
135    let Some(raw) = req.session().get(PASSWORD_CONFIRMED_AT) else {
136        return false;
137    };
138    let Ok(at) = raw.parse::<u64>() else {
139        return false;
140    };
141    now_secs().saturating_sub(at) <= PASSWORD_CONFIRM_TTL_SECS
142}
143
144pub fn mark_password_confirmed(req: &Request) {
145    req.session()
146        .set(PASSWORD_CONFIRMED_AT, now_secs().to_string());
147}
148
149/// Require a permission slug (`admin` role bypasses via [`CurrentUser::has_permission`]).
150pub fn permission(slug: impl Into<String>) -> MwEntry {
151    let slug = slug.into();
152    named(
153        format!("fortify-permission:{slug}"),
154        with_state(slug, |slug, req, next| async move {
155            match req.get::<CurrentUser>() {
156                Some(u) if u.has_permission(&slug) => next(req).await,
157                Some(_) => Error::custom(403, "Forbidden").into_response(),
158                None => Error::Unauthorized.into_response(),
159            }
160        }),
161    )
162}
163
164/// Require a role slug.
165pub fn role(slug: impl Into<String>) -> MwEntry {
166    let slug = slug.into();
167    named(
168        format!("fortify-role:{slug}"),
169        with_state(slug, |slug, req, next| async move {
170            match req.get::<CurrentUser>() {
171                Some(u) if u.has_role(&slug) => next(req).await,
172                Some(_) => Error::custom(403, "Forbidden").into_response(),
173                None => Error::Unauthorized.into_response(),
174            }
175        }),
176    )
177}
178
179/// Request helpers for RBAC (`CurrentUser` from Fortify / Passport session).
180pub trait AuthExt {
181    fn current_user(&self) -> Option<&CurrentUser>;
182    fn require_current_user(&self) -> Result<&CurrentUser>;
183    /// Alias for [`Self::require_current_user`].
184    fn profile(&self) -> Result<&CurrentUser>;
185    fn require_permission(&self, slug: &str) -> Result<&CurrentUser>;
186    fn require_role(&self, slug: &str) -> Result<&CurrentUser>;
187    fn password_confirmed(&self) -> bool;
188
189    /// Check a [`crate::Policy`] ability against `resource`.
190    fn can<P: crate::Policy<R> + Default, R>(&self, ability: crate::Ability, resource: &R) -> bool;
191
192    /// Like [`Self::can`], but returns [`Error::Forbidden`] on deny.
193    fn authorize<P: crate::Policy<R> + Default, R>(
194        &self,
195        ability: crate::Ability,
196        resource: &R,
197    ) -> Result<&CurrentUser>;
198
199    /// Custom predicate authorization.
200    fn authorize_with(
201        &self,
202        f: impl FnOnce(&CurrentUser) -> bool,
203    ) -> Result<&CurrentUser>;
204
205    /// Programmatic login: rotate session, persist passport user id, set [`CurrentUser`].
206    ///
207    /// ```ignore
208    /// let cu = load_current_user(db, id).await?.unwrap();
209    /// req.login_user(cu);
210    /// ```
211    fn login_user(&mut self, user: CurrentUser);
212
213    /// Clear Fortify/Passport session auth (pending 2FA, password confirm, CurrentUser).
214    fn logout_user(&mut self);
215
216    /// Kill every other device/session for the current user; keep this cookie.
217    fn logout_other_sessions(
218        &self,
219    ) -> impl std::future::Future<Output = Result<u64>> + Send;
220
221    /// Kill all sessions for the current user, including this one.
222    fn logout_all_sessions(
223        &mut self,
224    ) -> impl std::future::Future<Output = Result<u64>> + Send;
225}
226
227impl AuthExt for Request {
228    fn current_user(&self) -> Option<&CurrentUser> {
229        self.get::<CurrentUser>()
230    }
231
232    fn require_current_user(&self) -> Result<&CurrentUser> {
233        self.get::<CurrentUser>().ok_or(Error::Unauthorized)
234    }
235
236    fn profile(&self) -> Result<&CurrentUser> {
237        self.require_current_user()
238    }
239
240    fn require_permission(&self, slug: &str) -> Result<&CurrentUser> {
241        let u = self.require_current_user()?;
242        if u.has_permission(slug) {
243            Ok(u)
244        } else {
245            Err(Error::Forbidden)
246        }
247    }
248
249    fn require_role(&self, slug: &str) -> Result<&CurrentUser> {
250        let u = self.require_current_user()?;
251        if u.has_role(slug) {
252            Ok(u)
253        } else {
254            Err(Error::Forbidden)
255        }
256    }
257
258    fn password_confirmed(&self) -> bool {
259        is_password_confirmed(self)
260    }
261
262    fn can<P: crate::Policy<R> + Default, R>(&self, ability: crate::Ability, resource: &R) -> bool {
263        match self.current_user() {
264            Some(u) => crate::policy::can_ability::<P, R>(u, ability, resource),
265            None => false,
266        }
267    }
268
269    fn authorize<P: crate::Policy<R> + Default, R>(
270        &self,
271        ability: crate::Ability,
272        resource: &R,
273    ) -> Result<&CurrentUser> {
274        let u = self.require_current_user()?;
275        crate::policy::authorize_ability::<P, R>(u, ability, resource)?;
276        Ok(u)
277    }
278
279    fn authorize_with(&self, f: impl FnOnce(&CurrentUser) -> bool) -> Result<&CurrentUser> {
280        let u = self.require_current_user()?;
281        if f(u) {
282            Ok(u)
283        } else {
284            Err(Error::Forbidden)
285        }
286    }
287
288    fn login_user(&mut self, user: CurrentUser) {
289        self.session().remove(PENDING_2FA_KEY);
290        self.session().remove(PASSWORD_CONFIRMED_AT);
291        let id = user.id.to_string();
292        self.login(id, user);
293    }
294
295    fn logout_user(&mut self) {
296        self.session().remove(PENDING_2FA_KEY);
297        self.session().remove(PASSWORD_CONFIRMED_AT);
298        let _ = self.take::<CurrentUser>();
299        self.logout();
300    }
301
302    async fn logout_other_sessions(&self) -> Result<u64> {
303        SessionExt::logout_other_sessions(self).await
304    }
305
306    async fn logout_all_sessions(&mut self) -> Result<u64> {
307        self.session().remove(PENDING_2FA_KEY);
308        self.session().remove(PASSWORD_CONFIRMED_AT);
309        let n = SessionExt::logout_all_sessions(self).await?;
310        let _ = self.take::<CurrentUser>();
311        let _ = self.take::<sova_passport::Authenticated>();
312        Ok(n)
313    }
314}