feat(auth): add login rate limit protection (#269)

- Implement rate limiting for login attempts (10 attempts per 5min window, 15min lockout)
- Add Retry-After header and 429 response for rate-limited requests
- UI shows rate-limited state with countdown message
- Separate limits for identified IPs vs unknown clients (3 attempts without IP)
- Add cleanup mechanism for stale rate limit records

Co-authored-by: Jovines <jovines@qq.com>
This commit is contained in:
Jovines
2026-02-02 18:06:38 +02:00
committed by GitHub
co-authored by Jovines
parent 10165ec604
commit 1d9e7056f4
2 changed files with 282 additions and 22 deletions
@@ -62,27 +62,42 @@ const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing wo
</AuthShell>
);
const ErrorScreen: React.FC<{ onRetry: () => void }> = ({ onRetry }) => (
<AuthShell>
<div className="flex flex-col items-center gap-6 text-center">
<div className="space-y-2">
<h1 className="typography-ui-header font-semibold text-destructive">Unable to reach server</h1>
<p className="typography-meta text-muted-foreground max-w-xs">
We couldn't verify the UI session. Check that the service is running and try again.
</p>
const ErrorScreen: React.FC<ErrorScreenProps> = ({ onRetry, errorType = 'network', retryAfter }) => {
const isRateLimit = errorType === 'rate-limit';
const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1;
return (
<AuthShell>
<div className="flex flex-col items-center gap-6 text-center">
<div className="space-y-2">
<h1 className="typography-ui-header font-semibold text-destructive">
{isRateLimit ? 'Too many attempts' : 'Unable to reach server'}
</h1>
<p className="typography-meta text-muted-foreground max-w-xs">
{isRateLimit
? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.`
: "We couldn't verify the UI session. Check that the service is running and try again."}
</p>
</div>
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
Retry
</Button>
</div>
<Button type="button" onClick={onRetry} className="w-full max-w-xs">
Retry
</Button>
</div>
</AuthShell>
);
</AuthShell>
);
};
interface SessionAuthGateProps {
children: React.ReactNode;
}
type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
interface ErrorScreenProps {
onRetry: () => void;
errorType?: 'network' | 'rate-limit';
retryAfter?: number;
}
const getTokenFromUrl = (): string | null => {
try {
@@ -111,6 +126,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const [password, setPassword] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(skipAuth);
const hasTriedUrlTokenRef = React.useRef(false);
@@ -127,10 +143,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
if (response.ok) {
setState('authenticated');
setErrorMessage('');
setRetryAfter(undefined);
return;
}
if (response.status === 401) {
setState('locked');
setRetryAfter(undefined);
return;
}
if (response.status === 429) {
const data = await response.json().catch(() => ({}));
setRetryAfter(data.retryAfter);
setState('rate-limited');
return;
}
setState('error');
@@ -240,6 +264,13 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
return;
}
if (response.status === 429) {
const data = await response.json().catch(() => ({}));
setRetryAfter(data.retryAfter);
setState('rate-limited');
return;
}
setErrorMessage('Unexpected response from server.');
setState('error');
} catch (error) {
@@ -256,7 +287,11 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
}
if (state === 'error') {
return <ErrorScreen onRetry={() => void checkStatus()} />;
return <ErrorScreen onRetry={() => void checkStatus()} errorType="network" />;
}
if (state === 'rate-limited') {
return <ErrorScreen onRetry={() => void checkStatus()} errorType="rate-limit" retryAfter={retryAfter} />;
}
if (state === 'locked') {
+231 -6
View File
@@ -4,6 +4,207 @@ const SESSION_COOKIE_NAME = 'oc_ui_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
// Login rate limit configuration
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
// Rate limit tracker: IP -> { count, lastAttempt, lockedUntil }
const loginRateLimiter = new Map();
let rateLimitCleanupTimer = null;
// Concurrency control: key -> Promise<void>
const rateLimitLocks = new Map();
const getClientIp = (req) => {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
const ip = forwarded.split(',')[0].trim();
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
const ip = req.ip || req.connection?.remoteAddress;
if (ip) {
if (ip.startsWith('::ffff:')) {
return ip.substring(7);
}
return ip;
}
return null;
};
const getRateLimitKey = (req) => {
const ip = getClientIp(req);
if (ip) return ip;
return 'rate-limit:no-ip';
};
const getRateLimitConfig = (key) => {
if (key === 'rate-limit:no-ip') {
return {
maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
windowMs: RATE_LIMIT_WINDOW_MS
};
}
return {
maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
windowMs: RATE_LIMIT_WINDOW_MS
};
};
const acquireRateLimitLock = async (key) => {
const prev = rateLimitLocks.get(key) || Promise.resolve();
const curr = prev.then(() => rateLimitLocks.delete(key));
rateLimitLocks.set(key, curr);
await curr;
};
const checkRateLimit = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
const now = Date.now();
const { maxAttempts } = getRateLimitConfig(key);
let record;
try {
record = loginRateLimiter.get(key);
} catch (err) {
console.error('[RateLimit] Failed to get record', { key, error: err.message });
return {
allowed: true,
limit: maxAttempts,
remaining: maxAttempts,
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
};
}
if (record?.lockedUntil && now < record.lockedUntil) {
return {
allowed: false,
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
locked: true,
limit: maxAttempts,
remaining: 0,
reset: Math.ceil(record.lockedUntil / 1000)
};
}
if (record?.lockedUntil && now >= record.lockedUntil) {
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
}
}
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
return {
allowed: true,
limit: maxAttempts,
remaining: maxAttempts,
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
};
}
if (record.count >= maxAttempts) {
const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
try {
loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
} catch (err) {
console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
}
return {
allowed: false,
retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
locked: true,
limit: maxAttempts,
remaining: 0,
reset: Math.ceil(lockedUntil / 1000)
};
}
const remaining = maxAttempts - record.count;
const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
return {
allowed: true,
limit: maxAttempts,
remaining,
reset
};
};
const recordFailedAttempt = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
const now = Date.now();
const { maxAttempts } = getRateLimitConfig(key);
const record = loginRateLimiter.get(key);
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
try {
loginRateLimiter.set(key, { count: 1, lastAttempt: now });
} catch (err) {
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
}
} else {
const newCount = record.count + 1;
try {
loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
} catch (err) {
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
}
}
};
const clearRateLimit = async (req) => {
const key = getRateLimitKey(req);
await acquireRateLimitLock(key);
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Failed to clear', { key, error: err.message });
}
};
const cleanupRateLimitRecords = () => {
const now = Date.now();
for (const [key, record] of loginRateLimiter.entries()) {
const isExpired = record.lockedUntil && now >= record.lockedUntil;
const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
if (isExpired || isStale) {
try {
loginRateLimiter.delete(key);
} catch (err) {
console.error('[RateLimit] Cleanup failed', { key, error: err.message });
}
}
}
};
const startRateLimitCleanup = () => {
if (!rateLimitCleanupTimer) {
rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
rateLimitCleanupTimer.unref();
}
}
};
const stopRateLimitCleanup = () => {
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
};
const isSecureRequest = (req) => {
if (req.secure) {
return true;
@@ -221,6 +422,7 @@ export const createUiAuth = ({
};
startCleanup();
startRateLimitCleanup();
const respondUnauthorized = (req, res) => {
res.status(401);
@@ -254,14 +456,32 @@ export const createUiAuth = ({
res.status(401).json({ authenticated: false, locked: true });
};
const handleSessionCreate = (req, res) => {
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
if (!verifyPassword(candidate)) {
clearSessionCookie(req, res);
res.status(401).json({ error: 'Invalid password', locked: true });
const handleSessionCreate = async (req, res) => {
const rateLimitResult = await checkRateLimit(req);
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
if (!rateLimitResult.allowed) {
res.setHeader('Retry-After', rateLimitResult.retryAfter);
res.status(429).json({
error: 'Too many login attempts, please try again later',
retryAfter: rateLimitResult.retryAfter
});
return;
}
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
if (!verifyPassword(candidate)) {
await recordFailedAttempt(req);
clearSessionCookie(req, res);
res.status(401).json({ error: 'Invalid credentials' });
return;
}
await clearRateLimit(req);
const previousToken = getTokenFromRequest(req);
if (previousToken) {
dropSession(previousToken);
@@ -272,11 +492,16 @@ export const createUiAuth = ({
};
const dispose = () => {
sessions.clear();
loginRateLimiter.clear();
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
sessions.clear();
if (rateLimitCleanupTimer) {
clearInterval(rateLimitCleanupTimer);
rateLimitCleanupTimer = null;
}
};
return {