diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index d7b39ff24..39e45a8e5 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -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(), diff --git a/crates/handlers/src/views/account/emails/mod.rs b/crates/handlers/src/views/account/emails/mod.rs deleted file mode 100644 index 86aa5de33..000000000 --- a/crates/handlers/src/views/account/emails/mod.rs +++ /dev/null @@ -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; diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs deleted file mode 100644 index abb505330..000000000 --- a/crates/handlers/src/views/account/emails/verify.rs +++ /dev/null @@ -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, - State(url_builder): State, - activity_tracker: BoundActivityTracker, - mut repo: BoxRepository, - Query(_query): Query, - Path(id): Path, - cookie_jar: CookieJar, -) -> Result { - 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, - activity_tracker: BoundActivityTracker, - Query(query): Query, - Path(id): Path, - Form(form): Form>, -) -> Result { - 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()) -} diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 1ce31f3cf..336ec9f2f 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -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; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index a532f4355..dbd8a25ed 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -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( diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 72ff482e0..28251b51a 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -352,8 +352,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( diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/register/steps/mod.rs similarity index 50% rename from crates/handlers/src/views/account/mod.rs rename to crates/handlers/src/views/register/steps/mod.rs index f85da5266..8d658ed85 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -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; diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs new file mode 100644 index 000000000..b952bb1ab --- /dev/null +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -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.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, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, +) -> Result { + 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 = 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, + mut repo: BoxRepository, + cookie_jar: CookieJar, + user_agent: Option>, + State(url_builder): State, + activity_tracker: BoundActivityTracker, + Path(id): Path, + Form(form): Form>, +) -> Result { + 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 = 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 Some(username) = registration.username else { + todo!() + }; + let user = repo.user().add(&mut rng, &clock, 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 = 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()); +} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index d47368167..b46de76f5 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -444,47 +444,27 @@ impl From> 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, } -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) -> 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() } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 1f0fa958f..fea88e58d 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -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, - email: UserEmail, +pub struct RegisterStepsVerifyEmailContext { + form: FormState, + 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) -> Self { + pub fn with_form_state(self, form: FormState) -> Self { Self { form, ..self } } } -impl TemplateContext for EmailVerificationPageContext { +impl TemplateContext for RegisterStepsVerifyEmailContext { fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec 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, }] } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 166fd457a..56ab53741 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -35,16 +35,15 @@ mod macros; pub use self::{ context::{ ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, - DeviceLinkContext, DeviceLinkFormField, EmailAddContext, 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, + DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, EmailVerificationContext, + 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}, }; @@ -332,6 +331,9 @@ register_templates! { /// Render the password registration page pub fn render_password_register(WithLanguage>>) { "pages/register/password.html" } + /// Render the email verification page + pub fn render_register_steps_verify_email(WithLanguage>) { "pages/register/steps/verify_email.html" } + /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } @@ -344,9 +346,6 @@ register_templates! { /// Render the home page pub fn render_index(WithLanguage>>) { "pages/index.html" } - /// Render the email verification page - pub fn render_account_verify_email(WithLanguage>>) { "pages/account/emails/verify.html" } - /// Render the account recovery start page pub fn render_recovery_start(WithLanguage>) { "pages/recovery/start.html" } @@ -426,12 +425,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_add_email(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)?; diff --git a/templates/pages/account/emails/verify.html b/templates/pages/register/steps/verify_email.html similarity index 94% rename from templates/pages/account/emails/verify.html rename to templates/pages/register/steps/verify_email.html index e3330be4e..491721423 100644 --- a/templates/pages/account/emails/verify.html +++ b/templates/pages/register/steps/verify_email.html @@ -15,7 +15,7 @@

{{ _("mas.verify_email.headline") }}

-

{{ _("mas.verify_email.description", email=email.email) }}

+

{{ _("mas.verify_email.description", email=authentication.email) }}

@@ -30,7 +30,7 @@

{{ _("mas.verify_email.headline") }}

- {% 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") %}
%(email)s", "@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" } } }