mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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,
|
&state,
|
||||||
&request_context,
|
&request_context,
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.get::<crate::middleware::CfConnectingIp>()
|
||||||
|
.map(|value| value.0.as_str()),
|
||||||
local_proxy_body.as_ref(),
|
local_proxy_body.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
|
cf_connecting_ip: Option<&str>,
|
||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
) -> Option<Response<Body>> {
|
) -> Option<Response<Body>> {
|
||||||
let decision = request_context.control_decision.as_ref()?;
|
let decision = request_context.control_decision.as_ref()?;
|
||||||
@@ -118,7 +119,13 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if decision.route_family.as_deref() == Some("auth") {
|
if decision.route_family.as_deref() == Some("auth") {
|
||||||
return maybe_build_local_auth_response(state, request_context, headers, request_body)
|
return maybe_build_local_auth_response(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
headers,
|
||||||
|
cf_connecting_ip,
|
||||||
|
request_body,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ pub(crate) use auth_helpers::*;
|
|||||||
mod auth_email;
|
mod auth_email;
|
||||||
use auth_email::*;
|
use auth_email::*;
|
||||||
|
|
||||||
|
#[path = "auth_turnstile.rs"]
|
||||||
|
mod auth_turnstile;
|
||||||
|
use auth_turnstile::*;
|
||||||
|
|
||||||
#[path = "auth_ldap.rs"]
|
#[path = "auth_ldap.rs"]
|
||||||
mod auth_ldap;
|
mod auth_ldap;
|
||||||
use auth_ldap::*;
|
use auth_ldap::*;
|
||||||
@@ -258,6 +262,7 @@ pub(super) async fn maybe_build_local_auth_response(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
|
cf_connecting_ip: Option<&str>,
|
||||||
request_body: Option<&axum::body::Bytes>,
|
request_body: Option<&axum::body::Bytes>,
|
||||||
) -> Option<Response<Body>> {
|
) -> Option<Response<Body>> {
|
||||||
let decision = request_context.control_decision.as_ref()?;
|
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")
|
Some("send_verification_code")
|
||||||
if request_context.request_path == "/api/auth/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("login") if request_context.request_path == "/api/auth/login" => {
|
||||||
Some(handle_auth_login(state, request_context, headers, request_body).await)
|
Some(handle_auth_login(state, request_context, headers, request_body).await)
|
||||||
}
|
}
|
||||||
Some("register") if request_context.request_path == "/api/auth/register" => {
|
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("verify_email") if request_context.request_path == "/api/auth/verify-email" => {
|
||||||
Some(handle_auth_verify_email(state, request_body).await)
|
Some(handle_auth_verify_email(state, request_body).await)
|
||||||
@@ -325,8 +333,13 @@ mod tests {
|
|||||||
async fn auth_unhandled_route_returns_local_not_implemented_response() {
|
async fn auth_unhandled_route_returns_local_not_implemented_response() {
|
||||||
let state = AppState::new().expect("gateway should build");
|
let state = AppState::new().expect("gateway should build");
|
||||||
let request_context = request_context(Method::POST, "/api/auth/login/history", "login");
|
let request_context = request_context(Method::POST, "/api/auth/login/history", "login");
|
||||||
let response =
|
let response = maybe_build_local_auth_response(
|
||||||
maybe_build_local_auth_response(&state, &request_context, &HeaderMap::new(), None)
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&HeaderMap::new(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("auth handler should return response");
|
.expect("auth handler should return response");
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ pub(crate) async fn build_auth_registration_settings_payload(
|
|||||||
let password_policy_level_config = state
|
let password_policy_level_config = state
|
||||||
.read_system_config_json_value("password_policy_level")
|
.read_system_config_json_value("password_policy_level")
|
||||||
.await?;
|
.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
|
let email_configured = smtp_host
|
||||||
.as_ref()
|
.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,
|
Some(value) if matches!(value.as_str(), "weak" | "medium" | "strong") => value,
|
||||||
_ => "weak".to_string(),
|
_ => "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!({
|
Ok(json!({
|
||||||
"enable_registration": enable_registration,
|
"enable_registration": enable_registration,
|
||||||
"require_email_verification": require_email_verification,
|
"require_email_verification": require_email_verification,
|
||||||
"email_configured": email_configured,
|
"email_configured": email_configured,
|
||||||
"password_policy_level": password_policy_level,
|
"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 {
|
pub(super) fn normalize_auth_login_identifier(value: &str) -> String {
|
||||||
let normalized = value.trim();
|
let normalized = value.trim();
|
||||||
if normalized.contains('@') {
|
if normalized.contains('@') {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use super::{
|
|||||||
clear_auth_email_pending_code, clear_auth_email_verification, generate_auth_verification_code,
|
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,
|
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,
|
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;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ struct AuthRegisterRequest {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
turnstile_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AuthEmailRequest {
|
struct AuthEmailRequest {
|
||||||
email: String,
|
email: String,
|
||||||
|
turnstile_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -176,6 +179,8 @@ async fn validate_auth_email_suffix(
|
|||||||
|
|
||||||
pub(super) async fn handle_auth_send_verification_code(
|
pub(super) async fn handle_auth_send_verification_code(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
cf_connecting_ip: Option<&str>,
|
||||||
request_body: Option<&axum::body::Bytes>,
|
request_body: Option<&axum::body::Bytes>,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
let Some(request_body) = request_body else {
|
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);
|
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
|
if state
|
||||||
.find_user_auth_by_identifier(&email)
|
.find_user_auth_by_identifier(&email)
|
||||||
.await
|
.await
|
||||||
@@ -316,6 +333,8 @@ pub(super) async fn handle_auth_send_verification_code(
|
|||||||
|
|
||||||
pub(super) async fn handle_auth_register(
|
pub(super) async fn handle_auth_register(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
cf_connecting_ip: Option<&str>,
|
||||||
request_body: Option<&axum::body::Bytes>,
|
request_body: Option<&axum::body::Bytes>,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
let Some(request_body) = request_body else {
|
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);
|
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 {
|
let email_configured = match auth_registration_email_configured(state).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => {
|
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,
|
access_log_middleware, should_downgrade_access_log, RequestLogEmitted,
|
||||||
};
|
};
|
||||||
pub(crate) use frontdoor_cors::frontdoor_cors_middleware;
|
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 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.
|
/// Cloudflare-specific headers that are not part of the `cf-*` prefix family.
|
||||||
const CF_EXACT_HEADERS: &[&str] = &["cdn-loop", "true-client-ip"];
|
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 {
|
fn should_strip_cf_header(name: &HeaderName) -> bool {
|
||||||
let normalized = name.as_str();
|
let normalized = name.as_str();
|
||||||
normalized.starts_with("cf-") || CF_EXACT_HEADERS.contains(&normalized)
|
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) {
|
fn strip_cf_headers(headers: &mut HeaderMap) {
|
||||||
let to_remove: Vec<_> = headers
|
let to_remove: Vec<_> = headers
|
||||||
.keys()
|
.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 {
|
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());
|
strip_cf_headers(request.headers_mut());
|
||||||
|
|
||||||
let mut response = next.run(request).await;
|
let mut response = next.run(request).await;
|
||||||
@@ -51,6 +66,7 @@ mod tests {
|
|||||||
any(|headers: http::HeaderMap| async move {
|
any(|headers: http::HeaderMap| async move {
|
||||||
let leaked = headers.contains_key("cf-ipcity")
|
let leaked = headers.contains_key("cf-ipcity")
|
||||||
|| headers.contains_key("cf-ray")
|
|| headers.contains_key("cf-ray")
|
||||||
|
|| headers.contains_key("cf-connecting-ip")
|
||||||
|| headers.contains_key("true-client-ip")
|
|| headers.contains_key("true-client-ip")
|
||||||
|| headers.contains_key("cdn-loop");
|
|| headers.contains_key("cdn-loop");
|
||||||
let mut response =
|
let mut response =
|
||||||
@@ -81,6 +97,7 @@ mod tests {
|
|||||||
.uri("/")
|
.uri("/")
|
||||||
.header("cf-ipcity", "Shanghai")
|
.header("cf-ipcity", "Shanghai")
|
||||||
.header("cf-ray", "abc123")
|
.header("cf-ray", "abc123")
|
||||||
|
.header("cf-connecting-ip", "203.0.113.10")
|
||||||
.header("true-client-ip", "1.1.1.1")
|
.header("true-client-ip", "1.1.1.1")
|
||||||
.header("cdn-loop", "cloudflare")
|
.header("cdn-loop", "cloudflare")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
@@ -91,6 +108,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(response.headers().get("cf-ipcity").is_none());
|
assert!(response.headers().get("cf-ipcity").is_none());
|
||||||
assert!(response.headers().get("cf-cache-status").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("true-client-ip").is_none());
|
||||||
assert!(response.headers().get("cdn-loop").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::atomic::AtomicU64;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use aether_runtime::ConcurrencyGate;
|
use aether_runtime::ConcurrencyGate;
|
||||||
use aether_runtime_state::{RuntimeSemaphore, RuntimeState};
|
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) admin_monitoring_error_stats_reset_at: Arc<StdMutex<Option<u64>>>,
|
||||||
pub(crate) provider_delete_tasks: Arc<StdMutex<HashMap<String, LocalProviderDeleteTaskState>>>,
|
pub(crate) provider_delete_tasks: Arc<StdMutex<HashMap<String, LocalProviderDeleteTaskState>>>,
|
||||||
#[cfg(test)]
|
#[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>>>>,
|
pub(crate) provider_oauth_state_store: Option<Arc<StdMutex<HashMap<String, String>>>>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) provider_oauth_device_session_store: Option<Arc<StdMutex<HashMap<String, String>>>>,
|
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)),
|
admin_monitoring_error_stats_reset_at: Arc::new(StdMutex::new(None)),
|
||||||
provider_delete_tasks: Arc::new(StdMutex::new(HashMap::new())),
|
provider_delete_tasks: Arc::new(StdMutex::new(HashMap::new())),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
turnstile_siteverify_url_override: None,
|
||||||
|
#[cfg(test)]
|
||||||
|
turnstile_siteverify_timeout_override: None,
|
||||||
|
#[cfg(test)]
|
||||||
provider_oauth_state_store: None,
|
provider_oauth_state_store: None,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
provider_oauth_device_session_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
provider_oauth_device_session_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ impl AppState {
|
|||||||
self
|
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(
|
pub(crate) fn with_tunnel_identity_for_tests(
|
||||||
mut self,
|
mut self,
|
||||||
instance_id: &str,
|
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![
|
let data_state = GatewayDataState::disabled().with_system_config_values_for_tests(vec![
|
||||||
("request_log_level".to_string(), json!("headers")),
|
("request_log_level".to_string(), json!("headers")),
|
||||||
("smtp_password".to_string(), json!("encrypted-secret")),
|
("smtp_password".to_string(), json!("encrypted-secret")),
|
||||||
|
(
|
||||||
|
"turnstile_secret_key".to_string(),
|
||||||
|
json!("encrypted-turnstile-secret"),
|
||||||
|
),
|
||||||
("site_name".to_string(), json!("Aether Test")),
|
("site_name".to_string(), json!("Aether Test")),
|
||||||
]);
|
]);
|
||||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
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");
|
.expect("smtp_password should exist");
|
||||||
assert_eq!(smtp_password["value"], serde_json::Value::Null);
|
assert_eq!(smtp_password["value"], serde_json::Value::Null);
|
||||||
assert_eq!(smtp_password["is_set"], json!(true));
|
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);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
|
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_host".to_string(), json!("smtp.example.com")),
|
||||||
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||||
("password_policy_level".to_string(), json!("strong")),
|
("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;
|
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,
|
"require_email_verification": true,
|
||||||
"email_configured": true,
|
"email_configured": true,
|
||||||
"password_policy_level": "strong",
|
"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);
|
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();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_returns_service_unavailable_for_auth_register_without_storage() {
|
async fn gateway_returns_service_unavailable_for_auth_register_without_storage() {
|
||||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ struct AdminApiFormatDefinition {
|
|||||||
|
|
||||||
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
||||||
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
||||||
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password"];
|
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password", "turnstile_secret_key"];
|
||||||
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||||
AdminApiFormatDefinition {
|
AdminApiFormatDefinition {
|
||||||
value: "openai:chat",
|
value: "openai:chat",
|
||||||
@@ -1490,6 +1490,10 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
|||||||
"provider_priority_mode" => Some(json!("provider")),
|
"provider_priority_mode" => Some(json!("provider")),
|
||||||
"scheduling_mode" => Some(json!("cache_affinity")),
|
"scheduling_mode" => Some(json!("cache_affinity")),
|
||||||
"auto_delete_expired_keys" => Some(json!(false)),
|
"auto_delete_expired_keys" => Some(json!(false)),
|
||||||
|
"turnstile_enabled" => Some(json!(false)),
|
||||||
|
"turnstile_site_key" => Some(serde_json::Value::Null),
|
||||||
|
"turnstile_secret_key" => Some(serde_json::Value::Null),
|
||||||
|
"turnstile_allowed_hostnames" => Some(json!([])),
|
||||||
"email_suffix_mode" => Some(json!("none")),
|
"email_suffix_mode" => Some(json!("none")),
|
||||||
"email_suffix_list" => Some(json!([])),
|
"email_suffix_list" => Some(json!([])),
|
||||||
"enable_format_conversion" => Some(json!(false)),
|
"enable_format_conversion" => Some(json!(false)),
|
||||||
@@ -2821,6 +2825,21 @@ mod tests {
|
|||||||
fn sensitive_admin_system_config_keys_are_case_insensitive() {
|
fn sensitive_admin_system_config_keys_are_case_insensitive() {
|
||||||
assert!(is_sensitive_admin_system_config_key("smtp_password"));
|
assert!(is_sensitive_admin_system_config_key("smtp_password"));
|
||||||
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
|
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
|
||||||
|
assert!(is_sensitive_admin_system_config_key("turnstile_secret_key"));
|
||||||
|
assert!(is_sensitive_admin_system_config_key("TURNSTILE_SECRET_KEY"));
|
||||||
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_admin_system_config_detail_masks_turnstile_secret_key() {
|
||||||
|
let payload = build_admin_system_config_detail_payload(
|
||||||
|
"turnstile_secret_key",
|
||||||
|
Some(json!("encrypted-turnstile-secret")),
|
||||||
|
)
|
||||||
|
.expect("turnstile secret detail should build");
|
||||||
|
|
||||||
|
assert_eq!(payload["key"], "turnstile_secret_key");
|
||||||
|
assert_eq!(payload["value"], serde_json::Value::Null);
|
||||||
|
assert_eq!(payload["is_set"], json!(true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
frontend/src/api/__tests__/auth-turnstile.spec.ts
Normal file
45
frontend/src/api/__tests__/auth-turnstile.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { postMock } = vi.hoisted(() => ({
|
||||||
|
postMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
default: {
|
||||||
|
post: postMock,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
|
||||||
|
describe('authApi turnstile payloads', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
postMock.mockReset()
|
||||||
|
postMock.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes turnstile token when sending email verification code', async () => {
|
||||||
|
await authApi.sendVerificationCode('alice@example.com', 'turnstile-token')
|
||||||
|
|
||||||
|
expect(postMock).toHaveBeenCalledWith('/api/auth/send-verification-code', {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
turnstile_token: 'turnstile-token',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes turnstile token when registering', async () => {
|
||||||
|
await authApi.register({
|
||||||
|
email: 'alice@example.com',
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret123',
|
||||||
|
turnstile_token: 'turnstile-token',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(postMock).toHaveBeenCalledWith('/api/auth/register', {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret123',
|
||||||
|
turnstile_token: 'turnstile-token',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -755,13 +755,13 @@ export const adminApi = {
|
|||||||
async getSystemConfig(
|
async getSystemConfig(
|
||||||
key: string,
|
key: string,
|
||||||
options: { cacheTtlMs?: number } = {},
|
options: { cacheTtlMs?: number } = {},
|
||||||
): Promise<{ key: string; value: unknown }> {
|
): Promise<{ key: string; value: unknown; is_set?: boolean }> {
|
||||||
const cacheTtlMs = options.cacheTtlMs ?? 0
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
const cacheKey = buildCacheKey('admin:system:config', { key })
|
const cacheKey = buildCacheKey('admin:system:config', { key })
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const response = await apiClient.get<{ key: string; value: unknown }>(
|
const response = await apiClient.get<{ key: string; value: unknown; is_set?: boolean }>(
|
||||||
`/api/admin/system/configs/${key}`
|
`/api/admin/system/configs/${key}`
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface UserStats {
|
|||||||
|
|
||||||
export interface SendVerificationCodeRequest {
|
export interface SendVerificationCodeRequest {
|
||||||
email: string
|
email: string
|
||||||
|
turnstile_token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendVerificationCodeResponse {
|
export interface SendVerificationCodeResponse {
|
||||||
@@ -67,6 +68,7 @@ export interface RegisterRequest {
|
|||||||
email?: string
|
email?: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
turnstile_token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
@@ -81,6 +83,9 @@ export interface RegistrationSettingsResponse {
|
|||||||
require_email_verification: boolean
|
require_email_verification: boolean
|
||||||
email_configured: boolean
|
email_configured: boolean
|
||||||
password_policy_level: string
|
password_policy_level: string
|
||||||
|
turnstile_enabled?: boolean
|
||||||
|
turnstile_site_key?: string | null
|
||||||
|
turnstile_required_actions?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSettingsResponse {
|
export interface AuthSettingsResponse {
|
||||||
@@ -153,10 +158,17 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
|
async sendVerificationCode(
|
||||||
|
email: string,
|
||||||
|
turnstileToken?: string
|
||||||
|
): Promise<SendVerificationCodeResponse> {
|
||||||
|
const payload: SendVerificationCodeRequest = { email }
|
||||||
|
if (turnstileToken) {
|
||||||
|
payload.turnstile_token = turnstileToken
|
||||||
|
}
|
||||||
const response = await apiClient.post<SendVerificationCodeResponse>(
|
const response = await apiClient.post<SendVerificationCodeResponse>(
|
||||||
'/api/auth/send-verification-code',
|
'/api/auth/send-verification-code',
|
||||||
{ email }
|
payload
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
:require-email-verification="requireEmailVerification"
|
:require-email-verification="requireEmailVerification"
|
||||||
:email-configured="emailConfigured"
|
:email-configured="emailConfigured"
|
||||||
:password-policy-level="passwordPolicyLevel"
|
:password-policy-level="passwordPolicyLevel"
|
||||||
|
:turnstile-enabled="turnstileEnabled"
|
||||||
|
:turnstile-site-key="turnstileSiteKey"
|
||||||
@success="handleRegisterSuccess"
|
@success="handleRegisterSuccess"
|
||||||
@switch-to-login="handleSwitchToLogin"
|
@switch-to-login="handleSwitchToLogin"
|
||||||
/>
|
/>
|
||||||
@@ -271,6 +273,8 @@ const requireEmailVerification = ref(false)
|
|||||||
const emailConfigured = ref(true) // 邮箱服务是否已配置
|
const emailConfigured = ref(true) // 邮箱服务是否已配置
|
||||||
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
|
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
|
||||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||||
|
const turnstileEnabled = ref(false)
|
||||||
|
const turnstileSiteKey = ref<string | null>(null)
|
||||||
|
|
||||||
// LDAP authentication settings
|
// LDAP authentication settings
|
||||||
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
|
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
|
||||||
@@ -388,6 +392,8 @@ onMounted(async () => {
|
|||||||
requireEmailVerification.value = !!regSettings.require_email_verification
|
requireEmailVerification.value = !!regSettings.require_email_verification
|
||||||
emailConfigured.value = !!regSettings.email_configured
|
emailConfigured.value = !!regSettings.email_configured
|
||||||
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
|
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
|
||||||
|
turnstileEnabled.value = !!regSettings.turnstile_enabled
|
||||||
|
turnstileSiteKey.value = regSettings.turnstile_site_key || null
|
||||||
|
|
||||||
localEnabled.value = authSettings.local_enabled
|
localEnabled.value = authSettings.local_enabled
|
||||||
ldapEnabled.value = authSettings.ldap_enabled
|
ldapEnabled.value = authSettings.ldap_enabled
|
||||||
@@ -413,6 +419,8 @@ onMounted(async () => {
|
|||||||
requireEmailVerification.value = false
|
requireEmailVerification.value = false
|
||||||
emailConfigured.value = false
|
emailConfigured.value = false
|
||||||
passwordPolicyLevel.value = 'weak'
|
passwordPolicyLevel.value = 'weak'
|
||||||
|
turnstileEnabled.value = false
|
||||||
|
turnstileSiteKey.value = null
|
||||||
localEnabled.value = true
|
localEnabled.value = true
|
||||||
ldapEnabled.value = false
|
ldapEnabled.value = false
|
||||||
ldapExclusive.value = false
|
ldapExclusive.value = false
|
||||||
|
|||||||
@@ -99,7 +99,9 @@
|
|||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm">正在发送验证码...</span>
|
<span class="text-sm">
|
||||||
|
{{ sendCodeLoadingText }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 验证码输入框 -->
|
<!-- 验证码输入框 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -194,6 +196,12 @@
|
|||||||
两次输入的密码不一致
|
两次输入的密码不一致
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TurnstileWidget
|
||||||
|
v-if="turnstileRequired && turnstileSiteKey"
|
||||||
|
ref="turnstileWidgetRef"
|
||||||
|
:site-key="turnstileSiteKey"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- 登录链接 -->
|
<!-- 登录链接 -->
|
||||||
@@ -245,12 +253,15 @@ import { Dialog } from '@/components/ui'
|
|||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import TurnstileWidget from './TurnstileWidget.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
requireEmailVerification?: boolean
|
requireEmailVerification?: boolean
|
||||||
emailConfigured?: boolean
|
emailConfigured?: boolean
|
||||||
passwordPolicyLevel?: PasswordPolicyLevel
|
passwordPolicyLevel?: PasswordPolicyLevel
|
||||||
|
turnstileEnabled?: boolean
|
||||||
|
turnstileSiteKey?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
@@ -263,7 +274,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
open: false,
|
open: false,
|
||||||
requireEmailVerification: false,
|
requireEmailVerification: false,
|
||||||
emailConfigured: true,
|
emailConfigured: true,
|
||||||
passwordPolicyLevel: 'weak'
|
passwordPolicyLevel: 'weak',
|
||||||
|
turnstileEnabled: false,
|
||||||
|
turnstileSiteKey: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
@@ -379,6 +392,9 @@ const codeSentAt = ref<number | null>(null)
|
|||||||
const cooldownSeconds = ref(0)
|
const cooldownSeconds = ref(0)
|
||||||
const expireMinutes = ref(5)
|
const expireMinutes = ref(5)
|
||||||
const cooldownTimer = ref<number | null>(null)
|
const cooldownTimer = ref<number | null>(null)
|
||||||
|
const turnstileWidgetRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||||
|
const turnstileAction = ref<'send_verification_code' | 'register' | null>(null)
|
||||||
|
const turnstileRequired = computed(() => !!props.turnstileEnabled && !!props.turnstileSiteKey)
|
||||||
|
|
||||||
// Send code cooldown timer
|
// Send code cooldown timer
|
||||||
const canSendCode = computed(() => {
|
const canSendCode = computed(() => {
|
||||||
@@ -388,13 +404,21 @@ const canSendCode = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const sendCodeButtonText = computed(() => {
|
const sendCodeButtonText = computed(() => {
|
||||||
if (isSendingCode.value) return '发送中...'
|
if (isSendingCode.value) {
|
||||||
|
return turnstileAction.value === 'send_verification_code' ? '验证中...' : '发送中...'
|
||||||
|
}
|
||||||
if (emailVerified.value) return '验证成功'
|
if (emailVerified.value) return '验证成功'
|
||||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||||
if (codeSentAt.value) return '重新发送验证码'
|
if (codeSentAt.value) return '重新发送验证码'
|
||||||
return '发送验证码'
|
return '发送验证码'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sendCodeLoadingText = computed(() =>
|
||||||
|
turnstileAction.value === 'send_verification_code'
|
||||||
|
? '正在进行人机验证...'
|
||||||
|
: '正在发送验证码...'
|
||||||
|
)
|
||||||
|
|
||||||
// 用户名验证
|
// 用户名验证
|
||||||
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
|
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
|
||||||
const usernameError = computed(() => {
|
const usernameError = computed(() => {
|
||||||
@@ -563,6 +587,25 @@ const resetForm = () => {
|
|||||||
|
|
||||||
// Clear verification code inputs
|
// Clear verification code inputs
|
||||||
codeDigits.value = ['', '', '', '', '', '']
|
codeDigits.value = ['', '', '', '', '', '']
|
||||||
|
resetTurnstile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTurnstile = () => {
|
||||||
|
turnstileAction.value = null
|
||||||
|
turnstileWidgetRef.value?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeTurnstile = async (action: 'send_verification_code' | 'register') => {
|
||||||
|
if (!turnstileRequired.value) return undefined
|
||||||
|
turnstileAction.value = action
|
||||||
|
try {
|
||||||
|
return await turnstileWidgetRef.value?.execute(action)
|
||||||
|
} catch {
|
||||||
|
showError('人机验证失败,请重试', '验证失败')
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
turnstileAction.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendCode = async () => {
|
const handleSendCode = async () => {
|
||||||
@@ -581,7 +624,14 @@ const handleSendCode = async () => {
|
|||||||
isSendingCode.value = true
|
isSendingCode.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.sendVerificationCode(formData.value.email)
|
const turnstileToken = await executeTurnstile('send_verification_code')
|
||||||
|
if (turnstileRequired.value && !turnstileToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = await authApi.sendVerificationCode(
|
||||||
|
formData.value.email,
|
||||||
|
turnstileToken || undefined
|
||||||
|
)
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
codeSentAt.value = Date.now()
|
codeSentAt.value = Date.now()
|
||||||
@@ -605,6 +655,7 @@ const handleSendCode = async () => {
|
|||||||
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
isSendingCode.value = false
|
isSendingCode.value = false
|
||||||
|
resetTurnstile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,11 +710,21 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
loadingText.value = '注册中...'
|
loadingText.value = turnstileRequired.value ? '验证中...' : '注册中...'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const turnstileToken = await executeTurnstile('register')
|
||||||
|
if (turnstileRequired.value && !turnstileToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingText.value = '注册中...'
|
||||||
// 构建请求数据:邮箱可选
|
// 构建请求数据:邮箱可选
|
||||||
const registerData: { email?: string; username: string; password: string } = {
|
const registerData: {
|
||||||
|
email?: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
turnstile_token?: string
|
||||||
|
} = {
|
||||||
username: formData.value.username,
|
username: formData.value.username,
|
||||||
password: formData.value.password
|
password: formData.value.password
|
||||||
}
|
}
|
||||||
@@ -671,6 +732,9 @@ const handleSubmit = async () => {
|
|||||||
if (formData.value.email && formData.value.email.trim()) {
|
if (formData.value.email && formData.value.email.trim()) {
|
||||||
registerData.email = formData.value.email
|
registerData.email = formData.value.email
|
||||||
}
|
}
|
||||||
|
if (turnstileToken) {
|
||||||
|
registerData.turnstile_token = turnstileToken
|
||||||
|
}
|
||||||
|
|
||||||
const response = await authApi.register(registerData)
|
const response = await authApi.register(registerData)
|
||||||
|
|
||||||
@@ -682,6 +746,7 @@ const handleSubmit = async () => {
|
|||||||
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
resetTurnstile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
145
frontend/src/features/auth/components/TurnstileWidget.vue
Normal file
145
frontend/src/features/auth/components/TurnstileWidget.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="min-h-[1px]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
siteKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TurnstileWidgetId = string
|
||||||
|
|
||||||
|
interface TurnstileRenderOptions {
|
||||||
|
sitekey: string
|
||||||
|
action?: string
|
||||||
|
execution?: 'render' | 'execute'
|
||||||
|
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||||
|
callback?: (token: string) => void
|
||||||
|
'error-callback'?: () => void
|
||||||
|
'expired-callback'?: () => void
|
||||||
|
'timeout-callback'?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnstileApi {
|
||||||
|
render: (container: HTMLElement, options: TurnstileRenderOptions) => TurnstileWidgetId
|
||||||
|
execute: (widgetId: TurnstileWidgetId) => void
|
||||||
|
reset: (widgetId: TurnstileWidgetId) => void
|
||||||
|
remove?: (widgetId: TurnstileWidgetId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi
|
||||||
|
__aetherTurnstileScriptPromise?: Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const widgetId = ref<TurnstileWidgetId | null>(null)
|
||||||
|
let pendingReject: ((error: Error) => void) | null = null
|
||||||
|
|
||||||
|
function loadTurnstileScript(): Promise<void> {
|
||||||
|
if (window.turnstile) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (window.__aetherTurnstileScriptPromise) {
|
||||||
|
return window.__aetherTurnstileScriptPromise
|
||||||
|
}
|
||||||
|
window.__aetherTurnstileScriptPromise = new Promise((resolve, reject) => {
|
||||||
|
const rejectAndReset = (script: HTMLScriptElement) => {
|
||||||
|
script.remove()
|
||||||
|
delete window.__aetherTurnstileScriptPromise
|
||||||
|
reject(new Error('Turnstile script failed'))
|
||||||
|
}
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(
|
||||||
|
'script[data-aether-turnstile="true"]'
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
existing.addEventListener('error', () => rejectAndReset(existing), {
|
||||||
|
once: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.dataset.aetherTurnstile = 'true'
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => rejectAndReset(script)
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
return window.__aetherTurnstileScriptPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWidget() {
|
||||||
|
if (!widgetId.value || !window.turnstile) return
|
||||||
|
if (window.turnstile.remove) {
|
||||||
|
window.turnstile.remove(widgetId.value)
|
||||||
|
} else {
|
||||||
|
window.turnstile.reset(widgetId.value)
|
||||||
|
}
|
||||||
|
widgetId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(action: string): Promise<string> {
|
||||||
|
await loadTurnstileScript()
|
||||||
|
const turnstile = window.turnstile
|
||||||
|
const container = containerRef.value
|
||||||
|
if (!turnstile || !container) {
|
||||||
|
throw new Error('Turnstile unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWidget()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingReject = reject
|
||||||
|
const id = turnstile.render(container, {
|
||||||
|
sitekey: props.siteKey,
|
||||||
|
action,
|
||||||
|
execution: 'execute',
|
||||||
|
appearance: 'interaction-only',
|
||||||
|
callback: (token: string) => {
|
||||||
|
pendingReject = null
|
||||||
|
resolve(token)
|
||||||
|
},
|
||||||
|
'error-callback': () => {
|
||||||
|
pendingReject = null
|
||||||
|
reject(new Error('Turnstile challenge failed'))
|
||||||
|
},
|
||||||
|
'expired-callback': () => {
|
||||||
|
pendingReject = null
|
||||||
|
reject(new Error('Turnstile token expired'))
|
||||||
|
},
|
||||||
|
'timeout-callback': () => {
|
||||||
|
pendingReject = null
|
||||||
|
reject(new Error('Turnstile challenge timed out'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
widgetId.value = id
|
||||||
|
turnstile.execute(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (pendingReject) {
|
||||||
|
pendingReject(new Error('Turnstile reset'))
|
||||||
|
pendingReject = null
|
||||||
|
}
|
||||||
|
clearWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(reset)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
execute,
|
||||||
|
reset,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, nextTick } from 'vue'
|
||||||
|
import RegisterDialog from '../RegisterDialog.vue'
|
||||||
|
|
||||||
|
const { registerMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
|
||||||
|
registerMock: vi.fn(),
|
||||||
|
toastErrorMock: vi.fn(),
|
||||||
|
toastSuccessMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/auth', () => ({
|
||||||
|
authApi: {
|
||||||
|
register: registerMock,
|
||||||
|
sendVerificationCode: vi.fn(),
|
||||||
|
verifyEmail: vi.fn(),
|
||||||
|
getVerificationStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
success: toastSuccessMock,
|
||||||
|
error: toastErrorMock,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type TurnstileRenderOptions = {
|
||||||
|
callback?: (token: string) => void
|
||||||
|
'error-callback'?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type TurnstileMock = {
|
||||||
|
render: ReturnType<typeof vi.fn>
|
||||||
|
execute: ReturnType<typeof vi.fn>
|
||||||
|
reset: ReturnType<typeof vi.fn>
|
||||||
|
remove: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPromises() {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function installTurnstileMock(mode: 'success' | 'error'): TurnstileMock {
|
||||||
|
let renderOptions: TurnstileRenderOptions | null = null
|
||||||
|
const turnstile = {
|
||||||
|
render: vi.fn((_container: HTMLElement, options: TurnstileRenderOptions) => {
|
||||||
|
renderOptions = options
|
||||||
|
return 'widget-id'
|
||||||
|
}),
|
||||||
|
execute: vi.fn(() => {
|
||||||
|
window.queueMicrotask(() => {
|
||||||
|
if (mode === 'success') {
|
||||||
|
renderOptions?.callback?.('turnstile-token')
|
||||||
|
} else {
|
||||||
|
renderOptions?.['error-callback']?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
reset: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}
|
||||||
|
;(window as unknown as { turnstile: TurnstileMock }).turnstile = turnstile
|
||||||
|
return turnstile
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountRegisterDialog() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp(RegisterDialog, {
|
||||||
|
open: true,
|
||||||
|
emailConfigured: false,
|
||||||
|
requireEmailVerification: false,
|
||||||
|
passwordPolicyLevel: 'weak',
|
||||||
|
turnstileEnabled: true,
|
||||||
|
turnstileSiteKey: 'site-public-key',
|
||||||
|
})
|
||||||
|
app.mount(root)
|
||||||
|
await nextTick()
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
root,
|
||||||
|
unmount: () => {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillRegistrationForm() {
|
||||||
|
const inputs = Array.from(document.body.querySelectorAll('input'))
|
||||||
|
const usernameInput = inputs.find((input) => input.placeholder === '请输入用户名')
|
||||||
|
const passwordInput = inputs.find((input) => input.placeholder.includes('至少'))
|
||||||
|
const confirmInput = inputs.find((input) => input.placeholder === '再次输入密码')
|
||||||
|
|
||||||
|
for (const [input, value] of [
|
||||||
|
[usernameInput, 'alice'],
|
||||||
|
[passwordInput, 'secret123'],
|
||||||
|
[confirmInput, 'secret123'],
|
||||||
|
] as const) {
|
||||||
|
expect(input).toBeTruthy()
|
||||||
|
input!.value = value
|
||||||
|
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickRegister() {
|
||||||
|
const registerButton = Array.from(document.body.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.trim() === '注册'
|
||||||
|
)
|
||||||
|
expect(registerButton).toBeTruthy()
|
||||||
|
expect(registerButton!.hasAttribute('disabled')).toBe(false)
|
||||||
|
registerButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegisterDialog Turnstile flow', () => {
|
||||||
|
let mounted: Awaited<ReturnType<typeof mountRegisterDialog>> | null = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registerMock.mockReset()
|
||||||
|
registerMock.mockResolvedValue({ message: '注册成功' })
|
||||||
|
toastErrorMock.mockReset()
|
||||||
|
toastSuccessMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mounted?.unmount()
|
||||||
|
mounted = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
delete (window as unknown as { turnstile?: TurnstileMock }).turnstile
|
||||||
|
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
|
||||||
|
.__aetherTurnstileScriptPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gets a Turnstile token before submitting registration', async () => {
|
||||||
|
const turnstile = installTurnstileMock('success')
|
||||||
|
mounted = await mountRegisterDialog()
|
||||||
|
await fillRegistrationForm()
|
||||||
|
|
||||||
|
await clickRegister()
|
||||||
|
|
||||||
|
expect(turnstile.render).toHaveBeenCalledWith(
|
||||||
|
expect.any(HTMLElement),
|
||||||
|
expect.objectContaining({
|
||||||
|
sitekey: 'site-public-key',
|
||||||
|
action: 'register',
|
||||||
|
execution: 'execute',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(turnstile.execute).toHaveBeenCalledWith('widget-id')
|
||||||
|
expect(registerMock).toHaveBeenCalledWith({
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret123',
|
||||||
|
turnstile_token: 'turnstile-token',
|
||||||
|
})
|
||||||
|
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets Turnstile and blocks registration when verification fails', async () => {
|
||||||
|
const turnstile = installTurnstileMock('error')
|
||||||
|
mounted = await mountRegisterDialog()
|
||||||
|
await fillRegistrationForm()
|
||||||
|
|
||||||
|
await clickRegister()
|
||||||
|
|
||||||
|
expect(registerMock).not.toHaveBeenCalled()
|
||||||
|
expect(toastErrorMock).toHaveBeenCalledWith('人机验证失败,请重试', '验证失败')
|
||||||
|
expect(turnstile.remove).toHaveBeenCalledWith('widget-id')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import TurnstileWidget from '../TurnstileWidget.vue'
|
||||||
|
|
||||||
|
function mountTurnstileWidget() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp(TurnstileWidget, { siteKey: 'site-public-key' })
|
||||||
|
const instance = app.mount(root) as unknown as {
|
||||||
|
execute: (action: string) => Promise<string>
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
instance,
|
||||||
|
unmount: () => {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnstileScripts() {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll<HTMLScriptElement>('script[data-aether-turnstile="true"]')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TurnstileWidget script loading', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
document.head.querySelectorAll('script[data-aether-turnstile="true"]').forEach((script) => {
|
||||||
|
script.remove()
|
||||||
|
})
|
||||||
|
delete (window as unknown as { turnstile?: unknown }).turnstile
|
||||||
|
delete (window as unknown as { __aetherTurnstileScriptPromise?: Promise<void> })
|
||||||
|
.__aetherTurnstileScriptPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries loading the Turnstile script after a transient load failure', async () => {
|
||||||
|
const mounted = mountTurnstileWidget()
|
||||||
|
|
||||||
|
const firstAttempt = mounted.instance.execute('register')
|
||||||
|
const firstScript = turnstileScripts()[0]
|
||||||
|
expect(firstScript).toBeTruthy()
|
||||||
|
firstScript.dispatchEvent(new Event('error'))
|
||||||
|
await expect(firstAttempt).rejects.toThrow('Turnstile script failed')
|
||||||
|
|
||||||
|
const secondAttempt = mounted.instance.execute('register')
|
||||||
|
const scriptsAfterRetry = turnstileScripts()
|
||||||
|
expect(scriptsAfterRetry).toHaveLength(1)
|
||||||
|
expect(scriptsAfterRetry[0]).not.toBe(firstScript)
|
||||||
|
scriptsAfterRetry[0].dispatchEvent(new Event('error'))
|
||||||
|
await expect(secondAttempt).rejects.toThrow('Turnstile script failed')
|
||||||
|
|
||||||
|
mounted.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -61,6 +61,11 @@
|
|||||||
:rate-limit-per-minute="systemConfig.rate_limit_per_minute"
|
:rate-limit-per-minute="systemConfig.rate_limit_per_minute"
|
||||||
:enable-registration="systemConfig.enable_registration"
|
:enable-registration="systemConfig.enable_registration"
|
||||||
:password-policy-level="systemConfig.password_policy_level"
|
:password-policy-level="systemConfig.password_policy_level"
|
||||||
|
:turnstile-enabled="systemConfig.turnstile_enabled"
|
||||||
|
:turnstile-site-key="systemConfig.turnstile_site_key"
|
||||||
|
:turnstile-secret-key="systemConfig.turnstile_secret_key"
|
||||||
|
:turnstile-secret-configured="systemConfig.turnstile_secret_key_is_set"
|
||||||
|
:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr"
|
||||||
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
|
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
|
||||||
:enable-format-conversion="systemConfig.enable_format_conversion"
|
:enable-format-conversion="systemConfig.enable_format_conversion"
|
||||||
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
|
:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat"
|
||||||
@@ -71,6 +76,11 @@
|
|||||||
@update:rate-limit-per-minute="systemConfig.rate_limit_per_minute = $event"
|
@update:rate-limit-per-minute="systemConfig.rate_limit_per_minute = $event"
|
||||||
@update:enable-registration="systemConfig.enable_registration = $event"
|
@update:enable-registration="systemConfig.enable_registration = $event"
|
||||||
@update:password-policy-level="systemConfig.password_policy_level = $event"
|
@update:password-policy-level="systemConfig.password_policy_level = $event"
|
||||||
|
@update:turnstile-enabled="systemConfig.turnstile_enabled = $event"
|
||||||
|
@update:turnstile-site-key="systemConfig.turnstile_site_key = $event"
|
||||||
|
@update:turnstile-secret-key="systemConfig.turnstile_secret_key = $event"
|
||||||
|
@update:turnstile-allowed-hostnames-str="turnstileAllowedHostnamesStr = $event"
|
||||||
|
@clear-turnstile-secret="clearTurnstileSecret"
|
||||||
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
|
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
|
||||||
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
@update:enable-format-conversion="systemConfig.enable_format_conversion = $event"
|
||||||
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
|
@update:enable-openai-image-sync-heartbeat="systemConfig.enable_openai_image_sync_heartbeat = $event"
|
||||||
@@ -309,11 +319,13 @@ const {
|
|||||||
maxRequestBodySizeKB,
|
maxRequestBodySizeKB,
|
||||||
maxResponseBodySizeKB,
|
maxResponseBodySizeKB,
|
||||||
sensitiveHeadersStr,
|
sensitiveHeadersStr,
|
||||||
|
turnstileAllowedHostnamesStr,
|
||||||
loadSystemConfig,
|
loadSystemConfig,
|
||||||
loadSystemVersion,
|
loadSystemVersion,
|
||||||
saveSiteInfo,
|
saveSiteInfo,
|
||||||
saveProxyConfig,
|
saveProxyConfig,
|
||||||
saveBasicConfig,
|
saveBasicConfig,
|
||||||
|
clearTurnstileSecret,
|
||||||
saveLogConfig,
|
saveLogConfig,
|
||||||
saveCleanupConfig,
|
saveCleanupConfig,
|
||||||
handleAutoCleanupToggle,
|
handleAutoCleanupToggle,
|
||||||
|
|||||||
@@ -171,6 +171,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t pt-5">
|
||||||
|
<div class="flex items-center h-full">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="turnstile-enabled"
|
||||||
|
:checked="turnstileEnabled"
|
||||||
|
@update:checked="$emit('update:turnstileEnabled', $event)"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="turnstile-enabled"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
注册人机验证
|
||||||
|
</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
开启后注册与发送邮箱验证码前需要通过 Cloudflare Turnstile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="turnstile-site-key"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Turnstile Site Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="turnstile-site-key"
|
||||||
|
:model-value="turnstileSiteKey || ''"
|
||||||
|
type="text"
|
||||||
|
placeholder="0x4AAAA..."
|
||||||
|
class="mt-1"
|
||||||
|
@update:model-value="$emit('update:turnstileSiteKey', String($event || '').trim() || null)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
for="turnstile-secret-key"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Turnstile Secret Key
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
v-if="turnstileSecretConfigured"
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
class="h-auto p-0 text-xs"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="$emit('clearTurnstileSecret')"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="turnstile-secret-key"
|
||||||
|
:model-value="turnstileSecretKey"
|
||||||
|
type="password"
|
||||||
|
:placeholder="turnstileSecretConfigured ? '已配置,留空不修改' : '输入 Secret Key'"
|
||||||
|
class="mt-1"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@update:model-value="$emit('update:turnstileSecretKey', String($event || ''))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
for="turnstile-hostnames"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
允许的 Hostname
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="turnstile-hostnames"
|
||||||
|
:model-value="turnstileAllowedHostnamesStr"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com, app.example.com"
|
||||||
|
class="mt-1"
|
||||||
|
@update:model-value="$emit('update:turnstileAllowedHostnamesStr', String($event || ''))"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
留空则不额外校验 Cloudflare 返回的 hostname
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
</template>
|
</template>
|
||||||
@@ -192,6 +283,11 @@ defineProps<{
|
|||||||
rateLimitPerMinute: number
|
rateLimitPerMinute: number
|
||||||
enableRegistration: boolean
|
enableRegistration: boolean
|
||||||
passwordPolicyLevel: string
|
passwordPolicyLevel: string
|
||||||
|
turnstileEnabled: boolean
|
||||||
|
turnstileSiteKey: string | null
|
||||||
|
turnstileSecretKey: string
|
||||||
|
turnstileSecretConfigured: boolean
|
||||||
|
turnstileAllowedHostnamesStr: string
|
||||||
autoDeleteExpiredKeys: boolean
|
autoDeleteExpiredKeys: boolean
|
||||||
enableFormatConversion: boolean
|
enableFormatConversion: boolean
|
||||||
enableOpenaiImageSyncHeartbeat: boolean
|
enableOpenaiImageSyncHeartbeat: boolean
|
||||||
@@ -205,6 +301,11 @@ defineEmits<{
|
|||||||
'update:rateLimitPerMinute': [value: number]
|
'update:rateLimitPerMinute': [value: number]
|
||||||
'update:enableRegistration': [value: boolean]
|
'update:enableRegistration': [value: boolean]
|
||||||
'update:passwordPolicyLevel': [value: string]
|
'update:passwordPolicyLevel': [value: string]
|
||||||
|
'update:turnstileEnabled': [value: boolean]
|
||||||
|
'update:turnstileSiteKey': [value: string | null]
|
||||||
|
'update:turnstileSecretKey': [value: string]
|
||||||
|
'update:turnstileAllowedHostnamesStr': [value: string]
|
||||||
|
clearTurnstileSecret: []
|
||||||
'update:autoDeleteExpiredKeys': [value: boolean]
|
'update:autoDeleteExpiredKeys': [value: boolean]
|
||||||
'update:enableFormatConversion': [value: boolean]
|
'update:enableFormatConversion': [value: boolean]
|
||||||
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
|
'update:enableOpenaiImageSyncHeartbeat': [value: boolean]
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface SystemConfig {
|
|||||||
rate_limit_per_minute: number
|
rate_limit_per_minute: number
|
||||||
enable_registration: boolean
|
enable_registration: boolean
|
||||||
password_policy_level: string
|
password_policy_level: string
|
||||||
|
turnstile_enabled: boolean
|
||||||
|
turnstile_site_key: string | null
|
||||||
|
turnstile_secret_key: string
|
||||||
|
turnstile_secret_key_is_set: boolean
|
||||||
|
turnstile_allowed_hostnames: string[]
|
||||||
// 独立余额 Key 过期管理
|
// 独立余额 Key 过期管理
|
||||||
auto_delete_expired_keys: boolean
|
auto_delete_expired_keys: boolean
|
||||||
// 格式转换
|
// 格式转换
|
||||||
@@ -56,6 +61,10 @@ const CONFIG_KEYS = [
|
|||||||
'rate_limit_per_minute',
|
'rate_limit_per_minute',
|
||||||
'enable_registration',
|
'enable_registration',
|
||||||
'password_policy_level',
|
'password_policy_level',
|
||||||
|
'turnstile_enabled',
|
||||||
|
'turnstile_site_key',
|
||||||
|
'turnstile_secret_key',
|
||||||
|
'turnstile_allowed_hostnames',
|
||||||
// 独立余额 Key 过期管理
|
// 独立余额 Key 过期管理
|
||||||
'auto_delete_expired_keys',
|
'auto_delete_expired_keys',
|
||||||
// 格式转换
|
// 格式转换
|
||||||
@@ -98,6 +107,11 @@ function createDefaultConfig(): SystemConfig {
|
|||||||
rate_limit_per_minute: 0,
|
rate_limit_per_minute: 0,
|
||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
password_policy_level: 'weak',
|
password_policy_level: 'weak',
|
||||||
|
turnstile_enabled: false,
|
||||||
|
turnstile_site_key: null,
|
||||||
|
turnstile_secret_key: '',
|
||||||
|
turnstile_secret_key_is_set: false,
|
||||||
|
turnstile_allowed_hostnames: [],
|
||||||
// 独立余额 Key 过期管理
|
// 独立余额 Key 过期管理
|
||||||
auto_delete_expired_keys: false,
|
auto_delete_expired_keys: false,
|
||||||
// 格式转换
|
// 格式转换
|
||||||
@@ -165,6 +179,11 @@ export function useSystemConfig() {
|
|||||||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
|
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
|
||||||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
|
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
|
||||||
systemConfig.value.password_policy_level !== originalConfig.value.password_policy_level ||
|
systemConfig.value.password_policy_level !== originalConfig.value.password_policy_level ||
|
||||||
|
systemConfig.value.turnstile_enabled !== originalConfig.value.turnstile_enabled ||
|
||||||
|
systemConfig.value.turnstile_site_key !== originalConfig.value.turnstile_site_key ||
|
||||||
|
systemConfig.value.turnstile_secret_key.trim() !== '' ||
|
||||||
|
JSON.stringify(systemConfig.value.turnstile_allowed_hostnames) !==
|
||||||
|
JSON.stringify(originalConfig.value.turnstile_allowed_hostnames) ||
|
||||||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
|
||||||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
|
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion ||
|
||||||
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
|
systemConfig.value.enable_openai_image_sync_heartbeat !== originalConfig.value.enable_openai_image_sync_heartbeat
|
||||||
@@ -233,12 +252,27 @@ export function useSystemConfig() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const turnstileAllowedHostnamesStr = computed({
|
||||||
|
get: () => systemConfig.value.turnstile_allowed_hostnames.join(', '),
|
||||||
|
set: (val: string) => {
|
||||||
|
systemConfig.value.turnstile_allowed_hostnames = val
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
async function loadSystemConfig() {
|
async function loadSystemConfig() {
|
||||||
try {
|
try {
|
||||||
for (const key of CONFIG_KEYS) {
|
for (const key of CONFIG_KEYS) {
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.getSystemConfig(key)
|
const response = await adminApi.getSystemConfig(key)
|
||||||
|
if (key === 'turnstile_secret_key') {
|
||||||
|
systemConfig.value.turnstile_secret_key = ''
|
||||||
|
systemConfig.value.turnstile_secret_key_is_set = !!response.is_set
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (response.value !== null && response.value !== undefined) {
|
if (response.value !== null && response.value !== undefined) {
|
||||||
; (systemConfig.value as Record<string, unknown>)[key] = response.value
|
; (systemConfig.value as Record<string, unknown>)[key] = response.value
|
||||||
}
|
}
|
||||||
@@ -337,6 +371,21 @@ export function useSystemConfig() {
|
|||||||
value: systemConfig.value.password_policy_level,
|
value: systemConfig.value.password_policy_level,
|
||||||
description: '密码策略等级',
|
description: '密码策略等级',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'turnstile_enabled',
|
||||||
|
value: systemConfig.value.turnstile_enabled,
|
||||||
|
description: 'Cloudflare Turnstile 注册人机验证开关',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'turnstile_site_key',
|
||||||
|
value: systemConfig.value.turnstile_site_key?.trim() || null,
|
||||||
|
description: 'Cloudflare Turnstile 站点 Key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'turnstile_allowed_hostnames',
|
||||||
|
value: systemConfig.value.turnstile_allowed_hostnames,
|
||||||
|
description: 'Cloudflare Turnstile 允许的 hostname 列表',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'auto_delete_expired_keys',
|
key: 'auto_delete_expired_keys',
|
||||||
value: systemConfig.value.auto_delete_expired_keys,
|
value: systemConfig.value.auto_delete_expired_keys,
|
||||||
@@ -353,6 +402,14 @@ export function useSystemConfig() {
|
|||||||
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
|
description: '同步生图心跳开关:开启后外层 HTTP 状态固定为 200,上游失败写入响应体',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
const turnstileSecret = systemConfig.value.turnstile_secret_key.trim()
|
||||||
|
if (turnstileSecret) {
|
||||||
|
configItems.push({
|
||||||
|
key: 'turnstile_secret_key',
|
||||||
|
value: turnstileSecret,
|
||||||
|
description: 'Cloudflare Turnstile Secret Key',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
configItems.map((item) =>
|
configItems.map((item) =>
|
||||||
@@ -364,6 +421,17 @@ export function useSystemConfig() {
|
|||||||
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
|
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
|
||||||
originalConfig.value.enable_registration = systemConfig.value.enable_registration
|
originalConfig.value.enable_registration = systemConfig.value.enable_registration
|
||||||
originalConfig.value.password_policy_level = systemConfig.value.password_policy_level
|
originalConfig.value.password_policy_level = systemConfig.value.password_policy_level
|
||||||
|
originalConfig.value.turnstile_enabled = systemConfig.value.turnstile_enabled
|
||||||
|
originalConfig.value.turnstile_site_key = systemConfig.value.turnstile_site_key?.trim() || null
|
||||||
|
originalConfig.value.turnstile_allowed_hostnames = [
|
||||||
|
...systemConfig.value.turnstile_allowed_hostnames,
|
||||||
|
]
|
||||||
|
if (turnstileSecret) {
|
||||||
|
systemConfig.value.turnstile_secret_key = ''
|
||||||
|
systemConfig.value.turnstile_secret_key_is_set = true
|
||||||
|
originalConfig.value.turnstile_secret_key = ''
|
||||||
|
originalConfig.value.turnstile_secret_key_is_set = true
|
||||||
|
}
|
||||||
originalConfig.value.auto_delete_expired_keys =
|
originalConfig.value.auto_delete_expired_keys =
|
||||||
systemConfig.value.auto_delete_expired_keys
|
systemConfig.value.auto_delete_expired_keys
|
||||||
originalConfig.value.enable_format_conversion =
|
originalConfig.value.enable_format_conversion =
|
||||||
@@ -380,6 +448,29 @@ export function useSystemConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearTurnstileSecret() {
|
||||||
|
basicConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateSystemConfig(
|
||||||
|
'turnstile_secret_key',
|
||||||
|
'',
|
||||||
|
'Cloudflare Turnstile Secret Key'
|
||||||
|
)
|
||||||
|
systemConfig.value.turnstile_secret_key = ''
|
||||||
|
systemConfig.value.turnstile_secret_key_is_set = false
|
||||||
|
if (originalConfig.value) {
|
||||||
|
originalConfig.value.turnstile_secret_key = ''
|
||||||
|
originalConfig.value.turnstile_secret_key_is_set = false
|
||||||
|
}
|
||||||
|
success('Turnstile 密钥已清空')
|
||||||
|
} catch (err) {
|
||||||
|
error('清空 Turnstile 密钥失败')
|
||||||
|
log.error('清空 Turnstile 密钥失败:', err)
|
||||||
|
} finally {
|
||||||
|
basicConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveLogConfig() {
|
async function saveLogConfig() {
|
||||||
logConfigLoading.value = true
|
logConfigLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -559,6 +650,7 @@ export function useSystemConfig() {
|
|||||||
maxRequestBodySizeKB,
|
maxRequestBodySizeKB,
|
||||||
maxResponseBodySizeKB,
|
maxResponseBodySizeKB,
|
||||||
sensitiveHeadersStr,
|
sensitiveHeadersStr,
|
||||||
|
turnstileAllowedHostnamesStr,
|
||||||
// 加载函数
|
// 加载函数
|
||||||
loadSystemConfig,
|
loadSystemConfig,
|
||||||
loadSystemVersion,
|
loadSystemVersion,
|
||||||
@@ -566,6 +658,7 @@ export function useSystemConfig() {
|
|||||||
saveSiteInfo,
|
saveSiteInfo,
|
||||||
saveProxyConfig,
|
saveProxyConfig,
|
||||||
saveBasicConfig,
|
saveBasicConfig,
|
||||||
|
clearTurnstileSecret,
|
||||||
saveLogConfig,
|
saveLogConfig,
|
||||||
saveCleanupConfig,
|
saveCleanupConfig,
|
||||||
handleAutoCleanupToggle,
|
handleAutoCleanupToggle,
|
||||||
|
|||||||
Reference in New Issue
Block a user