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