|
1 |
| -// Copyright 2024 New Vector Ltd. |
| 1 | +// Copyright 2024, 2025 New Vector Ltd. |
2 | 2 | // Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
|
3 | 3 | //
|
4 | 4 | // SPDX-License-Identifier: AGPL-3.0-only
|
|
7 | 7 | use anyhow::Context as _;
|
8 | 8 | use async_graphql::{Context, Description, Enum, InputObject, Object, ID};
|
9 | 9 | use mas_storage::{
|
10 |
| - queue::{DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _}, |
| 10 | + queue::{ |
| 11 | + DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _, |
| 12 | + SendAccountRecoveryEmailsJob, |
| 13 | + }, |
11 | 14 | user::UserRepository,
|
12 | 15 | };
|
13 | 16 | use tracing::{info, warn};
|
| 17 | +use ulid::Ulid; |
| 18 | +use url::Url; |
14 | 19 | use zeroize::Zeroizing;
|
15 | 20 |
|
16 | 21 | use crate::graphql::{
|
@@ -323,6 +328,61 @@ impl SetPasswordPayload {
|
323 | 328 | }
|
324 | 329 | }
|
325 | 330 |
|
| 331 | +/// The input for the `resendRecoveryEmail` mutation. |
| 332 | +#[derive(InputObject)] |
| 333 | +pub struct ResendRecoveryEmailInput { |
| 334 | + /// The recovery ticket to use. |
| 335 | + ticket: String, |
| 336 | +} |
| 337 | + |
| 338 | +/// The return type for the `resendRecoveryEmail` mutation. |
| 339 | +#[derive(Description)] |
| 340 | +pub enum ResendRecoveryEmailPayload { |
| 341 | + NoSuchRecoveryTicket, |
| 342 | + RateLimited, |
| 343 | + Sent { recovery_session_id: Ulid }, |
| 344 | +} |
| 345 | + |
| 346 | +/// The status of the `resendRecoveryEmail` mutation. |
| 347 | +#[derive(Enum, Copy, Clone, Eq, PartialEq)] |
| 348 | +pub enum ResendRecoveryEmailStatus { |
| 349 | + /// The recovery ticket was not found. |
| 350 | + NoSuchRecoveryTicket, |
| 351 | + |
| 352 | + /// The rate limit was exceeded. |
| 353 | + RateLimited, |
| 354 | + |
| 355 | + /// The recovery email was sent. |
| 356 | + Sent, |
| 357 | +} |
| 358 | + |
| 359 | +#[Object(use_type_description)] |
| 360 | +impl ResendRecoveryEmailPayload { |
| 361 | + /// Status of the operation |
| 362 | + async fn status(&self) -> ResendRecoveryEmailStatus { |
| 363 | + match self { |
| 364 | + Self::NoSuchRecoveryTicket => ResendRecoveryEmailStatus::NoSuchRecoveryTicket, |
| 365 | + Self::RateLimited => ResendRecoveryEmailStatus::RateLimited, |
| 366 | + Self::Sent { .. } => ResendRecoveryEmailStatus::Sent, |
| 367 | + } |
| 368 | + } |
| 369 | + |
| 370 | + /// URL to continue the recovery process |
| 371 | + async fn progress_url(&self, context: &Context<'_>) -> Option<Url> { |
| 372 | + let state = context.state(); |
| 373 | + let url_builder = state.url_builder(); |
| 374 | + match self { |
| 375 | + Self::NoSuchRecoveryTicket | Self::RateLimited => None, |
| 376 | + Self::Sent { |
| 377 | + recovery_session_id, |
| 378 | + } => { |
| 379 | + let route = mas_router::AccountRecoveryProgress::new(*recovery_session_id); |
| 380 | + Some(url_builder.absolute_url_for(&route)) |
| 381 | + } |
| 382 | + } |
| 383 | + } |
| 384 | +} |
| 385 | + |
326 | 386 | fn valid_username_character(c: char) -> bool {
|
327 | 387 | c.is_ascii_lowercase()
|
328 | 388 | || c.is_ascii_digit()
|
@@ -760,4 +820,54 @@ impl UserMutations {
|
760 | 820 | status: SetPasswordStatus::Allowed,
|
761 | 821 | })
|
762 | 822 | }
|
| 823 | + |
| 824 | + /// Resend a user recovery email |
| 825 | + /// |
| 826 | + /// This is used when a user opens a recovery link that has expired. In this |
| 827 | + /// case, we display a link for them to get a new recovery email, which |
| 828 | + /// calls this mutation. |
| 829 | + pub async fn resend_recovery_email( |
| 830 | + &self, |
| 831 | + ctx: &Context<'_>, |
| 832 | + input: ResendRecoveryEmailInput, |
| 833 | + ) -> Result<ResendRecoveryEmailPayload, async_graphql::Error> { |
| 834 | + let state = ctx.state(); |
| 835 | + let requester_fingerprint = ctx.requester_fingerprint(); |
| 836 | + let clock = state.clock(); |
| 837 | + let mut rng = state.rng(); |
| 838 | + let limiter = state.limiter(); |
| 839 | + let mut repo = state.repository().await?; |
| 840 | + |
| 841 | + let Some(recovery_ticket) = repo.user_recovery().find_ticket(&input.ticket).await? else { |
| 842 | + return Ok(ResendRecoveryEmailPayload::NoSuchRecoveryTicket); |
| 843 | + }; |
| 844 | + |
| 845 | + let recovery_session = repo |
| 846 | + .user_recovery() |
| 847 | + .lookup_session(recovery_ticket.user_recovery_session_id) |
| 848 | + .await? |
| 849 | + .context("Could not load recovery session")?; |
| 850 | + |
| 851 | + if let Err(e) = |
| 852 | + limiter.check_account_recovery(requester_fingerprint, &recovery_session.email) |
| 853 | + { |
| 854 | + tracing::warn!(error = &e as &dyn std::error::Error); |
| 855 | + return Ok(ResendRecoveryEmailPayload::RateLimited); |
| 856 | + } |
| 857 | + |
| 858 | + // Schedule a new batch of emails |
| 859 | + repo.queue_job() |
| 860 | + .schedule_job( |
| 861 | + &mut rng, |
| 862 | + &clock, |
| 863 | + SendAccountRecoveryEmailsJob::new(&recovery_session), |
| 864 | + ) |
| 865 | + .await?; |
| 866 | + |
| 867 | + repo.save().await?; |
| 868 | + |
| 869 | + Ok(ResendRecoveryEmailPayload::Sent { |
| 870 | + recovery_session_id: recovery_session.id, |
| 871 | + }) |
| 872 | + } |
763 | 873 | }
|
0 commit comments