From 5aa5c9cb032baefe005e3150f6445e27e9cf5980 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 10 Jan 2025 16:11:25 +0100 Subject: [PATCH] Job to send the new email authentication codes --- crates/email/src/mailer.rs | 1 - .../handlers/src/views/password_register.rs | 10 +- crates/storage/src/queue/tasks.rs | 58 +++++++----- crates/tasks/src/email.rs | 91 ++++++++++++++++++- crates/tasks/src/lib.rs | 3 +- crates/templates/src/context.rs | 45 ++++++--- templates/emails/verification.html | 6 +- templates/emails/verification.subject | 4 +- templates/emails/verification.txt | 6 +- translations/en.json | 8 +- 10 files changed, 171 insertions(+), 61 deletions(-) diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index 79d84e66a..57cf3d385 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -110,7 +110,6 @@ impl Mailer { fields( email.to = %to, email.language = %context.language(), - user.id = %context.user().id, ), err, )] diff --git a/crates/handlers/src/views/password_register.rs b/crates/handlers/src/views/password_register.rs index a8cdc4f51..c53273e51 100644 --- a/crates/handlers/src/views/password_register.rs +++ b/crates/handlers/src/views/password_register.rs @@ -24,7 +24,7 @@ use mas_matrix::BoxHomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _, VerifyEmailJob}, + queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, BoxClock, BoxRepository, BoxRng, RepositoryAccess, }; @@ -327,14 +327,6 @@ pub(crate) async fn post( .authenticate_with_password(&mut rng, &clock, &session, &user_password) .await?; - repo.queue_job() - .schedule_job( - &mut rng, - &clock, - VerifyEmailJob::new(&user_email).with_language(locale.to_string()), - ) - .await?; - repo.queue_job() .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) .await?; diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index fe8b1f9e5..0beca30c6 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use mas_data_model::{Device, User, UserEmail, UserRecoverySession}; +use mas_data_model::{Device, User, UserEmailAuthentication, UserRecoverySession}; use serde::{Deserialize, Serialize}; use ulid::Ulid; @@ -17,28 +17,6 @@ pub struct VerifyEmailJob { } impl VerifyEmailJob { - /// Create a new job to verify an email address. - #[must_use] - pub fn new(user_email: &UserEmail) -> Self { - Self { - user_email_id: user_email.id, - language: None, - } - } - - /// Set the language to use for the email. - #[must_use] - pub fn with_language(mut self, language: String) -> Self { - self.language = Some(language); - self - } - - /// The language to use for the email. - #[must_use] - pub fn language(&self) -> Option<&str> { - self.language.as_deref() - } - /// The ID of the email address to verify. #[must_use] pub fn user_email_id(&self) -> Ulid { @@ -50,6 +28,40 @@ impl InsertableJob for VerifyEmailJob { const QUEUE_NAME: &'static str = "verify-email"; } +/// A job to send an email authentication code to a user. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SendEmailAuthenticationCodeJob { + user_email_authentication_id: Ulid, + language: String, +} + +impl SendEmailAuthenticationCodeJob { + /// Create a new job to send an email authentication code to a user. + #[must_use] + pub fn new(user_email_authentication: &UserEmailAuthentication, language: String) -> Self { + Self { + user_email_authentication_id: user_email_authentication.id, + language, + } + } + + /// The language to use for the email. + #[must_use] + pub fn language(&self) -> &str { + &self.language + } + + /// The ID of the email authentication to send the code for. + #[must_use] + pub fn user_email_authentication_id(&self) -> Ulid { + self.user_email_authentication_id + } +} + +impl InsertableJob for SendEmailAuthenticationCodeJob { + const QUEUE_NAME: &'static str = "send-email-authentication-code"; +} + /// A job to provision the user on the homeserver. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ProvisionUserJob { diff --git a/crates/tasks/src/email.rs b/crates/tasks/src/email.rs index 129c41474..fe2c2a51a 100644 --- a/crates/tasks/src/email.rs +++ b/crates/tasks/src/email.rs @@ -5,7 +5,12 @@ // Please see LICENSE in the repository root for full details. use async_trait::async_trait; -use mas_storage::queue::VerifyEmailJob; +use chrono::Duration; +use mas_email::{Address, EmailVerificationContext, Mailbox}; +use mas_storage::queue::{SendEmailAuthenticationCodeJob, VerifyEmailJob}; +use mas_templates::TemplateContext as _; +use rand::{distributions::Uniform, Rng}; +use tracing::info; use crate::{ new_queue::{JobContext, JobError, RunnableJob}, @@ -27,3 +32,87 @@ impl RunnableJob for VerifyEmailJob { Err(JobError::fail(anyhow::anyhow!("Not implemented"))) } } + +#[async_trait] +impl RunnableJob for SendEmailAuthenticationCodeJob { + #[tracing::instrument( + name = "job.send_email_authentication_code", + fields(user_email_authentication.id = %self.user_email_authentication_id()), + skip_all, + err, + )] + async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { + let clock = state.clock(); + let mailer = state.mailer(); + let mut rng = state.rng(); + let mut repo = state.repository().await.map_err(JobError::retry)?; + + let user_email_authentication = repo + .user_email() + .lookup_authentication(self.user_email_authentication_id()) + .await + .map_err(JobError::retry)? + .ok_or(JobError::fail(anyhow::anyhow!( + "User email authentication not found" + )))?; + + if user_email_authentication.completed_at.is_some() { + return Err(JobError::fail(anyhow::anyhow!( + "User email authentication already completed" + ))); + } + + // Load the browser session, if any + let browser_session = + if let Some(browser_session) = user_email_authentication.user_session_id { + Some( + repo.browser_session() + .lookup(browser_session) + .await + .map_err(JobError::retry)? + .ok_or(JobError::fail(anyhow::anyhow!( + "Failed to load browser session" + )))?, + ) + } else { + None + }; + + // Generate a new 6-digit authentication code + let range = Uniform::::from(0..1_000_000); + let code = rng.sample(range); + let code = format!("{code:06}"); + let code = repo + .user_email() + .add_authentication_code( + &mut rng, + &clock, + Duration::minutes(5), // TODO: make this configurable + &user_email_authentication, + code, + ) + .await + .map_err(JobError::retry)?; + + let address: Address = user_email_authentication + .email + .parse() + .map_err(JobError::fail)?; + let username = browser_session.as_ref().map(|s| s.user.username.clone()); + let mailbox = Mailbox::new(username, address); + + info!("Sending email verification code to {}", mailbox); + + let language = self.language().parse().map_err(JobError::fail)?; + + let context = EmailVerificationContext::new(code, browser_session).with_language(language); + mailer + .send_verification_email(mailbox, &context) + .await + .map_err(JobError::fail)?; + + repo.save().await.map_err(JobError::fail)?; + + Ok(()) + } +} diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index 38be3a91c..4ee635266 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -125,6 +125,7 @@ pub async fn init( .register_handler::() .register_handler::() .register_handler::() + .register_handler::() .register_handler::() .register_handler::() .add_schedule( diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index cf1b481a1..b8e407f92 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -22,7 +22,8 @@ use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmail, UserRecoverySession, + UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmail, UserEmailAuthenticationCode, + UserRecoverySession, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -877,27 +878,33 @@ impl TemplateContext for EmailRecoveryContext { /// Context used by the `emails/verification.{txt,html,subject}` templates #[derive(Serialize)] pub struct EmailVerificationContext { - user: User, - verification: serde_json::Value, + browser_session: Option, + authentication_code: UserEmailAuthenticationCode, } impl EmailVerificationContext { /// Constructs a context for the verification email #[must_use] - pub fn new(user: User, verification: serde_json::Value) -> Self { - Self { user, verification } + pub fn new( + authentication_code: UserEmailAuthenticationCode, + browser_session: Option, + ) -> Self { + Self { + browser_session, + authentication_code, + } } /// Get the user to which this email is being sent #[must_use] - pub fn user(&self) -> &User { - &self.user + pub fn user(&self) -> Option<&User> { + self.browser_session.as_ref().map(|s| &s.user) } /// Get the verification code being sent #[must_use] - pub fn verification(&self) -> &serde_json::Value { - &self.verification + pub fn code(&self) -> &str { + &self.authentication_code.code } } @@ -906,11 +913,21 @@ impl TemplateContext for EmailVerificationContext { where Self: Sized, { - User::samples(now, rng) + BrowserSession::samples(now, rng) .into_iter() - .map(|user| { - let verification = serde_json::json!({"code": "123456"}); - Self { user, verification } + .map(|browser_session| { + let authentication_code = UserEmailAuthenticationCode { + id: Ulid::from_datetime_with_source(now.into(), rng), + user_email_authentication_id: Ulid::from_datetime_with_source(now.into(), rng), + code: "123456".to_owned(), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + }; + + Self { + browser_session: Some(browser_session), + authentication_code, + } }) .collect() } diff --git a/templates/emails/verification.html b/templates/emails/verification.html index 027093e76..f958e3382 100644 --- a/templates/emails/verification.html +++ b/templates/emails/verification.html @@ -1,5 +1,5 @@ {# -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2021-2024 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only @@ -8,6 +8,6 @@ Please see LICENSE in the repository root for full details. {%- set _ = translator(lang) -%} -{{ _("mas.emails.greeting", username=user.username) }}
+{{ _("mas.emails.greeting", username=browser_session.user.username | default("user")) }}

-{{ _("mas.emails.verify.body_html", code=verification.code) }}
+{{ _("mas.emails.verify.body_html", code=authentication_code.code) }}
diff --git a/templates/emails/verification.subject b/templates/emails/verification.subject index 067599ae1..d4ed1b387 100644 --- a/templates/emails/verification.subject +++ b/templates/emails/verification.subject @@ -1,5 +1,5 @@ {# -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2021-2024 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only @@ -8,4 +8,4 @@ Please see LICENSE in the repository root for full details. {%- set _ = translator(lang) -%} -{{ _("mas.emails.verify.subject", code=verification.code) }} +{{ _("mas.emails.verify.subject", code=authentication_code.code) }} diff --git a/templates/emails/verification.txt b/templates/emails/verification.txt index 42077f2f7..f52a9ec5d 100644 --- a/templates/emails/verification.txt +++ b/templates/emails/verification.txt @@ -1,5 +1,5 @@ {# -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2021-2024 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only @@ -8,6 +8,6 @@ Please see LICENSE in the repository root for full details. {%- set _ = translator(lang) -%} -{{ _("mas.emails.greeting", username=user.username) }} +{{ _("mas.emails.greeting", username=browser_session.user.username | default("user")) }} -{{ _("mas.emails.verify.body_text", code=verification.code) }} +{{ _("mas.emails.verify.body_text", code=authentication_code.code) }} diff --git a/translations/en.json b/translations/en.json index 510a31c28..b4e957f80 100644 --- a/translations/en.json +++ b/translations/en.json @@ -230,7 +230,7 @@ "emails": { "greeting": "Hello %(username)s,", "@greeting": { - "context": "emails/verification.html:11:3-51, emails/verification.txt:11:3-51", + "context": "emails/verification.html:11:3-85, emails/verification.txt:11:3-85", "description": "Greeting at the top of emails sent to the user" }, "recovery": { @@ -262,17 +262,17 @@ "verify": { "body_html": "Your verification code to confirm this email address is: %(code)s", "@body_html": { - "context": "emails/verification.html:13:3-59", + "context": "emails/verification.html:13:3-66", "description": "The body of the email sent to verify an email address (HTML)" }, "body_text": "Your verification code to confirm this email address is: %(code)s", "@body_text": { - "context": "emails/verification.txt:13:3-59", + "context": "emails/verification.txt:13:3-66", "description": "The body of the email sent to verify an email address (text)" }, "subject": "Your email verification code is: %(code)s", "@subject": { - "context": "emails/verification.subject:11:3-57", + "context": "emails/verification.subject:11:3-64", "description": "The subject line of the email sent to verify an email address" } }