mas_handlers/views/register/steps/
verify_email.rs

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