1use crate::actions;
8use crate::feature::Feature;
9use crate::guard::{self, fortify_guard, fortify_guard_from_state};
10use crate::paths::FortifyPaths;
11use crate::state::{AfterRegisterFn, FortifyState};
12use crate::store::{self, CurrentUser};
13use sova_core::extend::{BoxFuture, MwEntry};
14use sova_core::{App, Error, Plugin, RateLimitIdentity, Request, Result, Router};
15use sova_db::DbExt;
16use sova_passport::Passport;
17use sova_rate_limit::RateLimit;
18use std::collections::HashSet;
19use std::future::Future;
20use std::sync::Arc;
21
22#[cfg(feature = "vld")]
23use sova_vld::ValidateRouteExt;
24
25pub struct Fortify {
27 features: HashSet<Feature>,
28 web_forms: bool,
30 mount: String,
31 api_mount: Option<String>,
32 paths: FortifyPaths,
33 secret: String,
34 public_url: String,
35 app_name: String,
36 home_path: String,
37 login_path: String,
38 profile_path: String,
39 verify_path: String,
40 confirm_password_path: String,
41 two_factor_challenge_path: String,
42 two_factor_path: String,
43 allow_system_role_delete: bool,
45 after_register: Option<AfterRegisterFn>,
46}
47
48impl Fortify {
49 pub fn new() -> Self {
50 Self {
51 features: [Feature::Registration].into_iter().collect(),
53 web_forms: false,
54 mount: "/".into(),
55 api_mount: Some("/api/auth".into()),
56 paths: FortifyPaths::default(),
57 secret: std::env::var("FORTIFY_SECRET")
58 .or_else(|_| std::env::var("APP_KEY"))
59 .unwrap_or_else(|_| "dev-fortify-secret-change-me".into()),
60 public_url: std::env::var("PUBLIC_URL")
61 .unwrap_or_else(|_| "http://127.0.0.1:3000".into()),
62 app_name: std::env::var("APP_NAME").unwrap_or_else(|_| "Sova".into()),
63 home_path: "/cabinet".into(),
64 login_path: "/login".into(),
65 profile_path: "/user/profile".into(),
66 verify_path: "/email/verify".into(),
67 confirm_password_path: "/user/confirm-password".into(),
68 two_factor_challenge_path: "/two-factor-challenge".into(),
69 two_factor_path: "/user/two-factor-authentication".into(),
70 allow_system_role_delete: false,
71 after_register: None,
72 }
73 }
74
75 pub fn features(mut self, features: impl IntoIterator<Item = Feature>) -> Self {
76 self.features = features.into_iter().collect();
77 self
78 }
79
80 pub fn web_forms(mut self, yes: bool) -> Self {
82 self.web_forms = yes;
83 self
84 }
85
86 pub fn mount(mut self, path: impl Into<String>) -> Self {
87 self.mount = path.into();
88 self
89 }
90
91 pub fn api_mount(mut self, path: impl Into<String>) -> Self {
92 self.api_mount = Some(path.into());
93 self
94 }
95
96 pub fn no_api(mut self) -> Self {
97 self.api_mount = None;
98 self
99 }
100
101 pub fn paths(mut self, paths: FortifyPaths) -> Self {
102 self.paths = paths;
103 self
104 }
105
106 pub fn secret(mut self, secret: impl Into<String>) -> Self {
107 self.secret = secret.into();
108 self
109 }
110
111 pub fn public_url(mut self, url: impl Into<String>) -> Self {
112 self.public_url = url.into();
113 self
114 }
115
116 pub fn app_name(mut self, name: impl Into<String>) -> Self {
117 self.app_name = name.into();
118 self
119 }
120
121 pub fn home(mut self, path: impl Into<String>) -> Self {
122 self.home_path = path.into();
123 self
124 }
125
126 pub fn login_path(mut self, path: impl Into<String>) -> Self {
127 self.login_path = path.into();
128 self
129 }
130
131 pub fn login_redirect(mut self, path: impl Into<String>) -> Self {
133 self.login_path = path.into();
134 self
135 }
136
137 pub fn profile_path(mut self, path: impl Into<String>) -> Self {
138 self.profile_path = path.into();
139 self
140 }
141
142 pub fn verify_path(mut self, path: impl Into<String>) -> Self {
143 self.verify_path = path.into();
144 self
145 }
146
147 pub fn confirm_password_path(mut self, path: impl Into<String>) -> Self {
148 self.confirm_password_path = path.into();
149 self
150 }
151
152 pub fn two_factor_challenge_path(mut self, path: impl Into<String>) -> Self {
153 self.two_factor_challenge_path = path.into();
154 self
155 }
156
157 pub fn allow_system_role_delete(mut self, yes: bool) -> Self {
158 self.allow_system_role_delete = yes;
159 self
160 }
161
162 pub fn after_register<F, Fut>(mut self, f: F) -> Self
163 where
164 F: Fn(CurrentUser, Request) -> Fut + Send + Sync + 'static,
165 Fut: Future<Output = Result<Request>> + Send + 'static,
166 {
167 self.after_register = Some(Arc::new(move |u, r| Box::pin(f(u, r)) as BoxFuture<_>)
168 as AfterRegisterFn);
169 self
170 }
171
172 pub fn guard() -> MwEntry {
174 fortify_guard_from_state()
175 }
176
177 pub fn guard_to(login: impl Into<String>) -> MwEntry {
178 fortify_guard(login)
179 }
180
181 pub fn verified() -> MwEntry {
183 guard::verified_from_state()
184 }
185
186 pub fn verified_to(path: impl Into<String>) -> MwEntry {
187 guard::verified(path)
188 }
189
190 pub fn password_confirmed() -> MwEntry {
192 guard::password_confirmed_from_state()
193 }
194
195 pub fn password_confirmed_to(path: impl Into<String>) -> MwEntry {
196 guard::password_confirmed(path)
197 }
198
199 pub fn permission(slug: impl Into<String>) -> MwEntry {
200 guard::permission(slug)
201 }
202
203 pub fn role(slug: impl Into<String>) -> MwEntry {
204 guard::role(slug)
205 }
206}
207
208impl Default for Fortify {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213
214impl Plugin for Fortify {
215 fn id(&self) -> &'static str {
216 "fortify"
217 }
218
219 fn requires(&self) -> &'static [&'static str] {
220 let needs_mail = self.features.contains(&Feature::EmailVerification)
221 || self.features.contains(&Feature::ResetPasswords);
222 if needs_mail {
223 &["db", "session", "mail"]
224 } else {
225 &["db", "session"]
226 }
227 }
228
229 fn meta(&self) -> sova_core::PluginMeta {
230 sova_core::PluginMeta::new("Fortify")
231 .description("Register/login, verify, reset, 2FA, profile, roles")
232 .version(env!("CARGO_PKG_VERSION"))
233 }
234
235 fn install(self, app: &mut App) {
236 if self.secret.is_empty() {
237 app.on_startup(|_s| async {
238 Err(Error::Internal(
239 "FORTIFY_SECRET / APP_KEY is empty".into(),
240 ))
241 });
242 return;
243 }
244
245 let state = FortifyState {
246 features: self.features.clone(),
247 secret: self.secret.clone(),
248 public_url: self.public_url.clone(),
249 app_name: self.app_name.clone(),
250 home_path: self.home_path.clone(),
251 login_path: self.login_path.clone(),
252 profile_path: self.profile_path.clone(),
253 verify_path: self.verify_path.clone(),
254 confirm_password_path: self.confirm_password_path.clone(),
255 two_factor_challenge_path: self.two_factor_challenge_path.clone(),
256 two_factor_path: self.two_factor_path.clone(),
257 paths: self.paths.clone(),
258 allow_system_role_delete: self.allow_system_role_delete,
259 after_register: self.after_register.clone(),
260 };
261 app.state(state);
262
263 app.install(
264 Passport::new()
265 .serialize_user(|req| {
266 let id = req
267 .get::<CurrentUser>()
268 .map(|u| u.id.to_string())
269 .or_else(|| {
270 req.get::<sova_passport::Authenticated>()
271 .map(|a| a.id.clone())
272 });
273 async move { Ok(id) }
274 })
275 .deserialize_user(|id, mut req| async move {
276 let Ok(uid) = id.parse::<i64>() else {
277 return Ok(req);
278 };
279 let db = req.db().clone();
280 if let Some(cu) = store::load_current_user(&db, uid).await? {
281 req.set(RateLimitIdentity(cu.id.to_string()));
282 req.set(cu);
283 }
284 Ok(req)
285 }),
286 );
287
288 if self.web_forms {
289 let mut web = Router::new();
290 mount_web(&mut web, &self.features);
291 let mount = if self.mount == "/" {
292 "".to_string()
293 } else {
294 self.mount.trim_end_matches('/').to_string()
295 };
296 if mount.is_empty() {
297 app.mount("", web);
298 } else {
299 app.mount(&mount, web);
300 }
301 }
302
303 if let Some(api) = self.api_mount {
304 let mut r = Router::new();
305 mount_api(&mut r, &self.features, &self.paths);
306 app.mount(&api, r);
307 }
308 }
309}
310
311fn mount_web(web: &mut Router, features: &HashSet<Feature>) {
312 if features.contains(&Feature::Registration) {
313 web.get("/register", actions::register_form);
314 web.post("/register", actions::register);
315 #[cfg(feature = "vld")]
316 web.validate_form::<crate::forms::RegisterForm>();
317 }
318
319 web.get("/login", actions::login_form);
320 web.post("/login", actions::login);
321 web.route_middleware(RateLimit::login().middleware());
322 #[cfg(feature = "vld")]
323 web.validate_form::<crate::forms::LoginForm>();
324
325 web.post("/logout", actions::logout);
326
327 if features.contains(&Feature::ResetPasswords) {
328 web.post("/forgot-password", actions::forgot_password);
329 web.route_middleware(RateLimit::forgot().middleware());
330 #[cfg(feature = "vld")]
331 web.validate_form::<crate::forms::ForgotForm>();
332
333 web.post("/reset-password", actions::reset_password);
334 #[cfg(feature = "vld")]
335 web.validate_form::<crate::forms::ResetForm>();
336 }
337 if features.contains(&Feature::EmailVerification) {
338 web.post("/email/verify", actions::verify_email);
339 web.post(
340 "/email/verification-notification",
341 actions::resend_verification,
342 );
343 web.route_middleware(RateLimit::resend().middleware());
344 }
345 if features.contains(&Feature::UpdateProfile) {
346 web.get("/user/profile", actions::profile_get);
347 web.post("/user/profile", actions::update_profile);
348 #[cfg(feature = "vld")]
349 web.validate_form::<crate::forms::ProfileForm>();
350 }
351 if features.contains(&Feature::UpdatePasswords) {
352 web.post("/user/password", actions::update_password);
353 #[cfg(feature = "vld")]
354 web.validate_form::<crate::forms::PasswordForm>();
355 }
356
357 web.post("/user/confirm-password", actions::confirm_password);
358 #[cfg(feature = "vld")]
359 web.validate_form::<crate::forms::ConfirmPasswordForm>();
360 web.get(
361 "/user/confirmed-password-status",
362 actions::confirmed_password_status,
363 );
364
365 if features.contains(&Feature::TwoFactor) {
366 web.post("/user/two-factor-authentication", actions::two_factor_enable);
367
368 web.post(
369 "/user/confirmed-two-factor-authentication",
370 actions::two_factor_confirm,
371 );
372 #[cfg(feature = "vld")]
373 web.validate_form::<crate::forms::TwoFactorCodeForm>();
374
375 web.post(
376 "/user/two-factor-authentication/disable",
377 actions::two_factor_disable,
378 );
379 #[cfg(feature = "vld")]
380 web.validate_form::<crate::forms::DisableTwoFactorForm>();
381
382 web.delete(
383 "/user/two-factor-authentication",
384 actions::two_factor_disable,
385 );
386 #[cfg(feature = "vld")]
387 web.validate_form::<crate::forms::DisableTwoFactorForm>();
388
389 web.get("/user/two-factor-qr-code", actions::two_factor_qr_code);
390 web.get("/user/two-factor-secret-key", actions::two_factor_secret_key);
391 web.get(
392 "/user/two-factor-recovery-codes",
393 actions::two_factor_recovery_codes_get,
394 );
395 web.post(
396 "/user/two-factor-recovery-codes",
397 actions::two_factor_recovery_codes_regen,
398 );
399
400 web.post("/two-factor-challenge", actions::two_factor_challenge);
401 web.route_middleware(RateLimit::challenge().middleware());
402 #[cfg(feature = "vld")]
403 web.validate_form::<crate::forms::TwoFactorCodeForm>();
404 }
405}
406
407fn mount_api(r: &mut Router, features: &HashSet<Feature>, paths: &FortifyPaths) {
408 if features.contains(&Feature::Registration) {
409 r.post(&paths.register, actions::register);
410 #[cfg(feature = "vld")]
411 r.validate_body::<crate::forms::RegisterForm>();
412 }
413 r.post(&paths.login, actions::login);
414 r.route_middleware(RateLimit::login().middleware());
415 #[cfg(feature = "vld")]
416 r.validate_body::<crate::forms::LoginForm>();
417
418 r.post(&paths.logout, actions::logout);
419 r.get(&paths.me, actions::me);
420 r.get(&paths.profile, actions::me);
421
422 if features.contains(&Feature::ResetPasswords) {
423 r.post(&paths.forgot_password, actions::forgot_password);
424 r.route_middleware(RateLimit::forgot().middleware());
425 #[cfg(feature = "vld")]
426 r.validate_body::<crate::forms::ForgotForm>();
427 r.post(&paths.reset_password, actions::reset_password);
428 #[cfg(feature = "vld")]
429 r.validate_body::<crate::forms::ResetForm>();
430 }
431 if features.contains(&Feature::EmailVerification) {
432 r.post(&paths.verify_email, actions::verify_email);
433 r.post(&paths.resend_verification, actions::resend_verification);
434 r.route_middleware(RateLimit::resend().middleware());
435 }
436 if features.contains(&Feature::UpdateProfile) {
437 r.post(&paths.profile, actions::update_profile);
438 #[cfg(feature = "vld")]
439 r.validate_body::<crate::forms::ProfileForm>();
440 }
441 if features.contains(&Feature::UpdatePasswords) {
442 r.post(&paths.password, actions::update_password);
443 #[cfg(feature = "vld")]
444 r.validate_body::<crate::forms::PasswordForm>();
445 }
446 r.post(&paths.confirm_password, actions::confirm_password);
447 #[cfg(feature = "vld")]
448 r.validate_body::<crate::forms::ConfirmPasswordForm>();
449 r.get(
450 &paths.confirmed_password_status,
451 actions::confirmed_password_status,
452 );
453
454 if features.contains(&Feature::TwoFactor) {
455 r.post(&paths.two_factor, actions::two_factor_enable);
456 r.post(&paths.two_factor_confirm, actions::two_factor_confirm);
457 #[cfg(feature = "vld")]
458 r.validate_body::<crate::forms::TwoFactorCodeForm>();
459 r.post(&paths.two_factor_disable, actions::two_factor_disable);
460 #[cfg(feature = "vld")]
461 r.validate_body::<crate::forms::DisableTwoFactorForm>();
462 r.delete(&paths.two_factor, actions::two_factor_disable);
463 #[cfg(feature = "vld")]
464 r.validate_body::<crate::forms::DisableTwoFactorForm>();
465 r.get(&paths.two_factor_qr, actions::two_factor_qr_code);
466 r.get(&paths.two_factor_secret, actions::two_factor_secret_key);
467 r.get(&paths.two_factor_recovery, actions::two_factor_recovery_codes_get);
468 r.post(
469 &paths.two_factor_recovery,
470 actions::two_factor_recovery_codes_regen,
471 );
472 r.post(&paths.two_factor_challenge, actions::two_factor_challenge);
473 r.route_middleware(RateLimit::challenge().middleware());
474 #[cfg(feature = "vld")]
475 r.validate_body::<crate::forms::TwoFactorCodeForm>();
476 }
477 if features.contains(&Feature::Roles) {
478 mount_rbac(r, paths);
479 }
480}
481
482fn mount_rbac(r: &mut Router, paths: &FortifyPaths) {
483 let roles = paths.roles.trim_end_matches('/').to_string();
484 let perms = paths.permissions.trim_end_matches('/').to_string();
485 let users = paths.users.trim_end_matches('/').to_string();
486
487 let role_id = format!("{roles}/:id");
488 let role_perms = format!("{roles}/:id/permissions");
489 let perm_id = format!("{perms}/:id");
490 let user_roles = format!("{users}/:id/roles");
491
492 r.get(&roles, actions::roles_list);
493 r.post(&roles, actions::roles_create);
494 r.get(&role_id, actions::roles_show);
495 r.put(&role_id, actions::roles_update);
496 r.patch(&role_id, actions::roles_update);
497 r.delete(&role_id, actions::roles_delete);
498 r.put(&role_perms, actions::roles_sync_permissions);
499
500 r.get(&perms, actions::permissions_list);
501 r.post(&perms, actions::permissions_create);
502 r.put(&perm_id, actions::permissions_update);
503 r.patch(&perm_id, actions::permissions_update);
504 r.delete(&perm_id, actions::permissions_delete);
505
506 r.get(&user_roles, actions::user_roles_list);
507 r.put(&user_roles, actions::user_roles_sync);
508}