You've already forked authentication-service
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:
@@ -16,7 +16,7 @@ use chrono::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// Which Captcha service is being used
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CaptchaService {
|
||||
RecaptchaV2,
|
||||
CloudflareTurnstile,
|
||||
|
280
crates/handlers/src/captcha.rs
Normal file
280
crates/handlers/src/captcha.rs
Normal 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(())
|
||||
}
|
||||
}
|
@@ -63,6 +63,7 @@ pub mod upstream_oauth2;
|
||||
mod views;
|
||||
|
||||
mod activity_tracker;
|
||||
mod captcha;
|
||||
mod preferred_language;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
|
@@ -24,6 +24,7 @@ use lettre::Address;
|
||||
use mas_axum_utils::{
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, CsrfToken, ProtectedForm},
|
||||
http_client_factory::HttpClientFactory,
|
||||
FancyError, SessionInfoExt,
|
||||
};
|
||||
use mas_data_model::{CaptchaConfig, UserAgent};
|
||||
@@ -44,7 +45,10 @@ use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
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)]
|
||||
pub(crate) struct RegisterForm {
|
||||
@@ -54,6 +58,9 @@ pub(crate) struct RegisterForm {
|
||||
password_confirm: String,
|
||||
#[serde(default)]
|
||||
accept_terms: String,
|
||||
|
||||
#[serde(flatten, skip_serializing)]
|
||||
captcha: CaptchaForm,
|
||||
}
|
||||
|
||||
impl ToFormState for RegisterForm {
|
||||
@@ -114,6 +121,7 @@ pub(crate) async fn post(
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
State(homeserver): State<BoxHomeserverConnection>,
|
||||
State(http_client_factory): State<HttpClientFactory>,
|
||||
mut policy: Policy,
|
||||
mut repo: BoxRepository,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
@@ -131,6 +139,17 @@ pub(crate) async fn post(
|
||||
|
||||
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
|
||||
let state = {
|
||||
let mut state = form.to_form_state();
|
||||
|
@@ -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
|
||||
#[must_use]
|
||||
pub fn http_base(&self) -> Url {
|
||||
|
Reference in New Issue
Block a user