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    /// Programmatic login: rotate session, persist passport user id, set [`CurrentUser`].
190    ///
191    /// ```ignore
192    /// let cu = load_current_user(db, id).await?.unwrap();
193    /// req.login_user(cu);
194    /// ```
195    fn login_user(&mut self, user: CurrentUser);
196
197    /// Clear Fortify/Passport session auth (pending 2FA, password confirm, CurrentUser).
198    fn logout_user(&mut self);
199
200    /// Kill every other device/session for the current user; keep this cookie.
201    fn logout_other_sessions(
202        &self,
203    ) -> impl std::future::Future<Output = Result<u64>> + Send;
204
205    /// Kill all sessions for the current user, including this one.
206    fn logout_all_sessions(
207        &mut self,
208    ) -> impl std::future::Future<Output = Result<u64>> + Send;
209}
210
211impl AuthExt for Request {
212    fn current_user(&self) -> Option<&CurrentUser> {
213        self.get::<CurrentUser>()
214    }
215
216    fn require_current_user(&self) -> Result<&CurrentUser> {
217        self.get::<CurrentUser>().ok_or(Error::Unauthorized)
218    }
219
220    fn profile(&self) -> Result<&CurrentUser> {
221        self.require_current_user()
222    }
223
224    fn require_permission(&self, slug: &str) -> Result<&CurrentUser> {
225        let u = self.require_current_user()?;
226        if u.has_permission(slug) {
227            Ok(u)
228        } else {
229            Err(Error::custom(403, "Forbidden"))
230        }
231    }
232
233    fn require_role(&self, slug: &str) -> Result<&CurrentUser> {
234        let u = self.require_current_user()?;
235        if u.has_role(slug) {
236            Ok(u)
237        } else {
238            Err(Error::custom(403, "Forbidden"))
239        }
240    }
241
242    fn password_confirmed(&self) -> bool {
243        is_password_confirmed(self)
244    }
245
246    fn login_user(&mut self, user: CurrentUser) {
247        self.session().remove(PENDING_2FA_KEY);
248        self.session().remove(PASSWORD_CONFIRMED_AT);
249        let id = user.id.to_string();
250        self.login(id, user);
251    }
252
253    fn logout_user(&mut self) {
254        self.session().remove(PENDING_2FA_KEY);
255        self.session().remove(PASSWORD_CONFIRMED_AT);
256        let _ = self.take::<CurrentUser>();
257        self.logout();
258    }
259
260    async fn logout_other_sessions(&self) -> Result<u64> {
261        SessionExt::logout_other_sessions(self).await
262    }
263
264    async fn logout_all_sessions(&mut self) -> Result<u64> {
265        self.session().remove(PENDING_2FA_KEY);
266        self.session().remove(PASSWORD_CONFIRMED_AT);
267        let n = SessionExt::logout_all_sessions(self).await?;
268        let _ = self.take::<CurrentUser>();
269        let _ = self.take::<sova_passport::Authenticated>();
270        Ok(n)
271    }
272}