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