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:
committed by
GitHub
co-authored by
Jovines
parent
10165ec604
commit
1d9e7056f4
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user