mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge remote-tracking branch 'origin/pr/462'
This commit is contained in:
@@ -1054,6 +1054,10 @@ pub(crate) async fn proxy_request(
|
||||
&state,
|
||||
&request_context,
|
||||
&parts.headers,
|
||||
parts
|
||||
.extensions
|
||||
.get::<crate::middleware::CfConnectingIp>()
|
||||
.map(|value| value.0.as_str()),
|
||||
local_proxy_body.as_ref(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -110,6 +110,7 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Option<Response<Body>> {
|
||||
let decision = request_context.control_decision.as_ref()?;
|
||||
@@ -118,8 +119,14 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("auth") {
|
||||
return maybe_build_local_auth_response(state, request_context, headers, request_body)
|
||||
.await;
|
||||
return maybe_build_local_auth_response(
|
||||
state,
|
||||
request_context,
|
||||
headers,
|
||||
cf_connecting_ip,
|
||||
request_body,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if decision.route_family.as_deref() == Some("oauth") {
|
||||
|
||||
@@ -22,6 +22,10 @@ pub(crate) use auth_helpers::*;
|
||||
mod auth_email;
|
||||
use auth_email::*;
|
||||
|
||||
#[path = "auth_turnstile.rs"]
|
||||
mod auth_turnstile;
|
||||
use auth_turnstile::*;
|
||||
|
||||
#[path = "auth_ldap.rs"]
|
||||
mod auth_ldap;
|
||||
use auth_ldap::*;
|
||||
@@ -258,6 +262,7 @@ pub(super) async fn maybe_build_local_auth_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Option<Response<Body>> {
|
||||
let decision = request_context.control_decision.as_ref()?;
|
||||
@@ -269,13 +274,16 @@ pub(super) async fn maybe_build_local_auth_response(
|
||||
Some("send_verification_code")
|
||||
if request_context.request_path == "/api/auth/send-verification-code" =>
|
||||
{
|
||||
Some(handle_auth_send_verification_code(state, request_body).await)
|
||||
Some(
|
||||
handle_auth_send_verification_code(state, headers, cf_connecting_ip, request_body)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
Some("login") if request_context.request_path == "/api/auth/login" => {
|
||||
Some(handle_auth_login(state, request_context, headers, request_body).await)
|
||||
}
|
||||
Some("register") if request_context.request_path == "/api/auth/register" => {
|
||||
Some(handle_auth_register(state, request_body).await)
|
||||
Some(handle_auth_register(state, headers, cf_connecting_ip, request_body).await)
|
||||
}
|
||||
Some("verify_email") if request_context.request_path == "/api/auth/verify-email" => {
|
||||
Some(handle_auth_verify_email(state, request_body).await)
|
||||
@@ -325,10 +333,15 @@ mod tests {
|
||||
async fn auth_unhandled_route_returns_local_not_implemented_response() {
|
||||
let state = AppState::new().expect("gateway should build");
|
||||
let request_context = request_context(Method::POST, "/api/auth/login/history", "login");
|
||||
let response =
|
||||
maybe_build_local_auth_response(&state, &request_context, &HeaderMap::new(), None)
|
||||
.await
|
||||
.expect("auth handler should return response");
|
||||
let response = maybe_build_local_auth_response(
|
||||
&state,
|
||||
&request_context,
|
||||
&HeaderMap::new(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("auth handler should return response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
|
||||
@@ -20,6 +20,12 @@ pub(crate) async fn build_auth_registration_settings_payload(
|
||||
let password_policy_level_config = state
|
||||
.read_system_config_json_value("password_policy_level")
|
||||
.await?;
|
||||
let turnstile_enabled_config = state
|
||||
.read_system_config_json_value("turnstile_enabled")
|
||||
.await?;
|
||||
let turnstile_site_key_config = state
|
||||
.read_system_config_json_value("turnstile_site_key")
|
||||
.await?;
|
||||
|
||||
let email_configured = smtp_host
|
||||
.as_ref()
|
||||
@@ -40,12 +46,17 @@ pub(crate) async fn build_auth_registration_settings_payload(
|
||||
Some(value) if matches!(value.as_str(), "weak" | "medium" | "strong") => value,
|
||||
_ => "weak".to_string(),
|
||||
};
|
||||
let turnstile_enabled = system_config_bool(turnstile_enabled_config.as_ref(), false);
|
||||
let turnstile_site_key = system_config_string(turnstile_site_key_config.as_ref());
|
||||
|
||||
Ok(json!({
|
||||
"enable_registration": enable_registration,
|
||||
"require_email_verification": require_email_verification,
|
||||
"email_configured": email_configured,
|
||||
"password_policy_level": password_policy_level,
|
||||
"turnstile_enabled": turnstile_enabled,
|
||||
"turnstile_site_key": turnstile_site_key,
|
||||
"turnstile_required_actions": ["send_verification_code", "register"],
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -290,6 +301,17 @@ pub(super) fn auth_client_ip(headers: &http::HeaderMap) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn auth_client_ip_with_cf(
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
) -> Option<String> {
|
||||
cf_connecting_ip
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(45).collect())
|
||||
.or_else(|| auth_client_ip(headers))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_auth_login_identifier(value: &str) -> String {
|
||||
let normalized = value.trim();
|
||||
if normalized.contains('@') {
|
||||
|
||||
@@ -5,7 +5,8 @@ use super::{
|
||||
clear_auth_email_pending_code, clear_auth_email_verification, generate_auth_verification_code,
|
||||
http, json, mark_auth_email_verified, read_auth_email_verification_code, read_auth_smtp_config,
|
||||
send_auth_email, store_auth_email_verification_code, system_config_bool, system_config_f64,
|
||||
system_config_string, system_config_string_list, AppState, Body, GatewayError, Regex, Response,
|
||||
system_config_string, system_config_string_list, verify_auth_turnstile, AppState,
|
||||
AuthTurnstileAction, Body, GatewayError, Regex, Response,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -16,11 +17,13 @@ struct AuthRegisterRequest {
|
||||
email: Option<String>,
|
||||
username: String,
|
||||
password: String,
|
||||
turnstile_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthEmailRequest {
|
||||
email: String,
|
||||
turnstile_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -176,6 +179,8 @@ async fn validate_auth_email_suffix(
|
||||
|
||||
pub(super) async fn handle_auth_send_verification_code(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Response<Body> {
|
||||
let Some(request_body) = request_body else {
|
||||
@@ -195,6 +200,18 @@ pub(super) async fn handle_auth_send_verification_code(
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "邮箱格式无效", false);
|
||||
};
|
||||
|
||||
if let Err(response) = verify_auth_turnstile(
|
||||
state,
|
||||
headers,
|
||||
cf_connecting_ip,
|
||||
payload.turnstile_token.as_deref(),
|
||||
AuthTurnstileAction::SendVerificationCode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
if state
|
||||
.find_user_auth_by_identifier(&email)
|
||||
.await
|
||||
@@ -316,6 +333,8 @@ pub(super) async fn handle_auth_send_verification_code(
|
||||
|
||||
pub(super) async fn handle_auth_register(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Response<Body> {
|
||||
let Some(request_body) = request_body else {
|
||||
@@ -370,6 +389,18 @@ pub(super) async fn handle_auth_register(
|
||||
return build_auth_error_response(http::StatusCode::FORBIDDEN, "系统暂不开放注册", false);
|
||||
}
|
||||
|
||||
if let Err(response) = verify_auth_turnstile(
|
||||
state,
|
||||
headers,
|
||||
cf_connecting_ip,
|
||||
payload.turnstile_token.as_deref(),
|
||||
AuthTurnstileAction::Register,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
let email_configured = match auth_registration_email_configured(state).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
use super::{
|
||||
auth_client_ip_with_cf, build_auth_error_response, decrypt_catalog_secret_with_fallbacks, http,
|
||||
system_config_bool, system_config_string, system_config_string_list, AppState, Body, Response,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
const TURNSTILE_SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const TURNSTILE_TOKEN_MAX_LEN: usize = 2048;
|
||||
const TURNSTILE_SITEVERIFY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) enum AuthTurnstileAction {
|
||||
SendVerificationCode,
|
||||
Register,
|
||||
}
|
||||
|
||||
impl AuthTurnstileAction {
|
||||
pub(super) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SendVerificationCode => "send_verification_code",
|
||||
Self::Register => "register",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AuthTurnstileConfig {
|
||||
enabled: bool,
|
||||
site_key: Option<String>,
|
||||
secret_key: Option<String>,
|
||||
allowed_hostnames: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TurnstileSiteverifyRequest<'a> {
|
||||
secret: &'a str,
|
||||
response: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
remoteip: Option<&'a str>,
|
||||
idempotency_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TurnstileSiteverifyResponse {
|
||||
#[serde(default)]
|
||||
success: bool,
|
||||
#[serde(default)]
|
||||
action: Option<String>,
|
||||
#[serde(default)]
|
||||
hostname: Option<String>,
|
||||
#[serde(default, rename = "error-codes")]
|
||||
error_codes: Vec<String>,
|
||||
}
|
||||
|
||||
enum AuthTurnstileFailure {
|
||||
BadRequest(&'static str),
|
||||
ServiceUnavailable(&'static str),
|
||||
}
|
||||
|
||||
impl AuthTurnstileFailure {
|
||||
fn into_response(self) -> Response<Body> {
|
||||
match self {
|
||||
Self::BadRequest(detail) => {
|
||||
build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
|
||||
}
|
||||
Self::ServiceUnavailable(detail) => {
|
||||
build_auth_error_response(http::StatusCode::SERVICE_UNAVAILABLE, detail, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn verify_auth_turnstile(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
token: Option<&str>,
|
||||
action: AuthTurnstileAction,
|
||||
) -> Result<(), Response<Body>> {
|
||||
match verify_auth_turnstile_inner(state, headers, cf_connecting_ip, token, action).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => Err(err.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_auth_turnstile_inner(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
cf_connecting_ip: Option<&str>,
|
||||
token: Option<&str>,
|
||||
action: AuthTurnstileAction,
|
||||
) -> Result<(), AuthTurnstileFailure> {
|
||||
let config = read_auth_turnstile_config(state).await?;
|
||||
if !config.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (Some(_site_key), Some(secret_key)) =
|
||||
(config.site_key.as_deref(), config.secret_key.as_deref())
|
||||
else {
|
||||
warn!("turnstile is enabled but site key or secret key is missing");
|
||||
return Err(AuthTurnstileFailure::ServiceUnavailable(
|
||||
"人机验证服务暂不可用,请稍后重试",
|
||||
));
|
||||
};
|
||||
|
||||
let token = token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(AuthTurnstileFailure::BadRequest("请先完成人机验证"))?;
|
||||
if token.len() > TURNSTILE_TOKEN_MAX_LEN {
|
||||
warn!(
|
||||
token_len = token.len(),
|
||||
"turnstile token exceeds maximum length"
|
||||
);
|
||||
return Err(AuthTurnstileFailure::BadRequest("人机验证失败,请重试"));
|
||||
}
|
||||
|
||||
let remoteip = auth_client_ip_with_cf(headers, cf_connecting_ip);
|
||||
let siteverify_request = TurnstileSiteverifyRequest {
|
||||
secret: secret_key,
|
||||
response: token,
|
||||
remoteip: remoteip.as_deref(),
|
||||
idempotency_key: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
let siteverify_url = turnstile_siteverify_url(state);
|
||||
let response = tokio::time::timeout(
|
||||
turnstile_siteverify_timeout(state),
|
||||
state
|
||||
.client
|
||||
.post(siteverify_url)
|
||||
.form(&siteverify_request)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!("turnstile siteverify request timed out");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "turnstile siteverify request failed");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
warn!(
|
||||
status,
|
||||
"turnstile siteverify returned non-success HTTP status"
|
||||
);
|
||||
return Err(AuthTurnstileFailure::ServiceUnavailable(
|
||||
"人机验证服务暂不可用,请稍后重试",
|
||||
));
|
||||
}
|
||||
let payload = response
|
||||
.json::<TurnstileSiteverifyResponse>()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "turnstile siteverify response decode failed");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?;
|
||||
|
||||
if !payload.success {
|
||||
warn!(
|
||||
error_codes = ?payload.error_codes,
|
||||
action = ?payload.action,
|
||||
hostname = ?payload.hostname,
|
||||
"turnstile siteverify rejected token"
|
||||
);
|
||||
if turnstile_siteverify_error_is_service_unavailable(&payload.error_codes) {
|
||||
return Err(AuthTurnstileFailure::ServiceUnavailable(
|
||||
"人机验证服务暂不可用,请稍后重试",
|
||||
));
|
||||
}
|
||||
return Err(AuthTurnstileFailure::BadRequest("人机验证失败,请重试"));
|
||||
}
|
||||
if payload.action.as_deref() != Some(action.as_str()) {
|
||||
warn!(
|
||||
expected_action = action.as_str(),
|
||||
actual_action = ?payload.action,
|
||||
"turnstile siteverify action mismatch"
|
||||
);
|
||||
return Err(AuthTurnstileFailure::BadRequest("人机验证失败,请重试"));
|
||||
}
|
||||
if !config.allowed_hostnames.is_empty() {
|
||||
let Some(hostname) = payload.hostname.as_deref().map(str::to_ascii_lowercase) else {
|
||||
warn!("turnstile siteverify response missing hostname");
|
||||
return Err(AuthTurnstileFailure::BadRequest("人机验证失败,请重试"));
|
||||
};
|
||||
if !config
|
||||
.allowed_hostnames
|
||||
.iter()
|
||||
.any(|allowed| allowed == &hostname)
|
||||
{
|
||||
warn!(
|
||||
hostname = %hostname,
|
||||
allowed_hostnames = ?config.allowed_hostnames,
|
||||
"turnstile siteverify hostname mismatch"
|
||||
);
|
||||
return Err(AuthTurnstileFailure::BadRequest("人机验证失败,请重试"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn turnstile_siteverify_error_is_service_unavailable(error_codes: &[String]) -> bool {
|
||||
error_codes.iter().any(|code| {
|
||||
matches!(
|
||||
code.trim().to_ascii_lowercase().as_str(),
|
||||
"missing-input-secret" | "invalid-input-secret" | "internal-error"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_auth_turnstile_config(
|
||||
state: &AppState,
|
||||
) -> Result<AuthTurnstileConfig, AuthTurnstileFailure> {
|
||||
let enabled = state
|
||||
.read_system_config_json_value("turnstile_enabled")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = ?err, "turnstile enabled config lookup failed");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?;
|
||||
let site_key = state
|
||||
.read_system_config_json_value("turnstile_site_key")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = ?err, "turnstile site key config lookup failed");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?;
|
||||
let secret_key = state
|
||||
.read_system_config_json_value("turnstile_secret_key")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = ?err, "turnstile secret key config lookup failed");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?;
|
||||
let allowed_hostnames = state
|
||||
.read_system_config_json_value("turnstile_allowed_hostnames")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = ?err, "turnstile hostname config lookup failed");
|
||||
AuthTurnstileFailure::ServiceUnavailable("人机验证服务暂不可用,请稍后重试")
|
||||
})?;
|
||||
|
||||
let secret_key = system_config_string(secret_key.as_ref()).map(|value| {
|
||||
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
||||
});
|
||||
|
||||
Ok(AuthTurnstileConfig {
|
||||
enabled: system_config_bool(enabled.as_ref(), false),
|
||||
site_key: system_config_string(site_key.as_ref()),
|
||||
secret_key,
|
||||
allowed_hostnames: system_config_string_list(allowed_hostnames.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
fn turnstile_siteverify_url(state: &AppState) -> &str {
|
||||
#[cfg(test)]
|
||||
if let Some(url) = state.turnstile_siteverify_url_override.as_deref() {
|
||||
return url;
|
||||
}
|
||||
TURNSTILE_SITEVERIFY_URL
|
||||
}
|
||||
|
||||
fn turnstile_siteverify_timeout(state: &AppState) -> Duration {
|
||||
#[cfg(test)]
|
||||
if let Some(timeout) = state.turnstile_siteverify_timeout_override {
|
||||
return timeout;
|
||||
}
|
||||
TURNSTILE_SITEVERIFY_TIMEOUT
|
||||
}
|
||||
@@ -6,5 +6,5 @@ pub(crate) use access_log::{
|
||||
access_log_middleware, should_downgrade_access_log, RequestLogEmitted,
|
||||
};
|
||||
pub(crate) use frontdoor_cors::frontdoor_cors_middleware;
|
||||
pub(crate) use strip_cf_headers::apply_cf_header_stripping;
|
||||
pub use strip_cf_headers::strip_cf_headers_middleware;
|
||||
pub(crate) use strip_cf_headers::{apply_cf_header_stripping, CfConnectingIp};
|
||||
|
||||
@@ -4,11 +4,23 @@ use http::{header::HeaderName, HeaderMap};
|
||||
/// Cloudflare-specific headers that are not part of the `cf-*` prefix family.
|
||||
const CF_EXACT_HEADERS: &[&str] = &["cdn-loop", "true-client-ip"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CfConnectingIp(pub(crate) String);
|
||||
|
||||
fn should_strip_cf_header(name: &HeaderName) -> bool {
|
||||
let normalized = name.as_str();
|
||||
normalized.starts_with("cf-") || CF_EXACT_HEADERS.contains(&normalized)
|
||||
}
|
||||
|
||||
fn cf_connecting_ip(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("cf-connecting-ip")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(45).collect())
|
||||
}
|
||||
|
||||
fn strip_cf_headers(headers: &mut HeaderMap) {
|
||||
let to_remove: Vec<_> = headers
|
||||
.keys()
|
||||
@@ -25,6 +37,9 @@ pub(crate) fn apply_cf_header_stripping(router: Router) -> Router {
|
||||
}
|
||||
|
||||
pub async fn strip_cf_headers_middleware(mut request: Request, next: Next) -> Response {
|
||||
if let Some(client_ip) = cf_connecting_ip(request.headers()) {
|
||||
request.extensions_mut().insert(CfConnectingIp(client_ip));
|
||||
}
|
||||
strip_cf_headers(request.headers_mut());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
@@ -51,6 +66,7 @@ mod tests {
|
||||
any(|headers: http::HeaderMap| async move {
|
||||
let leaked = headers.contains_key("cf-ipcity")
|
||||
|| headers.contains_key("cf-ray")
|
||||
|| headers.contains_key("cf-connecting-ip")
|
||||
|| headers.contains_key("true-client-ip")
|
||||
|| headers.contains_key("cdn-loop");
|
||||
let mut response =
|
||||
@@ -81,6 +97,7 @@ mod tests {
|
||||
.uri("/")
|
||||
.header("cf-ipcity", "Shanghai")
|
||||
.header("cf-ray", "abc123")
|
||||
.header("cf-connecting-ip", "203.0.113.10")
|
||||
.header("true-client-ip", "1.1.1.1")
|
||||
.header("cdn-loop", "cloudflare")
|
||||
.body(Body::empty())
|
||||
@@ -91,6 +108,7 @@ mod tests {
|
||||
|
||||
assert!(response.headers().get("cf-ipcity").is_none());
|
||||
assert!(response.headers().get("cf-cache-status").is_none());
|
||||
assert!(response.headers().get("cf-connecting-ip").is_none());
|
||||
assert!(response.headers().get("true-client-ip").is_none());
|
||||
assert!(response.headers().get("cdn-loop").is_none());
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_runtime::ConcurrencyGate;
|
||||
use aether_runtime_state::{RuntimeSemaphore, RuntimeState};
|
||||
@@ -76,6 +77,10 @@ pub struct AppState {
|
||||
pub(crate) admin_monitoring_error_stats_reset_at: Arc<StdMutex<Option<u64>>>,
|
||||
pub(crate) provider_delete_tasks: Arc<StdMutex<HashMap<String, LocalProviderDeleteTaskState>>>,
|
||||
#[cfg(test)]
|
||||
pub(crate) turnstile_siteverify_url_override: Option<String>,
|
||||
#[cfg(test)]
|
||||
pub(crate) turnstile_siteverify_timeout_override: Option<Duration>,
|
||||
#[cfg(test)]
|
||||
pub(crate) provider_oauth_state_store: Option<Arc<StdMutex<HashMap<String, String>>>>,
|
||||
#[cfg(test)]
|
||||
pub(crate) provider_oauth_device_session_store: Option<Arc<StdMutex<HashMap<String, String>>>>,
|
||||
|
||||
@@ -259,6 +259,10 @@ impl AppState {
|
||||
admin_monitoring_error_stats_reset_at: Arc::new(StdMutex::new(None)),
|
||||
provider_delete_tasks: Arc::new(StdMutex::new(HashMap::new())),
|
||||
#[cfg(test)]
|
||||
turnstile_siteverify_url_override: None,
|
||||
#[cfg(test)]
|
||||
turnstile_siteverify_timeout_override: None,
|
||||
#[cfg(test)]
|
||||
provider_oauth_state_store: None,
|
||||
#[cfg(test)]
|
||||
provider_oauth_device_session_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
||||
|
||||
@@ -21,6 +21,16 @@ impl AppState {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_turnstile_siteverify_url_for_tests(mut self, url: &str) -> Self {
|
||||
self.turnstile_siteverify_url_override = Some(url.trim().to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_turnstile_siteverify_timeout_for_tests(mut self, timeout: Duration) -> Self {
|
||||
self.turnstile_siteverify_timeout_override = Some(timeout);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_tunnel_identity_for_tests(
|
||||
mut self,
|
||||
instance_id: &str,
|
||||
|
||||
@@ -1178,6 +1178,10 @@ async fn gateway_handles_admin_system_configs_locally_with_trusted_admin_princip
|
||||
let data_state = GatewayDataState::disabled().with_system_config_values_for_tests(vec![
|
||||
("request_log_level".to_string(), json!("headers")),
|
||||
("smtp_password".to_string(), json!("encrypted-secret")),
|
||||
(
|
||||
"turnstile_secret_key".to_string(),
|
||||
json!("encrypted-turnstile-secret"),
|
||||
),
|
||||
("site_name".to_string(), json!("Aether Test")),
|
||||
]);
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
@@ -1211,6 +1215,12 @@ async fn gateway_handles_admin_system_configs_locally_with_trusted_admin_princip
|
||||
.expect("smtp_password should exist");
|
||||
assert_eq!(smtp_password["value"], serde_json::Value::Null);
|
||||
assert_eq!(smtp_password["is_set"], json!(true));
|
||||
let turnstile_secret_key = items
|
||||
.iter()
|
||||
.find(|item| item["key"] == "turnstile_secret_key")
|
||||
.expect("turnstile_secret_key should exist");
|
||||
assert_eq!(turnstile_secret_key["value"], serde_json::Value::Null);
|
||||
assert_eq!(turnstile_secret_key["is_set"], json!(true));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
|
||||
@@ -1409,6 +1409,12 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
|
||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||
("password_policy_level".to_string(), json!("strong")),
|
||||
("turnstile_enabled".to_string(), json!(true)),
|
||||
("turnstile_site_key".to_string(), json!("site-public-key")),
|
||||
(
|
||||
"turnstile_secret_key".to_string(),
|
||||
json!("secret-private-key"),
|
||||
),
|
||||
]);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
@@ -1434,6 +1440,9 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
|
||||
"require_email_verification": true,
|
||||
"email_configured": true,
|
||||
"password_policy_level": "strong",
|
||||
"turnstile_enabled": true,
|
||||
"turnstile_site_key": "site-public-key",
|
||||
"turnstile_required_actions": ["send_verification_code", "register"],
|
||||
})
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -7875,6 +7884,551 @@ async fn gateway_handles_auth_register_locally_without_proxying_upstream() {
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
async fn start_turnstile_siteverify_server(
|
||||
response_payload: serde_json::Value,
|
||||
status: StatusCode,
|
||||
) -> (
|
||||
String,
|
||||
Arc<Mutex<Vec<std::collections::HashMap<String, String>>>>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
start_turnstile_siteverify_server_with_delay(response_payload, status, None).await
|
||||
}
|
||||
|
||||
async fn start_turnstile_siteverify_server_with_delay(
|
||||
response_payload: serde_json::Value,
|
||||
status: StatusCode,
|
||||
delay: Option<Duration>,
|
||||
) -> (
|
||||
String,
|
||||
Arc<Mutex<Vec<std::collections::HashMap<String, String>>>>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let requests_clone = Arc::clone(&requests);
|
||||
let upstream = Router::new().route(
|
||||
"/turnstile/siteverify",
|
||||
any(
|
||||
move |axum::extract::Form(form): axum::extract::Form<
|
||||
std::collections::HashMap<String, String>,
|
||||
>| {
|
||||
let requests_inner = Arc::clone(&requests_clone);
|
||||
let response_payload = response_payload.clone();
|
||||
async move {
|
||||
if let Some(delay) = delay {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
requests_inner
|
||||
.lock()
|
||||
.expect("turnstile requests should lock")
|
||||
.push(form);
|
||||
(status, Json(response_payload))
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
let (url, handle) = start_server(upstream).await;
|
||||
(format!("{url}/turnstile/siteverify"), requests, handle)
|
||||
}
|
||||
|
||||
fn turnstile_enabled_data_state() -> crate::data::GatewayDataState {
|
||||
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(vec![
|
||||
("enable_registration".to_string(), json!(true)),
|
||||
("require_email_verification".to_string(), json!(true)),
|
||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||
("smtp_from_email".to_string(), json!("ops@example.com")),
|
||||
("default_user_initial_gift_usd".to_string(), json!(12.5)),
|
||||
("turnstile_enabled".to_string(), json!(true)),
|
||||
("turnstile_site_key".to_string(), json!("site-public-key")),
|
||||
(
|
||||
"turnstile_secret_key".to_string(),
|
||||
json!("secret-private-key"),
|
||||
),
|
||||
(
|
||||
"turnstile_allowed_hostnames".to_string(),
|
||||
json!(["gateway.example.com"]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_auth_register_without_turnstile_token_when_enabled() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "请先完成人机验证");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_auth_send_verification_code_without_turnstile_token_when_enabled() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/send-verification-code"))
|
||||
.json(&json!({ "email": "alice@example.com" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("send verification request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "请先完成人机验证");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_auth_register_with_oversized_turnstile_token() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "x".repeat(2049),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证失败,请重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_service_unavailable_when_turnstile_keys_are_incomplete() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
let data_state =
|
||||
turnstile_enabled_data_state().with_system_config_values_for_tests(vec![
|
||||
("enable_registration".to_string(), json!(true)),
|
||||
("require_email_verification".to_string(), json!(true)),
|
||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||
("smtp_from_email".to_string(), json!("ops@example.com")),
|
||||
("turnstile_enabled".to_string(), json!(true)),
|
||||
("turnstile_site_key".to_string(), json!("site-public-key")),
|
||||
("turnstile_secret_key".to_string(), serde_json::Value::Null),
|
||||
]);
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state)
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证服务暂不可用,请稍后重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_allows_auth_register_after_successful_turnstile_verification() {
|
||||
let (siteverify_url, turnstile_requests, turnstile_handle) = start_turnstile_siteverify_server(
|
||||
json!({
|
||||
"success": true,
|
||||
"action": "register",
|
||||
"hostname": "gateway.example.com",
|
||||
}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let register_response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.header("cf-connecting-ip", "203.0.113.10")
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(register_response.status(), StatusCode::OK);
|
||||
let register_payload: serde_json::Value = register_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(register_payload["message"], "注册成功");
|
||||
|
||||
let requests = turnstile_requests
|
||||
.lock()
|
||||
.expect("turnstile requests should lock");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].get("secret").map(String::as_str),
|
||||
Some("secret-private-key")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].get("response").map(String::as_str),
|
||||
Some("valid-token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].get("remoteip").map(String::as_str),
|
||||
Some("203.0.113.10")
|
||||
);
|
||||
assert!(requests[0].contains_key("idempotency_key"));
|
||||
drop(requests);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_auth_register_when_turnstile_action_mismatches() {
|
||||
let (siteverify_url, _turnstile_requests, turnstile_handle) =
|
||||
start_turnstile_siteverify_server(
|
||||
json!({
|
||||
"success": true,
|
||||
"action": "send_verification_code",
|
||||
"hostname": "gateway.example.com",
|
||||
}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证失败,请重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_auth_register_when_turnstile_siteverify_rejects_token() {
|
||||
let (siteverify_url, _turnstile_requests, turnstile_handle) =
|
||||
start_turnstile_siteverify_server(
|
||||
json!({
|
||||
"success": false,
|
||||
"error-codes": ["invalid-input-response"],
|
||||
}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "invalid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证失败,请重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_service_unavailable_when_turnstile_siteverify_reports_secret_error() {
|
||||
let (siteverify_url, _turnstile_requests, turnstile_handle) =
|
||||
start_turnstile_siteverify_server(
|
||||
json!({
|
||||
"success": false,
|
||||
"error-codes": ["invalid-input-secret"],
|
||||
}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证服务暂不可用,请稍后重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_auth_register_when_turnstile_hostname_mismatches() {
|
||||
let (siteverify_url, _turnstile_requests, turnstile_handle) =
|
||||
start_turnstile_siteverify_server(
|
||||
json!({
|
||||
"success": true,
|
||||
"action": "register",
|
||||
"hostname": "evil.example.com",
|
||||
}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证失败,请重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_service_unavailable_when_turnstile_siteverify_fails() {
|
||||
let (siteverify_url, _turnstile_requests, turnstile_handle) =
|
||||
start_turnstile_siteverify_server(
|
||||
json!({ "error": "unavailable" }),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证服务暂不可用,请稍后重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_service_unavailable_when_turnstile_siteverify_times_out() {
|
||||
let (siteverify_url, _turnstile_requests, turnstile_handle) =
|
||||
start_turnstile_siteverify_server_with_delay(
|
||||
json!({
|
||||
"success": true,
|
||||
"action": "register",
|
||||
"hostname": "gateway.example.com",
|
||||
}),
|
||||
StatusCode::OK,
|
||||
Some(Duration::from_millis(250)),
|
||||
)
|
||||
.await;
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(turnstile_enabled_data_state())
|
||||
.with_auth_email_verified_for_tests("alice@example.com")
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
.with_turnstile_siteverify_timeout_for_tests(Duration::from_millis(20))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/register"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"username": "alice",
|
||||
"password": "secret123",
|
||||
"turnstile_token": "valid-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "人机验证服务暂不可用,请稍后重试");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
turnstile_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_service_unavailable_for_auth_register_without_storage() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
|
||||
Reference in New Issue
Block a user