diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 9a60def9..51f3185e 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -389,6 +389,10 @@ where mas_router::AccountRecoveryProgress::route(), get(self::views::recovery::progress::get).post(self::views::recovery::progress::post), ) + .route( + mas_router::AccountRecoveryFinish::route(), + get(self::views::recovery::finish::get).post(self::views::recovery::finish::post), + ) .route( mas_router::OAuth2AuthorizationEndpoint::route(), get(self::oauth2::authorization::get), diff --git a/crates/handlers/src/views/recovery/finish.rs b/crates/handlers/src/views/recovery/finish.rs new file mode 100644 index 00000000..fef181df --- /dev/null +++ b/crates/handlers/src/views/recovery/finish.rs @@ -0,0 +1,246 @@ +// 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 anyhow::Context; +use axum::{ + extract::{Query, State}, + response::{Html, IntoResponse, Response}, + Form, +}; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, ProtectedForm}, + FancyError, +}; +use mas_policy::Policy; +use mas_router::UrlBuilder; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{ + ErrorContext, FieldError, FormState, RecoveryFinishContext, RecoveryFinishFormField, + TemplateContext, Templates, +}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{passwords::PasswordManager, PreferredLanguage}; + +#[derive(Deserialize)] +pub(crate) struct RouteQuery { + ticket: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct RouteForm { + new_password: String, + new_password_confirm: String, +} + +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + mut repo: BoxRepository, + State(templates): State, + PreferredLanguage(locale): PreferredLanguage, + cookie_jar: CookieJar, + Query(query): Query, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let ticket = repo + .user_recovery() + .find_ticket(&query.ticket) + .await? + .context("Unknown ticket")?; + + let session = repo + .user_recovery() + .lookup_session(ticket.user_recovery_session_id) + .await? + .context("Unknown session")?; + + if !ticket.active(clock.now()) || session.consumed_at.is_some() { + // TODO: render a 'link expired' page + let rendered = templates.render_error( + &ErrorContext::new() + .with_code("Link expired") + .with_language(&locale), + )?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + + let user_email = repo + .user_email() + .lookup(ticket.user_email_id) + .await? + // Only allow confirmed email addresses + .filter(|email| email.confirmed_at.is_some()) + .context("Unknown email address")?; + + let user = repo + .user() + .lookup(user_email.user_id) + .await? + .context("Invalid user")?; + + if !user.is_valid() { + // TODO: render a 'account locked' page + let rendered = templates.render_error( + &ErrorContext::new() + .with_code("Account locked") + .with_language(&locale), + )?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + + let context = RecoveryFinishContext::new(user) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let rendered = templates.render_recovery_finish(&context)?; + + Ok((cookie_jar, Html(rendered)).into_response()) +} + +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + mut repo: BoxRepository, + mut policy: Policy, + State(password_manager): State, + State(templates): State, + State(url_builder): State, + PreferredLanguage(locale): PreferredLanguage, + cookie_jar: CookieJar, + Query(query): Query, + Form(form): Form>, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let ticket = repo + .user_recovery() + .find_ticket(&query.ticket) + .await? + .context("Unknown ticket")?; + + let session = repo + .user_recovery() + .lookup_session(ticket.user_recovery_session_id) + .await? + .context("Unknown session")?; + + if !ticket.active(clock.now()) || session.consumed_at.is_some() { + // TODO: render a 'link expired' page + let rendered = templates.render_error( + &ErrorContext::new() + .with_code("Link expired") + .with_language(&locale), + )?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + + let user_email = repo + .user_email() + .lookup(ticket.user_email_id) + .await? + // Only allow confirmed email addresses + .filter(|email| email.confirmed_at.is_some()) + .context("Unknown email address")?; + + let user = repo + .user() + .lookup(user_email.user_id) + .await? + .context("Invalid user")?; + + if !user.is_valid() { + // TODO: render a 'account locked' page + let rendered = templates.render_error( + &ErrorContext::new() + .with_code("Account locked") + .with_language(&locale), + )?; + return Ok((cookie_jar, Html(rendered)).into_response()); + } + + let form = cookie_jar.verify_form(&clock, form)?; + + // Check the form + let mut form_state = FormState::from_form(&form); + + if form.new_password.is_empty() { + form_state = form_state + .with_error_on_field(RecoveryFinishFormField::NewPassword, FieldError::Required); + } + + if form.new_password_confirm.is_empty() { + form_state = form_state.with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::Required, + ); + } + + if form.new_password != form.new_password_confirm { + form_state = form_state + .with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Unspecified, + ) + .with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::PasswordMismatch, + ); + } + + let res = policy.evaluate_password(&form.new_password).await?; + + if !res.valid() { + form_state = form_state.with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Policy { + message: res.to_string(), + }, + ); + } + + if !form_state.is_valid() { + let context = RecoveryFinishContext::new(user) + .with_form_state(form_state) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let rendered = templates.render_recovery_finish(&context)?; + + return Ok((cookie_jar, Html(rendered)).into_response()); + } + + // Form is valid, change the password + let password = Zeroizing::new(form.new_password.into_bytes()); + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + repo.user_password() + .add(&mut rng, &clock, &user, version, hashed_password, None) + .await?; + + // Mark the session as consumed + repo.user_recovery() + .consume_ticket(&clock, ticket, session) + .await?; + + repo.save().await?; + + Ok(( + cookie_jar, + url_builder.redirect(&mas_router::Login::default()), + ) + .into_response()) +} diff --git a/crates/handlers/src/views/recovery/mod.rs b/crates/handlers/src/views/recovery/mod.rs index 9a10d9dd..567fbe74 100644 --- a/crates/handlers/src/views/recovery/mod.rs +++ b/crates/handlers/src/views/recovery/mod.rs @@ -12,5 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod finish; pub mod progress; pub mod start; diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 6acaeaa4..12371d80 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -191,9 +191,11 @@ pub(crate) async fn post( } if form.password != form.password_confirm { - state.add_error_on_form(FormError::PasswordMismatch); state.add_error_on_field(RegisterFormField::Password, FieldError::Unspecified); - state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Unspecified); + state.add_error_on_field( + RegisterFormField::PasswordConfirm, + FieldError::PasswordMismatch, + ); } // If the site has terms of service, the user must accept them diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 84bc9195..6bb40456 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1056,6 +1056,76 @@ impl TemplateContext for RecoveryProgressContext { } } +/// Fields of the account recovery finish form +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecoveryFinishFormField { + /// The new password + NewPassword, + + /// The new password confirmation + NewPasswordConfirm, +} + +impl FormField for RecoveryFinishFormField { + fn keep(&self) -> bool { + false + } +} + +/// Context used by the `pages/recovery/finish.html` template +#[derive(Serialize)] +pub struct RecoveryFinishContext { + user: User, + form: FormState, +} + +impl RecoveryFinishContext { + /// Constructs a context for the recovery finish page + #[must_use] + pub fn new(user: User) -> Self { + Self { + user, + form: FormState::default(), + } + } + + /// Set the form state + #[must_use] + pub fn with_form_state(mut self, form: FormState) -> Self { + self.form = form; + self + } +} + +impl TemplateContext for RecoveryFinishContext { + fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + User::samples(now, rng) + .into_iter() + .flat_map(|user| { + vec![ + Self::new(user.clone()), + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Invalid, + ), + ), + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::Invalid, + ), + ), + ] + }) + .collect() + } +} + /// Context used by the `pages/upstream_oauth2/{link_mismatch,do_login}.html` /// templates #[derive(Serialize)] diff --git a/crates/templates/src/forms.rs b/crates/templates/src/forms.rs index f2e79f2d..cf236dda 100644 --- a/crates/templates/src/forms.rs +++ b/crates/templates/src/forms.rs @@ -36,6 +36,9 @@ pub enum FieldError { /// Invalid value for this field Invalid, + /// The password confirmation doesn't match the password + PasswordMismatch, + /// That value already exists Exists, diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 6c0f586d..0d5268cf 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -46,11 +46,12 @@ pub use self::{ DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, - PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryProgressContext, - RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, - SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, - UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, - WithLanguage, WithOptionalSession, WithSession, + PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryFinishContext, + RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, + RecoveryStartFormField, RegisterContext, RegisterFormField, SiteBranding, SiteConfigExt, + SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, + UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, + WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -353,6 +354,8 @@ register_templates! { /// Render the account recovery start page pub fn render_recovery_progress(WithLanguage>) { "pages/recovery/progress.html" } + /// Render the account recovery finish page + pub fn render_recovery_finish(WithLanguage>) { "pages/recovery/finish.html" } /// Render the re-authentication form pub fn render_reauth(WithLanguage>>) { "pages/reauth.html" } @@ -421,6 +424,7 @@ impl Templates { check::render_account_verify_email(self, now, rng)?; check::render_recovery_start(self, now, rng)?; check::render_recovery_progress(self, now, rng)?; + check::render_recovery_finish(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/components/field.html b/templates/components/field.html index f930bc12..7981a47d 100644 --- a/templates/components/field.html +++ b/templates/components/field.html @@ -70,6 +70,8 @@ limitations under the License. {{ _("mas.errors.username_taken") }} {% elif error.kind == "policy" %} {{ _("mas.errors.denied_policy", policy=error.message) }} + {% elif error.kind == "password_mismatch" %} + {{ _("mas.errors.password_mismatch") }} {% else %} {{ error.kind }} {% endif %} diff --git a/templates/pages/recovery/finish.html b/templates/pages/recovery/finish.html new file mode 100644 index 00000000..614ee242 --- /dev/null +++ b/templates/pages/recovery/finish.html @@ -0,0 +1,55 @@ +{# +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.lock_solid() }} +
+ +
+

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

+

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

+
+
+ +
+ {# Hidden username field so that password manager can save the username #} + + + {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% call(f) field.field(label=_("mas.recovery.finish.new"), name="new_password", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("mas.recovery.finish.confirm"), name="new_password_confirm", form_state=form) %} + + {% endcall %} + + {{ button.button(text=_("mas.recovery.finish.save_and_continue"), type="submit") }} +
+{% endblock content %} diff --git a/templates/pages/register.html b/templates/pages/register.html index ba185aaf..cd827a50 100644 --- a/templates/pages/register.html +++ b/templates/pages/register.html @@ -49,11 +49,11 @@ limitations under the License. {% endcall %} - {% call(f) field.field(label=_("common.password"), name="password") %} + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} {% endcall %} - {% call(f) field.field(label=_("common.password_confirm"), name="password_confirm") %} + {% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %} {% endcall %} diff --git a/translations/en.json b/translations/en.json index 514b6441..67d85385 100644 --- a/translations/en.json +++ b/translations/en.json @@ -284,7 +284,7 @@ }, "password_mismatch": "Password fields don't match", "@password_mismatch": { - "context": "components/errors.html:21:7-40" + "context": "components/errors.html:21:7-40, components/field.html:74:17-50" }, "username_taken": "This username is already taken", "@username_taken": { @@ -356,7 +356,7 @@ }, "or_separator": "Or", "@or_separator": { - "context": "components/field.html:91:10-31", + "context": "components/field.html:93:10-31", "description": "Separator between the login methods" }, "policy_violation": { @@ -376,6 +376,33 @@ } }, "recovery": { + "finish": { + "confirm": "Enter new password again", + "@confirm": { + "context": "pages/recovery/finish.html:49:33-65", + "description": "Label for the password confirmation field" + }, + "description": "Choose a new password for your account.", + "@description": { + "context": "pages/recovery/finish.html:27:25-61", + "description": "Description for the final password recovery page" + }, + "heading": "Reset your password", + "@heading": { + "context": "pages/recovery/finish.html:26:27-59", + "description": "Heading for the final password recovery page" + }, + "new": "New password", + "@new": { + "context": "pages/recovery/finish.html:45:33-61", + "description": "Label for the new password field" + }, + "save_and_continue": "Save and continue", + "@save_and_continue": { + "context": "pages/recovery/finish.html:53:26-68", + "description": "Button to save the new password and continue" + } + }, "progress": { "change_email": "Try a different email", "@change_email": {