b5e5bed7dd
- Uniform auth responses remove the session-enumeration oracle: unknown session and wrong password now return an identical 401 at the API layer and an identical WS handshake/close; desktop-status and the 503 "not connected" signal are only exposed after successful auth. - Session-count cap re-checked after bcrypt.hash so concurrent creates can't overshoot maxSessions. - Relay web client reconnect backoff resets only after a successful auth response (an accept-then-close server no longer defeats the backoff). - idGenerator comment corrected to match the rejection-sampling; drop unused recordFailure return value. - DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
2.4 KiB
TypeScript
81 lines
2.4 KiB
TypeScript
// Auth brute-force lockout (in-memory).
|
|
//
|
|
// Tracks failed authentication attempts keyed by an opaque, namespaced key
|
|
// (e.g. `web:${sessionId}:${clientIp}` or `desktop:${sessionId}:${clientIp}`).
|
|
// Namespacing by path and client IP keeps the web and desktop keyspaces
|
|
// separate so one abusive web client cannot lock out other users or the
|
|
// desktop for the same session. After `authLockoutMaxAttempts` failures within
|
|
// `authLockoutWindowMs`, further attempts are rejected until
|
|
// `authLockoutDurationMs` has elapsed.
|
|
|
|
import { config } from '../config';
|
|
|
|
interface AttemptRecord {
|
|
count: number;
|
|
windowStart: number;
|
|
lockedUntil: number;
|
|
}
|
|
|
|
const records = new Map<string, AttemptRecord>();
|
|
|
|
/**
|
|
* Returns the number of milliseconds remaining on a lockout for this key,
|
|
* or 0 if the key is not currently locked.
|
|
*/
|
|
export function getLockoutRemaining(key: string): number {
|
|
const rec = records.get(key);
|
|
if (!rec) return 0;
|
|
const now = Date.now();
|
|
if (rec.lockedUntil > now) {
|
|
return rec.lockedUntil - now;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Record a failed auth attempt. Locks the key out for
|
|
* `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate
|
|
* within `authLockoutWindowMs`. Callers query the resulting lockout via
|
|
* getLockoutRemaining(), so this returns nothing.
|
|
*/
|
|
export function recordFailure(key: string): void {
|
|
const now = Date.now();
|
|
let rec = records.get(key);
|
|
|
|
// Start a fresh window if none exists or the current one has expired.
|
|
if (!rec || now - rec.windowStart > config.authLockoutWindowMs) {
|
|
rec = { count: 0, windowStart: now, lockedUntil: 0 };
|
|
records.set(key, rec);
|
|
}
|
|
|
|
rec.count += 1;
|
|
|
|
if (rec.count >= config.authLockoutMaxAttempts) {
|
|
rec.lockedUntil = now + config.authLockoutDurationMs;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear any recorded failures for this key (call on successful auth).
|
|
*/
|
|
export function recordSuccess(key: string): void {
|
|
records.delete(key);
|
|
}
|
|
|
|
// Periodically drop stale records so the map does not grow unbounded.
|
|
const cleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, rec] of records) {
|
|
const windowExpired = now - rec.windowStart > config.authLockoutWindowMs;
|
|
const notLocked = rec.lockedUntil <= now;
|
|
if (windowExpired && notLocked) {
|
|
records.delete(key);
|
|
}
|
|
}
|
|
}, Math.max(60000, config.authLockoutWindowMs));
|
|
|
|
// Do not keep the process alive solely for this timer.
|
|
if (typeof cleanup.unref === 'function') {
|
|
cleanup.unref();
|
|
}
|