Skip to main content

renox_core/auth/
module.rs

1use std::sync::Arc;
2
3use axum::extract::{Extension, State};
4use axum::response::{IntoResponse, Redirect, Response};
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7
8use super::events::{LockedOut, LoggedIn, LoggedOut, LoginFailed, Registered, announce};
9use super::user::dummy_hash;
10use super::{User, intended, login, logout, passwords, verification, verify_password};
11use crate::db::Migration;
12use crate::i18n::Lang;
13use crate::validation::{Errors, Valid, Validate, ValidationError, Validator};
14use crate::{
15    AppState, AuthUser, ClientIp, Htmx, HxRedirect, Module, Result, Routes, Session, View, context,
16    view,
17};
18
19const MIGRATIONS: &[Migration] = &[
20    crate::db::framework_migration!("auth", "00010101000000_create_users_table"),
21    crate::db::framework_migration!("auth", "00010101000001_create_password_reset_tokens_table"),
22    crate::db::framework_migration!("auth", "00010101000002_create_personal_access_tokens_table"),
23    crate::db::framework_migration!("auth", "00010101000003_create_notifications_table"),
24    crate::db::framework_migration!("auth", "00010101000004_add_sessions_revoked_at_to_users"),
25    crate::db::framework_migration!(
26        "auth",
27        "00010101000005_add_abilities_to_personal_access_tokens"
28    ),
29    crate::db::framework_migration!("auth", "00010101000006_create_revoked_sessions_table"),
30];
31
32pub(crate) struct Settings {
33    pub(super) password: crate::validation::Password,
34    pub(super) registration: bool,
35    redirect_to: Option<String>,
36    pub(super) verify_email: bool,
37    rules: Option<RulesFn>,
38    pub(super) on_registered: Option<RegisteredFn>,
39}
40
41type RulesFn = Arc<dyn Fn(&Registration, &mut Validator) + Send + Sync>;
42pub(super) type RegisteredFn = Arc<
43    dyn Fn(
44            AppState,
45            User,
46            Registration,
47        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result> + Send>>
48        + Send
49        + Sync,
50>;
51
52/// What was submitted to `/register`, for [`Auth::registration_rules`] and
53/// [`Auth::on_registered`]. Add the fields to your own
54/// `renox/auth/register.html` (copy the built-in one).
55#[derive(Debug, Clone)]
56#[non_exhaustive]
57pub struct Registration {
58    pub(super) fields: serde_json::Map<String, Value>,
59}
60
61impl Registration {
62    /// A submitted field, trimmed; `""` when it's missing.
63    pub fn get(&self, field: &str) -> String {
64        match self.fields.get(field) {
65            Some(Value::String(value)) => value.trim().to_owned(),
66            Some(Value::Array(values)) => values
67                .first()
68                .and_then(Value::as_str)
69                .unwrap_or_default()
70                .trim()
71                .to_owned(),
72            _ => String::new(),
73        }
74    }
75
76    /// Every value of a field sent several times (a group of checkboxes).
77    pub fn all(&self, field: &str) -> Vec<String> {
78        match self.fields.get(field) {
79            Some(Value::String(value)) => vec![value.clone()],
80            Some(Value::Array(values)) => values
81                .iter()
82                .filter_map(|v| v.as_str().map(str::to_owned))
83                .collect(),
84            _ => Vec::new(),
85        }
86    }
87}
88
89/// Login, registration, password reset and email verification pages, and
90/// the `users`, `password_reset_tokens` and `personal_access_tokens` tables.
91///
92/// Routes: `login`, `register` (unless `without_registration`), `logout`,
93/// `password.request`, `password.email`, `password.reset`,
94/// `password.update`, `password.confirm`, `verification.notice`,
95/// `verification.verify` and `verification.send`. [`Auth::account`] adds
96/// `account.show`, `account.profile`, `account.password`,
97/// `account.logout_others` and `account.destroy`;
98/// [`Auth::notifications`] adds the `notifications.*` routes.
99/// docs/authorization.md lists each one's method and address.
100/// The pages live in `renox/auth/*.html`, inside `renox/auth/layout.html`;
101/// create a file with the same name under your views to replace one.
102///
103/// Failed logins lock out 5 tries per email and IP a minute, 20 per email in
104/// 15 minutes from any IP, and 50 per IP in 15 minutes for any email (set
105/// `TRUSTED_PROXIES` behind a proxy). Logging out ends this device's
106/// session only; a new password or a password reset ends the other sessions.
107#[derive(Clone)]
108pub struct Auth {
109    password: crate::validation::Password,
110    account: bool,
111    registration: bool,
112    redirect_to: Option<String>,
113    verify_email: bool,
114    notifications: bool,
115    rules: Option<RulesFn>,
116    on_registered: Option<RegisteredFn>,
117}
118
119impl Auth {
120    /// Registration on, account pages and email verification off, the default `Password` policy.
121    pub fn new() -> Self {
122        Self {
123            password: crate::validation::Password::default(),
124            account: false,
125            registration: true,
126            redirect_to: None,
127            verify_email: false,
128            notifications: false,
129            rules: None,
130            on_registered: None,
131        }
132    }
133
134    /// Turns on the in-app notification list for the UI kit's
135    /// `notification_bell`: the `notifications.*` routes (a page that is
136    /// also the bell's panel, mark read or unread, delete, and a stream of
137    /// Server-Sent Events for new ones), and `unread_notifications` (the
138    /// logged-in user's unread count) in every view.
139    ///
140    /// ```
141    /// # use renox::prelude::*;
142    /// # let _ =
143    /// App::new().module(Auth::new().notifications())
144    /// # ;
145    /// ```
146    pub fn notifications(mut self) -> Self {
147        self.notifications = true;
148        self
149    }
150
151    /// Validates fields the app adds to the registration form, with the
152    /// built-in ones. Their errors show next to the inputs like any other.
153    ///
154    /// ```
155    /// # use renox::prelude::*;
156    /// # let _ =
157    /// Auth::new()
158    ///     .registration_rules(|form, v| {
159    ///         v.field("phone", &form.get("phone")).required().max(20);
160    ///     })
161    ///     .on_registered(|mut user, form, state| async move {
162    ///         // `phone` and `role` are columns the app added to `users`.
163    ///         user.set(&state.db, "phone", form.get("phone")).await?;
164    ///         let first = User::query().count(&state.db).await? == 1;
165    ///         user.set(&state.db, "role", if first { "admin" } else { "member" }).await
166    ///     })
167    /// # ;
168    /// ```
169    pub fn registration_rules(
170        mut self,
171        rules: impl Fn(&Registration, &mut Validator) + Send + Sync + 'static,
172    ) -> Self {
173        self.rules = Some(Arc::new(rules));
174        self
175    }
176
177    /// Runs after a new user is saved and before they're logged in, e.g. to
178    /// save the app's own fields or give a role. If it fails, the user is
179    /// deleted again and the visitor gets the error, so they can try again.
180    pub fn on_registered<F, Fut>(mut self, hook: F) -> Self
181    where
182        F: Fn(User, Registration, AppState) -> Fut + Send + Sync + 'static,
183        Fut: std::future::Future<Output = Result> + Send + 'static,
184    {
185        self.on_registered = Some(Arc::new(move |state, user, form| {
186            Box::pin(hook(user, form, state))
187        }));
188        self
189    }
190
191    /// Emails new users a verification link. Guard routes that need a
192    /// verified address with `Routes::require_verified()`.
193    pub fn verify_email(mut self) -> Self {
194        self.verify_email = true;
195        self
196    }
197
198    /// What passwords must contain on the register, reset and account
199    /// forms; `Password::min(8)` by default.
200    ///
201    /// ```
202    /// # use renox::prelude::*;
203    /// use renox::validation::Password;
204    /// # let _ =
205    /// Auth::new().password_rules(Password::min(12).mixed_case().numbers())
206    /// # ;
207    /// ```
208    pub fn password_rules(mut self, policy: crate::validation::Password) -> Self {
209        self.password = policy;
210        self
211    }
212
213    /// Adds the account page (`/account`, route `account.show`): change
214    /// name and email (a new email must be verified again with
215    /// `verify_email`), change the password, log out other devices, delete
216    /// the account. The last two ask for the password. Override the page at
217    /// `resources/views/renox/auth/account.html`.
218    pub fn account(mut self) -> Self {
219        self.account = true;
220        self
221    }
222
223    /// Hides `/register`, e.g. for back-office apps where an admin adds users.
224    pub fn without_registration(mut self) -> Self {
225        self.registration = false;
226        self
227    }
228
229    /// Where to go after logging in or registering when no page asked for
230    /// the login. Defaults to the `home` route, or `/`.
231    pub fn redirect_to(mut self, path: &str) -> Self {
232        self.redirect_to = Some(path.to_owned());
233        self
234    }
235
236    fn settings(&self) -> Settings {
237        Settings {
238            password: self.password.clone(),
239            registration: self.registration,
240            redirect_to: self.redirect_to.clone(),
241            verify_email: self.verify_email,
242            rules: self.rules.clone(),
243            on_registered: self.on_registered.clone(),
244        }
245    }
246}
247
248impl Default for Auth {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl Module for Auth {
255    fn name(&self) -> &'static str {
256        "auth"
257    }
258
259    fn migrations(&self) -> &'static [Migration] {
260        MIGRATIONS
261    }
262
263    fn register(&self, app: &mut crate::Registry) {
264        // For other ways of logging in (a social login): `auth::sign_in`.
265        app.auth = Some(Arc::new(self.settings()));
266        if self.notifications {
267            app.share(
268                "unread_notifications",
269                |ctx: crate::view::ViewContext| async move {
270                    match ctx.user {
271                        Some(user) => user.unread_notification_count(&ctx.state.db).await,
272                        None => Ok(0),
273                    }
274                },
275            );
276        }
277        // Schedule it, e.g. daily: `app.schedule().daily_at("03:00", …)`.
278        app.command(
279            "tokens:prune",
280            "Delete API tokens that expired more than a day ago",
281            |_args, state| async move {
282                let day = std::time::Duration::from_secs(24 * 60 * 60);
283                let pruned = super::prune_expired_tokens(&state.db, day).await?;
284                println!("Deleted {pruned} expired API tokens.");
285                Ok(())
286            },
287        );
288        app.command(
289            "notifications:prune",
290            "Delete notifications read more than --days ago (default 30)",
291            |args, state| async move {
292                let days: u64 = args
293                    .value("--days")
294                    .unwrap_or("30")
295                    .parse()
296                    .map_err(|_| crate::Error::BadRequest("--days must be a number".into()))?;
297                let age = std::time::Duration::from_secs(days * 24 * 60 * 60);
298                let pruned = super::prune_read_notifications(&state.db, age).await?;
299                println!("Deleted {pruned} notifications read more than {days} days ago.");
300                Ok(())
301            },
302        );
303    }
304
305    fn routes(&self) -> Routes {
306        let settings = Arc::new(self.settings());
307
308        let mut guest = Routes::new()
309            .get("/login", show_login)
310            .post("/login", store_login)
311            .name("login");
312        if self.registration {
313            guest = guest
314                .get("/register", show_register)
315                .post("/register", store_register)
316                .name("register");
317        }
318        let guest = guest
319            .get("/forgot-password", passwords::show_forgot)
320            .name("password.request")
321            .post("/forgot-password", passwords::send_link)
322            .name("password.email")
323            .get("/reset-password/{token}", passwords::show_reset)
324            .name("password.reset")
325            .post("/reset-password", passwords::reset)
326            .name("password.update")
327            .guest_only();
328        let verification = Routes::new()
329            .get("/verify-email", verification::notice)
330            .name("verification.notice")
331            .get("/verify-email/{id}/{hash}", verification::verify)
332            .name("verification.verify")
333            .post("/email/verification-notification", verification::resend)
334            .name("verification.send")
335            .require_auth();
336        let confirm = Routes::new()
337            .get("/confirm-password", super::account::show_confirm)
338            .post("/confirm-password", super::account::confirm)
339            .name("password.confirm")
340            .require_auth();
341        let mut routes = guest
342            .merge(verification)
343            .merge(confirm)
344            .merge(Routes::new().post("/logout", destroy).name("logout"));
345        if self.account {
346            routes = routes.merge(super::account::routes());
347        }
348        if self.notifications {
349            routes = routes.merge(super::inbox::routes());
350        }
351        routes.route_layer(Extension(settings))
352    }
353}
354
355/// The built-in (English) texts, with the app's `renox.auth.*`
356/// translations for the request's language on top.
357pub(super) fn texts(lang: &Lang) -> Value {
358    let mut base = text();
359    if let Value::Object(map) = &mut base {
360        for (key, value) in lang.texts().iter() {
361            if let Some(key) = key.strip_prefix("renox.auth.") {
362                map.insert(key.to_owned(), Value::String(value.clone()));
363            }
364        }
365    }
366    base
367}
368
369/// A built-in message (e.g. `auth.failed`), translated if the app's lang file has it.
370fn message(lang: &Lang, key: &str, params: &[(&str, String)]) -> String {
371    let template = crate::validation::template_for(Some(&lang.texts()), key);
372    crate::i18n::format(&template, params, None)
373}
374
375pub(super) fn after_login(state: &AppState, settings: &Settings, session: &Session) -> String {
376    let fallback = settings
377        .redirect_to
378        .clone()
379        .unwrap_or_else(|| state.url("home", &[]).unwrap_or_else(|_| "/".into()));
380    intended(session, fallback)
381}
382
383/// A full page load after logging in or out, since the layout changes.
384pub(super) fn go(htmx: &Htmx, to: String) -> Response {
385    if htmx.request {
386        HxRedirect(to).into_response()
387    } else {
388        Redirect::to(&to).into_response()
389    }
390}
391
392async fn show_login(Extension(settings): Extension<Arc<Settings>>, lang: Lang) -> View {
393    view(
394        "renox/auth/login.html",
395        context! { registration => settings.registration, text => texts(&lang) },
396    )
397}
398
399#[derive(Deserialize, Serialize)]
400struct LoginForm {
401    email: String,
402    password: String,
403    remember: Option<String>,
404}
405
406/// Built-in labels for Renox's own forms; an app's lang file wins.
407pub(super) fn label(_v: &Validator, field: &'static str) -> &'static str {
408    match field {
409        "current_password" => "current password",
410        _ => field,
411    }
412}
413
414impl Validate for LoginForm {
415    fn rules(&self, v: &mut Validator) {
416        v.field("email", &super::user::normalize_email(&self.email))
417            .required()
418            .email();
419        let password = label(v, "password");
420        v.field("password", &self.password)
421            .fallback_label(password)
422            .required();
423    }
424}
425
426async fn store_login(
427    Extension(settings): Extension<Arc<Settings>>,
428    State(state): State<AppState>,
429    session: Session,
430    htmx: Htmx,
431    ClientIp(ip): ClientIp,
432    lang: Lang,
433    Valid(form): Valid<LoginForm>,
434) -> Result<Response> {
435    let failed = |key: &str, seconds: Option<u64>| {
436        let mut errors = Errors::new();
437        let seconds = seconds.map(|s| s.to_string()).unwrap_or_default();
438        errors.add("email", message(&lang, key, &[("seconds", seconds)]));
439        ValidationError::new(errors)
440            .with_input(&json!({ "email": form.email, "remember": form.remember }))
441    };
442
443    let address = ip.map(|ip| ip.to_string());
444    if let Some(seconds) = state.throttle.blocked_for(&form.email, ip).await {
445        let event = LockedOut {
446            email: form.email.clone(),
447            ip: address,
448            seconds,
449        };
450        announce(&state, event).await;
451        return Err(failed("auth.throttle", Some(seconds)).into());
452    }
453
454    let user = User::find_by_email(&state.db, &form.email).await?;
455    // Hash even when the email is unknown, so timing doesn't reveal which emails exist.
456    let hash = user
457        .as_ref()
458        .map_or_else(dummy_hash, |u| u.password.clone());
459    let valid = verify_password(&form.password, &hash).await;
460    let Some(mut user) = user.filter(|_| valid) else {
461        state.throttle.fail(&form.email, ip).await;
462        let event = LoginFailed {
463            email: form.email.clone(),
464            ip: address,
465        };
466        announce(&state, event).await;
467        return Err(failed("auth.failed", None).into());
468    };
469    user.rehash_if_needed(&state.db, &form.password).await?;
470
471    // A second step (two-factor authentication) first: the login waits in
472    // the session. The throttle isn't cleared until it's passed, so knowing
473    // the password doesn't reset the count of wrong codes.
474    if let Some(second) = &state.second_factor
475        && (second.required)(user.clone(), state.clone()).await?
476    {
477        let to = after_login(&state, &settings, &session);
478        super::second_factor::begin(&session, &user, &form.email, form.remember.is_some(), to)?;
479        let challenge = state.url(&second.challenge, &[])?;
480        return Ok(go(&htmx, challenge));
481    }
482
483    state.throttle.clear(&form.email, ip).await;
484    let remember = form
485        .remember
486        .is_some()
487        .then_some(state.config.remember_lifetime);
488    login(&session, &user, remember)?;
489    super::account::mark_confirmed(&session)?;
490    let event = LoggedIn {
491        user_id: user.id,
492        ip: address,
493    };
494    announce(&state, event).await;
495    Ok(go(&htmx, after_login(&state, &settings, &session)))
496}
497
498async fn show_register(lang: Lang) -> View {
499    view(
500        "renox/auth/register.html",
501        context! { text => texts(&lang) },
502    )
503}
504
505#[derive(Deserialize)]
506struct RegisterForm {
507    name: String,
508    email: String,
509    password: String,
510    password_confirmation: Option<String>,
511}
512
513impl Validate for RegisterForm {
514    fn rules(&self, v: &mut Validator) {
515        let (name, password) = (label(v, "name"), label(v, "password"));
516        v.field("name", &self.name)
517            .fallback_label(name)
518            .required()
519            .max(255);
520        // Checked as it will be stored, so `unique` ignores case on every database.
521        v.field("email", &super::user::normalize_email(&self.email))
522            .required()
523            .email()
524            .max(255)
525            .unique("users", "email");
526        // Checked with the app's policy in `store_register`.
527        let _ = password;
528    }
529}
530
531async fn store_register(
532    Extension(settings): Extension<Arc<Settings>>,
533    State(state): State<AppState>,
534    session: Session,
535    htmx: Htmx,
536    lang: Lang,
537    req: axum::extract::Request,
538) -> Result<Response> {
539    let rules = settings.rules.clone();
540    let policy = settings.password.clone();
541    let validated = crate::validation::extract::validate_request(
542        req,
543        &state,
544        move |form: &RegisterForm, fields, v| {
545            let password = label(v, "password");
546            v.field("password", &form.password)
547                .fallback_label(password)
548                .required()
549                .password(&policy)
550                .confirmed(&form.password_confirmation);
551            if let Some(rules) = &rules {
552                rules(
553                    &Registration {
554                        fields: fields.clone(),
555                    },
556                    v,
557                );
558            }
559        },
560    )
561    .await;
562    let (form, fields) = match validated {
563        Ok(validated) => validated,
564        Err(rejection) => return Ok(rejection),
565    };
566    let user = match User::register(&state.db, &form.name, &form.email, &form.password).await {
567        Ok(user) => user,
568        // Two sign-ups with one email at the same moment: the database's
569        // unique index stops the second, which gets the `unique` rule's answer.
570        Err(err) if err.is_unique_violation() => {
571            let template = crate::validation::template_for(Some(&lang.texts()), "unique");
572            let mut errors = Errors::new();
573            errors.add("email", crate::validation::render(&template, "email", &[]));
574            return Err(ValidationError::new(errors)
575                .with_input(&json!({ "name": form.name, "email": form.email }))
576                .into());
577        }
578        Err(err) => return Err(err),
579    };
580    let user = match &settings.on_registered {
581        None => user,
582        Some(hook) => {
583            let id = user.id;
584            if let Err(err) = hook(state.clone(), user, Registration { fields }).await {
585                // Undo the sign-up, so the visitor can try again.
586                crate::db::sql("DELETE FROM users WHERE id = ?")
587                    .bind(id)
588                    .execute(&state.db)
589                    .await?;
590                return Err(err);
591            }
592            // Read it back with what the hook changed.
593            <User as crate::db::Model>::find_or_404(&state.db, id).await?
594        }
595    };
596    if settings.verify_email {
597        verification::send_verification(&state, &user).await?;
598    }
599    let event = Registered {
600        user_id: user.id,
601        email: user.email.clone(),
602    };
603    // A module whose second step every user must pass (not only the ones
604    // who set it up) gets it here too, as at the login page.
605    if let Some(second) = &state.second_factor
606        && (second.required)(user.clone(), state.clone()).await?
607    {
608        let to = after_login(&state, &settings, &session);
609        super::second_factor::begin(&session, &user, &user.email, false, to)?;
610        announce(&state, event).await;
611        let challenge = state.url(&second.challenge, &[])?;
612        return Ok(go(&htmx, challenge));
613    }
614    login(&session, &user, None)?;
615    announce(&state, event).await;
616    Ok(go(&htmx, after_login(&state, &settings, &session)))
617}
618
619async fn destroy(
620    State(state): State<AppState>,
621    session: Session,
622    user: Option<AuthUser>,
623    htmx: Htmx,
624) -> Result<Response> {
625    logout(&state.db, &session).await?;
626    if let Some(user) = user {
627        announce(&state, LoggedOut { user_id: user.id }).await;
628    }
629    Ok(go(
630        &htmx,
631        state.url("home", &[]).unwrap_or_else(|_| "/".into()),
632    ))
633}
634
635/// Words on the built-in pages (English; apps translate them with
636/// `renox.auth.*` keys in their lang files).
637pub(super) fn text() -> Value {
638    json!({
639        "login_title": "Log in",
640        "register_title": "Create an account",
641        "name": "Name",
642        "email": "Email",
643        "password": "Password",
644        "password_confirmation": "Confirm password",
645        "remember": "Remember me",
646        "login_button": "Log in",
647        "register_button": "Register",
648        "no_account": "No account yet?",
649        "have_account": "Already registered?",
650        "forgot_link": "Forgot your password?",
651        "forgot_title": "Forgot your password?",
652        "forgot_intro": "Enter your email and we'll send you a link to choose a new password.",
653        "send_link": "Email me a reset link",
654        "back_to_login": "Back to log in",
655        "reset_title": "Choose a new password",
656        "reset_button": "Reset password",
657        "reset_link_sent": "If that email has an account, a reset link is on its way.",
658        "reset_invalid": "This password reset link is invalid or has expired.",
659        "password_reset_done": "Your password has been reset. You can log in now.",
660        "verify_title": "Verify your email",
661        "verify_intro": "We've emailed you a link to verify your address. Didn't get it?",
662        "resend_button": "Send another link",
663        "logout": "Log out",
664        "verification_sent": "A new verification link has been sent.",
665        "verified": "Your email address is verified.",
666        "mail_reset_subject": "Reset your password",
667        "mail_reset_intro": "You asked to reset your password. Choose a new one with the button below.",
668        "mail_reset_outro": "The link works for 60 minutes. If you didn't ask for this, ignore this email.",
669        "mail_verify_subject": "Verify your email address",
670        "mail_verify_intro": "Please confirm that this is your email address.",
671        "mail_verify_outro": "The link works for 60 minutes.",
672        "account_title": "Your account",
673        "profile_title": "Profile",
674        "profile_saved": "Your profile is saved.",
675        "email_unverified": "Your new email address isn't verified yet: check your inbox.",
676        "save": "Save",
677        "password_title": "Change password",
678        "set_password_title": "Set a password",
679        "set_password_intro": "You log in with a linked account. Choose a password to log in with it too.",
680        "current_password": "Current password",
681        "new_password": "New password",
682        "password_changed": "Your password is changed. Your other devices are logged out.",
683        "other_devices_title": "Other devices",
684        "other_devices_intro": "Log out everywhere else, e.g. on a phone you lost.",
685        "other_devices_button": "Log out other devices",
686        "other_devices_logged_out": "Your other devices are logged out.",
687        "delete_title": "Delete account",
688        "delete_intro": "Your account and its data are deleted for good.",
689        "delete_button": "Delete my account",
690        "delete_confirm": "Delete your account for good?",
691        "confirm_title": "Confirm your password",
692        "confirm_intro": "This is a secure area. Please confirm your password to continue.",
693        "confirm_button": "Confirm",
694    })
695}