完善 HttpError 构造函数,添加 headers 参数;更新错误处理逻辑以支持 headers
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
constructor(readonly status: number, message: string) {
|
constructor(readonly status: number, message: string, readonly headers: HeadersInit = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ export default {
|
|||||||
ctx.waitUntil(processOutgoingDeliveries(env));
|
ctx.waitUntil(processOutgoingDeliveries(env));
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpError) return json({ error: error.message }, error.status);
|
if (error instanceof HttpError) return json({ error: error.message }, error.status, error.headers);
|
||||||
console.error("unhandled", error);
|
console.error("unhandled", error);
|
||||||
return json({ error: "internal_server_error" }, 500);
|
return json({ error: "internal_server_error" }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-3
@@ -105,6 +105,10 @@ const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|||||||
const MAX_STATUS_CHARS = 5000;
|
const MAX_STATUS_CHARS = 5000;
|
||||||
const REPORTED_MEDIA_ATTACHMENTS_LIMIT = 9999;
|
const REPORTED_MEDIA_ATTACHMENTS_LIMIT = 9999;
|
||||||
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
||||||
|
const AUTH_RATE_LIMIT_WINDOW_SECONDS = 15 * 60;
|
||||||
|
const AUTH_RATE_LIMIT_LOCK_SECONDS = 15 * 60;
|
||||||
|
const AUTH_RATE_LIMIT_MAX_IP_FAILURES = 20;
|
||||||
|
const AUTH_RATE_LIMIT_MAX_USERNAME_FAILURES = 8;
|
||||||
|
|
||||||
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]);
|
const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]);
|
||||||
@@ -279,10 +283,14 @@ export async function authorize(request: Request, env: Env): Promise<Response> {
|
|||||||
const redirectUri = selectRedirectUri(app, bodyString(body, "redirect_uri"));
|
const redirectUri = selectRedirectUri(app, bodyString(body, "redirect_uri"));
|
||||||
if (!redirectUri) return json({ error: "invalid_request" }, 400);
|
if (!redirectUri) return json({ error: "invalid_request" }, 400);
|
||||||
|
|
||||||
const user = await getUserByUsername(env, bodyString(body, "username"));
|
const username = bodyString(body, "username");
|
||||||
|
await assertAuthNotRateLimited(request, env, username);
|
||||||
|
const user = await getUserByUsername(env, username);
|
||||||
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) {
|
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) {
|
||||||
|
await recordAuthFailure(request, env, username);
|
||||||
return html("Invalid username or password", 401);
|
return html("Invalid username or password", 401);
|
||||||
}
|
}
|
||||||
|
await clearAuthFailures(request, env, username);
|
||||||
|
|
||||||
const code = tokenString(32);
|
const code = tokenString(32);
|
||||||
const scope = bodyString(body, "scope", app.scopes);
|
const scope = bodyString(body, "scope", app.scopes);
|
||||||
@@ -308,8 +316,14 @@ export async function token(request: Request, env: Env): Promise<Response> {
|
|||||||
let scopes = app.scopes;
|
let scopes = app.scopes;
|
||||||
|
|
||||||
if (grantType === "password") {
|
if (grantType === "password") {
|
||||||
const user = await getUserByUsername(env, bodyString(body, "username"));
|
const username = bodyString(body, "username");
|
||||||
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) return json({ error: "invalid_grant" }, 400);
|
await assertAuthNotRateLimited(request, env, username);
|
||||||
|
const user = await getUserByUsername(env, username);
|
||||||
|
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) {
|
||||||
|
await recordAuthFailure(request, env, username);
|
||||||
|
return json({ error: "invalid_grant" }, 400);
|
||||||
|
}
|
||||||
|
await clearAuthFailures(request, env, username);
|
||||||
userId = user.id;
|
userId = user.id;
|
||||||
scopes = bodyString(body, "scope", app.scopes);
|
scopes = bodyString(body, "scope", app.scopes);
|
||||||
} else if (grantType === "client_credentials") {
|
} else if (grantType === "client_credentials") {
|
||||||
@@ -341,6 +355,51 @@ export async function revoke(request: Request, env: Env): Promise<Response> {
|
|||||||
return json({});
|
return json({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthFailureRecord = {
|
||||||
|
count: number;
|
||||||
|
firstFailureAt: number;
|
||||||
|
lockedUntil?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function assertAuthNotRateLimited(request: Request, env: Env, username: string): Promise<void> {
|
||||||
|
const keys = authRateLimitKeys(request, username);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const records = await Promise.all(keys.map(({ key }) => env.KV.get<AuthFailureRecord>(key, "json")));
|
||||||
|
const lockedUntil = records.reduce((latest, record) => Math.max(latest, record?.lockedUntil ?? 0), 0);
|
||||||
|
if (lockedUntil > now) {
|
||||||
|
const retryAfter = String(Math.max(1, lockedUntil - now));
|
||||||
|
throw new HttpError(429, "rate_limited", { "retry-after": retryAfter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordAuthFailure(request: Request, env: Env, username: string): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await Promise.all(authRateLimitKeys(request, username).map(async ({ key, limit }) => {
|
||||||
|
const existing = await env.KV.get<AuthFailureRecord>(key, "json");
|
||||||
|
const inWindow = existing && now - existing.firstFailureAt < AUTH_RATE_LIMIT_WINDOW_SECONDS;
|
||||||
|
const next: AuthFailureRecord = {
|
||||||
|
count: inWindow ? existing.count + 1 : 1,
|
||||||
|
firstFailureAt: inWindow ? existing.firstFailureAt : now
|
||||||
|
};
|
||||||
|
if (next.count >= limit) next.lockedUntil = now + AUTH_RATE_LIMIT_LOCK_SECONDS;
|
||||||
|
const ttl = Math.max(AUTH_RATE_LIMIT_WINDOW_SECONDS, AUTH_RATE_LIMIT_LOCK_SECONDS);
|
||||||
|
await env.KV.put(key, JSON.stringify(next), { expirationTtl: ttl });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAuthFailures(request: Request, env: Env, username: string): Promise<void> {
|
||||||
|
await Promise.all(authRateLimitKeys(request, username).map(({ key }) => env.KV.delete(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function authRateLimitKeys(request: Request, username: string): { key: string; limit: number }[] {
|
||||||
|
const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const normalizedUsername = username.trim().toLowerCase() || "empty";
|
||||||
|
return [
|
||||||
|
{ key: `auth_fail:ip:${ip}`, limit: AUTH_RATE_LIMIT_MAX_IP_FAILURES },
|
||||||
|
{ key: `auth_fail:user:${normalizedUsername}`, limit: AUTH_RATE_LIMIT_MAX_USERNAME_FAILURES }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyCredentials(request: Request, env: Env): Promise<Response> {
|
export async function verifyCredentials(request: Request, env: Env): Promise<Response> {
|
||||||
const user = await requireUser(request, env);
|
const user = await requireUser(request, env);
|
||||||
const account = await accountJson(env, user) as Record<string, unknown>;
|
const account = await accountJson(env, user) as Record<string, unknown>;
|
||||||
|
|||||||
Reference in New Issue
Block a user