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