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` → `mail` → Fortify**.
4//! Passport is installed inside Fortify. CSRF stays app-level (e.g. cabinet).
5
6use 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
24/// Laravel Fortify-style auth layer on top of Passport + Mail + Db.
25pub struct Fortify {
26    features: HashSet<Feature>,
27    /// Opt-in HTML form GET/POST routes (Laravel-style paths). Default: off.
28    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 DELETE of seed roles `admin` / `user`.
43    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    /// Enable HTML GET form pages + POST routes under [`Self::mount`] (Laravel-style paths).
79    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    /// Alias for [`Self::login_path`] (HTML guard redirect).
130    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    /// Require authenticated [`CurrentUser`] (redirect uses `FortifyState.login_path`).
171    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    /// Require verified email (path from state).
180    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    /// Require recent password confirmation (path from state).
189    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}