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
367            .auth
368            .throttle_sign_ups()
369            .attempt(cx, "sign-up", "", client)
370            .await?
371        {
372            tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
373            return page_response(
374                cx,
375                &values,
376                &FieldErrors::new(),
377                next,
378                Some(Alert::Throttled),
379            )
380            .await;
381        }
382        let user = match registrar.sign_up(cx, values).await {
383            Ok(Outcome::Registered(user)) => user,
384            Ok(Outcome::Refused { values, errors }) => {
385                return page_response(cx, &values, &errors, next, None).await;
386            }
387            Err(error) => {
388                let error = infrastructure_failure(error);
389                if crate::error::TabloError::is_infrastructure(&error) {
390                    return unavailable(cx, next).await;
391                }
392                return Err(error);
393            }
394        };
395        if !user.can_access_panel(&panel.prefix) {
396            set_notification(
397                cx,
398                Notification::success(crate::t!(cx, "tablo-sign-up-created"))
399                    .description(crate::t!(cx, "tablo-sign-up-pending")),
400            );
401            return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
402        }
403        // Rotate as a login does, so a session presented before the sign-up cannot be replayed.
404        let recorded = match fresh_session(cx).await {
405            Ok(session) => record(cx, &session, &*user, panel).await,
406            Err(error) => Err(error),
407        };
408        if let Err(error) = recorded {
409            // The account is committed: send it to sign in rather than to a sign-up whose retry
410            // would find its email taken.
411            tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
412            set_notification(
413                cx,
414                Notification::success(crate::t!(cx, "tablo-sign-up-created"))
415                    .description(crate::t!(cx, "tablo-sign-up-sign-in")),
416            );
417            return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
418        }
419        super::verification::mail_after_sign_up(cx, &*user);
420        let target = if next.is_empty() {
421            panel_root(cx)
422        } else {
423            next
424        };
425        topcoat::router::error::see_other(target).into_response(cx)
426    })
427}
428
429/// The sign-up page with the outage alert.
430async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
431    let values = FormValues::new();
432    page_response(
433        cx,
434        &values,
435        &FieldErrors::new(),
436        next,
437        Some(Alert::Unavailable),
438    )
439    .await
440}
441
442/// What the sign-up page says above its form, besides a field's error.
443#[derive(Debug, Clone, Copy)]
444enum Alert {
445    /// The client is past its sign-up limit; answers 429.
446    Throttled,
447    /// The database behind sign-up could not answer; answers 503.
448    Unavailable,
449}
450
451impl Alert {
452    /// The message the page renders.
453    fn message(self) -> &'static str {
454        match self {
455            Self::Throttled => "tablo-sign-up-throttled",
456            Self::Unavailable => "tablo-sign-up-unavailable",
457        }
458    }
459
460    fn status(self) -> http::StatusCode {
461        match self {
462            Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
463            Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
464        }
465    }
466}
467
468/// The sign-up page: 422 for a refused submission, the alert's status with one, else 200.
469async fn page_response(
470    cx: &Cx,
471    values: &FormValues,
472    errors: &FieldErrors,
473    next: String,
474    alert: Option<Alert>,
475) -> topcoat::Result<Response> {
476    let status = match alert {
477        Some(alert) => Some(alert.status()),
478        None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
479    };
480    let page = render_page(cx, values, errors, next, alert, status).await?;
481    page.single().await?.into_response(cx)
482}
483
484/// Renders the standalone sign-up document: the brand, the registrar's form with its errors,
485/// and a link to the login page.
486async fn render_page<'a>(
487    cx: &'a Cx,
488    values: &FormValues,
489    errors: &FieldErrors,
490    next: String,
491    alert: Option<Alert>,
492    status: Option<http::StatusCode>,
493) -> topcoat::Result<BoxView<'a>> {
494    let schema = current_realm(cx)
495        .and_then(|panel| panel.auth.registrar())
496        .map_or_else(Schema::empty, DynRegistrar::schema);
497    let csrf = crate::csrf::ensure_token(cx);
498    let action = register_url(cx);
499    let login = with_next(login_url(cx), &next);
500    let providers = super::oidc::buttons(cx, &next);
501    let fields = schema.render(cx, Source::form(values, errors)).await?;
502    let form = topcoat::view::view! {
503        <form method="post" action=(action) class="flex flex-col gap-5">
504            (crate::csrf::field(cx, &csrf))
505            <input type="hidden" name=(NEXT_FIELD) value=(next)>
506            if let Some(alert) = alert {
507                tablo_ui::alert(
508                    variant: tablo_ui::AlertVariant::Destructive,
509                    attrs: topcoat::view::attributes! { role="alert" },
510                    tablo_ui::alert_title((crate::t!(cx, alert.message())))
511                )
512            }
513            (fields)
514            tablo_ui::button(
515                variant: tablo_ui::ButtonVariant::Primary,
516                attrs: topcoat::view::attributes! { type="submit" class="w-full" },
517                (crate::t!(cx, "tablo-sign-up-submit"))
518            )
519        </form>
520        if let Some(providers) = providers {
521            <div class="mt-5">(providers)</div>
522        }
523    }
524    .boxed();
525    super::page::render(
526        cx,
527        super::page::AuthPage {
528            title: crate::t!(cx, "tablo-sign-up-title"),
529            description: crate::t!(
530                cx,
531                "tablo-sign-up-description",
532                brand = hosted(cx)?.brand_name(cx)
533            ),
534            status,
535            form,
536            switch: Some(super::page::switch_link(
537                cx,
538                "tablo-sign-up-have-account",
539                &login,
540                "tablo-sign-up-sign-in-link",
541            )),
542            note: None,
543        },
544    )
545    .await
546}
547
548/// The mistakes in a panel's [`Auth::registration`](super::Auth::registration): a registration
549/// without auth, a registrar whose user is not the authenticator's, and a sign-up form field the
550/// page cannot serve.
551pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
552    use super::input::FieldFault;
553    use crate::{DeclarationErrorKind, declaration::SignUpFault};
554
555    let Some(registrar) = auth.registrar.as_deref() else {
556        return Vec::new();
557    };
558    if auth.authenticator().is_none() {
559        return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
560    }
561    let mut kinds = Vec::new();
562    let schema = registrar.schema();
563    kinds.extend(schema.declaration_errors());
564    kinds.extend(schema.empty_choices());
565    kinds.extend(
566        input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
567            DeclarationErrorKind::SignUp(match fault {
568                FieldFault::Reserved => SignUpFault::ReservedField(name),
569                FieldFault::File => SignUpFault::FileField(name),
570                FieldFault::Relationship => SignUpFault::RelationshipField(name),
571            })
572        }),
573    );
574    kinds
575}
576
577#[cfg(test)]
578mod tests;