Skip to main content

tablo_core/auth/
verification.rs

1//! The panel's email verification: the [`EmailVerifier`] trait that records an address an account
2//! proved it reads, the [`EmailVerification`] configuration, and the `{prefix}/verify-email`
3//! routes.
4
5mod pages;
6
7use std::{
8    any::{Any, TypeId},
9    future::Future,
10    marker::PhantomData,
11    time::Duration,
12};
13
14use topcoat::{
15    context::Cx,
16    router::{
17        Body, RouteFuture,
18        error::{see_other, too_many_requests},
19        response::IntoResponse,
20    },
21};
22
23pub(super) use self::pages::invalid_link;
24use self::pages::notice;
25use super::{
26    LoginThrottle, PanelUser, infrastructure_failure,
27    login::login_url,
28    mail::{after_response, private, token_from_query},
29    panel_root, resolved,
30    session::resolve,
31    token::{self, TokenPurpose, run_serializable},
32    unauthenticated_error,
33};
34use crate::{
35    BoxFuture,
36    auth::realm::{Realm, current_realm, hosted, panel_prefix},
37    flash::set_notification,
38    outcome::Notification,
39};
40
41/// How long a verification link works: one day, fixed.
42pub const VERIFICATION_LIFETIME: Duration = Duration::from_hours(24);
43
44/// How a panel records that an account reads the address it stores, installed with
45/// [`Auth::email_verification`](super::Auth::email_verification) through an
46/// [`EmailVerification`].
47///
48/// The link goes to the address [`PanelUser::email`] answers. The shipped
49/// [`PasswordAuth`](super::PasswordAuth) verifies an [`AdminUser`](super::AdminUser); an app with
50/// its own user table implements it beside its [`Authenticator`](super::Authenticator), for the
51/// same user type:
52///
53/// ```rust
54/// # use tablo_core::{PanelUser, auth::EmailVerifier};
55/// # use topcoat::context::Cx;
56/// # #[derive(Debug, Clone, toasty::Model)]
57/// # struct Staff {
58/// #     #[key] #[auto] id: uuid::Uuid,
59/// #     email: String,
60/// #     email_verified_at: Option<jiff::Timestamp>,
61/// #     name: String,
62/// # }
63/// # impl PanelUser for Staff {
64/// #     fn user_id(&self) -> String { self.id.to_string() }
65/// #     fn display_name(&self) -> &str { &self.name }
66/// #     fn email(&self) -> Option<&str> { Some(&self.email) }
67/// # }
68/// struct StaffAuth;
69///
70/// impl EmailVerifier for StaffAuth {
71///     type User = Staff;
72///
73///     fn is_verified(&self, staff: &Staff) -> bool {
74///         staff.email_verified_at.is_some()
75///     }
76///
77///     async fn store_verified_email(
78///         &self,
79///         _cx: &Cx,
80///         user_id: &str,
81///         email: &str,
82///         ex: &mut dyn toasty::Executor,
83///     ) -> topcoat::Result<()> {
84///         let id = user_id
85///             .parse::<uuid::Uuid>()
86///             .map_err(|_| topcoat::router::error::not_found())?;
87///         Staff::filter_by_id(id)
88///             .update()
89///             .email(email)
90///             .email_verified_at(Some(jiff::Timestamp::now()))
91///             .exec(ex)
92///             .await?;
93///         Ok(())
94///     }
95/// }
96/// ```
97pub trait EmailVerifier: Send + Sync + 'static {
98    /// The account a link verifies: the type the panel's [`Authenticator`](super::Authenticator)
99    /// loads, which mounting checks.
100    type User: PanelUser;
101
102    /// Whether `user` proved it reads the address [`PanelUser::email`] answers. A user who has
103    /// not reaches only the page that asks for the proof.
104    fn is_verified(&self, user: &Self::User) -> bool;
105
106    /// Record, through the transaction `ex`, which commits when it returns `Ok`, that the account
107    /// [`PanelUser::user_id`] `user_id` names proved it reads `email`: store `email` as the
108    /// address it signs in with, which an email change makes new, and mark it verified. An id
109    /// that names no account is an error, not a success: the link is spent either way. It may run
110    /// again for one link when the database aborts the transaction for a concurrent one, so it
111    /// writes only through `ex`.
112    fn store_verified_email(
113        &self,
114        cx: &Cx,
115        user_id: &str,
116        email: &str,
117        ex: &mut dyn toasty::Executor,
118    ) -> impl Future<Output = topcoat::Result<()>> + Send;
119}
120
121/// [`EmailVerifier`] with its user type erased, as the panel keeps it.
122pub(crate) trait DynEmailVerifier: Send + Sync {
123    /// Whether `user` is verified; a user of another type is not.
124    fn is_verified(&self, user: &dyn PanelUser) -> bool;
125    fn store_verified_email<'a>(
126        &'a self,
127        cx: &'a Cx,
128        user_id: &'a str,
129        email: &'a str,
130        ex: &'a mut dyn toasty::Executor,
131    ) -> BoxFuture<'a, topcoat::Result<()>>;
132    /// The [`TypeId`] of [`EmailVerifier::User`], the authenticator's: whose sessions a link ends.
133    fn user_type(&self) -> TypeId;
134}
135
136impl<V: EmailVerifier> DynEmailVerifier for V {
137    fn is_verified(&self, user: &dyn PanelUser) -> bool {
138        let user: &dyn Any = user;
139        user.downcast_ref::<V::User>()
140            .is_some_and(|user| EmailVerifier::is_verified(self, user))
141    }
142
143    fn store_verified_email<'a>(
144        &'a self,
145        cx: &'a Cx,
146        user_id: &'a str,
147        email: &'a str,
148        ex: &'a mut dyn toasty::Executor,
149    ) -> BoxFuture<'a, topcoat::Result<()>> {
150        Box::pin(EmailVerifier::store_verified_email(
151            self, cx, user_id, email, ex,
152        ))
153    }
154
155    fn user_type(&self) -> TypeId {
156        TypeId::of::<V::User>()
157    }
158}
159
160/// A panel's email verification, installed with
161/// [`Auth::email_verification`](super::Auth::email_verification): the [`EmailVerifier`], and how
162/// many links one account may ask for. The links go out through the panel's
163/// [`AuthMail`](super::AuthMail).
164///
165/// ```rust
166/// use tablo_core::{Auth, AuthMail, EmailVerification, PasswordAuth, PasswordSignUp};
167/// use topcoat::mail::Mailbox;
168///
169/// # fn main() -> Result<(), topcoat::mail::AddressError> {
170/// let auth = Auth::password()
171///     .mail(AuthMail::new(
172///         Mailbox::new("no-reply@example.com")?,
173///         "https://admin.example.com",
174///     ))
175///     .registration(PasswordSignUp::open())
176///     .email_verification(EmailVerification::new(PasswordAuth));
177/// # let _ = auth;
178/// # Ok(())
179/// # }
180/// ```
181///
182/// `U` is the user type the verifier loads; [`Auth`](crate::Auth) takes only one over the type its
183/// authenticator loads.
184pub struct EmailVerification<U> {
185    verifier: Box<dyn DynEmailVerifier>,
186    throttle: LoginThrottle,
187    user: PhantomData<fn() -> U>,
188}
189
190impl<U: PanelUser> EmailVerification<U> {
191    /// Verifies addresses through `verifier`.
192    #[must_use]
193    pub fn new(verifier: impl EmailVerifier<User = U>) -> Self {
194        Self {
195            verifier: Box::new(verifier),
196            throttle: LoginThrottle::new(3, Duration::from_mins(15)),
197            user: PhantomData,
198        }
199    }
200
201    /// How many links one signed-in account may ask for in a window, from any client; defaults
202    /// to three in fifteen minutes. The link a sign-up mails does not count.
203    #[must_use]
204    pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
205        self.throttle = throttle;
206        self
207    }
208
209    /// The same declaration, its user type checked by [`Auth`](crate::Auth).
210    pub(crate) fn erase(self) -> EmailVerification<()> {
211        EmailVerification {
212            verifier: self.verifier,
213            throttle: self.throttle,
214            user: PhantomData,
215        }
216    }
217}
218
219impl EmailVerification<()> {
220    pub(crate) fn verifier(&self) -> &dyn DynEmailVerifier {
221        &*self.verifier
222    }
223}
224
225impl<U> std::fmt::Debug for EmailVerification<U> {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("EmailVerification")
228            .field("throttle", &self.throttle)
229            .finish_non_exhaustive()
230    }
231}
232
233/// Where the panel asks a signed-in user to verify their address: `{prefix}/verify-email`.
234pub(crate) fn notice_url(cx: &Cx) -> String {
235    format!("{}/verify-email", panel_prefix(cx))
236}
237
238/// Where a verification link opens: `{prefix}/verify-email/confirm`.
239pub(crate) fn confirm_url(cx: &Cx) -> String {
240    format!("{}/verify-email/confirm", panel_prefix(cx))
241}
242
243/// Whether `panel` makes `user` verify their address before anything else: a user without one has
244/// nothing to prove, and no link could reach them.
245pub(crate) fn pending(panel: &Realm, user: &dyn PanelUser) -> bool {
246    user.email().is_some()
247        && panel
248            .auth
249            .verification()
250            .is_some_and(|verification| !verification.verifier().is_verified(user))
251}
252
253/// Mails a verification link to `user` after the response, when the request's panel verifies
254/// addresses and `user` stores one it has not verified: what a sign-up does.
255pub(super) fn mail_after_sign_up(cx: &Cx, user: &dyn PanelUser) {
256    let Some(panel) = current_realm(cx) else {
257        return;
258    };
259    let Some(email) = user.email().filter(|_| pending(panel, user)) else {
260        return;
261    };
262    let (email, user_id) = (email.to_string(), user.user_id());
263    let locale = user.locale();
264    after_response(cx, "verification link", move |cx, panel| async move {
265        let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
266        mail_link(&cx, &panel, &user_id, &email).await
267    });
268}
269
270/// Records a new verification token for `user_id` proving `email`, replacing the account's
271/// earlier ones on `panel`, and mails its link to `email`.
272async fn mail_link(cx: &Cx, panel: &Realm, user_id: &str, email: &str) -> topcoat::Result<()> {
273    let mail = panel
274        .auth
275        .auth_mail()
276        .ok_or_else(topcoat::router::error::not_found)?;
277    let token = token::issue_alone(
278        cx,
279        TokenPurpose::EmailVerification,
280        user_id,
281        Some(email.to_string()),
282        VERIFICATION_LIFETIME,
283    )
284    .await?;
285    let link = mail.link(&confirm_url(cx), &token);
286    let brand = hosted(cx)?.brand_name(cx);
287    let message = mail.compose(
288        email,
289        crate::t!(cx, "tablo-verify-mail-subject", brand = brand.clone()),
290        crate::t!(
291            cx,
292            "tablo-verify-mail-body",
293            email = email,
294            brand = brand,
295            link = link
296        ),
297    )?;
298    topcoat::mail::send(cx, message).await?;
299    Ok(())
300}
301
302/// `GET {prefix}/verify-email` — the page asking a signed-in user to verify their address, or
303/// the panel for one who has.
304pub(super) fn notice_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
305    Box::pin(async move {
306        let signed = resolved(cx).ok_or_else(|| unauthenticated_error(cx))?;
307        if !pending(&signed.realm, signed.user.as_ref()) {
308            return see_other(panel_root(cx)).into_response(cx);
309        }
310        notice(cx, signed.user.email().unwrap_or_default()).await
311    })
312}
313
314/// `POST {prefix}/verify-email` — mail the signed-in user a new link, then land back on the
315/// notice.
316pub(super) fn notice_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
317    Box::pin(async move {
318        let signed = resolved(cx).ok_or_else(|| unauthenticated_error(cx))?;
319        let verification = signed
320            .realm
321            .auth
322            .verification()
323            .ok_or_else(topcoat::router::error::not_found)?;
324        let form = crate::auth::input::read_form(cx, body).await?;
325        crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
326        if !pending(&signed.realm, signed.user.as_ref()) {
327            return see_other(panel_root(cx)).into_response(cx);
328        }
329        let user_id = signed.user.user_id();
330        if !verification.throttle.attempt(&user_id, None) {
331            tracing::warn!(panel = %signed.realm.prefix, "verification link request throttled");
332            return Err(too_many_requests(verification.throttle.window_secs()).into());
333        }
334        if let Some(email) = signed.user.email() {
335            let email = email.to_string();
336            let locale = signed.user.locale();
337            after_response(cx, "verification link", move |cx, panel| async move {
338                let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
339                mail_link(&cx, &panel, &user_id, &email).await
340            });
341        }
342        set_notification(
343            cx,
344            Notification::success(crate::t!(cx, "tablo-reset-sent"))
345                .description(crate::t!(cx, "tablo-verify-sent-description")),
346        );
347        see_other(notice_url(cx)).into_response(cx)
348    })
349}
350
351/// `GET {prefix}/verify-email/confirm?token=…` — verify the address the link was mailed to, then
352/// land on the panel, or on the login page for a browser that is not signed in as the account.
353///
354/// The link verifies on `GET`, so a mail scanner that opens it verifies the address too: the
355/// scanner fetched it from the mailbox the link proves.
356pub(super) fn confirm_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
357    Box::pin(async move {
358        let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
359        let verification = panel
360            .auth
361            .verification()
362            .ok_or_else(topcoat::router::error::not_found)?;
363        let encoded = token_from_query(cx);
364        let mut db = crate::db::db(cx)?;
365        let Some(row) = token::live(cx, &mut db, TokenPurpose::EmailVerification, &encoded).await?
366        else {
367            return invalid_link(cx).await;
368        };
369        // A link to an address the account no longer stores proves nothing about the one it
370        // stores now.
371        if !stores(cx, panel, &row).await? {
372            return invalid_link(cx).await;
373        }
374        let email = row.email.clone().unwrap_or_default();
375        let verified = run_serializable!(cx, |tx| async {
376            // Read again inside the transaction: of two requests racing on one link, one commits.
377            if token::live(cx, tx, TokenPurpose::EmailVerification, &encoded)
378                .await?
379                .is_none()
380            {
381                return Ok(false);
382            }
383            verification
384                .verifier()
385                .store_verified_email(cx, &row.user_id, &email, tx)
386                .await
387                .map_err(infrastructure_failure)?;
388            token::spend(tx, &row).await?;
389            Ok(true)
390        })?;
391        if !verified {
392            return invalid_link(cx).await;
393        }
394        set_notification(
395            cx,
396            Notification::success(crate::t!(cx, "tablo-verify-done")),
397        );
398        // The route answers signed out, so the gate resolved no session for it.
399        let signed_in_here = match panel.auth.authenticator() {
400            Some(authenticator) => resolve(cx, panel, authenticator)
401                .await?
402                .is_some_and(|signed| signed.user.user_id() == row.user_id),
403            None => false,
404        };
405        let target = if signed_in_here {
406            panel_root(cx)
407        } else {
408            login_url(cx)
409        };
410        private(see_other(target).into_response(cx)?)
411    })
412}
413
414/// Whether the account `row` names still signs in to `panel`, may enter it, and stores the
415/// address `row` proves.
416async fn stores(cx: &Cx, panel: &Realm, row: &token::AuthToken) -> topcoat::Result<bool> {
417    let Some(authenticator) = panel.auth.authenticator() else {
418        return Ok(false);
419    };
420    let user = authenticator
421        .find_by_id(cx, &row.user_id)
422        .await
423        .map_err(infrastructure_failure)?;
424    Ok(user.is_some_and(|user| {
425        user.can_access_panel() && user.email().is_some() && user.email() == row.email.as_deref()
426    }))
427}
428
429/// The mistakes in a panel's [`Auth::email_verification`](super::Auth::email_verification): a
430/// verification without auth, and a verifier whose user is not the authenticator's.
431pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
432    use crate::{DeclarationErrorKind, declaration::VerificationFault};
433
434    if auth.verification.is_some() && auth.authenticator().is_none() {
435        return vec![DeclarationErrorKind::EmailVerification(
436            VerificationFault::AuthDisabled,
437        )];
438    }
439    Vec::new()
440}