Skip to main content

tablo_core/auth/
password_reset.rs

1//! The panel's password reset: the [`PasswordResetter`] trait that finds an account by email and
2//! sets its password, the [`PasswordReset`] configuration, and the `{prefix}/forgot-password` and
3//! `{prefix}/reset-password` routes.
4
5mod pages;
6
7use std::{any::TypeId, future::Future, marker::PhantomData, time::Duration};
8
9use topcoat::{
10    context::Cx,
11    router::{Body, RouteFuture, error::see_other, response::IntoResponse},
12};
13
14use self::pages::{ForgotAlert, forgot_response, invalid_link, reset_response};
15use super::{
16    LoginThrottle, PanelUser, infrastructure_failure,
17    login::login_url,
18    mail::{TOKEN_FIELD, after_response, token_from_query},
19    registration::new_password_errors,
20    session::{credential_panels, end_credentials},
21    token::{self, REVOCATION, TokenPurpose, run_serializable},
22};
23use crate::{
24    BoxFuture,
25    auth::realm::{Realm, current_realm, hosted, panel_prefix},
26    flash::set_notification,
27    form::FieldErrors,
28    outcome::Notification,
29};
30
31/// How long a reset link works: one hour, fixed.
32pub const RESET_LIFETIME: Duration = Duration::from_hours(1);
33
34/// How a panel finds the account a password reset names and sets its new password, installed
35/// with [`Auth::password_reset`](super::Auth::password_reset) through a [`PasswordReset`].
36///
37/// The shipped [`PasswordAuth`](super::PasswordAuth) resets an [`AdminUser`](super::AdminUser);
38/// an app with its own user table implements it beside its
39/// [`Authenticator`](super::Authenticator), for the same user type. The link goes to the address
40/// [`PanelUser::email`] answers, the one the account stores, never the one the visitor typed: a
41/// lookup that matches loosely, such as a collation folding accents, would otherwise mail one
42/// account's link to another address. An account without one gets no link.
43///
44/// ```rust
45/// # use tablo_core::{PanelUser, auth::{PasswordResetter, hash_password}};
46/// # use topcoat::context::Cx;
47/// # #[derive(Debug, Clone, toasty::Model)]
48/// # struct Staff {
49/// #     #[key] #[auto] id: uuid::Uuid,
50/// #     #[unique] email: String,
51/// #     password_hash: String,
52/// #     name: String,
53/// # }
54/// # impl PanelUser for Staff {
55/// #     fn user_id(&self) -> String { self.id.to_string() }
56/// #     fn display_name(&self) -> &str { &self.name }
57/// #     fn can_access_panel(&self, _panel: &str) -> bool { true }
58/// #     fn email(&self) -> Option<&str> { Some(&self.email) }
59/// # }
60/// struct StaffAuth;
61///
62/// impl PasswordResetter for StaffAuth {
63///     type User = Staff;
64///
65///     async fn find_by_email(&self, cx: &Cx, email: &str) -> topcoat::Result<Option<Staff>> {
66///         let mut db = tablo_core::db::db(cx)?;
67///         Ok(Staff::filter_by_email(email).first().exec(&mut db).await?)
68///     }
69///
70///     async fn reset_password(
71///         &self,
72///         _cx: &Cx,
73///         user_id: &str,
74///         password: &str,
75///         ex: &mut dyn toasty::Executor,
76///     ) -> topcoat::Result<()> {
77///         let Ok(id) = user_id.parse::<uuid::Uuid>() else {
78///             return Ok(());
79///         };
80///         Staff::filter_by_id(id)
81///             .update()
82///             .password_hash(hash_password(password).await?)
83///             .exec(ex)
84///             .await?;
85///         Ok(())
86///     }
87/// }
88/// ```
89pub trait PasswordResetter: Send + Sync + 'static {
90    /// The account a reset names: the type the panel's [`Authenticator`](super::Authenticator)
91    /// loads, which mounting checks.
92    type User: PanelUser;
93
94    /// The account `email` signs in as, or `None`. The address arrives trimmed, as the visitor
95    /// typed it.
96    fn find_by_email(
97        &self,
98        cx: &Cx,
99        email: &str,
100    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
101
102    /// Set the password of the account [`PanelUser::user_id`] `user_id` names to `password`,
103    /// through the reset's transaction `ex`, which commits when it returns `Ok`. The panel's
104    /// authenticator still loads the account and it may enter the panel; the password is at
105    /// least [`MIN_PASSWORD_CHARS`](super::MIN_PASSWORD_CHARS) long and was typed twice. It may
106    /// run again for one reset when the database aborts the transaction for a concurrent one, so
107    /// it writes only through `ex`.
108    fn reset_password(
109        &self,
110        cx: &Cx,
111        user_id: &str,
112        password: &str,
113        ex: &mut dyn toasty::Executor,
114    ) -> impl Future<Output = topcoat::Result<()>> + Send;
115}
116
117/// The account a reset link is mailed to.
118pub(crate) struct Recipient {
119    address: String,
120    user_id: String,
121    /// The account's [`locale`](PanelUser::locale), which the mail is written in.
122    locale: Option<topcoat::l10n::Locale>,
123}
124
125/// [`PasswordResetter`] with its user type erased, as the panel keeps it.
126pub(crate) trait DynPasswordResetter: Send + Sync {
127    /// Whom to mail a reset link to for `email`, when an account that may enter the panel and
128    /// stores an address signs in as `email`.
129    fn recipient<'a>(
130        &'a self,
131        cx: &'a Cx,
132        panel: &'a str,
133        email: &'a str,
134    ) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>>;
135    fn reset_password<'a>(
136        &'a self,
137        cx: &'a Cx,
138        user_id: &'a str,
139        password: &'a str,
140        ex: &'a mut dyn toasty::Executor,
141    ) -> BoxFuture<'a, topcoat::Result<()>>;
142    /// The [`TypeId`] of [`PasswordResetter::User`], the authenticator's: whose sessions a link
143    /// ends.
144    fn user_type(&self) -> TypeId;
145}
146
147impl<R: PasswordResetter> DynPasswordResetter for R {
148    fn recipient<'a>(
149        &'a self,
150        cx: &'a Cx,
151        panel: &'a str,
152        email: &'a str,
153    ) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>> {
154        Box::pin(async move {
155            let user = self.find_by_email(cx, email).await?;
156            let Some(user) = user.filter(|user| user.can_access_panel(panel)) else {
157                return Ok(None);
158            };
159            let Some(email) = user.email() else {
160                // The page still answers that a link is on its way: it never says who exists.
161                tracing::warn!(
162                    user = std::any::type_name::<R::User>(),
163                    "a password reset found an account whose `PanelUser::email` is `None`"
164                );
165                return Ok(None);
166            };
167            Ok(Some(Recipient {
168                address: email.to_string(),
169                user_id: user.user_id(),
170                locale: user.locale(),
171            }))
172        })
173    }
174
175    fn reset_password<'a>(
176        &'a self,
177        cx: &'a Cx,
178        user_id: &'a str,
179        password: &'a str,
180        ex: &'a mut dyn toasty::Executor,
181    ) -> BoxFuture<'a, topcoat::Result<()>> {
182        Box::pin(PasswordResetter::reset_password(
183            self, cx, user_id, password, ex,
184        ))
185    }
186
187    fn user_type(&self) -> TypeId {
188        TypeId::of::<R::User>()
189    }
190}
191
192/// A panel's password reset, installed with
193/// [`Auth::password_reset`](super::Auth::password_reset): the [`PasswordResetter`], and how many
194/// links one address may ask for. The links go out through the panel's
195/// [`AuthMail`](super::AuthMail).
196///
197/// ```rust
198/// use tablo_core::{Auth, AuthMail, PasswordAuth, PasswordReset};
199/// use topcoat::mail::Mailbox;
200///
201/// # fn main() -> Result<(), topcoat::mail::AddressError> {
202/// let auth = Auth::password()
203///     .mail(AuthMail::new(
204///         Mailbox::new("no-reply@example.com")?,
205///         "https://admin.example.com",
206///     ))
207///     .password_reset(PasswordReset::new(PasswordAuth));
208/// # let _ = auth;
209/// # Ok(())
210/// # }
211/// ```
212///
213/// `U` is the user type the resetter loads; [`Auth`](crate::Auth) takes only one over the type its
214/// authenticator loads.
215pub struct PasswordReset<U> {
216    resetter: Box<dyn DynPasswordResetter>,
217    throttle: LoginThrottle,
218    user: PhantomData<fn() -> U>,
219}
220
221impl<U: PanelUser> PasswordReset<U> {
222    /// Resets passwords through `resetter`.
223    #[must_use]
224    pub fn new(resetter: impl PasswordResetter<User = U>) -> Self {
225        Self {
226            resetter: Box::new(resetter),
227            throttle: LoginThrottle::new(3, Duration::from_mins(15)),
228            user: PhantomData,
229        }
230    }
231
232    /// How many reset links one email address may request in a window, from any client;
233    /// defaults to three in fifteen minutes. Every request that names an address counts, whether
234    /// or not an account uses it. A limit per client belongs at the proxy.
235    #[must_use]
236    pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
237        self.throttle = throttle;
238        self
239    }
240
241    /// The same declaration, its user type checked by [`Auth`](crate::Auth).
242    pub(crate) fn erase(self) -> PasswordReset<()> {
243        PasswordReset {
244            resetter: self.resetter,
245            throttle: self.throttle,
246            user: PhantomData,
247        }
248    }
249}
250
251impl PasswordReset<()> {
252    pub(crate) fn resetter(&self) -> &dyn DynPasswordResetter {
253        &*self.resetter
254    }
255}
256
257impl<U> std::fmt::Debug for PasswordReset<U> {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("PasswordReset")
260            .field("throttle", &self.throttle)
261            .finish_non_exhaustive()
262    }
263}
264
265/// The key the forgot-password form posts the address under.
266const EMAIL_FIELD: &str = "email";
267/// The keys the reset form's password controls post under, as [`new_password_errors`] names
268/// them.
269const PASSWORD_FIELD: &str = "password";
270const CONFIRMATION_FIELD: &str = "password_confirmation";
271
272/// Where the panel's forgot-password page lives: `{prefix}/forgot-password`.
273pub(crate) fn forgot_url(cx: &Cx) -> String {
274    format!("{}/forgot-password", panel_prefix(cx))
275}
276
277/// Where the panel's reset page lives: `{prefix}/reset-password`.
278pub(crate) fn reset_url(cx: &Cx) -> String {
279    format!("{}/reset-password", panel_prefix(cx))
280}
281
282/// Whether the request's panel installs a [`PasswordReset`].
283pub(crate) fn offered(cx: &Cx) -> bool {
284    current_realm(cx).is_some_and(|panel| panel.auth.reset().is_some())
285}
286
287/// `GET {prefix}/forgot-password` — the form asking for an email address.
288pub(super) fn forgot_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
289    Box::pin(async move { forgot_response(cx, "", &FieldErrors::new(), None).await })
290}
291
292/// `POST {prefix}/forgot-password` — mail a reset link to the account the address names, then
293/// answer the same whether one does or not.
294pub(super) fn forgot_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
295    Box::pin(async move {
296        let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
297        let reset = panel
298            .auth
299            .reset()
300            .ok_or_else(topcoat::router::error::not_found)?;
301        let form = crate::auth::input::read_form(cx, body).await?;
302        crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
303        let email = form.last(EMAIL_FIELD).map_or("", str::trim);
304        if email.is_empty() {
305            let mut errors = FieldErrors::new();
306            errors.add(EMAIL_FIELD, crate::text!("tablo-reset-enter-email"));
307            return forgot_response(cx, email, &errors, None).await;
308        }
309        if !reset.throttle.attempt(email, None) {
310            tracing::warn!(panel = %panel.prefix, "password reset request throttled");
311            return forgot_response(cx, email, &FieldErrors::new(), Some(ForgotAlert::Throttled))
312                .await;
313        }
314        let recipient = match reset.resetter().recipient(cx, &panel.prefix, email).await {
315            Ok(recipient) => recipient,
316            Err(error) => {
317                let error = infrastructure_failure(error);
318                if crate::error::TabloError::is_infrastructure(&error) {
319                    return forgot_response(
320                        cx,
321                        email,
322                        &FieldErrors::new(),
323                        Some(ForgotAlert::Unavailable),
324                    )
325                    .await;
326                }
327                return Err(error);
328            }
329        };
330        if let Some(recipient) = recipient {
331            after_response(cx, "password reset link", move |cx, panel| async move {
332                mail_link(&cx, &panel, &recipient).await
333            });
334        }
335        set_notification(
336            cx,
337            Notification::success(crate::t!(cx, "tablo-reset-sent"))
338                .description(crate::t!(cx, "tablo-reset-sent-description")),
339        );
340        see_other(login_url(cx)).into_response(cx)
341    })
342}
343
344/// Records a new reset token for `recipient` on `panel`, replacing the account's earlier ones
345/// there, and mails its link to the account's address, in its locale.
346async fn mail_link(cx: &Cx, panel: &Realm, recipient: &Recipient) -> topcoat::Result<()> {
347    let Recipient {
348        address,
349        user_id,
350        locale,
351    } = recipient;
352    let cx = &crate::i18n::addressed_to(cx, locale.as_ref());
353    let mail = panel
354        .auth
355        .auth_mail()
356        .ok_or_else(topcoat::router::error::not_found)?;
357    let token = token::issue_alone(
358        cx,
359        TokenPurpose::PasswordReset,
360        user_id,
361        None,
362        RESET_LIFETIME,
363    )
364    .await?;
365    let link = mail.link(&reset_url(cx), &token);
366    let brand = hosted(cx)?.brand_name(cx);
367    let minutes = RESET_LIFETIME.as_secs() / 60;
368    let message = mail.compose(
369        address,
370        crate::t!(cx, "tablo-reset-mail-subject", brand = brand.clone()),
371        crate::t!(
372            cx,
373            "tablo-reset-mail-body",
374            brand = brand,
375            minutes = minutes,
376            link = link
377        ),
378    )?;
379    topcoat::mail::send(cx, message).await?;
380    Ok(())
381}
382
383/// The live reset token `encoded` names on the request's panel.
384async fn live_token(
385    cx: &Cx,
386    ex: &mut dyn toasty::Executor,
387    encoded: &str,
388) -> topcoat::Result<Option<token::AuthToken>> {
389    token::live(cx, ex, TokenPurpose::PasswordReset, encoded).await
390}
391
392/// `GET {prefix}/reset-password?token=…` — the form choosing a new password, or the page saying
393/// the link no longer works.
394pub(super) fn reset_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
395    Box::pin(async move {
396        let token = token_from_query(cx);
397        let mut db = crate::db::db(cx)?;
398        let live = live_token(cx, &mut db, &token).await?;
399        if live.is_none() {
400            return invalid_link(cx).await;
401        }
402        reset_response(cx, &token, &FieldErrors::new()).await
403    })
404}
405
406/// `POST {prefix}/reset-password` — set the new password, end every session and reset link the
407/// account holds, and send it to sign in.
408pub(super) fn reset_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
409    Box::pin(async move {
410        let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
411        let reset = panel
412            .auth
413            .reset()
414            .ok_or_else(topcoat::router::error::not_found)?;
415        let form = crate::auth::input::read_form(cx, body).await?;
416        crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
417        let token = form.last(TOKEN_FIELD).unwrap_or_default().to_string();
418        let password = form.last(PASSWORD_FIELD).unwrap_or_default();
419        let confirmation = form.last(CONFIRMATION_FIELD).unwrap_or_default();
420        let mut db = crate::db::db(cx)?;
421        let Some(row) = live_token(cx, &mut db, &token).await? else {
422            return invalid_link(cx).await;
423        };
424        // The account is loaded before the transaction opens: the authenticator reads through a
425        // connection of its own.
426        if !may_enter(cx, panel, &row.user_id).await? {
427            return invalid_link(cx).await;
428        }
429        let errors = new_password_errors(password, confirmation);
430        if !errors.is_empty() {
431            return reset_response(cx, &token, &errors).await;
432        }
433        let reset_done = run_serializable!(cx, |tx| async {
434            // Read again inside the transaction: of two requests racing on one link, one commits.
435            if live_token(cx, tx, &token).await?.is_none() {
436                return Ok(false);
437            }
438            reset
439                .resetter()
440                .reset_password(cx, &row.user_id, password, tx)
441                .await
442                .map_err(infrastructure_failure)?;
443            let user_type = reset.resetter().user_type();
444            let panels = credential_panels(cx, user_type, panel);
445            end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
446            Ok(true)
447        })?;
448        if !reset_done {
449            return invalid_link(cx).await;
450        }
451        set_notification(
452            cx,
453            Notification::success(crate::t!(cx, "tablo-reset-done"))
454                .description(crate::t!(cx, "tablo-reset-done-description")),
455        );
456        see_other(login_url(cx)).into_response(cx)
457    })
458}
459
460/// Whether the account `user_id` names still signs in to `panel` and may enter it: a link mailed
461/// before the account was deactivated or deleted resets nothing.
462async fn may_enter(cx: &Cx, panel: &Realm, user_id: &str) -> topcoat::Result<bool> {
463    let Some(authenticator) = panel.auth.authenticator() else {
464        return Ok(false);
465    };
466    let user = authenticator
467        .find_by_id(cx, user_id)
468        .await
469        .map_err(infrastructure_failure)?;
470    Ok(user.is_some_and(|user| user.can_access_panel(&panel.prefix)))
471}
472
473/// The mistakes in a panel's [`Auth::password_reset`](super::Auth::password_reset): a reset without
474/// auth, and a resetter whose user is not the authenticator's. What its mail needs,
475/// [`super::mail_declaration_errors`] checks.
476pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
477    use crate::{DeclarationErrorKind, declaration::ResetFault};
478
479    if auth.reset.is_some() && auth.authenticator().is_none() {
480        return vec![DeclarationErrorKind::PasswordReset(
481            ResetFault::AuthDisabled,
482        )];
483    }
484    Vec::new()
485}