1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +03:00

Implement the password change form

This commit is contained in:
Quentin Gliech
2024-06-25 17:33:45 +02:00
parent d633d33ab2
commit 09fca9fd75
11 changed files with 425 additions and 11 deletions

View File

@@ -389,6 +389,10 @@ where
mas_router::AccountRecoveryProgress::route(), mas_router::AccountRecoveryProgress::route(),
get(self::views::recovery::progress::get).post(self::views::recovery::progress::post), 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( .route(
mas_router::OAuth2AuthorizationEndpoint::route(), mas_router::OAuth2AuthorizationEndpoint::route(),
get(self::oauth2::authorization::get), get(self::oauth2::authorization::get),

View File

@@ -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<Templates>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
Query(query): Query<RouteQuery>,
) -> Result<Response, FancyError> {
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<PasswordManager>,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
Query(query): Query<RouteQuery>,
Form(form): Form<ProtectedForm<RouteForm>>,
) -> Result<Response, FancyError> {
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())
}

View File

@@ -12,5 +12,6 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
pub mod finish;
pub mod progress; pub mod progress;
pub mod start; pub mod start;

View File

@@ -191,9 +191,11 @@ pub(crate) async fn post(
} }
if form.password != form.password_confirm { 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::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 // If the site has terms of service, the user must accept them

View File

@@ -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<RecoveryFinishFormField>,
}
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<RecoveryFinishFormField>) -> Self {
self.form = form;
self
}
}
impl TemplateContext for RecoveryFinishContext {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
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` /// Context used by the `pages/upstream_oauth2/{link_mismatch,do_login}.html`
/// templates /// templates
#[derive(Serialize)] #[derive(Serialize)]

View File

@@ -36,6 +36,9 @@ pub enum FieldError {
/// Invalid value for this field /// Invalid value for this field
Invalid, Invalid,
/// The password confirmation doesn't match the password
PasswordMismatch,
/// That value already exists /// That value already exists
Exists, Exists,

View File

@@ -46,11 +46,12 @@ pub use self::{
DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext, DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext,
EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext,
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryProgressContext, PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryFinishContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, RecoveryStartFormField, RegisterContext, RegisterFormField, SiteBranding, SiteConfigExt,
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister,
WithLanguage, WithOptionalSession, WithSession, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage,
WithOptionalSession, WithSession,
}, },
forms::{FieldError, FormError, FormField, FormState, ToFormState}, forms::{FieldError, FormError, FormField, FormState, ToFormState},
}; };
@@ -353,6 +354,8 @@ register_templates! {
/// Render the account recovery start page /// Render the account recovery start page
pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" } pub fn render_recovery_progress(WithLanguage<WithCsrf<RecoveryProgressContext>>) { "pages/recovery/progress.html" }
/// Render the account recovery finish page
pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
/// Render the re-authentication form /// Render the re-authentication form
pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "pages/reauth.html" } pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "pages/reauth.html" }
@@ -421,6 +424,7 @@ impl Templates {
check::render_account_verify_email(self, now, rng)?; check::render_account_verify_email(self, now, rng)?;
check::render_recovery_start(self, now, rng)?; check::render_recovery_start(self, now, rng)?;
check::render_recovery_progress(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_reauth(self, now, rng)?;
check::render_form_post::<EmptyContext>(self, now, rng)?; check::render_form_post::<EmptyContext>(self, now, rng)?;
check::render_error(self, now, rng)?; check::render_error(self, now, rng)?;

View File

@@ -70,6 +70,8 @@ limitations under the License.
{{ _("mas.errors.username_taken") }} {{ _("mas.errors.username_taken") }}
{% elif error.kind == "policy" %} {% elif error.kind == "policy" %}
{{ _("mas.errors.denied_policy", policy=error.message) }} {{ _("mas.errors.denied_policy", policy=error.message) }}
{% elif error.kind == "password_mismatch" %}
{{ _("mas.errors.password_mismatch") }}
{% else %} {% else %}
{{ error.kind }} {{ error.kind }}
{% endif %} {% endif %}

View File

@@ -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 %}
<header class="page-heading">
<div class="icon">
{{ icon.lock_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.recovery.finish.heading") }}</h1>
<p class="text">{{ _("mas.recovery.finish.description") }}</p>
</div>
</header>
<form class="cpd-form-root" method="POST">
{# Hidden username field so that password manager can save the username #}
<input class="hidden" aria-hidden="true" type="text" name="username" autocomplete="username" value="{{ user.username }}" />
{% if form.errors is not empty %}
{% for error in form.errors %}
<div class="text-critical font-medium">
{{ errors.form_error_message(error=error) }}
</div>
{% endfor %}
{% endif %}
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% call(f) field.field(label=_("mas.recovery.finish.new"), name="new_password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autofocus autocomplete="new-password" required />
{% endcall %}
{% call(f) field.field(label=_("mas.recovery.finish.confirm"), name="new_password_confirm", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %}
{{ button.button(text=_("mas.recovery.finish.save_and_continue"), type="submit") }}
</form>
{% endblock content %}

View File

@@ -49,11 +49,11 @@ limitations under the License.
<input {{ field.attributes(f) }} class="cpd-text-control" type="email" autocomplete="email" required /> <input {{ field.attributes(f) }} class="cpd-text-control" type="email" autocomplete="email" required />
{% endcall %} {% endcall %}
{% call(f) field.field(label=_("common.password"), name="password") %} {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required /> <input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %} {% 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) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required /> <input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %} {% endcall %}

View File

@@ -284,7 +284,7 @@
}, },
"password_mismatch": "Password fields don't match", "password_mismatch": "Password fields don't match",
"@password_mismatch": { "@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": "This username is already taken",
"@username_taken": { "@username_taken": {
@@ -356,7 +356,7 @@
}, },
"or_separator": "Or", "or_separator": "Or",
"@or_separator": { "@or_separator": {
"context": "components/field.html:91:10-31", "context": "components/field.html:93:10-31",
"description": "Separator between the login methods" "description": "Separator between the login methods"
}, },
"policy_violation": { "policy_violation": {
@@ -376,6 +376,33 @@
} }
}, },
"recovery": { "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": { "progress": {
"change_email": "Try a different email", "change_email": "Try a different email",
"@change_email": { "@change_email": {