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, displayname_change_allowed: experimental_config.displayname_change_allowed,
password_change_allowed: password_config.enabled() password_change_allowed: password_config.enabled()
&& experimental_config.password_change_allowed, && experimental_config.password_change_allowed,
account_recovery_allowed: password_config.enabled()
&& experimental_config.account_recovery_enabled,
captcha, captcha,
}) })
} }

View File

@@ -36,6 +36,15 @@ const fn is_default_true(value: &bool) -> bool {
*value == default_true() *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 /// Configuration sections for experimental options
/// ///
/// Do not change these options unless you know what you are doing. /// 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`. /// Whether users are allowed to change their passwords. Defaults to `true`.
#[serde(default = "default_true", skip_serializing_if = "is_default_true")] #[serde(default = "default_true", skip_serializing_if = "is_default_true")]
pub password_change_allowed: bool, 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 { impl Default for ExperimentalConfig {
@@ -91,6 +104,7 @@ impl Default for ExperimentalConfig {
email_change_allowed: default_true(), email_change_allowed: default_true(),
displayname_change_allowed: default_true(), displayname_change_allowed: default_true(),
password_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.email_change_allowed)
&& is_default_true(&self.displayname_change_allowed) && is_default_true(&self.displayname_change_allowed)
&& is_default_true(&self.password_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. /// Whether users can change their password.
pub password_change_allowed: bool, pub password_change_allowed: bool,
/// Whether users can recover their account via email.
pub account_recovery_allowed: bool,
/// Captcha configuration /// Captcha configuration
pub captcha: Option<CaptchaConfig>, pub captcha: Option<CaptchaConfig>,
} }

View File

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

View File

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

View File

@@ -22,12 +22,13 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::SiteConfig;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
job::{JobRepositoryExt, SendAccountRecoveryEmailsJob}, job::{JobRepositoryExt, SendAccountRecoveryEmailsJob},
BoxClock, BoxRepository, BoxRng, BoxClock, BoxRepository, BoxRng,
}; };
use mas_templates::{RecoveryProgressContext, TemplateContext, Templates}; use mas_templates::{EmptyContext, RecoveryProgressContext, TemplateContext, Templates};
use ulid::Ulid; use ulid::Ulid;
use crate::PreferredLanguage; use crate::PreferredLanguage;
@@ -36,12 +37,19 @@ pub(crate) async fn get(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar, cookie_jar: CookieJar,
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
) -> Result<Response, FancyError> { ) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@@ -75,6 +83,7 @@ pub(crate) async fn post(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
@@ -82,6 +91,12 @@ pub(crate) async fn post(
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
Form(form): Form<ProtectedForm<()>>, Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, FancyError> { ) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); 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}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::UserAgent; use mas_data_model::{SiteConfig, UserAgent};
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
job::{JobRepositoryExt, SendAccountRecoveryEmailsJob}, job::{JobRepositoryExt, SendAccountRecoveryEmailsJob},
BoxClock, BoxRepository, BoxRng, BoxClock, BoxRepository, BoxRng,
}; };
use mas_templates::{ use mas_templates::{
FieldError, FormState, RecoveryStartContext, RecoveryStartFormField, TemplateContext, Templates, EmptyContext, FieldError, FormState, RecoveryStartContext, RecoveryStartFormField,
TemplateContext, Templates,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -47,11 +48,18 @@ pub(crate) async fn get(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar, cookie_jar: CookieJar,
) -> Result<Response, FancyError> { ) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@@ -78,12 +86,19 @@ pub(crate) async fn post(
mut repo: BoxRepository, mut repo: BoxRepository,
user_agent: TypedHeader<headers::UserAgent>, user_agent: TypedHeader<headers::UserAgent>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
State(site_config): State<SiteConfig>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
cookie_jar: CookieJar, cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<StartRecoveryForm>>, Form(form): Form<ProtectedForm<StartRecoveryForm>>,
) -> Result<impl IntoResponse, FancyError> { ) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);

View File

@@ -54,6 +54,7 @@ impl SiteConfigExt for SiteConfig {
SiteFeatures { SiteFeatures {
password_registration: self.password_registration_enabled, password_registration: self.password_registration_enabled,
password_login: self.password_login_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. /// Whether local password-based login is enabled.
pub password_login: bool, pub password_login: bool,
/// Whether email-based account recovery is enabled.
pub account_recovery: bool,
} }
impl Object for SiteFeatures { impl Object for SiteFeatures {
@@ -34,11 +37,16 @@ impl Object for SiteFeatures {
match field.as_str()? { match field.as_str()? {
"password_registration" => Some(Value::from(self.password_registration)), "password_registration" => Some(Value::from(self.password_registration)),
"password_login" => Some(Value::from(self.password_login)), "password_login" => Some(Value::from(self.password_login)),
"account_recovery" => Some(Value::from(self.account_recovery)),
_ => None, _ => None,
} }
} }
fn enumerate(self: &Arc<Self>) -> Enumerator { 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 /// Render the account recovery finish page
pub fn render_recovery_finish(WithLanguage<WithCsrf<RecoveryFinishContext>>) { "pages/recovery/finish.html" } 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 /// 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" }
@@ -425,6 +428,7 @@ impl Templates {
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_recovery_finish(self, now, rng)?;
check::render_recovery_disabled(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)?;
@@ -455,6 +459,7 @@ mod tests {
let features = SiteFeatures { let features = SiteFeatures {
password_login: true, password_login: true,
password_registration: true, password_registration: true,
account_recovery: true,
}; };
let vite_manifest_path = let vite_manifest_path =
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");

View File

@@ -2038,6 +2038,10 @@
"password_change_allowed": { "password_change_allowed": {
"description": "Whether users are allowed to change their passwords. Defaults to `true`.", "description": "Whether users are allowed to change their passwords. Defaults to `true`.",
"type": "boolean" "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 %} {% from "components/idp_brand.html" import logo %}
{% block content %} {% block content %}
<main class="flex flex-col gap-6"> <main class="flex flex-col gap-10">
{% if features.password_login %} {% if features.password_login %}
<header class="page-heading"> <header class="page-heading">
<div class="icon"> <div class="icon">
@@ -58,6 +58,10 @@ limitations under the License.
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="password" required /> <input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="password" required />
{% endcall %} {% endcall %}
{% if features.account_recovery %}
{{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }}
{% endif %}
{{ button.button(text=_("action.continue")) }} {{ button.button(text=_("action.continue")) }}
</form> </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": { "action": {
"back": "Back",
"@back": {
"context": "pages/recovery/disabled.html:30:32-48"
},
"cancel": "Cancel", "cancel": "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": "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": "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": "Sign in",
"@sign_in": { "@sign_in": {
@@ -294,17 +298,22 @@
"login": { "login": {
"call_to_register": "Don't have an account yet?", "call_to_register": "Don't have an account yet?",
"@call_to_register": { "@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": "Continue with %(provider)s",
"@continue_with_provider": { "@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": "Button to log in with an upstream provider"
}, },
"description": "Please sign in to continue:", "description": "Please sign in to continue:",
"@description": { "@description": {
"context": "pages/login.html:38:31-57" "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": "Sign in",
"@headline": { "@headline": {
"context": "pages/login.html:37:33-56" "context": "pages/login.html:37:33-56"
@@ -321,7 +330,7 @@
}, },
"no_login_methods": "No login methods available.", "no_login_methods": "No login methods available.",
"@no_login_methods": { "@no_login_methods": {
"context": "pages/login.html:94:11-42" "context": "pages/login.html:98:11-42"
} }
}, },
"navbar": { "navbar": {
@@ -376,6 +385,16 @@
} }
}, },
"recovery": { "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": { "finish": {
"confirm": "Enter new password again", "confirm": "Enter new password again",
"@confirm": { "@confirm": {