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

Actually verify the CAPTCHA during registration

This commit is contained in:
Quentin Gliech
2024-05-13 14:39:36 +02:00
parent 0e270d5449
commit 4d9d8a8ba3
5 changed files with 314 additions and 2 deletions

View File

@@ -16,7 +16,7 @@ use chrono::Duration;
use url::Url; use url::Url;
/// Which Captcha service is being used /// Which Captcha service is being used
#[derive(Debug, Clone)] #[derive(Debug, Clone, Copy)]
pub enum CaptchaService { pub enum CaptchaService {
RecaptchaV2, RecaptchaV2,
CloudflareTurnstile, CloudflareTurnstile,

View File

@@ -0,0 +1,280 @@
// 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::net::IpAddr;
use axum::BoxError;
use hyper::Request;
use mas_axum_utils::http_client_factory::HttpClientFactory;
use mas_data_model::{CaptchaConfig, CaptchaService};
use mas_http::HttpServiceExt;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tower::{Service, ServiceExt};
use crate::BoundActivityTracker;
// https://developers.google.com/recaptcha/docs/verify#api_request
const RECAPTCHA_VERIFY_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
// https://docs.hcaptcha.com/#verify-the-user-response-server-side
const HCAPTCHA_VERIFY_URL: &str = "https://api.hcaptcha.com/siteverify";
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
const CF_TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
#[derive(Debug, Error)]
pub enum Error {
#[error("A CAPTCHA response was expected, but none was provided")]
MissingCaptchaResponse,
#[error("A CAPTCHA response was provided, but no CAPTCHA provider is configured")]
NoCaptchaConfigured,
#[error("The CAPTCHA response provided is not valid for the configured service")]
CaptchaResponseMismatch,
#[error("The CAPTCHA response provided is invalid: {0:?}")]
InvalidCaptcha(Vec<ErrorCode>),
#[error("The CAPTCHA provider returned an invalid response")]
InvalidResponse,
#[error("The hostname in the CAPTCHA response ({got:?}) does not match the site hostname ({expected:?})")]
HostnameMismatch { expected: String, got: String },
#[error("The CAPTCHA provider returned an error")]
RequestFailed(#[source] BoxError),
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Form {
g_recaptcha_response: Option<String>,
h_captcha_response: Option<String>,
cf_turnstile_response: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerificationRequest<'a> {
secret: &'a str,
response: &'a str,
remoteip: Option<IpAddr>,
}
#[derive(Debug, Deserialize)]
struct VerificationResponse {
success: bool,
#[serde(rename = "error-codes")]
error_codes: Option<Vec<ErrorCode>>,
challenge_ts: Option<String>,
hostname: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorCode {
/// The secret parameter is missing.
///
/// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
MissingInputSecret,
/// The secret parameter is invalid or malformed.
///
/// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
InvalidInputSecret,
/// The response parameter is missing.
///
/// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
MissingInputResponse,
/// The response parameter is invalid or malformed.
///
/// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
InvalidInputResponse,
/// The widget ID extracted from the parsed site secret key was invalid or
/// did not exist.
///
/// Used by Cloudflare Turnstile
InvalidWidgetId,
/// The secret extracted from the parsed site secret key was invalid.
///
/// Used by Cloudflare Turnstile
InvalidParsedSecret,
/// The request is invalid or malformed.
///
/// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
BadRequest,
/// The remoteip parameter is missing.
///
/// Used by hCaptcha
MissingRemoteip,
/// The remoteip parameter is not a valid IP address or blinded value.
///
/// Used by hCaptcha
InvalidRemoteip,
/// The response parameter has already been checked, or has another issue.
///
/// Used by hCaptcha
InvalidOrAlreadySeenResponse,
/// You have used a testing sitekey but have not used its matching secret.
///
/// Used by hCaptcha
NotUsingDummyPasscode,
/// The sitekey is not registered with the provided secret.
///
/// Used by hCaptcha
SitekeySecretMismatch,
/// The response is no longer valid: either is too old or has been used
/// previously.
///
/// Used by Cloudflare Turnstile, reCAPTCHA
TimeoutOrDisplicate,
/// An internal error happened while validating the response. The request
/// can be retried.
///
/// Used by Cloudflare Turnstile
InternalError,
}
impl Form {
#[tracing::instrument(
skip_all,
name = "captcha.verify",
fields(captcha.hostname, captcha.challenge_ts, captcha.service),
err
)]
pub async fn verify(
&self,
activity_tracker: &BoundActivityTracker,
http_client_factory: &HttpClientFactory,
site_hostname: &str,
config: Option<&CaptchaConfig>,
) -> Result<(), Error> {
let Some(config) = config else {
if self.g_recaptcha_response.is_some()
|| self.h_captcha_response.is_some()
|| self.cf_turnstile_response.is_some()
{
return Err(Error::NoCaptchaConfigured);
}
return Ok(());
};
let remoteip = activity_tracker.ip();
let secret = &config.secret_key;
let span = tracing::Span::current();
span.record("captcha.service", tracing::field::debug(config.service));
let request = match (
config.service,
&self.g_recaptcha_response,
&self.h_captcha_response,
&self.cf_turnstile_response,
) {
(_, None, None, None) => return Err(Error::MissingCaptchaResponse),
// reCAPTCHA v2
(CaptchaService::RecaptchaV2, Some(response), None, None) => {
Request::post(RECAPTCHA_VERIFY_URL)
.body(VerificationRequest {
secret,
response,
remoteip,
})
.unwrap()
}
// hCaptcha
// XXX: this is also allowing g-recaptcha-response, because apparently hCaptcha thought
// it was a good idea to send both
(CaptchaService::HCaptcha, _, Some(response), None) => {
Request::post(HCAPTCHA_VERIFY_URL)
.body(VerificationRequest {
secret,
response,
remoteip,
})
.unwrap()
}
// Cloudflare Turnstile
(CaptchaService::CloudflareTurnstile, None, None, Some(response)) => {
Request::post(CF_TURNSTILE_VERIFY_URL)
.body(VerificationRequest {
secret,
response,
remoteip,
})
.unwrap()
}
_ => return Err(Error::CaptchaResponseMismatch),
};
let client = http_client_factory
.client("captcha")
.request_bytes_to_body()
.form_urlencoded_request()
.response_body_to_bytes()
.json_response::<VerificationResponse>()
.map_err(|e| Error::RequestFailed(e.into()));
let response = client.ready_oneshot().await?.call(request).await?;
let response = response.into_body();
if !response.success {
return Err(Error::InvalidCaptcha(
response.error_codes.unwrap_or_default(),
));
}
// If the response is successful, we should have both the hostname and the
// challenge_ts
let Some(hostname) = response.hostname else {
return Err(Error::InvalidResponse);
};
let Some(challenge_ts) = response.challenge_ts else {
return Err(Error::InvalidResponse);
};
span.record("captcha.hostname", &hostname);
span.record("captcha.challenge_ts", &challenge_ts);
if hostname != site_hostname {
return Err(Error::HostnameMismatch {
expected: site_hostname.to_owned(),
got: hostname,
});
}
Ok(())
}
}

View File

@@ -63,6 +63,7 @@ pub mod upstream_oauth2;
mod views; mod views;
mod activity_tracker; mod activity_tracker;
mod captcha;
mod preferred_language; mod preferred_language;
#[cfg(test)] #[cfg(test)]
mod test_utils; mod test_utils;

View File

@@ -24,6 +24,7 @@ use lettre::Address;
use mas_axum_utils::{ use mas_axum_utils::{
cookies::CookieJar, cookies::CookieJar,
csrf::{CsrfExt, CsrfToken, ProtectedForm}, csrf::{CsrfExt, CsrfToken, ProtectedForm},
http_client_factory::HttpClientFactory,
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::{CaptchaConfig, UserAgent}; use mas_data_model::{CaptchaConfig, UserAgent};
@@ -44,7 +45,10 @@ use serde::{Deserialize, Serialize};
use zeroize::Zeroizing; use zeroize::Zeroizing;
use super::shared::OptionalPostAuthAction; use super::shared::OptionalPostAuthAction;
use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage, SiteConfig}; use crate::{
captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker,
PreferredLanguage, SiteConfig,
};
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub(crate) struct RegisterForm { pub(crate) struct RegisterForm {
@@ -54,6 +58,9 @@ pub(crate) struct RegisterForm {
password_confirm: String, password_confirm: String,
#[serde(default)] #[serde(default)]
accept_terms: String, accept_terms: String,
#[serde(flatten, skip_serializing)]
captcha: CaptchaForm,
} }
impl ToFormState for RegisterForm { impl ToFormState for RegisterForm {
@@ -114,6 +121,7 @@ pub(crate) async fn post(
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>, State(site_config): State<SiteConfig>,
State(homeserver): State<BoxHomeserverConnection>, State(homeserver): State<BoxHomeserverConnection>,
State(http_client_factory): State<HttpClientFactory>,
mut policy: Policy, mut policy: Policy,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@@ -131,6 +139,17 @@ pub(crate) async fn post(
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
// Validate the captcha
// TODO: display a nice error message to the user
form.captcha
.verify(
&activity_tracker,
&http_client_factory,
url_builder.public_hostname(),
site_config.captcha.as_ref(),
)
.await?;
// Validate the form // Validate the form
let state = { let state = {
let mut state = form.to_form_state(); let mut state = form.to_form_state();

View File

@@ -112,6 +112,18 @@ impl UrlBuilder {
} }
} }
/// Site public hostname
///
/// # Panics
///
/// Panics if the base URL does not have a host
#[must_use]
pub fn public_hostname(&self) -> &str {
self.http_base
.host_str()
.expect("base URL must have a host")
}
/// HTTP base /// HTTP base
#[must_use] #[must_use]
pub fn http_base(&self) -> Url { pub fn http_base(&self) -> Url {