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