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    InternalError,
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, InternalError> {
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        .map_err(InternalError::from_anyhow)?;
59
60    // If the registration is completed, we can go to the registration destination
61    // XXX: this might not be the right thing to do? Maybe an error page would be
62    // better?
63    if registration.completed_at.is_some() {
64        let post_auth_action: Option<PostAuthAction> = registration
65            .post_auth_action
66            .map(serde_json::from_value)
67            .transpose()?;
68
69        return Ok((
70            cookie_jar,
71            OptionalPostAuthAction::from(post_auth_action)
72                .go_next(&url_builder)
73                .into_response(),
74        )
75            .into_response());
76    }
77
78    let email_authentication_id = registration
79        .email_authentication_id
80        .context("No email authentication started for this registration")
81        .map_err(InternalError::from_anyhow)?;
82    let email_authentication = repo
83        .user_email()
84        .lookup_authentication(email_authentication_id)
85        .await?
86        .context("Could not find email authentication")
87        .map_err(InternalError::from_anyhow)?;
88
89    if email_authentication.completed_at.is_some() {
90        // XXX: display a better error here
91        return Err(InternalError::from_anyhow(anyhow::anyhow!(
92            "Email authentication already completed"
93        )));
94    }
95
96    let ctx = RegisterStepsVerifyEmailContext::new(email_authentication)
97        .with_csrf(csrf_token.form_value())
98        .with_language(locale);
99
100    let content = templates.render_register_steps_verify_email(&ctx)?;
101
102    Ok((cookie_jar, Html(content)).into_response())
103}
104
105#[tracing::instrument(
106    name = "handlers.views.account_email_verify.post",
107    fields(user_email.id = %id),
108    skip_all,
109)]
110pub(crate) async fn post(
111    clock: BoxClock,
112    mut rng: BoxRng,
113    PreferredLanguage(locale): PreferredLanguage,
114    State(templates): State<Templates>,
115    State(limiter): State<Limiter>,
116    mut repo: BoxRepository,
117    cookie_jar: CookieJar,
118    State(url_builder): State<UrlBuilder>,
119    Path(id): Path<Ulid>,
120    Form(form): Form<ProtectedForm<CodeForm>>,
121) -> Result<Response, InternalError> {
122    let form = cookie_jar.verify_form(&clock, form)?;
123
124    let registration = repo
125        .user_registration()
126        .lookup(id)
127        .await?
128        .context("Could not find user registration")
129        .map_err(InternalError::from_anyhow)?;
130
131    // If the registration is completed, we can go to the registration destination
132    // XXX: this might not be the right thing to do? Maybe an error page would be
133    // better?
134    if registration.completed_at.is_some() {
135        let post_auth_action: Option<PostAuthAction> = registration
136            .post_auth_action
137            .map(serde_json::from_value)
138            .transpose()?;
139
140        return Ok((
141            cookie_jar,
142            OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
143        )
144            .into_response());
145    }
146
147    let email_authentication_id = registration
148        .email_authentication_id
149        .context("No email authentication started for this registration")
150        .map_err(InternalError::from_anyhow)?;
151    let email_authentication = repo
152        .user_email()
153        .lookup_authentication(email_authentication_id)
154        .await?
155        .context("Could not find email authentication")
156        .map_err(InternalError::from_anyhow)?;
157
158    if email_authentication.completed_at.is_some() {
159        // XXX: display a better error here
160        return Err(InternalError::from_anyhow(anyhow::anyhow!(
161            "Email authentication already completed"
162        )));
163    }
164
165    if let Err(e) = limiter.check_email_authentication_attempt(&email_authentication) {
166        tracing::warn!(error = &e as &dyn std::error::Error);
167        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
168        let ctx = RegisterStepsVerifyEmailContext::new(email_authentication)
169            .with_form_state(
170                form.to_form_state()
171                    .with_error_on_form(mas_templates::FormError::RateLimitExceeded),
172            )
173            .with_csrf(csrf_token.form_value())
174            .with_language(locale);
175
176        let content = templates.render_register_steps_verify_email(&ctx)?;
177
178        return Ok((cookie_jar, Html(content)).into_response());
179    }
180
181    let Some(code) = repo
182        .user_email()
183        .find_authentication_code(&email_authentication, &form.code)
184        .await?
185    else {
186        let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
187        let ctx =
188            RegisterStepsVerifyEmailContext::new(email_authentication)
189                .with_form_state(form.to_form_state().with_error_on_field(
190                    RegisterStepsVerifyEmailFormField::Code,
191                    FieldError::Invalid,
192                ))
193                .with_csrf(csrf_token.form_value())
194                .with_language(locale);
195
196        let content = templates.render_register_steps_verify_email(&ctx)?;
197
198        return Ok((cookie_jar, Html(content)).into_response());
199    };
200
201    repo.user_email()
202        .complete_authentication(&clock, email_authentication, &code)
203        .await?;
204
205    repo.save().await?;
206
207    let destination = mas_router::RegisterFinish::new(registration.id);
208    return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
209}