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