From 1d9e7056f4efa78a4ecfacdbe75b3ea3a093d757 Mon Sep 17 00:00:00 2001 From: Jovines <1246634075@qq.com> Date: Tue, 3 Feb 2026 00:06:38 +0800 Subject: [PATCH] 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 --- .../src/components/auth/SessionAuthGate.tsx | 67 +++-- packages/web/server/lib/ui-auth.js | 237 +++++++++++++++++- 2 files changed, 282 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 5bb177e4..99f6b3bf 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -62,27 +62,42 @@ const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing wo ); -const ErrorScreen: React.FC<{ onRetry: () => void }> = ({ onRetry }) => ( - -
-
-

Unable to reach server

-

- We couldn't verify the UI session. Check that the service is running and try again. -

+const ErrorScreen: React.FC = ({ onRetry, errorType = 'network', retryAfter }) => { + const isRateLimit = errorType === 'rate-limit'; + const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1; + + return ( + +
+
+

+ {isRateLimit ? 'Too many attempts' : 'Unable to reach server'} +

+

+ {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."} +

+
+
- -
- -); + + ); +}; 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 = ({ children }) => const [password, setPassword] = React.useState(''); const [isSubmitting, setIsSubmitting] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); + const [retryAfter, setRetryAfter] = React.useState(undefined); const passwordInputRef = React.useRef(null); const hasResyncedRef = React.useRef(skipAuth); const hasTriedUrlTokenRef = React.useRef(false); @@ -127,10 +143,18 @@ export const SessionAuthGate: React.FC = ({ 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 = ({ 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 = ({ children }) => } if (state === 'error') { - return void checkStatus()} />; + return void checkStatus()} errorType="network" />; + } + + if (state === 'rate-limited') { + return void checkStatus()} errorType="rate-limit" retryAfter={retryAfter} />; } if (state === 'locked') { diff --git a/packages/web/server/lib/ui-auth.js b/packages/web/server/lib/ui-auth.js index 2d15654b..21e95c1b 100644 --- a/packages/web/server/lib/ui-auth.js +++ b/packages/web/server/lib/ui-auth.js @@ -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 +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 {