diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 5efb6caa..db1d1634 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -381,6 +381,10 @@ where get(self::views::account::emails::add::get) .post(self::views::account::emails::add::post), ) + .route( + mas_router::AccountRecoveryStart::route(), + get(self::views::recovery::start::get).post(self::views::recovery::start::post), + ) .route( mas_router::OAuth2AuthorizationEndpoint::route(), get(self::oauth2::authorization::get), diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs index b349e19c..86b45085 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/account/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2021, 2022 The Matrix.org Foundation C.I.C. +// Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 2f28ba98..46ec3404 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -18,5 +18,6 @@ pub mod index; pub mod login; pub mod logout; pub mod reauth; +pub mod recovery; pub mod register; pub mod shared; diff --git a/crates/handlers/src/views/recovery/mod.rs b/crates/handlers/src/views/recovery/mod.rs new file mode 100644 index 00000000..1faef2c0 --- /dev/null +++ b/crates/handlers/src/views/recovery/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub mod start; diff --git a/crates/handlers/src/views/recovery/start.rs b/crates/handlers/src/views/recovery/start.rs new file mode 100644 index 00000000..1d922905 --- /dev/null +++ b/crates/handlers/src/views/recovery/start.rs @@ -0,0 +1,137 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::str::FromStr; + +use axum::{ + extract::State, + response::{Html, IntoResponse, Response}, + Form, TypedHeader, +}; +use lettre::Address; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, ProtectedForm}, + FancyError, SessionInfoExt, +}; +use mas_data_model::UserAgent; +use mas_router::UrlBuilder; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{ + FieldError, FormState, RecoveryStartContext, RecoveryStartFormField, TemplateContext, Templates, +}; +use serde::{Deserialize, Serialize}; + +use crate::{BoundActivityTracker, PreferredLanguage}; + +#[derive(Deserialize, Serialize)] +pub(crate) struct StartRecoveryForm { + email: String, +} + +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + mut repo: BoxRepository, + State(templates): State, + State(url_builder): State, + PreferredLanguage(locale): PreferredLanguage, + cookie_jar: CookieJar, +) -> Result { + let (session_info, cookie_jar) = cookie_jar.session_info(); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let maybe_session = session_info.load_session(&mut repo).await?; + if maybe_session.is_some() { + // TODO: redirect to continue whatever action was going on + return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); + } + + let context = RecoveryStartContext::new() + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + repo.save().await?; + + let rendered = templates.render_recovery_start(&context)?; + + Ok((cookie_jar, Html(rendered)).into_response()) +} + +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + mut repo: BoxRepository, + user_agent: TypedHeader, + activity_tracker: BoundActivityTracker, + State(templates): State, + State(url_builder): State, + PreferredLanguage(locale): PreferredLanguage, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let (session_info, cookie_jar) = cookie_jar.session_info(); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let maybe_session = session_info.load_session(&mut repo).await?; + if maybe_session.is_some() { + // TODO: redirect to continue whatever action was going on + return Ok((cookie_jar, url_builder.redirect(&mas_router::Index)).into_response()); + } + + let user_agent = UserAgent::parse(user_agent.as_str().to_owned()); + let ip_address = activity_tracker.ip(); + + let form = cookie_jar.verify_form(&clock, form)?; + let mut form_state = FormState::from_form(&form); + + if Address::from_str(&form.email).is_err() { + form_state = + form_state.with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid); + } + + if !form_state.is_valid() { + repo.save().await?; + let context = RecoveryStartContext::new() + .with_form_state(form_state) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let rendered = templates.render_recovery_start(&context)?; + + return Ok((cookie_jar, Html(rendered)).into_response()); + } + + let session = repo + .user_recovery() + .add_session( + &mut rng, + &clock, + form.email, + user_agent, + ip_address, + locale.to_string(), + ) + .await?; + + // TODO: spawn a job which will send all the emails + + repo.save().await?; + + Ok(( + cookie_jar, + url_builder.redirect(&mas_router::AccountRecoveryProgress::new(session.id)), + ) + .into_response()) +} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index cc619c9f..aa114898 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -776,6 +776,38 @@ impl SimpleRoute for OAuth2DeviceAuthorizationEndpoint { const PATH: &'static str = "/oauth2/device"; } +/// `GET|POST /recover` +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct AccountRecoveryStart; + +impl SimpleRoute for AccountRecoveryStart { + const PATH: &'static str = "/recover"; +} + +/// `GET|POST /recover/progress/:session_id` +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct AccountRecoveryProgress { + session_id: Ulid, +} + +impl AccountRecoveryProgress { + #[must_use] + pub fn new(session_id: Ulid) -> Self { + Self { session_id } + } +} + +impl Route for AccountRecoveryProgress { + type Query = (); + fn route() -> &'static str { + "/recover/progress/:session_id" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/recover/progress/{}", self.session_id).into() + } +} + /// `GET /assets` pub struct StaticAsset { path: String, diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index d37f1cba..0aa33c10 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -913,6 +913,61 @@ impl TemplateContext for EmailAddContext { } } +/// Fields of the account recovery start form +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecoveryStartFormField { + /// The email + Email, +} + +impl FormField for RecoveryStartFormField { + fn keep(&self) -> bool { + match self { + Self::Email => true, + } + } +} + +/// Context used by the `pages/recovery/start.html` template +#[derive(Serialize, Default)] +pub struct RecoveryStartContext { + form: FormState, +} + +impl RecoveryStartContext { + /// Constructs a context for the recovery start page + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the form state + #[must_use] + pub fn with_form_state(self, form: FormState) -> Self { + Self { form } + } +} + +impl TemplateContext for RecoveryStartContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + vec![ + Self::new(), + Self::new().with_form_state( + FormState::default() + .with_error_on_field(RecoveryStartFormField::Email, FieldError::Required), + ), + Self::new().with_form_state( + FormState::default() + .with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid), + ), + ] + } +} + /// Context used by the `pages/upstream_oauth2/{link_mismatch,do_login}.html` /// templates #[derive(Serialize)] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index ecbdc15c..38b96013 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -47,9 +47,10 @@ pub use self::{ DeviceLinkFormField, EmailAddContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, - PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, - SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, - UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCsrf, WithLanguage, + PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryStartContext, + RecoveryStartFormField, RegisterContext, RegisterFormField, SiteBranding, SiteConfigExt, + SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, + UpstreamRegisterFormField, UpstreamSuggestLink, WithCsrf, WithLanguage, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, @@ -347,6 +348,9 @@ register_templates! { /// Render the email verification page pub fn render_account_add_email(WithLanguage>>) { "pages/account/emails/add.html" } + /// Render the account recovery start page + pub fn render_recovery_start(WithLanguage>) { "pages/recovery/start.html" } + /// Render the re-authentication form pub fn render_reauth(WithLanguage>>) { "pages/reauth.html" } @@ -403,6 +407,7 @@ impl Templates { 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_reauth(self, now, rng)?; check::render_form_post::(self, now, rng)?; check::render_error(self, now, rng)?; diff --git a/templates/pages/recovery/start.html b/templates/pages/recovery/start.html new file mode 100644 index 00000000..7893960c --- /dev/null +++ b/templates/pages/recovery/start.html @@ -0,0 +1,48 @@ +{# +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.email_solid() }} +
+ +
+

{{ _("mas.recovery.start.heading") }}

+

{{ _("mas.recovery.start.description") }}

+
+
+ +
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} + + {% endcall %} + + {{ button.button(text=_("action.continue"), type="submit") }} +
+{% endblock content %} diff --git a/translations/en.json b/translations/en.json index 428958b4..56d03b98 100644 --- a/translations/en.json +++ b/translations/en.json @@ -6,7 +6,7 @@ }, "continue": "Continue", "@continue": { - "context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:63:28-48, pages/device_consent.html:129:13-33, pages/device_link.html:48:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:84:28-48, pages/sso.html:45:28-48" + "context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:63:28-48, pages/device_consent.html:129:13-33, pages/device_link.html:48:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/recovery/start.html:45:26-46, pages/register.html:84:28-48, pages/sso.html:45:28-48" }, "create_account": "Create Account", "@create_account": { @@ -67,7 +67,7 @@ }, "email_address": "Email address", "@email_address": { - "context": "pages/account/emails/add.html:41:33-58, pages/register.html:48:35-60, pages/upstream_oauth2/do_register.html:87:37-62" + "context": "pages/account/emails/add.html:41:33-58, pages/recovery/start.html:42:33-58, pages/register.html:48:35-60, pages/upstream_oauth2/do_register.html:87:37-62" }, "mxid": "Matrix ID", "@mxid": { @@ -349,6 +349,20 @@ "context": "pages/policy_violation.html:43:11-86" } }, + "recovery": { + "start": { + "description": "An email will be sent with a link to reset your password.", + "@description": { + "context": "pages/recovery/start.html:27:25-60", + "description": "The description of the page to initiate an account recovery" + }, + "heading": "Enter your email to continue", + "@heading": { + "context": "pages/recovery/start.html:26:27-58", + "description": "The title of the page to initiate an account recovery" + } + } + }, "register": { "call_to_login": "Already have an account?", "@call_to_login": {