Skip to main content

tablo_core/auth/
registration.rs

1//! The panel's sign-up page: the [`Registrar`] trait that creates accounts, the shipped
2//! [`SignUp`] form, and the `{prefix}/register` routes.
3
4use std::{future::Future, sync::Arc};
5
6use topcoat::{
7    context::Cx,
8    router::{
9        Body, RouteFuture,
10        request::client_ip,
11        response::{IntoResponse, Response},
12    },
13    view::{BoxView, ViewExt},
14};
15
16use super::{
17    PanelUser, infrastructure_failure, input,
18    login::{NEXT_FIELD, login_url, next_from_query, safe_next},
19    panel_root,
20    session::{fresh_session, record},
21};
22use crate::{
23    BoxFuture,
24    auth::realm::{current_realm, hosted, panel_prefix},
25    flash::set_notification,
26    form::{FieldError, FieldErrors, FormInput, FormValues, parse_password, parse_scalar},
27    outcome::Notification,
28    schema::{Field, Schema, Source},
29};
30
31/// How a panel creates accounts from its sign-up page, installed with
32/// [`Auth::registration`](super::Auth::registration).
33///
34/// It mirrors an [`Action`](crate::Action) with input: [`Input`](Self::Input) is the form the
35/// page asks for, [`validate`](Self::validate) refuses a submission field by field, and
36/// [`register`](Self::register) writes the account. Both run in one transaction the framework
37/// opens, so a uniqueness probe in `validate` sees what `register` writes against.
38///
39/// The shipped [`PasswordSignUp`](crate::PasswordSignUp) registers an
40/// [`AdminUser`](super::AdminUser) from a [`SignUp`]; an app with its own user table implements
41/// it beside its [`Authenticator`](super::Authenticator), for the same user type:
42///
43/// ```rust
44/// # use tablo_core::{FieldErrors, PanelUser, Registrar, SignUp, auth::hash_password};
45/// # use topcoat::context::Cx;
46/// # #[derive(Debug, Clone, toasty::Model)]
47/// # struct Staff {
48/// #     #[key] #[auto] id: uuid::Uuid,
49/// #     #[unique] email: String,
50/// #     password_hash: String,
51/// #     name: String,
52/// # }
53/// # impl PanelUser for Staff {
54/// #     fn user_id(&self) -> String { self.id.to_string() }
55/// #     fn display_name(&self) -> &str { &self.name }
56/// #     fn can_access_panel(&self, _panel: &str) -> bool { true }
57/// # }
58/// struct StaffAuth;
59///
60/// impl Registrar for StaffAuth {
61///     type Input = SignUp;
62///     type User = Staff;
63///
64///     async fn validate(
65///         &self,
66///         _cx: &Cx,
67///         sign_up: &SignUp,
68///         ex: &mut dyn toasty::Executor,
69///     ) -> topcoat::Result<FieldErrors> {
70///         let mut errors = sign_up.password_errors();
71///         let taken = Staff::filter_by_email(&sign_up.email)
72///             .first()
73///             .exec(ex)
74///             .await?;
75///         if taken.is_some() {
76///             errors.add("email", "Email has already been taken");
77///         }
78///         Ok(errors)
79///     }
80///
81///     async fn register(
82///         &self,
83///         _cx: &Cx,
84///         sign_up: SignUp,
85///         ex: &mut dyn toasty::Executor,
86///     ) -> topcoat::Result<Staff> {
87///         Ok(toasty::create!(Staff {
88///             email: sign_up.email,
89///             password_hash: hash_password(&sign_up.password).await?,
90///             name: sign_up.name,
91///         })
92///         .exec(ex)
93///         .await?)
94///     }
95/// }
96/// ```
97///
98/// A new account that [may access the panel](PanelUser::can_access_panel) is signed in and lands
99/// on the panel; one that may not, such as an account awaiting approval, lands on the login page
100/// with a notification saying it was created.
101pub trait Registrar: Send + Sync + 'static {
102    /// What the sign-up page asks for: [`SignUp`], or the app's own
103    /// [`#[derive(FormInput)]`](derive@crate::FormInput) struct. `#[form(password)]` masks a
104    /// field, and `#[form(email)]` checks an address.
105    type Input: FormInput;
106
107    /// The account a sign-up creates: the type the panel's
108    /// [`Authenticator`](super::Authenticator) loads, which mounting checks.
109    type User: PanelUser;
110
111    /// Refuse a submission [`register`](Self::register) should not receive, such as an email
112    /// another account holds: each error names an input field's key and renders under its
113    /// control. Defaults to none. A key no control renders is a declaration error: the page
114    /// would show no message.
115    ///
116    /// It runs inside the sign-up's transaction `ex`, after the input parses and its controls'
117    /// own rules pass.
118    fn validate(
119        &self,
120        _cx: &Cx,
121        _input: &Self::Input,
122        _ex: &mut dyn toasty::Executor,
123    ) -> impl Future<Output = topcoat::Result<FieldErrors>> + Send {
124        async { Ok(FieldErrors::new()) }
125    }
126
127    /// Create the account from a validated `input` through the transaction `ex`, which commits
128    /// when it returns `Ok`.
129    fn register(
130        &self,
131        cx: &Cx,
132        input: Self::Input,
133        ex: &mut dyn toasty::Executor,
134    ) -> impl Future<Output = topcoat::Result<Self::User>> + Send;
135}
136
137/// What a sign-up submission came to.
138pub(crate) enum Outcome {
139    /// The committed account.
140    Registered(Arc<dyn PanelUser>),
141    /// The refused submission, its values, and why. The page renders a password control empty.
142    Refused {
143        values: FormValues,
144        errors: FieldErrors,
145    },
146}
147
148/// [`Registrar`] with its input and user types erased, as the panel keeps it.
149pub(crate) trait DynRegistrar: Send + Sync {
150    /// The sign-up form.
151    fn schema(&self) -> Schema;
152    /// Parses, checks, validates and registers one submission.
153    fn sign_up<'a>(
154        &'a self,
155        cx: &'a Cx,
156        values: FormValues,
157    ) -> BoxFuture<'a, topcoat::Result<Outcome>>;
158}
159
160impl<R: Registrar> DynRegistrar for R {
161    fn schema(&self) -> Schema {
162        R::Input::schema()
163    }
164
165    fn sign_up<'a>(
166        &'a self,
167        cx: &'a Cx,
168        values: FormValues,
169    ) -> BoxFuture<'a, topcoat::Result<Outcome>> {
170        Box::pin(async move {
171            let input =
172                match input::parse::<R::Input>(cx, &values, &FormValues::new(), "sign-up").await? {
173                    Ok(input) => input,
174                    Err(errors) => return Ok(Outcome::Refused { values, errors }),
175                };
176            let mut db = crate::db::db(cx)?;
177            let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
178            let errors = self.validate(cx, &input, &mut tx).await?;
179            input::placed(&R::Input::schema(), &errors, "the registrar", "sign-up")?;
180            if !errors.is_empty() {
181                return Ok(Outcome::Refused { values, errors });
182            }
183            let user = self.register(cx, input, &mut tx).await?;
184            tx.commit().await.map_err(infrastructure_failure)?;
185            Ok(Outcome::Registered(Arc::new(user)))
186        })
187    }
188}
189
190/// The sign-up form [`PasswordSignUp`](crate::PasswordSignUp) registers from, and a custom
191/// [`Registrar`] can take as its input: a name, an email address, and a new password typed
192/// twice.
193#[derive(Clone, PartialEq, Eq)]
194pub struct SignUp {
195    /// The display name, posted as `name`.
196    pub name: String,
197    /// The email address the account signs in with, posted as `email`.
198    pub email: String,
199    /// The new password, posted as `password`.
200    pub password: String,
201    /// The password typed again, posted as `password_confirmation`.
202    pub password_confirmation: String,
203}
204
205impl SignUp {
206    /// The refusals [`new_password_errors`] makes of this sign-up's passwords.
207    pub fn password_errors(&self) -> FieldErrors {
208        new_password_errors(&self.password, &self.password_confirmation)
209    }
210}
211
212/// Redacts the passwords, so a sign-up logged with `?` writes none to the log.
213impl std::fmt::Debug for SignUp {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("SignUp")
216            .field("name", &self.name)
217            .field("email", &self.email)
218            .field("password", &"<redacted>")
219            .field("password_confirmation", &"<redacted>")
220            .finish()
221    }
222}
223
224impl FormInput for SignUp {
225    type Field = std::convert::Infallible;
226
227    fn key(field: Self::Field) -> &'static str {
228        match field {}
229    }
230
231    fn schema() -> Schema {
232        let field = crate::form::required_input;
233        Schema::new((
234            field(Field::text_input::<String>("name").label(crate::text!("tablo-auth-name"))),
235            field(
236                Field::text_input::<String>("email")
237                    .email()
238                    .label(crate::text!("tablo-auth-email")),
239            ),
240            field(
241                Field::text_input::<String>(PASSWORD_FIELD)
242                    .password()
243                    .label(crate::text!("tablo-auth-password")),
244            ),
245            field(
246                Field::text_input::<String>(CONFIRMATION_FIELD)
247                    .password()
248                    .label(crate::text!("tablo-auth-confirm-password")),
249            ),
250        ))
251    }
252
253    fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
254        let mut errors = Vec::new();
255        type Parse = fn(&str, &FormValues, Option<String>) -> Result<String, FieldError>;
256        let mut read = |key: &str, parse: Parse| {
257            parse(key, values, None)
258                .map_err(|error| errors.push(error))
259                .ok()
260        };
261        let name = read("name", parse_scalar::<String>);
262        let email = read("email", parse_scalar::<String>);
263        let password = read(PASSWORD_FIELD, parse_password);
264        let confirmation = read(CONFIRMATION_FIELD, parse_password);
265        match (name, email, password, confirmation) {
266            (Some(name), Some(email), Some(password), Some(password_confirmation))
267                if errors.is_empty() =>
268            {
269                Ok(Self {
270                    name,
271                    email,
272                    password,
273                    password_confirmation,
274                })
275            }
276            _ => Err(errors),
277        }
278    }
279
280    fn write(&self, out: &mut FormValues) {
281        out.insert("name".to_string(), self.name.clone());
282        out.insert("email".to_string(), self.email.clone());
283    }
284}
285
286/// The key [`SignUp`]'s new password posts under.
287const PASSWORD_FIELD: &str = "password";
288/// The key [`SignUp`]'s repeated password posts under.
289const CONFIRMATION_FIELD: &str = "password_confirmation";
290
291/// The fewest characters a new password holds, as NIST SP 800-63B asks of a password a user
292/// chooses.
293pub const MIN_PASSWORD_CHARS: usize = 8;
294
295/// Refuses a new `password` shorter than [`MIN_PASSWORD_CHARS`] under the `password` key, and a
296/// `confirmation` that differs from it under `password_confirmation`: the checks a sign-up form
297/// with [`SignUp`]'s password keys runs in [`Registrar::validate`].
298#[must_use]
299pub fn new_password_errors(password: &str, confirmation: &str) -> FieldErrors {
300    let mut errors = FieldErrors::new();
301    if password.chars().count() < MIN_PASSWORD_CHARS {
302        errors.add(
303            PASSWORD_FIELD,
304            crate::text!("tablo-password-too-short", min = MIN_PASSWORD_CHARS),
305        );
306    }
307    if password != confirmation {
308        errors.add(CONFIRMATION_FIELD, crate::text!("tablo-password-mismatch"));
309    }
310    errors
311}
312
313/// The keys a sign-up POST carries besides its input, which no input field may post.
314pub(crate) const RESERVED_KEYS: [&str; 2] = [crate::csrf::FIELD_NAME, NEXT_FIELD];
315
316/// Where the panel's sign-up page lives: `{prefix}/register`.
317pub(crate) fn register_url(cx: &Cx) -> String {
318    format!("{}/register", panel_prefix(cx))
319}
320
321/// `url` with `next` in its query: how the login and sign-up pages carry it to each other.
322pub(crate) fn with_next(url: String, next: &str) -> String {
323    if next.is_empty() {
324        return url;
325    }
326    let mut serializer = form_urlencoded::Serializer::new(String::new());
327    serializer.append_pair(NEXT_FIELD, next);
328    format!("{url}?{}", serializer.finish())
329}
330
331/// Whether the request's panel installs a [`Registrar`].
332pub(crate) fn offered(cx: &Cx) -> bool {
333    current_realm(cx).is_some_and(|panel| panel.auth.registrar().is_some())
334}
335
336/// `GET {prefix}/register` — the standalone sign-up page.
337pub(super) fn register_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
338    Box::pin(async move {
339        let next = next_from_query(cx).unwrap_or_default();
340        // The page starts from each field's default, as the POST reads a disabled one.
341        let values = current_realm(cx)
342            .and_then(|panel| panel.auth.registrar())
343            .map_or_else(FormValues::new, |registrar| registrar.schema().defaults());
344        page_response(cx, &values, &FieldErrors::new(), next, None).await
345    })
346}
347
348/// `POST {prefix}/register` — create the account, then sign it in or send it to the login page.
349pub(super) fn register_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
350    Box::pin(async move {
351        let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
352        let registrar = panel
353            .auth
354            .registrar()
355            .ok_or_else(topcoat::router::error::not_found)?;
356        let form = crate::auth::input::read_form(cx, body).await?;
357        crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
358        let next = form
359            .last(NEXT_FIELD)
360            .and_then(safe_next)
361            .map(str::to_string)
362            .unwrap_or_default();
363        let schema = registrar.schema();
364        let values = schema.read_input(cx, &form.pairs, &RESERVED_KEYS)?;
365        let client = client_ip(cx);
366        if !panel.auth.throttle_sign_ups().attempt("", client) {
367            tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
368            return page_response(
369                cx,
370                &values,
371                &FieldErrors::new(),
372                next,
373                Some(Alert::Throttled),
374            )
375            .await;
376        }
377        let user = match registrar.sign_up(cx, values).await {
378            Ok(Outcome::Registered(user)) => user,
379            Ok(Outcome::Refused { values, errors }) => {
380                return page_response(cx, &values, &errors, next, None).await;
381            }
382            Err(error) => {
383                let error = infrastructure_failure(error);
384                if crate::error::TabloError::is_infrastructure(&error) {
385                    return unavailable(cx, next).await;
386                }
387                return Err(error);
388            }
389        };
390        if !user.can_access_panel(&panel.prefix) {
391            set_notification(
392                cx,
393                Notification::success(crate::t!(cx, "tablo-sign-up-created"))
394                    .description(crate::t!(cx, "tablo-sign-up-pending")),
395            );
396            return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
397        }
398        // Rotate as a login does, so a session presented before the sign-up cannot be replayed.
399        let recorded = match fresh_session(cx).await {
400            Ok(session) => record(cx, &session, &*user, panel).await,
401            Err(error) => Err(error),
402        };
403        if let Err(error) = recorded {
404            // The account is committed: send it to sign in rather than to a sign-up whose retry
405            // would find its email taken.
406            tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
407            set_notification(
408                cx,
409                Notification::success(crate::t!(cx, "tablo-sign-up-created"))
410                    .description(crate::t!(cx, "tablo-sign-up-sign-in")),
411            );
412            return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
413        }
414        super::verification::mail_after_sign_up(cx, &*user);
415        let target = if next.is_empty() {
416            panel_root(cx)
417        } else {
418            next
419        };
420        topcoat::router::error::see_other(target).into_response(cx)
421    })
422}
423
424/// The sign-up page with the outage alert.
425async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
426    let values = FormValues::new();
427    page_response(
428        cx,
429        &values,
430        &FieldErrors::new(),
431        next,
432        Some(Alert::Unavailable),
433    )
434    .await
435}
436
437/// What the sign-up page says above its form, besides a field's error.
438#[derive(Debug, Clone, Copy)]
439enum Alert {
440    /// The client is past its sign-up limit; answers 429.
441    Throttled,
442    /// The database behind sign-up could not answer; answers 503.
443    Unavailable,
444}
445
446impl Alert {
447    /// The message the page renders.
448    fn message(self) -> &'static str {
449        match self {
450            Self::Throttled => "tablo-sign-up-throttled",
451            Self::Unavailable => "tablo-sign-up-unavailable",
452        }
453    }
454
455    fn status(self) -> http::StatusCode {
456        match self {
457            Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
458            Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
459        }
460    }
461}
462
463/// The sign-up page: 422 for a refused submission, the alert's status with one, else 200.
464async fn page_response(
465    cx: &Cx,
466    values: &FormValues,
467    errors: &FieldErrors,
468    next: String,
469    alert: Option<Alert>,
470) -> topcoat::Result<Response> {
471    let status = match alert {
472        Some(alert) => Some(alert.status()),
473        None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
474    };
475    let page = render_page(cx, values, errors, next, alert, status).await?;
476    page.single().await?.into_response(cx)
477}
478
479/// Renders the standalone sign-up document: the brand, the registrar's form with its errors,
480/// and a link to the login page.
481async fn render_page<'a>(
482    cx: &'a Cx,
483    values: &FormValues,
484    errors: &FieldErrors,
485    next: String,
486    alert: Option<Alert>,
487    status: Option<http::StatusCode>,
488) -> topcoat::Result<BoxView<'a>> {
489    let schema = current_realm(cx)
490        .and_then(|panel| panel.auth.registrar())
491        .map_or_else(Schema::empty, DynRegistrar::schema);
492    let csrf = crate::csrf::ensure_token(cx);
493    let action = register_url(cx);
494    let login = with_next(login_url(cx), &next);
495    let fields = schema.render(cx, Source::form(values, errors)).await?;
496    let form = topcoat::view::view! {
497        <form method="post" action=(action) class="flex flex-col gap-5">
498            (crate::csrf::field(cx, &csrf))
499            <input type="hidden" name=(NEXT_FIELD) value=(next)>
500            if let Some(alert) = alert {
501                tablo_ui::alert(
502                    variant: tablo_ui::AlertVariant::Destructive,
503                    attrs: topcoat::view::attributes! { role="alert" },
504                    tablo_ui::alert_title((crate::t!(cx, alert.message())))
505                )
506            }
507            (fields)
508            tablo_ui::button(
509                variant: tablo_ui::ButtonVariant::Primary,
510                attrs: topcoat::view::attributes! { type="submit" class="w-full" },
511                (crate::t!(cx, "tablo-sign-up-submit"))
512            )
513        </form>
514    }
515    .boxed();
516    super::page::render(
517        cx,
518        super::page::AuthPage {
519            title: crate::t!(cx, "tablo-sign-up-title"),
520            description: crate::t!(
521                cx,
522                "tablo-sign-up-description",
523                brand = hosted(cx)?.brand_name(cx)
524            ),
525            status,
526            form,
527            switch: Some(super::page::switch_link(
528                cx,
529                "tablo-sign-up-have-account",
530                &login,
531                "tablo-sign-up-sign-in-link",
532            )),
533            note: None,
534        },
535    )
536    .await
537}
538
539/// The mistakes in a panel's [`Auth::registration`](super::Auth::registration): a registration
540/// without auth, a registrar whose user is not the authenticator's, and a sign-up form field the
541/// page cannot serve.
542pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
543    use super::input::FieldFault;
544    use crate::{DeclarationErrorKind, declaration::SignUpFault};
545
546    let Some(registrar) = auth.registrar.as_deref() else {
547        return Vec::new();
548    };
549    if auth.authenticator().is_none() {
550        return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
551    }
552    let mut kinds = Vec::new();
553    let schema = registrar.schema();
554    kinds.extend(schema.declaration_errors());
555    kinds.extend(schema.empty_choices());
556    kinds.extend(
557        input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
558            DeclarationErrorKind::SignUp(match fault {
559                FieldFault::Reserved => SignUpFault::ReservedField(name),
560                FieldFault::File => SignUpFault::FileField(name),
561                FieldFault::Relationship => SignUpFault::RelationshipField(name),
562            })
563        }),
564    );
565    kinds
566}
567
568#[cfg(test)]
569mod tests;