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