Job to send the new email authentication codes

This commit is contained in:
Quentin Gliech
2025-01-10 16:11:25 +01:00
parent 9db14f6743
commit 5aa5c9cb03
10 changed files with 171 additions and 61 deletions

View File

@@ -110,7 +110,6 @@ impl Mailer {
fields(
email.to = %to,
email.language = %context.language(),
user.id = %context.user().id,
),
err,
)]

View File

@@ -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?;

View File

@@ -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 {

View File

@@ -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::<u32>::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(())
}
}

View File

@@ -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::<mas_storage::queue::ProvisionUserJob>()
.register_handler::<mas_storage::queue::ReactivateUserJob>()
.register_handler::<mas_storage::queue::SendAccountRecoveryEmailsJob>()
.register_handler::<mas_storage::queue::SendEmailAuthenticationCodeJob>()
.register_handler::<mas_storage::queue::SyncDevicesJob>()
.register_handler::<mas_storage::queue::VerifyEmailJob>()
.add_schedule(

View File

@@ -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<BrowserSession>,
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<BrowserSession>,
) -> 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()
}

View File

@@ -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) }}<br />
{{ _("mas.emails.greeting", username=browser_session.user.username | default("user")) }}<br />
<br />
{{ _("mas.emails.verify.body_html", code=verification.code) }}<br />
{{ _("mas.emails.verify.body_html", code=authentication_code.code) }}<br />

View File

@@ -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) }}

View File

@@ -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) }}

View File

@@ -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: <strong>%(code)s</strong>",
"@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"
}
}