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