.
This commit is contained in:
+20
-8
@@ -3,7 +3,9 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
|
||||
import { cleanupDatabase } from "./lib/db";
|
||||
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
||||
import { config } from "./lib/config";
|
||||
import { HttpError } from "./lib/errors";
|
||||
import { serveFrontendBuild } from "./lib/frontend";
|
||||
import { clearAccessKeyLoginRateLimit, enforceAggressiveLoginRateLimit, recordFailedAccessKeyLogin } from "./lib/security";
|
||||
import {
|
||||
createUserSchema,
|
||||
loginSchema,
|
||||
@@ -65,15 +67,25 @@ app.get(
|
||||
app.post(
|
||||
"/api/auth/login",
|
||||
route(async ({ request }) => {
|
||||
const body = await parseJson(request, loginSchema);
|
||||
const result = await loginAdmin(body.accessKey);
|
||||
enforceAggressiveLoginRateLimit(request);
|
||||
|
||||
return jsonResponse(
|
||||
request,
|
||||
200,
|
||||
{ session: result.session },
|
||||
{ "set-cookie": result.cookie }
|
||||
);
|
||||
try {
|
||||
const body = await parseJson(request, loginSchema);
|
||||
const result = await loginAdmin(body.accessKey);
|
||||
clearAccessKeyLoginRateLimit(request);
|
||||
|
||||
return jsonResponse(
|
||||
request,
|
||||
200,
|
||||
{ session: result.session },
|
||||
{ "set-cookie": result.cookie }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
recordFailedAccessKeyLogin(request);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly headers?: HeadersInit;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
constructor(status: number, message: string, headers?: HeadersInit) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const errorResponse = (request: Request, error: unknown): Response => {
|
||||
error: {
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}, error.headers);
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
@@ -115,4 +115,3 @@ export const errorResponse = (request: Request, error: unknown): Response => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,102 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { HttpError } from "./errors";
|
||||
|
||||
type LoginRateLimitState = {
|
||||
blockedUntil: number;
|
||||
lastSeenAt: number;
|
||||
recentAttempts: number[];
|
||||
failedAttempts: number[];
|
||||
};
|
||||
|
||||
const loginRateLimitStates = new Map<string, LoginRateLimitState>();
|
||||
|
||||
const LOGIN_BURST_WINDOW_MS = 15 * 60_000;
|
||||
const LOGIN_BURST_LIMIT = 1;
|
||||
const LOGIN_FAILURE_TRACK_MS = 365 * 24 * 60 * 60_000;
|
||||
const LOGIN_STATE_TTL_MS = 400 * 24 * 60 * 60_000;
|
||||
|
||||
const getRetryAfterSeconds = (blockedUntil: number): number =>
|
||||
Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
|
||||
|
||||
const getClientIp = (request: Request): string => {
|
||||
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
const first = forwardedFor.split(",")[0]?.trim();
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
const realIp = request.headers.get("x-real-ip")?.trim();
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const getLoginRateLimitKey = (request: Request): string =>
|
||||
getClientIp(request);
|
||||
|
||||
const pruneLoginRateLimitState = (state: LoginRateLimitState, now: number): void => {
|
||||
state.recentAttempts = state.recentAttempts.filter((timestamp) => now - timestamp <= LOGIN_BURST_WINDOW_MS);
|
||||
state.failedAttempts = state.failedAttempts.filter((timestamp) => now - timestamp <= LOGIN_FAILURE_TRACK_MS);
|
||||
};
|
||||
|
||||
const getLoginRateLimitState = (key: string, now: number): LoginRateLimitState => {
|
||||
for (const [stateKey, state] of loginRateLimitStates.entries()) {
|
||||
if (now - state.lastSeenAt > LOGIN_STATE_TTL_MS && now >= state.blockedUntil) {
|
||||
loginRateLimitStates.delete(stateKey);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = loginRateLimitStates.get(key);
|
||||
if (existing) {
|
||||
existing.lastSeenAt = now;
|
||||
pruneLoginRateLimitState(existing, now);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextState: LoginRateLimitState = {
|
||||
blockedUntil: 0,
|
||||
lastSeenAt: now,
|
||||
recentAttempts: [],
|
||||
failedAttempts: []
|
||||
};
|
||||
|
||||
loginRateLimitStates.set(key, nextState);
|
||||
return nextState;
|
||||
};
|
||||
|
||||
const createRateLimitError = (blockedUntil: number): HttpError =>
|
||||
new HttpError(
|
||||
429,
|
||||
"Too many access key login attempts. Login is locked for this client.",
|
||||
{
|
||||
"Retry-After": String(getRetryAfterSeconds(blockedUntil))
|
||||
}
|
||||
);
|
||||
|
||||
const applyLoginFailurePenalty = (state: LoginRateLimitState, now: number): void => {
|
||||
const failureCount = state.failedAttempts.length;
|
||||
|
||||
let blockMs = 0;
|
||||
if (failureCount >= 5) {
|
||||
blockMs = 365 * 24 * 60 * 60_000;
|
||||
} else if (failureCount >= 4) {
|
||||
blockMs = 180 * 24 * 60 * 60_000;
|
||||
} else if (failureCount >= 3) {
|
||||
blockMs = 30 * 24 * 60 * 60_000;
|
||||
} else if (failureCount >= 2) {
|
||||
blockMs = 7 * 24 * 60 * 60_000;
|
||||
} else if (failureCount >= 1) {
|
||||
blockMs = 24 * 60 * 60_000;
|
||||
}
|
||||
|
||||
if (blockMs > 0) {
|
||||
state.blockedUntil = Math.max(state.blockedUntil, now + blockMs);
|
||||
}
|
||||
};
|
||||
|
||||
export const hashToken = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
@@ -54,3 +152,31 @@ export const readCookie = (request: Request, name: string): string | null => {
|
||||
export const sanitizeHeaderValue = (value: string): string =>
|
||||
value.replace(/[\r\n]+/g, " ").trim();
|
||||
|
||||
export const enforceAggressiveLoginRateLimit = (request: Request): void => {
|
||||
const now = Date.now();
|
||||
const key = getLoginRateLimitKey(request);
|
||||
const state = getLoginRateLimitState(key, now);
|
||||
|
||||
if (now < state.blockedUntil) {
|
||||
throw createRateLimitError(state.blockedUntil);
|
||||
}
|
||||
|
||||
state.recentAttempts.push(now);
|
||||
if (state.recentAttempts.length > LOGIN_BURST_LIMIT) {
|
||||
state.blockedUntil = now + 24 * 60 * 60_000;
|
||||
throw createRateLimitError(state.blockedUntil);
|
||||
}
|
||||
};
|
||||
|
||||
export const recordFailedAccessKeyLogin = (request: Request): void => {
|
||||
const now = Date.now();
|
||||
const key = getLoginRateLimitKey(request);
|
||||
const state = getLoginRateLimitState(key, now);
|
||||
|
||||
state.failedAttempts.push(now);
|
||||
applyLoginFailurePenalty(state, now);
|
||||
};
|
||||
|
||||
export const clearAccessKeyLoginRateLimit = (request: Request): void => {
|
||||
loginRateLimitStates.delete(getLoginRateLimitKey(request));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user