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            // Registration-only by default — mail-backed features are opt-in.
52            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    /// Enable HTML GET form pages + POST routes under [`Self::mount`] (Laravel-style paths).
81    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    /// Alias for [`Self::login_path`] (HTML guard redirect).
132    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    /// Require authenticated [`CurrentUser`] (redirect uses `FortifyState.login_path`).
173    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    /// Require verified email (path from state).
182    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    /// Require recent password confirmation (path from state).
191    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}