Skip to main content

sova_auth/
plugin.rs

1//! Fortify plugin: Passport session + JSON API (web forms opt-in).
2//!
3//! Install order (runtime `requires`): **`db` → `session` → Fortify**.
4//! Add **`mail`** before Fortify when enabling `EmailVerification` or `ResetPasswords`.
5//! Passport is installed inside Fortify. CSRF stays app-level (e.g. cabinet).
6
7use 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
25/// Laravel Fortify-style auth layer on top of Passport + Mail + Db.
26pub struct Fortify {
27    features: HashSet<Feature>,
28    /// Opt-in HTML form GET/POST routes (Laravel-style paths). Default: off.
29    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 DELETE of seed roles `admin` / `user`.
44    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    /// Enable HTML GET form pages + POST routes under [`Self::mount`] (Laravel-style paths).
80    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    /// Alias for [`Self::login_path`] (HTML guard redirect).
131    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    /// Require authenticated [`CurrentUser`] (redirect uses `FortifyState.login_path`).
172    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    /// Require verified email (path from state).
181    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    /// Require recent password confirmation (path from state).
190    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}