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

Start recovery view

This commit is contained in:
Quentin Gliech
2024-06-21 15:11:01 +02:00
parent 43582e7eca
commit 319c43abc5
10 changed files with 317 additions and 6 deletions

View File

@@ -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),

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
) -> Result<Response, FancyError> {
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<headers::UserAgent>,
activity_tracker: BoundActivityTracker,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<StartRecoveryForm>>,
) -> Result<impl IntoResponse, FancyError> {
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())
}

View File

@@ -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,

View File

@@ -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<RecoveryStartFormField>,
}
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<RecoveryStartFormField>) -> Self {
Self { form }
}
}
impl TemplateContext for RecoveryStartContext {
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
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)]

View File

@@ -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<WithCsrf<WithSession<EmailAddContext>>>) { "pages/account/emails/add.html" }
/// Render the account recovery start page
pub fn render_recovery_start(WithLanguage<WithCsrf<RecoveryStartContext>>) { "pages/recovery/start.html" }
/// Render the re-authentication form
pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "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::<EmptyContext>(self, now, rng)?;
check::render_error(self, now, rng)?;

View File

@@ -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 %}
<header class="page-heading">
<div class="icon">
{{ icon.email_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.recovery.start.heading") }}</h1>
<p class="text">{{ _("mas.recovery.start.description") }}</p>
</div>
</header>
<form class="cpd-form-root" method="POST">
{% 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=_("common.email_address"), name="email", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="email" autocomplete="email" required />
{% endcall %}
{{ button.button(text=_("action.continue"), type="submit") }}
</form>
{% endblock content %}

View File

@@ -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": {