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