feat(auth): 增加管理员登录的速率限制和错误处理
This commit is contained in:
1 parent
8336649477
commit
3dc1fed1c1
5 files changed
+460
-3
No files matched your search
@@ -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<HonoCustomType>,
|
||||
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);
|
||||
|
||||
|
||||
+275
-1
@@ -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 | null | undefined>): 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<HonoCustomType>): LoginRequestMetadata => {
|
||||
export const getRequestMetadata = (c: Context<HonoCustomType>): LoginRequestMetadata => {
|
||||
const requestCf = (c.req.raw.cf || {}) as Partial<IncomingRequestCfProperties<unknown>>;
|
||||
|
||||
return {
|
||||
@@ -93,8 +168,65 @@ const toSha256Hex = async (value: string): Promise<string> => {
|
||||
.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<HonoCustomType>,
|
||||
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<HonoCustomType>) => {
|
||||
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<HonoCustomType>) => {
|
||||
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<HonoCustomType>,
|
||||
policy: AdminRateLimitPolicy,
|
||||
bucketId: string
|
||||
): Promise<AdminRateLimitResult> => {
|
||||
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<StoredAdminRateLimitRow>();
|
||||
|
||||
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<HonoCustomType>,
|
||||
category: string,
|
||||
bucketId: string
|
||||
): Promise<void> => {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM admin_rate_limit WHERE bucket_key = ?`
|
||||
).bind(buildAdminRateLimitBucketKey(category, bucketId)).run();
|
||||
};
|
||||
|
||||
const throwIfAdminRateLimited = async (
|
||||
c: Context<HonoCustomType>,
|
||||
policy: AdminRateLimitPolicy,
|
||||
bucketId: string
|
||||
): Promise<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
+43
-2
@@ -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
|
||||
@@ -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\"}}')"
|
||||
|
||||
Reference in new issue
Block a user