1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-06 06:02:40 +03:00

Gate account recovery behing a configuration flag

This commit is contained in:
Quentin Gliech
2024-06-26 11:41:23 +02:00
parent 09fca9fd75
commit f9f2f4a3be
14 changed files with 152 additions and 13 deletions

View File

@@ -170,6 +170,8 @@ pub fn site_config_from_config(
displayname_change_allowed: experimental_config.displayname_change_allowed,
password_change_allowed: password_config.enabled()
&& experimental_config.password_change_allowed,
account_recovery_allowed: password_config.enabled()
&& experimental_config.account_recovery_enabled,
captcha,
})
}

View File

@@ -36,6 +36,15 @@ const fn is_default_true(value: &bool) -> bool {
*value == default_true()
}
const fn default_false() -> bool {
false
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_default_false(value: &bool) -> bool {
*value == default_false()
}
/// Configuration sections for experimental options
///
/// Do not change these options unless you know what you are doing.
@@ -80,6 +89,10 @@ pub struct ExperimentalConfig {
/// Whether users are allowed to change their passwords. Defaults to `true`.
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
pub password_change_allowed: bool,
/// Whether email-based account recovery is enabled. Defaults to `false`.
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
pub account_recovery_enabled: bool,
}
impl Default for ExperimentalConfig {
@@ -91,6 +104,7 @@ impl Default for ExperimentalConfig {
email_change_allowed: default_true(),
displayname_change_allowed: default_true(),
password_change_allowed: default_true(),
account_recovery_enabled: default_false(),
}
}
}
@@ -103,6 +117,7 @@ impl ExperimentalConfig {
&& is_default_true(&self.email_change_allowed)
&& is_default_true(&self.displayname_change_allowed)
&& is_default_true(&self.password_change_allowed)
&& is_default_false(&self.account_recovery_enabled)
}
}

View File

@@ -73,6 +73,9 @@ pub struct SiteConfig {
/// Whether users can change their password.
pub password_change_allowed: bool,
/// Whether users can recover their account via email.
pub account_recovery_allowed: bool,
/// Captcha configuration
pub captcha: Option<CaptchaConfig>,
}

View File

@@ -133,6 +133,7 @@ pub fn test_site_config() -> SiteConfig {
email_change_allowed: true,
displayname_change_allowed: true,
password_change_allowed: true,
account_recovery_allowed: true,
captcha: None,
}
}

View File

@@ -23,12 +23,13 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm},
FancyError,
};
use mas_data_model::SiteConfig;
use mas_policy::Policy;
use mas_router::UrlBuilder;
use mas_storage::{BoxClock, BoxRepository, BoxRng};
use mas_templates::{
ErrorContext, FieldError, FormState, RecoveryFinishContext, RecoveryFinishFormField,
TemplateContext, Templates,
EmptyContext, ErrorContext, FieldError, FormState, RecoveryFinishContext,
RecoveryFinishFormField, TemplateContext, Templates,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
@@ -50,11 +51,18 @@ pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
Query(query): Query<RouteQuery>,
) -> Result<Response, FancyError> {
if !site_config.account_recovery_allowed {
let context = EmptyContext.with_language(locale);
let rendered = templates.render_recovery_disabled(&context)?;
return Ok((cookie_jar, Html(rendered)).into_response());
}
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let ticket = repo
@@ -117,6 +125,7 @@ pub(crate) async fn post(
clock: BoxClock,
mut repo: BoxRepository,
mut policy: Policy,
State(site_config): State<SiteConfig>,
State(password_manager): State<PasswordManager>,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
@@ -125,6 +134,12 @@ pub(crate) async fn post(
Query(query): Query<RouteQuery>,
Form(form): Form<ProtectedForm<RouteForm>>,
) -> Result<Response, FancyError> {
if !site_config.account_recovery_allowed {
let context = EmptyContext.with_language(locale);
let rendered = templates.render_recovery_disabled(&context)?;
return Ok((cookie_jar, Html(rendered)).into_response());
}
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let ticket = repo

View File

@@ -22,12 +22,13 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_data_model::SiteConfig;
use mas_router::UrlBuilder;
use mas_storage::{
job::{JobRepositoryExt, SendAccountRecoveryEmailsJob},
BoxClock, BoxRepository, BoxRng,
};
use mas_templates::{RecoveryProgressContext, TemplateContext, Templates};
use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates};
use ulid::Ulid;
use crate::PreferredLanguage;
@@ -36,12 +37,19 @@ pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
Path(id): Path<Ulid>,
) -> Result<Response, FancyError> {
if !site_config.account_recovery_allowed {
let context = EmptyContext.with_language(locale);
let rendered = templates.render_recovery_disabled(&context)?;
return Ok((cookie_jar, Html(rendered)).into_response());
}
let (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@@ -75,6 +83,7 @@ pub(crate) async fn post(
mut rng: BoxRng,
clock: BoxClock,
mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
@@ -82,6 +91,12 @@ pub(crate) async fn post(
Path(id): Path<Ulid>,
Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, FancyError> {
if !site_config.account_recovery_allowed {
let context = EmptyContext.with_language(locale);
let rendered = templates.render_recovery_disabled(&context)?;
return Ok((cookie_jar, Html(rendered)).into_response());
}
let (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);

View File

@@ -25,14 +25,15 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_data_model::UserAgent;
use mas_data_model::{SiteConfig, UserAgent};
use mas_router::UrlBuilder;
use mas_storage::{
job::{JobRepositoryExt, SendAccountRecoveryEmailsJob},
BoxClock, BoxRepository, BoxRng,
};
use mas_templates::{
FieldError, FormState, RecoveryStartContext, RecoveryStartFormField, TemplateContext, Templates,
EmptyContext, FieldError, FormState, RecoveryStartContext, RecoveryStartFormField,
TemplateContext, Templates,
};
use serde::{Deserialize, Serialize};
@@ -47,11 +48,18 @@ pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
) -> Result<Response, FancyError> {
if !site_config.account_recovery_allowed {
let context = EmptyContext.with_language(locale);
let rendered = templates.render_recovery_disabled(&context)?;
return Ok((cookie_jar, Html(rendered)).into_response());
}
let (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@@ -78,12 +86,19 @@ pub(crate) async fn post(
mut repo: BoxRepository,
user_agent: TypedHeader<headers::UserAgent>,
activity_tracker: BoundActivityTracker,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<StartRecoveryForm>>,
) -> Result<impl IntoResponse, FancyError> {
if !site_config.account_recovery_allowed {
let context = EmptyContext.with_language(locale);
let rendered = templates.render_recovery_disabled(&context)?;
return Ok((cookie_jar, Html(rendered)).into_response());
}
let (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);

View File

@@ -54,6 +54,7 @@ impl SiteConfigExt for SiteConfig {
SiteFeatures {
password_registration: self.password_registration_enabled,
password_login: self.password_login_enabled,
account_recovery: self.account_recovery_allowed,
}
}
}

View File

@@ -27,6 +27,9 @@ pub struct SiteFeatures {
/// Whether local password-based login is enabled.
pub password_login: bool,
/// Whether email-based account recovery is enabled.
pub account_recovery: bool,
}
impl Object for SiteFeatures {
@@ -34,11 +37,16 @@ impl Object for SiteFeatures {
match field.as_str()? {
"password_registration" => Some(Value::from(self.password_registration)),
"password_login" => Some(Value::from(self.password_login)),
"account_recovery" => Some(Value::from(self.account_recovery)),
_ => None,
}
}
fn enumerate(self: &Arc<Self>) -> Enumerator {
Enumerator::Str(&["password_registration", "password_login"])
Enumerator::Str(&[
"password_registration",
"password_login",
"account_recovery",
])
}
}

View File

@@ -357,6 +357,9 @@ register_templates! {
/// Render the account recovery finish page
pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" }
/// Render the account recovery disabled page
pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
/// Render the re-authentication form
pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "pages/reauth.html" }
@@ -425,6 +428,7 @@ impl Templates {
check::render_recovery_start(self, now, rng)?;
check::render_recovery_progress(self, now, rng)?;
check::render_recovery_finish(self, now, rng)?;
check::render_recovery_disabled(self, now, rng)?;
check::render_reauth(self, now, rng)?;
check::render_form_post::<EmptyContext>(self, now, rng)?;
check::render_error(self, now, rng)?;
@@ -455,6 +459,7 @@ mod tests {
let features = SiteFeatures {
password_login: true,
password_registration: true,
account_recovery: true,
};
let vite_manifest_path =
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");

View File

@@ -2038,6 +2038,10 @@
"password_change_allowed": {
"description": "Whether users are allowed to change their passwords. Defaults to `true`.",
"type": "boolean"
},
"account_recovery_enabled": {
"description": "Whether email-based account recovery is enabled. Defaults to `false`.",
"type": "boolean"
}
}
}

View File

@@ -19,7 +19,7 @@ limitations under the License.
{% from "components/idp_brand.html" import logo %}
{% block content %}
<main class="flex flex-col gap-6">
<main class="flex flex-col gap-10">
{% if features.password_login %}
<header class="page-heading">
<div class="icon">
@@ -59,6 +59,10 @@ limitations under the License.
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="password" required />
{% endcall %}
{% if features.account_recovery %}
{{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }}
{% endif %}
{{ button.button(text=_("action.continue")) }}
</form>

View File

@@ -0,0 +1,32 @@
{#
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 invalid">
{{ icon.lock_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.recovery.disabled.heading") }}</h1>
<p class="text">{{ _("mas.recovery.disabled.description") }}</p>
</div>
{{ button.link_outline(text=_("action.back"), href="/login") }}
</header>
{% endblock content %}

View File

@@ -1,16 +1,20 @@
{
"action": {
"back": "Back",
"@back": {
"context": "pages/recovery/disabled.html:30:32-48"
},
"cancel": "Cancel",
"@cancel": {
"context": "pages/consent.html:75:11-29, pages/device_consent.html:132:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:89:13-31"
"context": "pages/consent.html:75:11-29, pages/device_consent.html:132:13-31, pages/login.html:104:13-31, pages/policy_violation.html:52:13-31, pages/register.html:89:13-31"
},
"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/recovery/start.html:45:26-46, 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:66:30-50, pages/reauth.html:40:28-48, pages/recovery/start.html:46:26-46, pages/register.html:84:28-48, pages/sso.html:45:28-48"
},
"create_account": "Create Account",
"@create_account": {
"context": "pages/login.html:72:35-61, pages/upstream_oauth2/do_register.html:157:26-52"
"context": "pages/login.html:76:35-61, pages/upstream_oauth2/do_register.html:157:26-52"
},
"sign_in": "Sign in",
"@sign_in": {
@@ -294,17 +298,22 @@
"login": {
"call_to_register": "Don't have an account yet?",
"@call_to_register": {
"context": "pages/login.html:68:15-46"
"context": "pages/login.html:72:15-46"
},
"continue_with_provider": "Continue with %(provider)s",
"@continue_with_provider": {
"context": "pages/login.html:87:13-65",
"context": "pages/login.html:91:13-65",
"description": "Button to log in with an upstream provider"
},
"description": "Please sign in to continue:",
"@description": {
"context": "pages/login.html:38:31-57"
},
"forgot_password": "Forgot password?",
"@forgot_password": {
"context": "pages/login.html:63:35-65",
"description": "On the login page, link to the account recovery process"
},
"headline": "Sign in",
"@headline": {
"context": "pages/login.html:37:33-56"
@@ -321,7 +330,7 @@
},
"no_login_methods": "No login methods available.",
"@no_login_methods": {
"context": "pages/login.html:94:11-42"
"context": "pages/login.html:98:11-42"
}
},
"navbar": {
@@ -376,6 +385,16 @@
}
},
"recovery": {
"disabled": {
"description": "If you have lost your credentials, please contact the administrator to recover your account.",
"@description": {
"context": "pages/recovery/disabled.html:27:25-63"
},
"heading": "Account recovery is disabled",
"@heading": {
"context": "pages/recovery/disabled.html:26:27-61"
}
},
"finish": {
"confirm": "Enter new password again",
"@confirm": {