From 3dc1fed1c16043ec224526c4ee33a2d3f4072ccd Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Tue, 14 Apr 2026 17:01:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=A2=9E=E5=8A=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E7=99=BB=E5=BD=95=E7=9A=84=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E9=99=90=E5=88=B6=E5=92=8C=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/admin/auth_api.ts | 126 ++++++++++++++++++ src/admin/auth_shared.ts | 276 ++++++++++++++++++++++++++++++++++++++- src/db/index.ts | 15 +++ tests/run-e2e.sh | 45 ++++++- tests/setup-test-data.sh | 1 + 5 files changed, 460 insertions(+), 3 deletions(-) diff --git a/src/admin/auth_api.ts b/src/admin/auth_api.ts index b667894..e57b75f 100644 --- a/src/admin/auth_api.ts +++ b/src/admin/auth_api.ts @@ -5,10 +5,14 @@ import { z } from "zod"; import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"; import { + AdminRateLimitError, + clearAdminRateLimitBucket, clearAdminSessionCookie, + consumeAdminRateLimit, createAdminLoginChallenge, createAdminSession, deleteAdminLoginChallenge, + getRequestMetadata, getAdminSessionTokenFromRequest, invalidateAdminSession, setAdminSessionCookie, @@ -17,6 +21,44 @@ import { verifyAdminLoginChallenge, } from "./auth_shared"; +const ADMIN_LOGIN_START_ATTEMPT_POLICY = { + category: "admin-login-start:attempt", + maxAttempts: 6, + windowMs: 10 * 60 * 1000, + blockDurationMs: 15 * 60 * 1000, + message: "管理员登录请求过于频繁,请稍后再试", +} as const; +const ADMIN_LOGIN_START_FAILURE_POLICY = { + category: "admin-login-start:failure", + maxAttempts: 5, + windowMs: 30 * 60 * 1000, + blockDurationMs: 30 * 60 * 1000, + message: "管理员登录失败次数过多,请稍后再试", +} as const; +const ADMIN_LOGIN_VERIFY_ATTEMPT_POLICY = { + category: "admin-login-verify:attempt", + maxAttempts: 8, + windowMs: 10 * 60 * 1000, + blockDurationMs: 15 * 60 * 1000, + message: "验证码验证请求过于频繁,请稍后再试", +} as const; +const ADMIN_LOGIN_VERIFY_FAILURE_POLICY = { + category: "admin-login-verify:failure", + maxAttempts: 6, + windowMs: 30 * 60 * 1000, + blockDurationMs: 30 * 60 * 1000, + message: "验证码验证失败次数过多,请稍后再试", +} as const; + +const buildRateLimitedResponse = ( + c: Context, + message: string, + retryAfterSeconds: number +) => { + c.header("Retry-After", String(retryAfterSeconds)); + return c.text(message, 429); +}; + const loginStartRequestSchema = z.object({ token: z.string().trim().min(1, "管理员令牌不能为空"), }); @@ -81,11 +123,32 @@ export class AdminLoginStartEndpoint extends OpenAPIRoute { } const body = parsedBody.data; + const metadata = getRequestMetadata(c); + const rateLimitBucketId = metadata.clientIp || "unknown"; + const startAttemptLimit = await consumeAdminRateLimit( + c, + ADMIN_LOGIN_START_ATTEMPT_POLICY, + rateLimitBucketId + ); + if (!startAttemptLimit.ok) { + return buildRateLimitedResponse( + c, + startAttemptLimit.message, + startAttemptLimit.retryAfterSeconds + ); + } + const systemConfig = await getSystemConfig(c); const securityConfig = systemConfig.adminSecurity; const securityEnabled = isTelegramSecurityEnabled(securityConfig); if (!c.env.ADMIN_TOKEN || body.token !== c.env.ADMIN_TOKEN) { + const failureLimit = await consumeAdminRateLimit( + c, + ADMIN_LOGIN_START_FAILURE_POLICY, + rateLimitBucketId + ); + if (securityEnabled) { await sendAdminLoginResultNotification( securityConfig, @@ -97,9 +160,23 @@ export class AdminLoginStartEndpoint extends OpenAPIRoute { }); } + if (!failureLimit.ok) { + return buildRateLimitedResponse( + c, + failureLimit.message, + failureLimit.retryAfterSeconds + ); + } + return c.text("管理员令牌无效", 401); } + await clearAdminRateLimitBucket( + c, + ADMIN_LOGIN_START_FAILURE_POLICY.category, + rateLimitBucketId + ); + if (!securityEnabled) { return buildDirectLoginResponse(c); } @@ -116,6 +193,14 @@ export class AdminLoginStartEndpoint extends OpenAPIRoute { } catch (error) { await deleteAdminLoginChallenge(c, challenge.challengeId); + if (error instanceof AdminRateLimitError) { + return buildRateLimitedResponse( + c, + error.message, + error.retryAfterSeconds + ); + } + return c.text( `Telegram 发送验证码失败:${error instanceof Error ? error.message : "unknown error"}`, 500 @@ -164,6 +249,21 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute { } const body = parsedBody.data; + const metadata = getRequestMetadata(c); + const rateLimitBucketId = metadata.clientIp || "unknown"; + const verifyAttemptLimit = await consumeAdminRateLimit( + c, + ADMIN_LOGIN_VERIFY_ATTEMPT_POLICY, + rateLimitBucketId + ); + if (!verifyAttemptLimit.ok) { + return buildRateLimitedResponse( + c, + verifyAttemptLimit.message, + verifyAttemptLimit.retryAfterSeconds + ); + } + const systemConfig = await getSystemConfig(c); const securityConfig = systemConfig.adminSecurity; @@ -178,6 +278,11 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute { ); if (!result.ok) { + const failureLimit = await consumeAdminRateLimit( + c, + ADMIN_LOGIN_VERIFY_FAILURE_POLICY, + rateLimitBucketId + ); await sendAdminLoginResultNotification( securityConfig, c, @@ -187,9 +292,30 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute { console.error("Failed to send Telegram login failure notification:", error); }); + if (!failureLimit.ok) { + return buildRateLimitedResponse( + c, + failureLimit.message, + failureLimit.retryAfterSeconds + ); + } + return c.text(result.reason || "验证码验证失败", 401); } + await Promise.all([ + clearAdminRateLimitBucket( + c, + ADMIN_LOGIN_START_FAILURE_POLICY.category, + rateLimitBucketId + ), + clearAdminRateLimitBucket( + c, + ADMIN_LOGIN_VERIFY_FAILURE_POLICY.category, + rateLimitBucketId + ), + ]); + const session = await createAdminSession(c); setAdminSessionCookie(c, session.sessionToken, session.expiresAt); diff --git a/src/admin/auth_shared.ts b/src/admin/auth_shared.ts index 273194b..a5e7cb2 100644 --- a/src/admin/auth_shared.ts +++ b/src/admin/auth_shared.ts @@ -5,8 +5,51 @@ const ADMIN_SESSION_COOKIE_NAME = "oaw_admin_session"; const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000; const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5; +const ADMIN_RATE_LIMIT_RETENTION_MS = 24 * 60 * 60 * 1000; const DEFAULT_SYSTEM_TIMEZONE = "Asia/Shanghai"; const LOCAL_DEV_HOSTNAMES = new Set(["0.0.0.0", "127.0.0.1", "::1", "localhost"]); +const ADMIN_TELEGRAM_CODE_NOTIFY_PER_IP_POLICY = { + category: "admin-telegram-code:ip", + maxAttempts: 3, + windowMs: 10 * 60 * 1000, + blockDurationMs: 10 * 60 * 1000, + message: "验证码发送过于频繁,请稍后再试", +} as const; +const ADMIN_TELEGRAM_CODE_NOTIFY_GLOBAL_POLICY = { + category: "admin-telegram-code:global", + maxAttempts: 12, + windowMs: 10 * 60 * 1000, + blockDurationMs: 10 * 60 * 1000, + message: "验证码发送过于频繁,请稍后再试", +} as const; +const ADMIN_TELEGRAM_RESULT_FAILURE_PER_IP_POLICY = { + category: "admin-telegram-result-failure:ip", + maxAttempts: 2, + windowMs: 10 * 60 * 1000, + blockDurationMs: 10 * 60 * 1000, + message: "登录失败通知发送过于频繁,已自动静默", +} as const; +const ADMIN_TELEGRAM_RESULT_FAILURE_GLOBAL_POLICY = { + category: "admin-telegram-result-failure:global", + maxAttempts: 20, + windowMs: 10 * 60 * 1000, + blockDurationMs: 10 * 60 * 1000, + message: "登录失败通知发送过于频繁,已自动静默", +} as const; +const ADMIN_TELEGRAM_RESULT_SUCCESS_PER_IP_POLICY = { + category: "admin-telegram-result-success:ip", + maxAttempts: 3, + windowMs: 30 * 60 * 1000, + blockDurationMs: 30 * 60 * 1000, + message: "登录成功通知发送过于频繁,已自动静默", +} as const; +const ADMIN_TELEGRAM_RESULT_SUCCESS_GLOBAL_POLICY = { + category: "admin-telegram-result-success:global", + maxAttempts: 20, + windowMs: 30 * 60 * 1000, + blockDurationMs: 30 * 60 * 1000, + message: "登录成功通知发送过于频繁,已自动静默", +} as const; type LoginRequestMetadata = { clientIp: string; @@ -17,6 +60,38 @@ type LoginRequestMetadata = { timezone: string; }; +type StoredAdminRateLimitRow = { + attempts: number | null; + blocked_until: string | null; + bucket_id: string; + bucket_key: string; + category: string; + last_event_at: string; + window_started_at: string; +}; + +type AdminRateLimitPolicy = { + blockDurationMs: number; + category: string; + maxAttempts: number; + message: string; + windowMs: number; +}; + +type AdminRateLimitResult = + | { ok: true } + | { ok: false; message: string; retryAfterSeconds: number }; + +export class AdminRateLimitError extends Error { + readonly retryAfterSeconds: number; + + constructor(message: string, retryAfterSeconds: number) { + super(message); + this.name = "AdminRateLimitError"; + this.retryAfterSeconds = retryAfterSeconds; + } +} + const firstNonEmpty = (...values: Array): string => { for (const value of values) { if (typeof value === "string" && value.trim().length > 0) { @@ -45,7 +120,7 @@ const formatTimestampInTimezone = (date: Date, timezone: string): string => { return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`; }; -const getRequestMetadata = (c: Context): LoginRequestMetadata => { +export const getRequestMetadata = (c: Context): LoginRequestMetadata => { const requestCf = (c.req.raw.cf || {}) as Partial>; return { @@ -93,8 +168,65 @@ const toSha256Hex = async (value: string): Promise => { .join(""); }; +const getAdminRateLimitBucketId = (metadata: LoginRequestMetadata): string => { + return metadata.clientIp || "unknown"; +}; + +const buildAdminRateLimitBucketKey = ( + category: string, + bucketId: string +): string => { + return `${category}:${bucketId}`; +}; + +const toRetryAfterSeconds = (retryAfterMs: number): number => { + return Math.max(1, Math.ceil(retryAfterMs / 1000)); +}; + +const upsertAdminRateLimit = async ( + c: Context, + row: { + attempts: number; + blockedUntil: string | null; + bucketId: string; + bucketKey: string; + category: string; + lastEventAt: string; + windowStartedAt: string; + } +) => { + await c.env.DB.prepare( + `INSERT INTO admin_rate_limit ( + bucket_key, + category, + bucket_id, + window_started_at, + attempts, + blocked_until, + last_event_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(bucket_key) DO UPDATE SET + category = excluded.category, + bucket_id = excluded.bucket_id, + window_started_at = excluded.window_started_at, + attempts = excluded.attempts, + blocked_until = excluded.blocked_until, + last_event_at = excluded.last_event_at, + updated_at = datetime('now')` + ).bind( + row.bucketKey, + row.category, + row.bucketId, + row.windowStartedAt, + row.attempts, + row.blockedUntil, + row.lastEventAt + ).run(); +}; + const cleanupExpiredArtifacts = async (c: Context) => { const now = new Date().toISOString(); + const rateLimitRetentionCutoff = new Date(Date.now() - ADMIN_RATE_LIMIT_RETENTION_MS).toISOString(); await Promise.all([ c.env.DB.prepare( @@ -103,9 +235,109 @@ const cleanupExpiredArtifacts = async (c: Context) => { c.env.DB.prepare( `DELETE FROM admin_session WHERE expires_at <= ?` ).bind(now).run(), + c.env.DB.prepare( + `DELETE FROM admin_rate_limit WHERE last_event_at <= ?` + ).bind(rateLimitRetentionCutoff).run(), ]); }; +export const consumeAdminRateLimit = async ( + c: Context, + policy: AdminRateLimitPolicy, + bucketId: string +): Promise => { + await cleanupExpiredArtifacts(c); + + const now = new Date(); + const nowMs = now.getTime(); + const nowIso = now.toISOString(); + const bucketKey = buildAdminRateLimitBucketKey(policy.category, bucketId); + const existing = await c.env.DB.prepare( + `SELECT + bucket_key, + category, + bucket_id, + window_started_at, + attempts, + blocked_until, + last_event_at + FROM admin_rate_limit + WHERE bucket_key = ?` + ).bind(bucketKey).first(); + + const blockedUntilMs = existing?.blocked_until ? Date.parse(existing.blocked_until) : Number.NaN; + if (existing?.blocked_until && Number.isFinite(blockedUntilMs) && blockedUntilMs > nowMs) { + return { + ok: false, + message: policy.message, + retryAfterSeconds: toRetryAfterSeconds(blockedUntilMs - nowMs), + }; + } + + const windowStartedAtMs = existing?.window_started_at + ? Date.parse(existing.window_started_at) + : Number.NaN; + const isSameWindow = Number.isFinite(windowStartedAtMs) + && nowMs - windowStartedAtMs < policy.windowMs; + const windowStartedAt = isSameWindow && existing?.window_started_at + ? existing.window_started_at + : nowIso; + const previousAttempts = isSameWindow ? existing?.attempts || 0 : 0; + const nextAttempts = previousAttempts + 1; + + if (nextAttempts > policy.maxAttempts) { + const blockedUntil = new Date(nowMs + policy.blockDurationMs).toISOString(); + await upsertAdminRateLimit(c, { + attempts: nextAttempts, + blockedUntil, + bucketId, + bucketKey, + category: policy.category, + lastEventAt: nowIso, + windowStartedAt, + }); + + return { + ok: false, + message: policy.message, + retryAfterSeconds: toRetryAfterSeconds(policy.blockDurationMs), + }; + } + + await upsertAdminRateLimit(c, { + attempts: nextAttempts, + blockedUntil: null, + bucketId, + bucketKey, + category: policy.category, + lastEventAt: nowIso, + windowStartedAt, + }); + + return { ok: true }; +}; + +export const clearAdminRateLimitBucket = async ( + c: Context, + category: string, + bucketId: string +): Promise => { + await c.env.DB.prepare( + `DELETE FROM admin_rate_limit WHERE bucket_key = ?` + ).bind(buildAdminRateLimitBucketKey(category, bucketId)).run(); +}; + +const throwIfAdminRateLimited = async ( + c: Context, + policy: AdminRateLimitPolicy, + bucketId: string +): Promise => { + const result = await consumeAdminRateLimit(c, policy, bucketId); + if (!result.ok) { + throw new AdminRateLimitError(result.message, result.retryAfterSeconds); + } +}; + const sendTelegramMessage = async ( securityConfig: AdminSecurityConfig, text: string @@ -340,6 +572,19 @@ export const verifyAdminLoginChallenge = async ( }; } + const metadata = getRequestMetadata(c); + if ( + challenge.request_ip + && metadata.clientIp + && challenge.request_ip !== metadata.clientIp + ) { + await deleteAdminLoginChallenge(c, challengeId); + return { + ok: false, + reason: "登录来源已变化,请重新获取验证码", + }; + } + if (Date.parse(challenge.expires_at) <= Date.now()) { await deleteAdminLoginChallenge(c, challengeId); return { @@ -398,6 +643,18 @@ export const sendAdminLoginCodeNotification = async ( const occurredAt = new Date(); const expiresDate = new Date(expiresAt); const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE; + const bucketId = getAdminRateLimitBucketId(metadata); + + await throwIfAdminRateLimited( + c, + ADMIN_TELEGRAM_CODE_NOTIFY_PER_IP_POLICY, + bucketId + ); + await throwIfAdminRateLimited( + c, + ADMIN_TELEGRAM_CODE_NOTIFY_GLOBAL_POLICY, + "global" + ); await sendTelegramMessage( securityConfig, @@ -417,6 +674,23 @@ export const sendAdminLoginResultNotification = async ( ): Promise => { const metadata = getRequestMetadata(c); const occurredAt = new Date(); + const bucketId = getAdminRateLimitBucketId(metadata); + const policies = status === "success" + ? [ADMIN_TELEGRAM_RESULT_SUCCESS_PER_IP_POLICY, ADMIN_TELEGRAM_RESULT_SUCCESS_GLOBAL_POLICY] + : [ADMIN_TELEGRAM_RESULT_FAILURE_PER_IP_POLICY, ADMIN_TELEGRAM_RESULT_FAILURE_GLOBAL_POLICY]; + + for (const policy of policies) { + const result = await consumeAdminRateLimit( + c, + policy, + policy.category.endsWith(":global") ? "global" : bucketId + ); + + if (!result.ok) { + console.warn(`Skipped Telegram login ${status} notification due to rate limit: ${policy.category}`); + return; + } + } await sendTelegramMessage( securityConfig, diff --git a/src/db/index.ts b/src/db/index.ts index 9e9b5ff..449b84e 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -46,10 +46,25 @@ CREATE TABLE IF NOT EXISTS admin_session ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS admin_rate_limit ( + bucket_key TEXT PRIMARY KEY, + category TEXT NOT NULL, + bucket_id TEXT NOT NULL, + window_started_at TEXT NOT NULL, + attempts INTEGER DEFAULT 0, + blocked_until TEXT, + last_event_at TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); CREATE INDEX IF NOT EXISTS idx_admin_login_challenge_expires_at ON admin_login_challenge (expires_at); CREATE INDEX IF NOT EXISTS idx_admin_session_expires_at ON admin_session (expires_at); +CREATE INDEX IF NOT EXISTS idx_admin_rate_limit_category_bucket + ON admin_rate_limit (category, bucket_id); +CREATE INDEX IF NOT EXISTS idx_admin_rate_limit_blocked_until + ON admin_rate_limit (blocked_until); ` let dbReadyPromise: Promise | null = null; diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh index be8fafc..77fecc6 100755 --- a/tests/run-e2e.sh +++ b/tests/run-e2e.sh @@ -59,6 +59,19 @@ do_get() { curl -s -w "\n%{http_code}" "$@"; } do_post() { curl -s -w "\n%{http_code}" -X POST "$@"; } reset_mock_inspection() { curl -s -X POST "$MOCK_API/__reset" > /dev/null; } get_mock_inspection() { curl -sG "$MOCK_API/__inspect" --data-urlencode "key=$1"; } +set_system_config_disabled() { + "${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":false,\"telegramBotToken\":\"\",\"telegramChatId\":\"\",\"verifiedFingerprint\":\"\",\"verifiedAt\":null},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1 +} +set_system_config_with_telegram() { + local bot_token="$1" chat_id="$2" fingerprint + fingerprint=$(node -e "const source = process.argv[1] + '::' + process.argv[2]; let hash = 2166136261; for (let index = 0; index < source.length; index += 1) { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 16777619); } process.stdout.write(source ? 'tgv1-' + ((hash >>> 0).toString(16)) : '');" "$bot_token" "$chat_id") + "${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":true,\"telegramBotToken\":\"$bot_token\",\"telegramChatId\":\"$chat_id\",\"verifiedFingerprint\":\"$fingerprint\",\"verifiedAt\":\"2026-04-14T00:00:00.000Z\"},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1 +} +seed_blocked_rate_limit() { + local category="$1" bucket_id="$2" attempts="$3" blocked_until="$4" now_iso + now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + "${DB_EXEC[@]}" "INSERT OR REPLACE INTO admin_rate_limit (bucket_key, category, bucket_id, window_started_at, attempts, blocked_until, last_event_at) VALUES ('$category:$bucket_id', '$category', '$bucket_id', '$now_iso', $attempts, '$blocked_until', '$now_iso')" > /dev/null 2>&1 +} if [[ -f ".dev.vars" ]]; then set -a @@ -67,6 +80,8 @@ if [[ -f ".dev.vars" ]]; then set +a fi +LOGIN_PAYLOAD=$(printf '{"token":"%s"}' "$ADMIN_TOKEN") + echo "" echo "=========================================" echo " E2E Tests with Mock Upstream" @@ -241,9 +256,8 @@ assert_http "quota exceeded → 402" "402" "Quota exceeded" "$R" echo "" echo "--- Admin session cookie ---" -"${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":false,\"telegramBotToken\":\"\",\"telegramChatId\":\"\",\"verifiedFingerprint\":\"\",\"verifiedAt\":null},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1 +set_system_config_disabled COOKIE_JAR=$(mktemp) -LOGIN_PAYLOAD=$(printf '{"token":"%s"}' "$ADMIN_TOKEN") R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -c "$COOKIE_JAR" -d "$LOGIN_PAYLOAD") assert_http "admin login → 200" "200" "\"requiresVerification\":false" "$R" @@ -261,6 +275,32 @@ assert_http "admin cookie invalid after logout" "401" "Unauthorized" "$R" rm -f "$COOKIE_JAR" +# ---- Admin anti-bruteforce ---- +echo "" +echo "--- Admin anti-bruteforce ---" + +set_system_config_disabled +LIMIT_IP="203.0.113.70" +"${DB_EXEC[@]}" "DELETE FROM admin_rate_limit" > /dev/null 2>&1 +for attempt in 1 2 3 4 5; do + R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -H "CF-Connecting-IP: $LIMIT_IP" -d '{"token":"bad-token"}') + assert_http "admin invalid token attempt $attempt → 401" "401" "管理员令牌无效" "$R" +done +R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -H "CF-Connecting-IP: $LIMIT_IP" -d '{"token":"bad-token"}') +assert_http "admin invalid token freeze → 429" "429" "管理员登录失败次数过多" "$R" + +# ---- Telegram notification throttle ---- +echo "" +echo "--- Telegram notification throttle ---" + +THROTTLE_IP="203.0.113.71" +THROTTLE_UNTIL=$(date -u -d "+15 minutes" +"%Y-%m-%dT%H:%M:%SZ") +set_system_config_with_telegram "dummy-bot-token" "123456" +"${DB_EXEC[@]}" "DELETE FROM admin_rate_limit" > /dev/null 2>&1 +seed_blocked_rate_limit "admin-telegram-code:ip" "$THROTTLE_IP" 99 "$THROTTLE_UNTIL" +R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -H "CF-Connecting-IP: $THROTTLE_IP" -d "$LOGIN_PAYLOAD") +assert_http "telegram code notification throttle → 429" "429" "验证码发送过于频繁" "$R" + # ---- Error recovery ---- echo "" echo "--- Error recovery ---" @@ -284,6 +324,7 @@ done for key in sk-mock-all sk-mock-openai sk-mock-quota; do "${DB_EXEC[@]}" "DELETE FROM api_token WHERE key = '$key'" > /dev/null 2>&1 done +"${DB_EXEC[@]}" "DELETE FROM admin_rate_limit; DELETE FROM admin_login_challenge; DELETE FROM admin_session; DELETE FROM settings WHERE key = 'SYSTEM_CONFIG'" > /dev/null 2>&1 echo "Cleanup done." exit $FAIL diff --git a/tests/setup-test-data.sh b/tests/setup-test-data.sh index 1a5b790..bd8d427 100755 --- a/tests/setup-test-data.sh +++ b/tests/setup-test-data.sh @@ -12,6 +12,7 @@ echo "Setting up test channels and tokens..." # Warm up the worker so schema migrations run against the same local D1 database. curl -s "$API_URL/api/admin/channel" > /dev/null 2>&1 || true +"${DB_EXEC[@]}" "DELETE FROM admin_rate_limit; DELETE FROM admin_login_challenge; DELETE FROM admin_session; DELETE FROM settings WHERE key = 'SYSTEM_CONFIG'" > /dev/null 2>&1 || true # Channel: openai type "${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-openai', '{\"name\":\"test-openai\",\"type\":\"openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o\":\"gpt-4o\",\"gpt-4\":\"gpt-4\"}}')"