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::{any::TypeId, 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    session,
14    view::{BoxView, ViewExt},
15};
16
17use super::{
18    PanelUser, infrastructure_failure,
19    login::{NEXT_FIELD, login_url, next_from_query, safe_next},
20    panel_root,
21    session::{delete_session, record},
22};
23use crate::{
24    ActionInput, BoxFuture,
25    form::{FieldError, FieldErrors, FormValues, parse_password, parse_scalar},
26    notification::{Notification, set_notification},
27    panel::{Panel, panel_prefix, state::current},
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 [`PasswordAuth`](super::PasswordAuth) 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, auth::{Registrar, SignUp, 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)?,
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(ActionInput)]`](derive@crate::ActionInput) struct. `#[form(password)]` masks a
103    /// field, and `#[form(email)]` checks an address.
104    type Input: ActionInput;
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    /// The [`TypeId`] and name of [`Registrar::User`], which mounting compares with the
158    /// authenticator's.
159    fn user_type(&self) -> (TypeId, &'static str);
160}
161
162impl<R: Registrar> DynRegistrar for R {
163    fn schema(&self) -> Schema {
164        R::Input::schema()
165    }
166
167    fn sign_up<'a>(
168        &'a self,
169        cx: &'a Cx,
170        values: FormValues,
171    ) -> BoxFuture<'a, topcoat::Result<Outcome>> {
172        Box::pin(async move {
173            let schema = R::Input::schema();
174            let mut errors = FieldErrors::new();
175            let parsed = match R::Input::parse(cx, &values) {
176                Ok(parsed) => Some(parsed),
177                Err(refused) => {
178                    for error in refused {
179                        errors.push(error);
180                    }
181                    None
182                }
183            };
184            schema
185                .check_controls(cx, &values, &FormValues::new(), &mut errors)
186                .await;
187            let refused = |errors| Outcome::Refused {
188                values: values.clone(),
189                errors,
190            };
191            if let Some(key) = schema.unplaced_error(&errors) {
192                return Err(crate::error::declaration(format!(
193                    "the sign-up input refused key `{key}`, which no sign-up control renders"
194                )));
195            }
196            let Some(input) = parsed.filter(|_| errors.is_empty()) else {
197                return Ok(refused(errors));
198            };
199            let mut db = crate::db::db(cx);
200            let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
201            let errors = self.validate(cx, &input, &mut tx).await?;
202            if let Some(key) = schema.unplaced_error(&errors) {
203                return Err(crate::error::declaration(format!(
204                    "the registrar refused key `{key}`, which no sign-up control renders"
205                )));
206            }
207            if !errors.is_empty() {
208                return Ok(refused(errors));
209            }
210            let user = self.register(cx, input, &mut tx).await?;
211            tx.commit().await.map_err(infrastructure_failure)?;
212            Ok(Outcome::Registered(Arc::new(user)))
213        })
214    }
215
216    fn user_type(&self) -> (TypeId, &'static str) {
217        (TypeId::of::<R::User>(), std::any::type_name::<R::User>())
218    }
219}
220
221/// The sign-up form [`PasswordAuth`](super::PasswordAuth) registers from, and a custom
222/// [`Registrar`] can take as its input: a name, an email address, and a new password typed
223/// twice.
224#[derive(Clone, PartialEq, Eq)]
225pub struct SignUp {
226    /// The display name, posted as `name`.
227    pub name: String,
228    /// The email address the account signs in with, posted as `email`.
229    pub email: String,
230    /// The new password, posted as `password`.
231    pub password: String,
232    /// The password typed again, posted as `password_confirmation`.
233    pub password_confirmation: String,
234}
235
236impl SignUp {
237    /// The refusals [`new_password_errors`] makes of this sign-up's passwords.
238    pub fn password_errors(&self) -> FieldErrors {
239        new_password_errors(&self.password, &self.password_confirmation)
240    }
241}
242
243/// Redacts the passwords, so a sign-up logged with `?` writes none to the log.
244impl std::fmt::Debug for SignUp {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        f.debug_struct("SignUp")
247            .field("name", &self.name)
248            .field("email", &self.email)
249            .field("password", &"<redacted>")
250            .field("password_confirmation", &"<redacted>")
251            .finish()
252    }
253}
254
255impl ActionInput for SignUp {
256    fn schema() -> Schema {
257        let field = crate::resource::required_input;
258        Schema::new((
259            field(Field::text_input::<String>("name").label("Name")),
260            field(Field::text_input::<String>("email").email().label("Email")),
261            field(
262                Field::text_input::<String>(PASSWORD_FIELD)
263                    .password()
264                    .label("Password"),
265            ),
266            field(
267                Field::text_input::<String>(CONFIRMATION_FIELD)
268                    .password()
269                    .label("Confirm password"),
270            ),
271        ))
272    }
273
274    fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
275        let mut errors = Vec::new();
276        type Parse = fn(&str, &FormValues, Option<String>) -> Result<String, FieldError>;
277        let mut read = |key: &str, parse: Parse| {
278            parse(key, values, None)
279                .map_err(|error| errors.push(error))
280                .ok()
281        };
282        let name = read("name", parse_scalar::<String>);
283        let email = read("email", parse_scalar::<String>);
284        let password = read(PASSWORD_FIELD, parse_password);
285        let confirmation = read(CONFIRMATION_FIELD, parse_password);
286        match (name, email, password, confirmation) {
287            (Some(name), Some(email), Some(password), Some(password_confirmation))
288                if errors.is_empty() =>
289            {
290                Ok(Self {
291                    name,
292                    email,
293                    password,
294                    password_confirmation,
295                })
296            }
297            _ => Err(errors),
298        }
299    }
300}
301
302/// The key [`SignUp`]'s new password posts under.
303const PASSWORD_FIELD: &str = "password";
304/// The key [`SignUp`]'s repeated password posts under.
305const CONFIRMATION_FIELD: &str = "password_confirmation";
306
307/// The fewest characters a new password holds, as NIST SP 800-63B asks of a password a user
308/// chooses.
309pub const MIN_PASSWORD_CHARS: usize = 8;
310
311/// Refuses a new `password` shorter than [`MIN_PASSWORD_CHARS`] under the `password` key, and a
312/// `confirmation` that differs from it under `password_confirmation`: the checks a sign-up form
313/// with [`SignUp`]'s password keys runs in [`Registrar::validate`].
314#[must_use]
315pub fn new_password_errors(password: &str, confirmation: &str) -> FieldErrors {
316    let mut errors = FieldErrors::new();
317    if password.chars().count() < MIN_PASSWORD_CHARS {
318        errors.add(
319            PASSWORD_FIELD,
320            format!("Password must be at least {MIN_PASSWORD_CHARS} characters"),
321        );
322    }
323    if password != confirmation {
324        errors.add(CONFIRMATION_FIELD, "Passwords do not match");
325    }
326    errors
327}
328
329/// The keys a sign-up POST carries besides its input, which no input field may post.
330pub(crate) const RESERVED_KEYS: [&str; 2] = [crate::csrf::FIELD_NAME, NEXT_FIELD];
331
332/// Where the panel's sign-up page lives: `{prefix}/register`.
333pub(crate) fn register_url(cx: &Cx) -> String {
334    format!("{}/register", panel_prefix(cx))
335}
336
337/// `url` with `next` in its query: how the login and sign-up pages carry it to each other.
338pub(crate) fn with_next(url: String, next: &str) -> String {
339    if next.is_empty() {
340        return url;
341    }
342    let mut serializer = form_urlencoded::Serializer::new(String::new());
343    serializer.append_pair(NEXT_FIELD, next);
344    format!("{url}?{}", serializer.finish())
345}
346
347/// Whether the request's panel installs a [`Registrar`].
348pub(crate) fn offered(cx: &Cx) -> bool {
349    current(cx).is_some_and(|panel| panel.auth.registrar().is_some())
350}
351
352/// `GET {prefix}/register` — the standalone sign-up page.
353pub(crate) fn register_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
354    Box::pin(async move {
355        let next = next_from_query(cx).unwrap_or_default();
356        page_response(cx, &FormValues::new(), &FieldErrors::new(), next, None).await
357    })
358}
359
360/// `POST {prefix}/register` — create the account, then sign it in or send it to the login page.
361pub(crate) fn register_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
362    Box::pin(async move {
363        let panel = current(cx).ok_or_else(topcoat::router::error::not_found)?;
364        let registrar = panel
365            .auth
366            .registrar()
367            .ok_or_else(topcoat::router::error::not_found)?;
368        let form = crate::panel::parse_form_body(cx, body).await?;
369        crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
370        let next = form
371            .last(NEXT_FIELD)
372            .and_then(safe_next)
373            .map(str::to_string)
374            .unwrap_or_default();
375        let schema = registrar.schema();
376        let values = schema.read_input(&form.pairs, &RESERVED_KEYS)?;
377        let client = client_ip(cx);
378        if !panel.auth.throttle_sign_ups().attempt("", client) {
379            tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
380            return page_response(
381                cx,
382                &values,
383                &FieldErrors::new(),
384                next,
385                Some(Alert::Throttled),
386            )
387            .await;
388        }
389        let user = match registrar.sign_up(cx, values).await {
390            Ok(Outcome::Registered(user)) => user,
391            Ok(Outcome::Refused { values, errors }) => {
392                return page_response(cx, &values, &errors, next, None).await;
393            }
394            Err(error) => {
395                let error = infrastructure_failure(error);
396                if crate::error::TabloError::is_infrastructure(&error) {
397                    return unavailable(cx, next).await;
398                }
399                return Err(error);
400            }
401        };
402        if !user.can_access_panel() {
403            set_notification(
404                cx,
405                Notification::success("Account created")
406                    .description("You can sign in once it is approved."),
407            );
408            return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
409        }
410        // Rotate as a login does, so a session presented before the sign-up cannot be replayed.
411        if let Some(hash) = session::token_hash(cx).await? {
412            delete_session(cx, &hash).await?;
413        }
414        let session = session::start(cx).await?;
415        if let Err(error) = record(cx, &session, &*user, panel).await {
416            // The account is committed: send it to sign in rather than to a sign-up whose retry
417            // would find its email taken.
418            tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
419            set_notification(
420                cx,
421                Notification::success("Account created").description("Sign in to continue."),
422            );
423            return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
424        }
425        let target = if next.is_empty() {
426            panel_root(cx)
427        } else {
428            next
429        };
430        topcoat::router::error::see_other(target).into_response(cx)
431    })
432}
433
434/// The sign-up page with the outage alert.
435async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
436    let values = FormValues::new();
437    page_response(
438        cx,
439        &values,
440        &FieldErrors::new(),
441        next,
442        Some(Alert::Unavailable),
443    )
444    .await
445}
446
447/// What the sign-up page says above its form, besides a field's error.
448#[derive(Debug, Clone, Copy)]
449enum Alert {
450    /// The client is past its sign-up limit; answers 429.
451    Throttled,
452    /// The database behind sign-up could not answer; answers 503.
453    Unavailable,
454}
455
456impl Alert {
457    fn message(self) -> &'static str {
458        match self {
459            Self::Throttled => "Too many sign-up attempts. Try again shortly.",
460            Self::Unavailable => "Sign-up is unavailable right now. Try again shortly.",
461        }
462    }
463
464    fn status(self) -> http::StatusCode {
465        match self {
466            Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
467            Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
468        }
469    }
470}
471
472/// The sign-up page: 422 for a refused submission, the alert's status with one, else 200.
473async fn page_response(
474    cx: &Cx,
475    values: &FormValues,
476    errors: &FieldErrors,
477    next: String,
478    alert: Option<Alert>,
479) -> topcoat::Result<Response> {
480    let status = match alert {
481        Some(alert) => Some(alert.status()),
482        None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
483    };
484    let page = render_page(cx, values, errors, next, alert, status).await?;
485    page.single().await?.into_response(cx)
486}
487
488/// Renders the standalone sign-up document: the brand, the registrar's form with its errors,
489/// and a link to the login page.
490async fn render_page<'a>(
491    cx: &'a Cx,
492    values: &FormValues,
493    errors: &FieldErrors,
494    next: String,
495    alert: Option<Alert>,
496    status: Option<http::StatusCode>,
497) -> topcoat::Result<BoxView<'a>> {
498    let schema = current(cx)
499        .and_then(|panel| panel.auth.registrar())
500        .map_or_else(Schema::empty, DynRegistrar::schema);
501    let csrf = crate::csrf::ensure_token(cx);
502    let action = register_url(cx);
503    let login = with_next(login_url(cx), &next);
504    let brand = Panel::render_brand(cx).await?;
505    let fields = schema.render(cx, Source::form(values, errors)).await?;
506    let body = topcoat::view::view! {
507        cx =>
508        <div class="flex min-h-svh items-center justify-center bg-muted p-6">
509            <div
510                class="flex w-full max-w-sm flex-col gap-6 rounded-xl border border-border bg-card p-6 text-card-foreground shadow-sm"
511            >
512                if let Some(status) = status {
513                    (status)
514                }
515                <div class="flex flex-col items-center gap-2">
516                    (brand)
517                    <h1 class="text-lg font-semibold text-foreground">
518                        "Create an account"
519                    </h1>
520                </div>
521                <form method="post" action=(action) class="flex flex-col gap-4">
522                    (crate::csrf::field(cx, &csrf))
523                    <input type="hidden" name=(NEXT_FIELD) value=(next)>
524                    if let Some(alert) = alert {
525                        tablo_ui::alert(
526                            variant: tablo_ui::AlertVariant::Destructive,
527                            attrs: topcoat::view::attributes! { role="alert" },
528                            tablo_ui::alert_title((alert.message()))
529                        )
530                    }
531                    (fields)
532                    tablo_ui::button(
533                        variant: tablo_ui::ButtonVariant::Primary,
534                        attrs: topcoat::view::attributes! { type="submit" class="w-full" },
535                        "Sign up"
536                    )
537                </form>
538                <p class="text-center text-sm text-muted-foreground">
539                    "Already have an account? "
540                    <a
541                        (crate::navigation::runtime_link(cx, &login))
542                        class="font-medium text-foreground underline underline-offset-4"
543                    >
544                        "Sign in"
545                    </a>
546                </p>
547            </div>
548        </div>
549    }
550    .boxed();
551    Panel::render_document(cx, "Sign up".to_string(), body).await
552}
553
554#[cfg(test)]
555mod tests;