Skip to main content

tablo_core/auth/
email_change.rs

1//! Email-change confirmation: [`request_email_change`], which a profile editor calls to keep the
2//! old address until the new one proves itself, and the `{prefix}/email-change` routes its two
3//! links open.
4
5use std::{
6    sync::{Arc, Mutex},
7    time::Duration,
8};
9
10use topcoat::{
11    context::{Cx, try_request_context},
12    router::{
13        Body, RouteFuture,
14        error::see_other,
15        response::{IntoResponse, Response},
16    },
17    session::Token,
18    view::{ViewExt, attributes, view},
19};
20
21use super::{
22    PanelUser, SignedIn, infrastructure_failure,
23    login::login_url,
24    mail::{TOKEN_FIELD, after_response, private, token_from_query},
25    page::{AuthPage, render},
26    panel_root,
27    session::{credential_panels, end_credentials, resolve, start_once},
28    token::{self, AuthToken, CREDENTIALS, REVOCATION, TokenPurpose, run_serializable},
29    verification::VERIFICATION_LIFETIME,
30};
31use crate::{
32    auth::realm::{Realm, current_realm, hosted, panel_prefix},
33    flash::set_notification,
34    outcome::Notification,
35};
36
37/// How long the old address may undo a change: long enough to notice a mail while away.
38const CANCEL_LIFETIME: Duration = Duration::from_hours(24 * 7);
39
40/// What [`request_email_change`] did with a new address.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[must_use]
43pub enum EmailChange {
44    /// The panel mails the new address a link that stores it, and the old one a link that
45    /// cancels: keep the old address until the first is opened.
46    Pending,
47    /// The panel verifies no address: store the new one now.
48    Immediate,
49}
50
51/// Asks the account `user` to confirm `email` as its new address before it changes, when the
52/// request's panel installs [`Auth::email_verification`](super::Auth::email_verification); call
53/// it from [`ProfileEditor::save`](super::ProfileEditor::save) through the save's transaction
54/// `ex`, in place of writing the address.
55///
56/// It answers [`EmailChange::Pending`] once it has recorded the change: the panel mails the new
57/// address a link that stores it, verified, and the old address a link that keeps it, both after
58/// the save commits. Confirming the first is a credential change. The second works for a week,
59/// before or after the change goes through: it restores the old address and ends every session
60/// the account holds. It answers [`EmailChange::Immediate`] on a panel that verifies no address,
61/// and writes nothing: store the address yourself.
62///
63/// The link proves only that whoever asked reads the new mailbox, and a stolen session can name
64/// one its thief reads: ask for the current password before calling it, as
65/// [`PasswordAuth`](super::PasswordAuth)'s profile does.
66///
67/// ```rust
68/// # use tablo_core::{PanelUser, auth::{self, EmailChange}};
69/// # use topcoat::context::Cx;
70/// # #[derive(Debug, Clone, toasty::Model)]
71/// # struct Staff { #[key] #[auto] id: uuid::Uuid, email: String, name: String }
72/// # impl PanelUser for Staff {
73/// #     fn user_id(&self) -> String { self.id.to_string() }
74/// #     fn display_name(&self) -> &str { &self.name }
75/// #     fn email(&self) -> Option<&str> { Some(&self.email) }
76/// # }
77/// async fn save_email(
78///     cx: &Cx,
79///     staff: &Staff,
80///     email: String,
81///     ex: &mut dyn toasty::Executor,
82/// ) -> topcoat::Result<()> {
83///     if auth::request_email_change(cx, ex, staff, &email).await? == EmailChange::Immediate {
84///         Staff::filter_by_id(staff.id)
85///             .update()
86///             .email(email)
87///             .exec(ex)
88///             .await?;
89///     }
90///     Ok(())
91/// }
92/// ```
93///
94/// # Errors
95///
96/// The opaque outage when the database cannot answer.
97pub async fn request_email_change(
98    cx: &Cx,
99    ex: &mut dyn toasty::Executor,
100    user: &dyn PanelUser,
101    email: &str,
102) -> topcoat::Result<EmailChange> {
103    if !current_realm(cx).is_some_and(|panel| panel.auth.verification().is_some()) {
104        return Ok(EmailChange::Immediate);
105    }
106    let user_id = user.user_id();
107    let confirm = token::issue(
108        cx,
109        &mut *ex,
110        TokenPurpose::EmailChange,
111        &user_id,
112        Some(email.to_string()),
113        VERIFICATION_LIFETIME,
114    )
115    .await?;
116    let old = user.email().map(str::to_string);
117    // The cancel link carries the address it restores; an account without one has none to keep.
118    let cancel = match &old {
119        Some(old) => Some(
120            token::issue(
121                cx,
122                ex,
123                TokenPurpose::EmailChangeCancel,
124                &user_id,
125                Some(old.clone()),
126                CANCEL_LIFETIME,
127            )
128            .await?,
129        ),
130        None => None,
131    };
132    let request = Request {
133        old,
134        new: email.to_string(),
135        confirm,
136        cancel,
137    };
138    match try_request_context::<Requested>(cx) {
139        // Mailed once the save commits: a rolled-back save mails nothing.
140        Some(requested) => requested.push(request),
141        None => mail(cx, request),
142    }
143    Ok(EmailChange::Pending)
144}
145
146/// One recorded change: the addresses, and the tokens of its two links.
147struct Request {
148    old: Option<String>,
149    new: String,
150    confirm: Token,
151    cancel: Option<Token>,
152}
153
154/// The changes a request recorded, which its handler mails once it commits.
155#[derive(Clone, Default)]
156pub(super) struct Requested(Arc<Mutex<Vec<Request>>>);
157
158impl Requested {
159    fn push(&self, request: Request) {
160        self.0
161            .lock()
162            .unwrap_or_else(|poison| poison.into_inner())
163            .push(request);
164    }
165
166    /// Mails every recorded change, and answers whether there was one.
167    pub(super) fn mail(&self, cx: &Cx) -> bool {
168        let requests =
169            std::mem::take(&mut *self.0.lock().unwrap_or_else(|poison| poison.into_inner()));
170        let any = !requests.is_empty();
171        for request in requests {
172            mail(cx, request);
173        }
174        any
175    }
176}
177
178/// Mails `request`'s two links after the response.
179fn mail(cx: &Cx, request: Request) {
180    after_response(cx, "email change links", move |cx, panel| async move {
181        let mail = panel
182            .auth
183            .auth_mail()
184            .ok_or_else(topcoat::router::error::not_found)?;
185        let brand = hosted(&cx)?.brand_name(&cx);
186        let confirm = mail.link(&confirm_url(&cx), &request.confirm);
187        let new = &request.new;
188        let message = mail.compose(
189            new,
190            crate::t!(
191                &cx,
192                "tablo-email-change-mail-subject",
193                brand = brand.clone()
194            ),
195            crate::t!(
196                &cx,
197                "tablo-email-change-mail-body",
198                new = new,
199                brand = brand.clone(),
200                link = confirm
201            ),
202        )?;
203        topcoat::mail::send(&cx, message).await?;
204        if let (Some(old), Some(cancel)) = (&request.old, &request.cancel) {
205            let cancel = mail.link(&cancel_url(&cx), cancel);
206            let message = mail.compose(
207                old,
208                crate::t!(
209                    &cx,
210                    "tablo-email-change-notice-subject",
211                    brand = brand.clone()
212                ),
213                crate::t!(
214                    &cx,
215                    "tablo-email-change-notice-body",
216                    brand = brand.clone(),
217                    new = new,
218                    old = old,
219                    link = cancel
220                ),
221            )?;
222            topcoat::mail::send(&cx, message).await?;
223        }
224        Ok(())
225    });
226}
227
228/// Where the link to the new address opens: `{prefix}/email-change/confirm`.
229pub(crate) fn confirm_url(cx: &Cx) -> String {
230    format!("{}/email-change/confirm", panel_prefix(cx))
231}
232
233/// Where the link to the old address opens: `{prefix}/email-change/cancel`.
234pub(crate) fn cancel_url(cx: &Cx) -> String {
235    format!("{}/email-change/cancel", panel_prefix(cx))
236}
237
238/// A link the account may still use: its live token, the account, and the signed-in user of this
239/// browser when it is that account.
240struct Opened {
241    row: AuthToken,
242    account: Arc<dyn PanelUser>,
243    signed: Option<SignedIn>,
244}
245
246/// The live token for `purpose` that `encoded` names, on an account that may still enter the
247/// panel, or `None`.
248async fn opened(
249    cx: &Cx,
250    panel: &Arc<Realm>,
251    purpose: TokenPurpose,
252    encoded: &str,
253) -> topcoat::Result<Option<Opened>> {
254    let Some(authenticator) = panel.auth.authenticator() else {
255        return Ok(None);
256    };
257    let mut db = crate::db::db(cx)?;
258    let Some(row) = token::live(cx, &mut db, purpose, encoded).await? else {
259        return Ok(None);
260    };
261    let account = authenticator
262        .find_by_id(cx, &row.user_id)
263        .await
264        .map_err(infrastructure_failure)?
265        .filter(|account| account.can_access_panel());
266    let Some(account) = account else {
267        return Ok(None);
268    };
269    // The routes answer signed out, so the gate resolved no session for them.
270    let signed = resolve(cx, panel, authenticator)
271        .await?
272        .filter(|signed| signed.user.user_id() == row.user_id);
273    Ok(Some(Opened {
274        row,
275        account,
276        signed,
277    }))
278}
279
280/// The page a link opens: what posting it does, and the button that does it. Opening a link
281/// changes nothing, so a mail scanner that fetches it changes nothing either.
282async fn link_page(
283    cx: &Cx,
284    title: String,
285    description: String,
286    button: String,
287) -> topcoat::Result<Response> {
288    let csrf = crate::csrf::ensure_token(cx);
289    let action = topcoat::router::request::uri(cx).path().to_string();
290    let token = token_from_query(cx);
291    let form = view! {
292        cx =>
293        <form method="post" action=(action) data-email-change="">
294            (crate::csrf::field(cx, &csrf))
295            <input type="hidden" name=(TOKEN_FIELD) value=(token)>
296            tablo_ui::button(
297                variant: tablo_ui::ButtonVariant::Primary,
298                attrs: attributes! { type="submit" class="w-full" },
299                (button)
300            )
301        </form>
302    }
303    .boxed();
304    let page = render(
305        cx,
306        AuthPage {
307            title,
308            description,
309            status: None,
310            form,
311            switch: None,
312            note: None,
313        },
314    )
315    .await?;
316    private(page.single().await?.into_response(cx)?)
317}
318
319/// The panel's verification and the link `encoded` names for `purpose`, or the page an unusable
320/// link lands on.
321async fn link(
322    cx: &Cx,
323    purpose: TokenPurpose,
324    encoded: &str,
325) -> topcoat::Result<Result<(Arc<Realm>, Opened), Response>> {
326    let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
327    if panel.auth.verification().is_none() {
328        return Err(topcoat::router::error::not_found().into());
329    }
330    match opened(cx, panel, purpose, encoded).await? {
331        Some(opened) => Ok(Ok((Arc::clone(panel), opened))),
332        None => Ok(Err(super::verification::invalid_link(cx).await?)),
333    }
334}
335
336/// The token a link's form posts.
337async fn posted_token(cx: &Cx, body: Body) -> topcoat::Result<String> {
338    let form = crate::auth::input::read_form(cx, body).await?;
339    crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
340    Ok(form.last(TOKEN_FIELD).unwrap_or_default().to_string())
341}
342
343/// `GET {prefix}/email-change/confirm?token=…` — the button that confirms the new address.
344pub(super) fn confirm_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
345    Box::pin(async move {
346        let (_, opened) = match link(cx, TokenPurpose::EmailChange, &token_from_query(cx)).await? {
347            Ok(link) => link,
348            Err(invalid) => return Ok(invalid),
349        };
350        let email = opened.row.email.unwrap_or_default();
351        link_page(
352            cx,
353            crate::t!(cx, "tablo-email-change-confirm-title"),
354            crate::t!(cx, "tablo-email-change-confirm-description", email = email),
355            crate::t!(cx, "tablo-confirm"),
356        )
357        .await
358    })
359}
360
361/// `POST {prefix}/email-change/confirm` — store the new address, verified, and end the account's
362/// sessions and sign-in links on each panel signing in its user type, keeping this browser's when
363/// it is the account's.
364pub(super) fn confirm_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
365    Box::pin(async move {
366        let encoded = posted_token(cx, body).await?;
367        let (panel, Opened { row, signed, .. }) =
368            match link(cx, TokenPurpose::EmailChange, &encoded).await? {
369                Ok(link) => link,
370                Err(invalid) => return Ok(invalid),
371            };
372        let verifier = panel
373            .auth
374            .verification()
375            .ok_or_else(topcoat::router::error::not_found)?
376            .verifier();
377        let email = row.email.clone().unwrap_or_default();
378        let user_type = verifier.user_type();
379        let mut started = None;
380        let landing = 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::EmailChange, &encoded)
383                .await?
384                .is_none()
385            {
386                return Ok(None);
387            }
388            token::spend(tx, &row).await?;
389            verifier
390                .store_verified_email(cx, &row.user_id, &email, tx)
391                .await
392                .map_err(infrastructure_failure)?;
393            Ok(Some(match &signed {
394                Some(signed) => {
395                    let session = start_once(cx, &mut started).await?;
396                    super::session::replace_sessions(cx, tx, signed, user_type, &session).await?;
397                    panel_root(cx)
398                }
399                None => {
400                    let panels = credential_panels(cx, user_type, &panel);
401                    end_credentials(tx, &row.user_id, &panels, CREDENTIALS).await?;
402                    login_url(cx)
403                }
404            }))
405        })?;
406        let Some(landing) = landing else {
407            return super::verification::invalid_link(cx).await;
408        };
409        set_notification(
410            cx,
411            Notification::success(crate::t!(cx, "tablo-email-change-done")).description(crate::t!(
412                cx,
413                "tablo-email-change-done-description",
414                email = email
415            )),
416        );
417        private(see_other(landing).into_response(cx)?)
418    })
419}
420
421/// `GET {prefix}/email-change/cancel?token=…` — the button that keeps the old address.
422pub(super) fn cancel_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
423    Box::pin(async move {
424        let (_, opened) =
425            match link(cx, TokenPurpose::EmailChangeCancel, &token_from_query(cx)).await? {
426                Ok(link) => link,
427                Err(invalid) => return Ok(invalid),
428            };
429        let email = opened.row.email.unwrap_or_default();
430        link_page(
431            cx,
432            crate::t!(cx, "tablo-email-change-keep-title"),
433            crate::t!(cx, "tablo-email-change-keep-description", email = email),
434            crate::t!(cx, "tablo-email-change-keep"),
435        )
436        .await
437    })
438}
439
440/// `POST {prefix}/email-change/cancel` — keep the old address, restoring it when the change went
441/// through, and end every session, sign-in link and pending change the account holds on each panel
442/// signing in its user type: whoever asked for the change may hold one.
443pub(super) fn cancel_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
444    Box::pin(async move {
445        let encoded = posted_token(cx, body).await?;
446        let (panel, Opened { row, account, .. }) =
447            match link(cx, TokenPurpose::EmailChangeCancel, &encoded).await? {
448                Ok(link) => link,
449                Err(invalid) => return Ok(invalid),
450            };
451        let verifier = panel
452            .auth
453            .verification()
454            .ok_or_else(topcoat::router::error::not_found)?
455            .verifier();
456        let old = row.email.clone().unwrap_or_default();
457        let cancelled = run_serializable!(cx, |tx| async {
458            if token::live(cx, tx, TokenPurpose::EmailChangeCancel, &encoded)
459                .await?
460                .is_none()
461            {
462                return Ok(false);
463            }
464            token::spend(tx, &row).await?;
465            if account.email() != Some(old.as_str()) {
466                // The change went through: the old address, which this link proved, comes back.
467                verifier
468                    .store_verified_email(cx, &row.user_id, &old, tx)
469                    .await
470                    .map_err(infrastructure_failure)?;
471            }
472            let panels = credential_panels(cx, verifier.user_type(), &panel);
473            end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
474            Ok(true)
475        })?;
476        if !cancelled {
477            return super::verification::invalid_link(cx).await;
478        }
479        // The cancelling browser may hold one of the ended sessions; its cookie goes too.
480        topcoat::session::stop(cx).await?;
481        set_notification(
482            cx,
483            Notification::success(crate::t!(cx, "tablo-email-change-kept")).description(crate::t!(
484                cx,
485                "tablo-email-change-kept-description",
486                email = old
487            )),
488        );
489        private(see_other(login_url(cx)).into_response(cx)?)
490    })
491}