1use 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
13pub 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
30pub 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
55pub 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
96pub 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
149pub 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
164pub 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
179pub trait AuthExt {
181 fn current_user(&self) -> Option<&CurrentUser>;
182 fn require_current_user(&self) -> Result<&CurrentUser>;
183 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 fn login_user(&mut self, user: CurrentUser);
196
197 fn logout_user(&mut self);
199
200 fn logout_other_sessions(
202 &self,
203 ) -> impl std::future::Future<Output = Result<u64>> + Send;
204
205 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}