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

Render reCAPTCHA challenge on the registration form

This commit is contained in:
Quentin Gliech
2024-05-10 17:17:19 +02:00
parent c422c29a60
commit a3beeb2398
18 changed files with 342 additions and 19 deletions

View File

@@ -145,7 +145,8 @@ impl Options {
&config.matrix,
&config.experimental,
&config.passwords,
);
&config.captcha,
)?;
// Load and compile the templates
let templates =

View File

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

View File

@@ -52,7 +52,8 @@ impl Options {
&config.matrix,
&config.experimental,
&config.passwords,
);
&config.captcha,
)?;
// Load and compile the templates
let templates =

View File

@@ -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<Option<mas_data_model::CaptchaConfig>, 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<SiteConfig, anyhow::Error> {
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(

View File

@@ -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<CaptchaServiceKind>,
/// The site key to use
#[serde(skip_serializing_if = "Option::is_none")]
pub site_key: Option<String>,
/// The secret key to use
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_key: Option<String>,
}
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(())
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CaptchaConfig>,
) -> Result<String, FancyError> {
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)

View File

@@ -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<mas_data_model::CaptchaConfig>) -> WithCaptcha<Self>
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

View File

@@ -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<Self>, key: &Value) -> Option<Value> {
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<Self>) -> Enumerator {
Enumerator::Str(&["service", "site_key"])
}
}
/// Context with an optional CAPTCHA configuration in it
#[derive(Serialize)]
pub struct WithCaptcha<T> {
captcha: Option<Value>,
#[serde(flatten)]
inner: T,
}
impl<T> WithCaptcha<T> {
#[must_use]
pub fn new(captcha: Option<mas_data_model::CaptchaConfig>, inner: T) -> Self {
Self {
captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))),
inner,
}
}
}
impl<T: TemplateContext> TemplateContext for WithCaptcha<T> {
fn sample(
now: chrono::DateTime<chrono::prelude::Utc>,
rng: &mut impl rand::prelude::Rng,
) -> Vec<Self>
where
Self: Sized,
{
let inner = T::sample(now, rng);
inner
.into_iter()
.map(|inner| Self::new(None, inner))
.collect()
}
}

View File

@@ -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<WithCsrf<LoginContext>>) { "pages/login.html" }
/// Render the registration page
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register.html" }
pub fn render_register(WithLanguage<WithCsrf<WithCaptcha<RegisterContext>>>) { "pages/register.html" }
/// Render the client consent page
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }

View File

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

View File

@@ -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 %}
<!DOCTYPE html>
<html lang="{{ lang }}">
@@ -31,6 +32,7 @@ limitations under the License.
<title>{% block title %}{{ _("app.name") }}{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ include_asset('src/templates.css', preload=true) | indent(4) | safe }}
{{ captcha.head() }}
</head>
<body>
<div class="layout-container">

View File

@@ -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" -%}
<div class="g-recaptcha" data-sitekey="{{ captcha.site_key }}"></div>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{% endmacro %}
{% macro head() -%}
{%- if captcha|default(False) -%}
{%- if captcha.service == "recaptcha_v2" -%}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{%- endmacro %}

View File

@@ -69,6 +69,8 @@ limitations under the License.
{% endcall %}
{% endif %}
{{ captcha.form() }}
{{ button.button(text=_("action.continue")) }}
</form>

View File

@@ -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: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>",
@@ -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 <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {