Implement email verification in the registration flow

This commit is contained in:
Quentin Gliech
2025-01-14 15:32:05 +01:00
parent 90fb2f0369
commit 588c1bdcd4
13 changed files with 320 additions and 216 deletions

View File

@@ -379,9 +379,9 @@ where
get(self::views::register::password::get).post(self::views::register::password::post),
)
.route(
mas_router::AccountVerifyEmail::route(),
get(self::views::account::emails::verify::get)
.post(self::views::account::emails::verify::post),
mas_router::RegisterVerifyEmail::route(),
get(self::views::register::steps::verify_email::get)
.post(self::views::register::steps::verify_email::post),
)
.route(
mas_router::AccountRecoveryStart::route(),

View File

@@ -1,7 +0,0 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
pub mod verify;

View File

@@ -1,135 +0,0 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use anyhow::Context;
use axum::{
extract::{Form, Path, Query, State},
response::{Html, IntoResponse, Response},
};
use mas_axum_utils::{
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_router::UrlBuilder;
use mas_storage::{
queue::{ProvisionUserJob, QueueJobRepositoryExt as _},
user::UserEmailRepository,
BoxClock, BoxRepository, BoxRng, RepositoryAccess,
};
use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates};
use serde::Deserialize;
use ulid::Ulid;
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage};
#[expect(dead_code)]
#[derive(Deserialize, Debug)]
pub struct CodeForm {
code: String,
}
#[tracing::instrument(
name = "handlers.views.account_email_verify.get",
fields(user_email.id = %id),
skip_all,
err,
)]
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker,
mut repo: BoxRepository,
Query(_query): Query<OptionalPostAuthAction>,
Path(id): Path<Ulid>,
cookie_jar: CookieJar,
) -> Result<Response, FancyError> {
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let (session_info, cookie_jar) = cookie_jar.session_info();
let maybe_session = session_info.load_session(&mut repo).await?;
let Some(session) = maybe_session else {
let login = mas_router::Login::default();
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
};
activity_tracker
.record_browser_session(&clock, &session)
.await;
let user_email = repo
.user_email()
.lookup(id)
.await?
.filter(|u| u.user_id == session.user.id)
.context("Could not find user email")?;
let ctx = EmailVerificationPageContext::new(user_email)
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_account_verify_email(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
#[tracing::instrument(
name = "handlers.views.account_email_verify.post",
fields(user_email.id = %id),
skip_all,
err,
)]
pub(crate) async fn post(
clock: BoxClock,
mut rng: BoxRng,
mut repo: BoxRepository,
cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>,
Path(id): Path<Ulid>,
Form(form): Form<ProtectedForm<CodeForm>>,
) -> Result<Response, FancyError> {
let _form = cookie_jar.verify_form(&clock, form)?;
let (session_info, cookie_jar) = cookie_jar.session_info();
let maybe_session = session_info.load_session(&mut repo).await?;
let Some(session) = maybe_session else {
let login = mas_router::Login::default();
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
};
let _user_email = repo
.user_email()
.lookup(id)
.await?
.filter(|u| u.user_id == session.user.id)
.context("Could not find user email")?;
// XXX: this logic should be extracted somewhere else, since most of it is
// duplicated in mas_graphql
// TODO: Use the new email authentication codes
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&session.user))
.await?;
repo.save().await?;
activity_tracker
.record_browser_session(&clock, &session)
.await;
let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default());
Ok((cookie_jar, destination).into_response())
}

View File

@@ -4,7 +4,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
pub mod account;
pub mod app;
pub mod index;
pub mod login;

View File

@@ -18,6 +18,7 @@ use super::shared::OptionalPostAuthAction;
use crate::{BoundActivityTracker, PreferredLanguage};
pub(crate) mod password;
pub(crate) mod steps;
#[tracing::instrument(name = "handlers.views.register.get", skip_all, err)]
pub(crate) async fn get(

View File

@@ -354,8 +354,9 @@ pub(crate) async fn post(
repo.save().await?;
// TODO: redirect to the next step on the registration
Ok(format!("{}", registration.id).into_response())
Ok(url_builder
.redirect(&mas_router::RegisterVerifyEmail::new(registration.id))
.into_response())
}
async fn render(
@@ -468,10 +469,19 @@ mod tests {
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_status(StatusCode::SEE_OTHER);
let location = response.headers().get(LOCATION).unwrap();
// The handler redirects with the ID as the last portion of the path
let id = location
.to_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.parse()
.unwrap();
// The handler gives us the registration ID in the body for now
let id = response.body().parse().unwrap();
// There should be a new registration in the database
let mut repo = state.repository().await.unwrap();
let registration = repo.user_registration().lookup(id).await.unwrap().unwrap();

View File

@@ -1,7 +1,6 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
pub mod emails;
pub(crate) mod verify_email;

View File

@@ -0,0 +1,253 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use anyhow::Context;
use axum::{
extract::{Form, Path, State},
response::{Html, IntoResponse, Response},
};
use axum_extra::TypedHeader;
use mas_axum_utils::{
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_data_model::UserAgent;
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{
queue::{ProvisionUserJob, QueueJobRepositoryExt as _},
user::UserEmailRepository,
BoxClock, BoxRepository, BoxRng, RepositoryAccess,
};
use mas_templates::{
FieldError, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField,
TemplateContext, Templates, ToFormState,
};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage};
#[derive(Serialize, Deserialize, Debug)]
pub struct CodeForm {
code: String,
}
impl ToFormState for CodeForm {
type Field = mas_templates::RegisterStepsVerifyEmailFormField;
}
#[tracing::instrument(
name = "handlers.views.register.steps.verify_email.get",
fields(user_registration.id = %id),
skip_all,
err,
)]
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository,
Path(id): Path<Ulid>,
cookie_jar: CookieJar,
) -> Result<Response, FancyError> {
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let registration = repo
.user_registration()
.lookup(id)
.await?
.context("Could not find user registration")?;
// If the registration is completed, we can go to the registration destination
// XXX: this might not be the right thing to do? Maybe an error page would be
// better?
if registration.completed_at.is_some() {
let post_auth_action: Option<PostAuthAction> = registration
.post_auth_action
.map(serde_json::from_value)
.transpose()?;
return Ok(OptionalPostAuthAction::from(post_auth_action)
.go_next(&url_builder)
.into_response());
}
let email_authentication_id = registration
.email_authentication_id
.context("No email authentication started for this registration")?;
let email_authentication = repo
.user_email()
.lookup_authentication(email_authentication_id)
.await?
.context("Could not find email authentication")?;
if email_authentication.completed_at.is_some() {
// XXX: display a better error here
return Err(FancyError::from(anyhow::anyhow!(
"Email authentication already completed"
)));
}
let ctx = RegisterStepsVerifyEmailContext::new(email_authentication)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_register_steps_verify_email(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
#[tracing::instrument(
name = "handlers.views.account_email_verify.post",
fields(user_email.id = %id),
skip_all,
err,
)]
pub(crate) async fn post(
clock: BoxClock,
mut rng: BoxRng,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
mut repo: BoxRepository,
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<headers::UserAgent>>,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker,
Path(id): Path<Ulid>,
Form(form): Form<ProtectedForm<CodeForm>>,
) -> Result<Response, FancyError> {
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let form = cookie_jar.verify_form(&clock, form)?;
let registration = repo
.user_registration()
.lookup(id)
.await?
.context("Could not find user registration")?;
// If the registration is completed, we can go to the registration destination
// XXX: this might not be the right thing to do? Maybe an error page would be
// better?
if registration.completed_at.is_some() {
let post_auth_action: Option<PostAuthAction> = registration
.post_auth_action
.map(serde_json::from_value)
.transpose()?;
return Ok(OptionalPostAuthAction::from(post_auth_action)
.go_next(&url_builder)
.into_response());
}
let email_authentication_id = registration
.email_authentication_id
.context("No email authentication started for this registration")?;
let email_authentication = repo
.user_email()
.lookup_authentication(email_authentication_id)
.await?
.context("Could not find email authentication")?;
if email_authentication.completed_at.is_some() {
// XXX: display a better error here
return Err(FancyError::from(anyhow::anyhow!(
"Email authentication already completed"
)));
}
let Some(code) = repo
.user_email()
.find_authentication_code(&email_authentication, &form.code)
.await?
else {
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let ctx =
RegisterStepsVerifyEmailContext::new(email_authentication)
.with_form_state(form.to_form_state().with_error_on_field(
RegisterStepsVerifyEmailFormField::Code,
FieldError::Invalid,
))
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_register_steps_verify_email(&ctx)?;
return Ok((cookie_jar, Html(content)).into_response());
};
let email_authentication = repo
.user_email()
.complete_authentication(&clock, email_authentication, &code)
.await?;
let registration = repo
.user_registration()
.complete(&clock, registration)
.await?;
// XXX: this should move somewhere else, and it doesn't check for uniqueness
let user = repo
.user()
.add(&mut rng, &clock, registration.username)
.await?;
let user_session = repo
.browser_session()
.add(&mut rng, &clock, &user, user_agent)
.await?;
repo.user_email()
.add(&mut rng, &clock, &user, email_authentication.email)
.await?;
if let Some(password) = registration.password {
let user_password = repo
.user_password()
.add(
&mut rng,
&clock,
&user,
password.version,
password.hashed_password,
None,
)
.await?;
repo.browser_session()
.authenticate_with_password(&mut rng, &clock, &user_session, &user_password)
.await?;
}
if let Some(terms_url) = registration.terms_url {
repo.user_terms()
.accept_terms(&mut rng, &clock, &user, terms_url)
.await?;
}
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
repo.save().await?;
activity_tracker
.record_browser_session(&clock, &user_session)
.await;
let post_auth_action: Option<PostAuthAction> = registration
.post_auth_action
.map(serde_json::from_value)
.transpose()?;
let cookie_jar = cookie_jar.set_session(&user_session);
return Ok((
cookie_jar,
OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder),
)
.into_response());
}

View File

@@ -444,47 +444,27 @@ impl From<Option<PostAuthAction>> for PasswordRegister {
}
}
/// `GET|POST /verify-email/:id`
/// `GET|POST /register/steps/verify-email/:id`
#[derive(Debug, Clone)]
pub struct AccountVerifyEmail {
pub struct RegisterVerifyEmail {
id: Ulid,
post_auth_action: Option<PostAuthAction>,
}
impl AccountVerifyEmail {
impl RegisterVerifyEmail {
#[must_use]
pub fn new(id: Ulid) -> Self {
Self {
id,
post_auth_action: None,
}
}
#[must_use]
pub fn and_maybe(mut self, action: Option<PostAuthAction>) -> Self {
self.post_auth_action = action;
self
}
#[must_use]
pub fn and_then(mut self, action: PostAuthAction) -> Self {
self.post_auth_action = Some(action);
self
Self { id }
}
}
impl Route for AccountVerifyEmail {
type Query = PostAuthAction;
impl Route for RegisterVerifyEmail {
type Query = ();
fn route() -> &'static str {
"/verify-email/:id"
}
fn query(&self) -> Option<&Self::Query> {
self.post_auth_action.as_ref()
"/register/steps/verify-email/:id"
}
fn path(&self) -> std::borrow::Cow<'static, str> {
format!("/verify-email/{}", self.id).into()
format!("/register/steps/verify-email/{}", self.id).into()
}
}

View File

@@ -22,8 +22,8 @@ use mas_data_model::{
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports,
UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode,
UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmail, UserEmailAuthenticationCode,
UserRecoverySession, UserRegistration,
UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmailAuthentication,
UserEmailAuthenticationCode, UserRecoverySession, UserRegistration,
};
use mas_i18n::DataLocale;
use mas_iana::jose::JsonWebSignatureAlg;
@@ -942,12 +942,12 @@ impl TemplateContext for EmailVerificationContext {
/// Fields of the email verification form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EmailVerificationFormField {
pub enum RegisterStepsVerifyEmailFormField {
/// The code field
Code,
}
impl FormField for EmailVerificationFormField {
impl FormField for RegisterStepsVerifyEmailFormField {
fn keep(&self) -> bool {
match self {
Self::Code => true,
@@ -955,45 +955,47 @@ impl FormField for EmailVerificationFormField {
}
}
/// Context used by the `pages/account/verify.html` templates
/// Context used by the `pages/register/steps/verify_email.html` templates
#[derive(Serialize)]
pub struct EmailVerificationPageContext {
form: FormState<EmailVerificationFormField>,
email: UserEmail,
pub struct RegisterStepsVerifyEmailContext {
form: FormState<RegisterStepsVerifyEmailFormField>,
authentication: UserEmailAuthentication,
}
impl EmailVerificationPageContext {
impl RegisterStepsVerifyEmailContext {
/// Constructs a context for the email verification page
#[must_use]
pub fn new(email: UserEmail) -> Self {
pub fn new(authentication: UserEmailAuthentication) -> Self {
Self {
form: FormState::default(),
email,
authentication,
}
}
/// Set the form state
#[must_use]
pub fn with_form_state(self, form: FormState<EmailVerificationFormField>) -> Self {
pub fn with_form_state(self, form: FormState<RegisterStepsVerifyEmailFormField>) -> Self {
Self { form, ..self }
}
}
impl TemplateContext for EmailVerificationPageContext {
impl TemplateContext for RegisterStepsVerifyEmailContext {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
let email = UserEmail {
let authentication = UserEmailAuthentication {
id: Ulid::from_datetime_with_source(now.into(), rng),
user_id: Ulid::from_datetime_with_source(now.into(), rng),
user_session_id: None,
user_registration_id: None,
email: "foobar@example.com".to_owned(),
created_at: now,
completed_at: None,
};
vec![Self {
form: FormState::default(),
email,
authentication,
}]
}
}

View File

@@ -36,14 +36,15 @@ pub use self::{
context::{
ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext,
DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, EmailVerificationContext,
EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext,
PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext,
UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext,
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext,
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding,
SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext,
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf,
WithLanguage, WithOptionalSession, WithSession,
},
forms::{FieldError, FormError, FormField, FormState, ToFormState},
};
@@ -331,6 +332,9 @@ register_templates! {
/// Render the password registration page
pub fn render_password_register(WithLanguage<WithCsrf<WithCaptcha<PasswordRegisterContext>>>) { "pages/register/password.html" }
/// Render the email verification page
pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
/// Render the client consent page
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
@@ -343,9 +347,6 @@ register_templates! {
/// Render the home page
pub fn render_index(WithLanguage<WithCsrf<WithOptionalSession<IndexContext>>>) { "pages/index.html" }
/// Render the email verification page
pub fn render_account_verify_email(WithLanguage<WithCsrf<WithSession<EmailVerificationPageContext>>>) { "pages/account/emails/verify.html" }
/// Render the account recovery start page
pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
@@ -425,11 +426,12 @@ impl Templates {
check::render_swagger_callback(self, now, rng)?;
check::render_login(self, now, rng)?;
check::render_register(self, now, rng)?;
check::render_password_register(self, now, rng)?;
check::render_register_steps_verify_email(self, now, rng)?;
check::render_consent(self, now, rng)?;
check::render_policy_violation(self, now, rng)?;
check::render_sso_login(self, now, rng)?;
check::render_index(self, now, rng)?;
check::render_account_verify_email(self, now, rng)?;
check::render_recovery_start(self, now, rng)?;
check::render_recovery_progress(self, now, rng)?;
check::render_recovery_finish(self, now, rng)?;

View File

@@ -15,7 +15,7 @@ Please see LICENSE in the repository root for full details.
</div>
<div class="header">
<h1 class="title">{{ _("mas.verify_email.headline") }}</h1>
<p class="text">{{ _("mas.verify_email.description", email=email.email) }}</p>
<p class="text">{{ _("mas.verify_email.description", email=authentication.email) }}</p>
</div>
</header>
@@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details.
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", class="mb-4 self-center") %}
{% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", form_state=form, class="mb-4 self-center") %}
<div class="cpd-mfa-container">
<input {{ field.attributes(f) }}
id="mfa-code-input"

View File

@@ -10,7 +10,7 @@
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:25:28-48, pages/account/emails/verify.html:52:26-46, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/sso.html:37:28-48"
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/verify_email.html:52:26-46, pages/sso.html:37:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -670,15 +670,15 @@
"verify_email": {
"6_digit_code": "6-digit code",
"@6_digit_code": {
"context": "pages/account/emails/verify.html:33:33-67"
"context": "pages/register/steps/verify_email.html:33:33-67"
},
"description": "Enter the 6-digit code sent to: <em>%(email)s</em>",
"@description": {
"context": "pages/account/emails/verify.html:18:25-77"
"context": "pages/register/steps/verify_email.html:18:25-86"
},
"headline": "Verify your email",
"@headline": {
"context": "pages/account/emails/verify.html:17:27-57"
"context": "pages/register/steps/verify_email.html:17:27-57"
}
}
}