mas_handlers/views/recovery/
start.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use std::str::FromStr;
8
9use axum::{
10    Form,
11    extract::State,
12    response::{Html, IntoResponse, Response},
13};
14use axum_extra::typed_header::TypedHeader;
15use lettre::Address;
16use mas_axum_utils::{
17    FancyError, SessionInfoExt,
18    cookies::CookieJar,
19    csrf::{CsrfExt, ProtectedForm},
20};
21use mas_data_model::{SiteConfig, UserAgent};
22use mas_router::UrlBuilder;
23use mas_storage::{
24    BoxClock, BoxRepository, BoxRng,
25    queue::{QueueJobRepositoryExt as _, SendAccountRecoveryEmailsJob},
26};
27use mas_templates::{
28    EmptyContext, FieldError, FormError, FormState, RecoveryStartContext, RecoveryStartFormField,
29    TemplateContext, Templates,
30};
31use serde::{Deserialize, Serialize};
32
33use crate::{BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint};
34
35#[derive(Deserialize, Serialize)]
36pub(crate) struct StartRecoveryForm {
37    email: String,
38}
39
40pub(crate) async fn get(
41    mut rng: BoxRng,
42    clock: BoxClock,
43    mut repo: BoxRepository,
44    State(site_config): State<SiteConfig>,
45    State(templates): State<Templates>,
46    State(url_builder): State<UrlBuilder>,
47    PreferredLanguage(locale): PreferredLanguage,
48    cookie_jar: CookieJar,
49) -> Result<Response, FancyError> {
50    if !site_config.account_recovery_allowed {
51        let context = EmptyContext.with_language(locale);
52        let rendered = templates.render_recovery_disabled(&context)?;
53        return Ok((cookie_jar, Html(rendered)).into_response());
54    }
55
56    let (session_info, cookie_jar) = cookie_jar.session_info();
57    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
58
59    let maybe_session = session_info.load_active_session(&mut repo).await?;
60    if maybe_session.is_some() {
61        // TODO: redirect to continue whatever action was going on
62        return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response());
63    }
64
65    let context = RecoveryStartContext::new()
66        .with_csrf(csrf_token.form_value())
67        .with_language(locale);
68
69    repo.save().await?;
70
71    let rendered = templates.render_recovery_start(&context)?;
72
73    Ok((cookie_jar, Html(rendered)).into_response())
74}
75
76pub(crate) async fn post(
77    mut rng: BoxRng,
78    clock: BoxClock,
79    mut repo: BoxRepository,
80    user_agent: TypedHeader<headers::UserAgent>,
81    activity_tracker: BoundActivityTracker,
82    State(site_config): State<SiteConfig>,
83    State(templates): State<Templates>,
84    State(url_builder): State<UrlBuilder>,
85    (State(limiter), requester): (State<Limiter>, RequesterFingerprint),
86    PreferredLanguage(locale): PreferredLanguage,
87    cookie_jar: CookieJar,
88    Form(form): Form<ProtectedForm<StartRecoveryForm>>,
89) -> Result<impl IntoResponse, FancyError> {
90    if !site_config.account_recovery_allowed {
91        let context = EmptyContext.with_language(locale);
92        let rendered = templates.render_recovery_disabled(&context)?;
93        return Ok((cookie_jar, Html(rendered)).into_response());
94    }
95
96    let (session_info, cookie_jar) = cookie_jar.session_info();
97    let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
98
99    let maybe_session = session_info.load_active_session(&mut repo).await?;
100    if maybe_session.is_some() {
101        // TODO: redirect to continue whatever action was going on
102        return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response());
103    }
104
105    let user_agent = UserAgent::parse(user_agent.as_str().to_owned());
106    let ip_address = activity_tracker.ip();
107
108    let form = cookie_jar.verify_form(&clock, form)?;
109    let mut form_state = FormState::from_form(&form);
110
111    if Address::from_str(&form.email).is_err() {
112        form_state =
113            form_state.with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid);
114    }
115
116    if form_state.is_valid() {
117        // Check the rate limit if we are about to process the form
118        if let Err(e) = limiter.check_account_recovery(requester, &form.email) {
119            tracing::warn!(error = &e as &dyn std::error::Error);
120            form_state.add_error_on_form(FormError::RateLimitExceeded);
121        }
122    }
123
124    if !form_state.is_valid() {
125        repo.save().await?;
126        let context = RecoveryStartContext::new()
127            .with_form_state(form_state)
128            .with_csrf(csrf_token.form_value())
129            .with_language(locale);
130
131        let rendered = templates.render_recovery_start(&context)?;
132
133        return Ok((cookie_jar, Html(rendered)).into_response());
134    }
135
136    let session = repo
137        .user_recovery()
138        .add_session(
139            &mut rng,
140            &clock,
141            form.email,
142            user_agent,
143            ip_address,
144            locale.to_string(),
145        )
146        .await?;
147
148    repo.queue_job()
149        .schedule_job(
150            &mut rng,
151            &clock,
152            SendAccountRecoveryEmailsJob::new(&session),
153        )
154        .await?;
155
156    repo.save().await?;
157
158    Ok((
159        cookie_jar,
160        url_builder.redirect(&mas_router::AccountRecoveryProgress::new(session.id)),
161    )
162        .into_response())
163}