mas_handlers/views/register/steps/
verify_email.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6use anyhow::Context;
7use axum::{
8    extract::{Form, Path, State},
9    response::{Html, IntoResponse, Response},
10};
11use mas_axum_utils::{
12    FancyError,
13    cookies::CookieJar,
14    csrf::{CsrfExt, ProtectedForm},
15};
16use mas_router::{PostAuthAction, UrlBuilder};
17use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryAccess, user::UserEmailRepository};
18use mas_templates::{
19    FieldError, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField,
20    TemplateContext, Templates, ToFormState,
21};
22use serde::{Deserialize, Serialize};
23use ulid::Ulid;
24
25use crate::{Limiter, PreferredLanguage, views::shared::OptionalPostAuthAction};
26
27#[derive(Serialize, Deserialize, Debug)]
28pub struct CodeForm {
29    code: String,
30}
31
32impl ToFormState for CodeForm {
33    type Field = mas_templates::RegisterStepsVerifyEmailFormField;
34}
35
36#[tracing::instrument(
37    name = "handlers.views.register.steps.verify_email.get",
38    fields(user_registration.id = %id),
39    skip_all,
40)]
41pub(crate) async fn get(
42    mut rng: BoxRng,
43    clock: BoxClock,
44    PreferredLanguage(locale): PreferredLanguage,
45    State(templates): State<Templates>,
46    State(url_builder): State<UrlBuilder>,
47    mut repo: BoxRepository,
48    Path(id): Path<Ulid>,
49    cookie_jar: CookieJar,
50) -> Result<Response, FancyError> {
51    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
52
53    let registration = repo
54        .user_registration()
55        .lookup(id)
56        .await?
57        .context("Could not find user registration")?;
58
59    // If the registration is completed, we can go to the registration destination
60    // XXX: this might not be the right thing to do? Maybe an error page would be
61    // better?
62    if registration.completed_at.is_some() {
63        let post_auth_action: Option<PostAuthAction> = registration
64            .post_auth_action
65            .map(serde_json::from_value)
66            .transpose()?;
67
68        return Ok((
69            cookie_jar,
70            OptionalPostAuthAction::from(post_auth_action)
71                .go_next(&url_builder)
72                .into_response(),
73        )
74            .into_response());
75    }
76
77    let email_authentication_id = registration
78        .email_authentication_id
79        .context("No email authentication started for this registration")?;
80    let email_authentication = repo
81        .user_email()
82        .lookup_authentication(email_authentication_id)
83        .await?
84        .context("Could not find email authentication")?;
85
86    if email_authentication.completed_at.is_some() {
87        // XXX: display a better error here
88        return Err(FancyError::from(anyhow::anyhow!(
89            "Email authentication already completed"
90        )));
91    }
92
93    let ctx = RegisterStepsVerifyEmailContext::new(email_authentication)
94        .with_csrf(csrf_token.form_value())
95        .with_language(locale);
96
97    let content = templates.render_register_steps_verify_email(&ctx)?;
98
99    Ok((cookie_jar, Html(content)).into_response())
100}
101
102#[tracing::instrument(
103    name = "handlers.views.account_email_verify.post",
104    fields(user_email.id = %id),
105    skip_all,
106)]
107pub(crate) async fn post(
108    clock: BoxClock,
109    mut rng: BoxRng,
110    PreferredLanguage(locale): PreferredLanguage,
111    State(templates): State<Templates>,
112    State(limiter): State<Limiter>,
113    mut repo: BoxRepository,
114    cookie_jar: CookieJar,
115    State(url_builder): State<UrlBuilder>,
116    Path(id): Path<Ulid>,
117    Form(form): Form<ProtectedForm<CodeForm>>,
118) -> Result<Response, FancyError> {
119    let form = cookie_jar.verify_form(&clock, form)?;
120
121    let registration = repo
122        .user_registration()
123        .lookup(id)
124        .await?
125        .context("Could not find user registration")?;
126
127    // If the registration is completed, we can go to the registration destination
128    // XXX: this might not be the right thing to do? Maybe an error page would be
129    // better?
130    if registration.completed_at.is_some() {
131        let post_auth_action: Option<PostAuthAction> = registration
132            .post_auth_action
133            .map(serde_json::from_value)
134            .transpose()?;
135
136        return Ok((
137            cookie_jar,
138            OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
139        )
140            .into_response());
141    }
142
143    let email_authentication_id = registration
144        .email_authentication_id
145        .context("No email authentication started for this registration")?;
146    let email_authentication = repo
147        .user_email()
148        .lookup_authentication(email_authentication_id)
149        .await?
150        .context("Could not find email authentication")?;
151
152    if email_authentication.completed_at.is_some() {
153        // XXX: display a better error here
154        return Err(FancyError::from(anyhow::anyhow!(
155            "Email authentication already completed"
156        )));
157    }
158
159    if let Err(e) = limiter.check_email_authentication_attempt(&email_authentication) {
160        tracing::warn!(error = &e as &dyn std::error::Error);
161        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
162        let ctx = RegisterStepsVerifyEmailContext::new(email_authentication)
163            .with_form_state(
164                form.to_form_state()
165                    .with_error_on_form(mas_templates::FormError::RateLimitExceeded),
166            )
167            .with_csrf(csrf_token.form_value())
168            .with_language(locale);
169
170        let content = templates.render_register_steps_verify_email(&ctx)?;
171
172        return Ok((cookie_jar, Html(content)).into_response());
173    }
174
175    let Some(code) = repo
176        .user_email()
177        .find_authentication_code(&email_authentication, &form.code)
178        .await?
179    else {
180        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
181        let ctx =
182            RegisterStepsVerifyEmailContext::new(email_authentication)
183                .with_form_state(form.to_form_state().with_error_on_field(
184                    RegisterStepsVerifyEmailFormField::Code,
185                    FieldError::Invalid,
186                ))
187                .with_csrf(csrf_token.form_value())
188                .with_language(locale);
189
190        let content = templates.render_register_steps_verify_email(&ctx)?;
191
192        return Ok((cookie_jar, Html(content)).into_response());
193    };
194
195    repo.user_email()
196        .complete_authentication(&clock, email_authentication, &code)
197        .await?;
198
199    repo.save().await?;
200
201    let destination = mas_router::RegisterFinish::new(registration.id);
202    return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
203}