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    pub(in crate::auth) 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
310            .throttle
311            .attempt(cx, "password-reset", email, None)
312            .await?
313        {
314            tracing::warn!(panel = %panel.prefix, "password reset request throttled");
315            return forgot_response(cx, email, &FieldErrors::new(), Some(ForgotAlert::Throttled))
316                .await;
317        }
318        let recipient = match reset.resetter().recipient(cx, &panel.prefix, email).await {
319            Ok(recipient) => recipient,
320            Err(error) => {
321                let error = infrastructure_failure(error);
322                if crate::error::TabloError::is_infrastructure(&error) {
323                    return forgot_response(
324                        cx,
325                        email,
326                        &FieldErrors::new(),
327                        Some(ForgotAlert::Unavailable),
328                    )
329                    .await;
330                }
331                return Err(error);
332            }
333        };
334        if let Some(recipient) = recipient {
335            after_response(cx, "password reset link", move |cx, panel| async move {
336                mail_link(&cx, &panel, &recipient).await
337            });
338        }
339        set_notification(
340            cx,
341            Notification::success(crate::t!(cx, "tablo-reset-sent"))
342                .description(crate::t!(cx, "tablo-reset-sent-description")),
343        );
344        see_other(login_url(cx)).into_response(cx)
345    })
346}
347
348/// Records a new reset token for `recipient` on `panel`, replacing the account's earlier ones
349/// there, and mails its link to the account's address, in its locale.
350async fn mail_link(cx: &Cx, panel: &Realm, recipient: &Recipient) -> topcoat::Result<()> {
351    let Recipient {
352        address,
353        user_id,
354        locale,
355    } = recipient;
356    let cx = &crate::i18n::addressed_to(cx, locale.as_ref());
357    let mail = panel
358        .auth
359        .auth_mail()
360        .ok_or_else(topcoat::router::error::not_found)?;
361    let token = token::issue_alone(
362        cx,
363        TokenPurpose::PasswordReset,
364        user_id,
365        None,
366        RESET_LIFETIME,
367    )
368    .await?;
369    let link = mail.link(&reset_url(cx), &token);
370    let brand = hosted(cx)?.brand_name(cx);
371    let minutes = RESET_LIFETIME.as_secs() / 60;
372    let message = mail.compose(
373        address,
374        crate::t!(cx, "tablo-reset-mail-subject", brand = brand.clone()),
375        crate::t!(
376            cx,
377            "tablo-reset-mail-body",
378            brand = brand,
379            minutes = minutes,
380            link = link
381        ),
382    )?;
383    topcoat::mail::send(cx, message).await?;
384    Ok(())
385}
386
387/// The live reset token `encoded` names on the request's panel.
388async fn live_token(
389    cx: &Cx,
390    ex: &mut dyn toasty::Executor,
391    encoded: &str,
392) -> topcoat::Result<Option<token::AuthToken>> {
393    token::live(cx, ex, TokenPurpose::PasswordReset, encoded).await
394}
395
396/// `GET {prefix}/reset-password?token=…` — the form choosing a new password, or the page saying
397/// the link no longer works.
398pub(super) fn reset_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
399    Box::pin(async move {
400        let token = token_from_query(cx);
401        let mut db = crate::db::db(cx)?;
402        let live = live_token(cx, &mut db, &token).await?;
403        if live.is_none() {
404            return invalid_link(cx).await;
405        }
406        reset_response(cx, &token, &FieldErrors::new()).await
407    })
408}
409
410/// `POST {prefix}/reset-password` — set the new password, end every session and reset link the
411/// account holds, and send it to sign in.
412pub(super) fn reset_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
413    Box::pin(async move {
414        let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
415        let reset = panel
416            .auth
417            .reset()
418            .ok_or_else(topcoat::router::error::not_found)?;
419        let form = crate::auth::input::read_form(cx, body).await?;
420        crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
421        let token = form.last(TOKEN_FIELD).unwrap_or_default().to_string();
422        let password = form.last(PASSWORD_FIELD).unwrap_or_default();
423        let confirmation = form.last(CONFIRMATION_FIELD).unwrap_or_default();
424        let mut db = crate::db::db(cx)?;
425        let Some(row) = live_token(cx, &mut db, &token).await? else {
426            return invalid_link(cx).await;
427        };
428        // The account is loaded before the transaction opens: the authenticator reads through a
429        // connection of its own.
430        if !may_enter(cx, panel, &row.user_id).await? {
431            return invalid_link(cx).await;
432        }
433        let errors = new_password_errors(password, confirmation);
434        if !errors.is_empty() {
435            return reset_response(cx, &token, &errors).await;
436        }
437        let reset_done = run_serializable!(cx, |tx| async {
438            // Read again inside the transaction: of two requests racing on one link, one commits.
439            if live_token(cx, tx, &token).await?.is_none() {
440                return Ok(false);
441            }
442            reset
443                .resetter()
444                .reset_password(cx, &row.user_id, password, tx)
445                .await
446                .map_err(infrastructure_failure)?;
447            let user_type = reset.resetter().user_type();
448            let panels = credential_panels(cx, user_type, panel);
449            end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
450            Ok(true)
451        })?;
452        if !reset_done {
453            return invalid_link(cx).await;
454        }
455        set_notification(
456            cx,
457            Notification::success(crate::t!(cx, "tablo-reset-done"))
458                .description(crate::t!(cx, "tablo-reset-done-description")),
459        );
460        see_other(login_url(cx)).into_response(cx)
461    })
462}
463
464/// Whether the account `user_id` names still signs in to `panel` and may enter it: a link mailed
465/// before the account was deactivated or deleted resets nothing.
466async fn may_enter(cx: &Cx, panel: &Realm, user_id: &str) -> topcoat::Result<bool> {
467    let Some(authenticator) = panel.auth.authenticator() else {
468        return Ok(false);
469    };
470    let user = authenticator
471        .find_by_id(cx, user_id)
472        .await
473        .map_err(infrastructure_failure)?;
474    Ok(user.is_some_and(|user| user.can_access_panel(&panel.prefix)))
475}
476
477/// The mistakes in a panel's [`Auth::password_reset`](super::Auth::password_reset): a reset without
478/// auth, and a resetter whose user is not the authenticator's. What its mail needs,
479/// [`super::mail_declaration_errors`] checks.
480pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
481    use crate::{DeclarationErrorKind, declaration::ResetFault};
482
483    if auth.reset.is_some() && auth.authenticator().is_none() {
484        return vec![DeclarationErrorKind::PasswordReset(
485            ResetFault::AuthDisabled,
486        )];
487    }
488    Vec::new()
489}