diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 61371742..40cbf973 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -145,7 +145,8 @@ impl Options { &config.matrix, &config.experimental, &config.passwords, - ); + &config.captcha, + )?; // Load and compile the templates let templates = diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index 6c905cfd..9975965e 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -15,8 +15,8 @@ use clap::Parser; use figment::Figment; use mas_config::{ - BrandingConfig, ConfigurationSection, ExperimentalConfig, MatrixConfig, PasswordsConfig, - TemplatesConfig, + BrandingConfig, CaptchaConfig, ConfigurationSection, ExperimentalConfig, MatrixConfig, + PasswordsConfig, TemplatesConfig, }; use mas_storage::{Clock, SystemClock}; use rand::SeedableRng; @@ -48,6 +48,7 @@ impl Options { let matrix_config = MatrixConfig::extract(figment)?; let experimental_config = ExperimentalConfig::extract(figment)?; let password_config = PasswordsConfig::extract(figment)?; + let captcha_config = CaptchaConfig::extract(figment)?; let clock = SystemClock::default(); // XXX: we should disallow SeedableRng::from_entropy @@ -59,7 +60,8 @@ impl Options { &matrix_config, &experimental_config, &password_config, - ); + &captcha_config, + )?; let templates = templates_from_config(&template_config, &site_config, &url_builder).await?; templates.check_render(clock.now(), &mut rng)?; diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 79120f20..afaeecbf 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -52,7 +52,8 @@ impl Options { &config.matrix, &config.experimental, &config.passwords, - ); + &config.captcha, + )?; // Load and compile the templates let templates = diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 4f3ceb3d..ba3e3d7a 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -16,7 +16,7 @@ use std::time::Duration; use anyhow::Context; use mas_config::{ - BrandingConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportKind, + BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, EmailTransportKind, ExperimentalConfig, MatrixConfig, PasswordsConfig, PolicyConfig, TemplatesConfig, }; use mas_data_model::SiteConfig; @@ -120,13 +120,39 @@ pub async fn policy_factory_from_config( .context("failed to load the policy") } +pub fn captcha_config_from_config( + captcha_config: &CaptchaConfig, +) -> Result, anyhow::Error> { + let Some(service) = captcha_config.service else { + return Ok(None); + }; + + let service = match service { + mas_config::CaptchaServiceKind::RecaptchaV2 => mas_data_model::CaptchaService::RecaptchaV2, + }; + + Ok(Some(mas_data_model::CaptchaConfig { + service, + site_key: captcha_config + .site_key + .clone() + .context("missing site key")?, + secret_key: captcha_config + .secret_key + .clone() + .context("missing secret key")?, + })) +} + pub fn site_config_from_config( branding_config: &BrandingConfig, matrix_config: &MatrixConfig, experimental_config: &ExperimentalConfig, password_config: &PasswordsConfig, -) -> SiteConfig { - SiteConfig { + captcha_config: &CaptchaConfig, +) -> Result { + let captcha = captcha_config_from_config(captcha_config)?; + Ok(SiteConfig { access_token_ttl: experimental_config.access_token_ttl, compat_token_ttl: experimental_config.compat_token_ttl, server_name: matrix_config.homeserver.clone(), @@ -140,7 +166,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, - } + captcha, + }) } pub async fn templates_from_config( diff --git a/crates/config/src/sections/captcha.rs b/crates/config/src/sections/captcha.rs new file mode 100644 index 00000000..00badc69 --- /dev/null +++ b/crates/config/src/sections/captcha.rs @@ -0,0 +1,80 @@ +// 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 schemars::JsonSchema; +use serde::{de::Error, Deserialize, Serialize}; + +use crate::ConfigurationSection; + +/// Which service should be used for CAPTCHA protection +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +pub enum CaptchaServiceKind { + /// Use Google's reCAPTCHA v2 API + #[serde(rename = "recaptcha_v2")] + RecaptchaV2, +} + +/// Configuration section to setup CAPTCHA protection on a few operations +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize, Default)] +pub struct CaptchaConfig { + /// Which service should be used for CAPTCHA protection + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + + /// The site key to use + #[serde(skip_serializing_if = "Option::is_none")] + pub site_key: Option, + + /// The secret key to use + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_key: Option, +} + +impl CaptchaConfig { + /// Returns true if the configuration is the default one + pub(crate) fn is_default(&self) -> bool { + self.service.is_none() && self.site_key.is_none() && self.secret_key.is_none() + } +} + +impl ConfigurationSection for CaptchaConfig { + const PATH: Option<&'static str> = Some("captcha"); + + fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { + let metadata = figment.find_metadata(Self::PATH.unwrap()); + + let error_on_field = |mut error: figment::error::Error, field: &'static str| { + error.metadata = metadata.cloned(); + error.profile = Some(figment::Profile::Default); + error.path = vec![Self::PATH.unwrap().to_owned(), field.to_owned()]; + error + }; + + let missing_field = |field: &'static str| { + error_on_field(figment::error::Error::missing_field(field), field) + }; + + if let Some(CaptchaServiceKind::RecaptchaV2) = self.service { + if self.site_key.is_none() { + return Err(missing_field("site_key")); + } + + if self.secret_key.is_none() { + return Err(missing_field("secret_key")); + } + } + + Ok(()) + } +} diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 17bbb8ad..91074ad4 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -17,6 +17,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; mod branding; +mod captcha; mod clients; mod database; mod email; @@ -32,6 +33,7 @@ mod upstream_oauth2; pub use self::{ branding::BrandingConfig, + captcha::{CaptchaConfig, CaptchaServiceKind}, clients::{ClientAuthMethodConfig, ClientConfig, ClientsConfig}, database::DatabaseConfig, email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, @@ -107,6 +109,10 @@ pub struct RootConfig { #[serde(default, skip_serializing_if = "BrandingConfig::is_default")] pub branding: BrandingConfig, + /// Configuration section to setup CAPTCHA protection on a few operations + #[serde(default, skip_serializing_if = "CaptchaConfig::is_default")] + pub captcha: CaptchaConfig, + /// Experimental configuration options #[serde(default, skip_serializing_if = "ExperimentalConfig::is_default")] pub experimental: ExperimentalConfig, @@ -126,6 +132,7 @@ impl ConfigurationSection for RootConfig { self.policy.validate(figment)?; self.upstream_oauth2.validate(figment)?; self.branding.validate(figment)?; + self.captcha.validate(figment)?; self.experimental.validate(figment)?; Ok(()) @@ -155,6 +162,7 @@ impl RootConfig { policy: PolicyConfig::default(), upstream_oauth2: UpstreamOAuth2Config::default(), branding: BrandingConfig::default(), + captcha: CaptchaConfig::default(), experimental: ExperimentalConfig::default(), }) } @@ -175,6 +183,7 @@ impl RootConfig { policy: PolicyConfig::default(), upstream_oauth2: UpstreamOAuth2Config::default(), branding: BrandingConfig::default(), + captcha: CaptchaConfig::default(), experimental: ExperimentalConfig::default(), } } @@ -209,6 +218,9 @@ pub struct AppConfig { #[serde(default)] pub branding: BrandingConfig, + #[serde(default)] + pub captcha: CaptchaConfig, + #[serde(default)] pub experimental: ExperimentalConfig, } @@ -224,6 +236,7 @@ impl ConfigurationSection for AppConfig { self.matrix.validate(figment)?; self.policy.validate(figment)?; self.branding.validate(figment)?; + self.captcha.validate(figment)?; self.experimental.validate(figment)?; Ok(()) diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index c8ccc262..20f8af07 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -40,7 +40,7 @@ pub use self::{ AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant, DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState, }, - site_config::SiteConfig, + site_config::{CaptchaConfig, CaptchaService, SiteConfig}, tokens::{ AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, }, diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index cf36237d..e33dc89e 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -15,6 +15,25 @@ use chrono::Duration; use url::Url; +/// Which Captcha service is being used +#[derive(Debug, Clone)] +pub enum CaptchaService { + RecaptchaV2, +} + +/// Captcha configuration +#[derive(Debug, Clone)] +pub struct CaptchaConfig { + /// Which Captcha service is being used + pub service: CaptchaService, + + /// The site key used by the instance + pub site_key: String, + + /// The secret key used by the instance + pub secret_key: String, +} + /// Random site configuration we want accessible in various places. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] @@ -51,4 +70,7 @@ pub struct SiteConfig { /// Whether users can change their password. pub password_change_allowed: bool, + + /// Captcha configuration + pub captcha: Option, } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index bc32eb42..ce994f62 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -132,6 +132,7 @@ pub fn test_site_config() -> SiteConfig { email_change_allowed: true, displayname_change_allowed: true, password_change_allowed: true, + captcha: None, } } diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 0912a2a7..002d8e7d 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -26,7 +26,7 @@ use mas_axum_utils::{ csrf::{CsrfExt, CsrfToken, ProtectedForm}, FancyError, SessionInfoExt, }; -use mas_data_model::UserAgent; +use mas_data_model::{CaptchaConfig, UserAgent}; use mas_i18n::DataLocale; use mas_matrix::BoxHomeserverConnection; use mas_policy::Policy; @@ -96,6 +96,7 @@ pub(crate) async fn get( csrf_token, &mut repo, &templates, + site_config.captcha.clone(), ) .await?; @@ -216,6 +217,7 @@ pub(crate) async fn post( csrf_token, &mut repo, &templates, + site_config.captcha.clone(), ) .await?; @@ -278,6 +280,7 @@ async fn render( csrf_token: CsrfToken, repo: &mut impl RepositoryAccess, templates: &Templates, + captcha_config: Option, ) -> Result { let next = action.load_context(repo).await?; let ctx = if let Some(next) = next { @@ -285,7 +288,10 @@ async fn render( } else { ctx }; - let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); + let ctx = ctx + .with_captcha(captcha_config) + .with_csrf(csrf_token.form_value()) + .with_language(locale); let content = templates.render_register(&ctx)?; Ok(content) diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index d34e0d06..d37f1cba 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -15,6 +15,7 @@ //! Contexts used in templates mod branding; +mod captcha; mod ext; mod features; @@ -41,7 +42,9 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize}; use ulid::Ulid; use url::Url; -pub use self::{branding::SiteBranding, ext::SiteConfigExt, features::SiteFeatures}; +pub use self::{ + branding::SiteBranding, captcha::WithCaptcha, ext::SiteConfigExt, features::SiteFeatures, +}; use crate::{FieldError, FormField, FormState}; /// Helper trait to construct context wrappers @@ -95,6 +98,14 @@ pub trait TemplateContext: Serialize { } } + /// Attach a CAPTCHA configuration to the template context + fn with_captcha(self, captcha: Option) -> WithCaptcha + where + Self: Sized, + { + WithCaptcha::new(captcha, self) + } + /// Generate sample values for this context type /// /// This is then used to check for template validity in unit tests and in diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs new file mode 100644 index 00000000..23416a2f --- /dev/null +++ b/crates/templates/src/context/captcha.rs @@ -0,0 +1,77 @@ +// 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::sync::Arc; + +use minijinja::{ + value::{Enumerator, Object}, + Value, +}; +use serde::Serialize; + +use crate::TemplateContext; + +#[derive(Debug)] +struct CaptchaConfig(mas_data_model::CaptchaConfig); + +impl Object for CaptchaConfig { + fn get_value(self: &Arc, key: &Value) -> Option { + match key.as_str() { + Some("service") => Some(match &self.0.service { + mas_data_model::CaptchaService::RecaptchaV2 => "recaptcha_v2".into(), + }), + Some("site_key") => Some(self.0.site_key.clone().into()), + _ => None, + } + } + + fn enumerate(self: &Arc) -> Enumerator { + Enumerator::Str(&["service", "site_key"]) + } +} + +/// Context with an optional CAPTCHA configuration in it +#[derive(Serialize)] +pub struct WithCaptcha { + captcha: Option, + + #[serde(flatten)] + inner: T, +} + +impl WithCaptcha { + #[must_use] + pub fn new(captcha: Option, inner: T) -> Self { + Self { + captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))), + inner, + } + } +} + +impl TemplateContext for WithCaptcha { + fn sample( + now: chrono::DateTime, + rng: &mut impl rand::prelude::Rng, + ) -> Vec + where + Self: Sized, + { + let inner = T::sample(now, rng); + inner + .into_iter() + .map(|inner| Self::new(None, inner)) + .collect() + } +} diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index a063dc0c..f71ee5c8 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -22,6 +22,7 @@ use std::{collections::HashSet, sync::Arc}; use anyhow::Context as _; use arc_swap::ArcSwap; use camino::{Utf8Path, Utf8PathBuf}; +use context::WithCaptcha; use mas_i18n::Translator; use mas_router::UrlBuilder; use mas_spa::ViteManifest; @@ -326,7 +327,7 @@ register_templates! { pub fn render_login(WithLanguage>) { "pages/login.html" } /// Render the registration page - pub fn render_register(WithLanguage>) { "pages/register.html" } + pub fn render_register(WithLanguage>>) { "pages/register.html" } /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } diff --git a/docs/config.schema.json b/docs/config.schema.json index a97f8020..5faa9dc4 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -184,6 +184,14 @@ } ] }, + "captcha": { + "description": "Configuration section to setup CAPTCHA protection on a few operations", + "allOf": [ + { + "$ref": "#/definitions/CaptchaConfig" + } + ] + }, "experimental": { "description": "Experimental configuration options", "allOf": [ @@ -1949,6 +1957,40 @@ } } }, + "CaptchaConfig": { + "description": "Configuration section to setup CAPTCHA protection on a few operations", + "type": "object", + "properties": { + "service": { + "description": "Which service should be used for CAPTCHA protection", + "allOf": [ + { + "$ref": "#/definitions/CaptchaServiceKind" + } + ] + }, + "site_key": { + "description": "The site key to use", + "type": "string" + }, + "secret_key": { + "description": "The secret key to use", + "type": "string" + } + } + }, + "CaptchaServiceKind": { + "description": "Which service should be used for CAPTCHA protection", + "oneOf": [ + { + "description": "Use Google's reCAPTCHA v2 API", + "type": "string", + "enum": [ + "recaptcha_v2" + ] + } + ] + }, "ExperimentalConfig": { "description": "Configuration sections for experimental options\n\nDo not change these options unless you know what you are doing.", "type": "object", diff --git a/templates/base.html b/templates/base.html index 95392f36..88472948 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,6 +23,7 @@ limitations under the License. {% import "components/errors.html" as errors %} {% import "components/icon.html" as icon %} {% import "components/scope.html" as scope %} +{% import "components/captcha.html" as captcha %} @@ -31,6 +32,7 @@ limitations under the License. {% block title %}{{ _("app.name") }}{% endblock title %} {{ include_asset('src/templates.css', preload=true) | indent(4) | safe }} + {{ captcha.head() }}
diff --git a/templates/components/captcha.html b/templates/components/captcha.html new file mode 100644 index 00000000..5fbd139e --- /dev/null +++ b/templates/components/captcha.html @@ -0,0 +1,35 @@ +{# +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. +#} + +{% macro form() -%} + {%- if captcha|default(False) -%} + {%- if captcha.service == "recaptcha_v2" -%} +
+ {%- else -%} + {{ throw(message="Invalid captcha service setup") }} + {%- endif %} + {%- endif -%} +{% endmacro %} + +{% macro head() -%} + {%- if captcha|default(False) -%} + {%- if captcha.service == "recaptcha_v2" -%} + + {%- else -%} + {{ throw(message="Invalid captcha service setup") }} + {%- endif %} + {%- endif -%} +{%- endmacro %} diff --git a/templates/pages/register.html b/templates/pages/register.html index 983bb884..aa47508f 100644 --- a/templates/pages/register.html +++ b/templates/pages/register.html @@ -69,6 +69,8 @@ limitations under the License. {% endcall %} {% endif %} + {{ captcha.form() }} + {{ button.button(text=_("action.continue")) }} diff --git a/translations/en.json b/translations/en.json index 2100cc97..9a12c551 100644 --- a/translations/en.json +++ b/translations/en.json @@ -2,11 +2,11 @@ "action": { "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:77:13-31" + "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:79: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/register.html:72: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/register.html:74:28-48, pages/sso.html:45:28-48" }, "create_account": "Create Account", "@create_account": { @@ -29,7 +29,7 @@ }, "name": "matrix-authentication-service", "@name": { - "context": "app.html:25:14-27, base.html:31:31-44", + "context": "app.html:25:14-27, base.html:32:31-44", "description": "Name of the application" }, "technical_description": "OpenID Connect discovery document: %(discovery_url)s", @@ -349,7 +349,7 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register.html:87:11-42", + "context": "pages/register.html:89:11-42", "description": "Displayed on the registration page to suggest to log in instead" }, "create_account": { @@ -364,7 +364,7 @@ }, "sign_in_instead": "Sign in instead", "@sign_in_instead": { - "context": "pages/register.html:91:31-64" + "context": "pages/register.html:93:31-64" }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": {