Files
Hermes-ui/packages/server/src/services/login-limiter.ts
T

338 lines
8.7 KiB
TypeScript

import { readFile, writeFile, mkdir } from 'fs/promises'
import { writeFileSync } from 'fs'
import { join } from 'path'
import { config } from '../config'
const APP_HOME = config.appHome
const LOCK_FILE = join(APP_HOME, '.login-lock.json')
// Per-IP settings
const IP_MAX_FAILURES = 3
const IP_FAILURE_WINDOW_MS = 15 * 60_000 // 15 minutes
const IP_LOCK_DURATION_MS = 60 * 60_000 // 1 hour
const IP_MAP_MAX_SIZE = 10000
// Global safety net (against distributed attacks)
const GLOBAL_WINDOW_MS = 60_000
const GLOBAL_MAX_REQUESTS_PER_WINDOW = 100
const GLOBAL_MAX_TOTAL_FAILURES = 50
const GLOBAL_LOCK_DURATION_MS = 30 * 60_000 // 30 minutes
interface IpEntry {
failures: number
lockedUntil: number
firstFailureAt?: number
}
interface LimiterState {
passwordIpMap: Record<string, IpEntry>
tokenIpMap: Record<string, IpEntry>
globalMinuteCount: number
globalMinuteWindow: number
globalTotalFailures: number
globalLockedUntil: number
}
let state: LimiterState = {
passwordIpMap: {},
tokenIpMap: {},
globalMinuteCount: 0,
globalMinuteWindow: 0,
globalTotalFailures: 0,
globalLockedUntil: 0,
}
let dirty = false
let persistTimer: ReturnType<typeof setTimeout> | null = null
function now(): number {
return Date.now()
}
function extractIp(ctx: any): string {
return ctx?.ip || ctx?.request?.ip || 'unknown'
}
function pruneIpMap(map: Record<string, IpEntry>): void {
const keys = Object.keys(map)
if (keys.length <= IP_MAP_MAX_SIZE) return
const t = now()
for (const key of keys) {
if (map[key].lockedUntil > 0 && t >= map[key].lockedUntil) {
delete map[key]
}
}
const remaining = Object.keys(map)
if (remaining.length <= IP_MAP_MAX_SIZE) return
remaining.sort((a, b) => (map[a].lockedUntil || 0) - (map[b].lockedUntil || 0))
for (let i = 0; i < remaining.length - IP_MAP_MAX_SIZE; i++) {
delete map[remaining[i]]
}
}
async function loadState(): Promise<void> {
try {
const raw = await readFile(LOCK_FILE, 'utf-8')
const parsed = JSON.parse(raw)
state = {
passwordIpMap: parsed.passwordIpMap || {},
tokenIpMap: parsed.tokenIpMap || {},
globalMinuteCount: parsed.globalMinuteCount || 0,
globalMinuteWindow: parsed.globalMinuteWindow || 0,
globalTotalFailures: parsed.globalTotalFailures || 0,
globalLockedUntil: parsed.globalLockedUntil || 0,
}
} catch {
// use defaults
}
}
async function persistState(): Promise<void> {
try {
await mkdir(APP_HOME, { recursive: true })
await writeFile(LOCK_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
dirty = false
} catch {
// best effort
}
}
function persistStateSync(): void {
try {
writeFileSync(LOCK_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
dirty = false
} catch {
// best effort
}
}
function schedulePersist(): void {
if (persistTimer) return
persistTimer = setTimeout(() => {
persistTimer = null
if (dirty) persistState().catch(() => {})
}, 2000)
}
export type CheckResult =
| { allowed: true }
| { allowed: false; status: 429 | 503 }
function checkGlobalLimits(): CheckResult | null {
const t = now()
if (state.globalLockedUntil > 0 && t < state.globalLockedUntil) {
return { allowed: false, status: 503 }
}
if (state.globalLockedUntil > 0 && t >= state.globalLockedUntil) {
state.globalLockedUntil = 0
state.globalTotalFailures = 0
dirty = true
}
if (t - state.globalMinuteWindow >= GLOBAL_WINDOW_MS) {
state.globalMinuteWindow = t
state.globalMinuteCount = 0
}
if (state.globalMinuteCount >= GLOBAL_MAX_REQUESTS_PER_WINDOW) {
return { allowed: false, status: 429 }
}
return null
}
function checkIpLock(ip: string, map: Record<string, IpEntry>): CheckResult | null {
const t = now()
const entry = map[ip]
if (entry && entry.lockedUntil > 0 && t < entry.lockedUntil) {
return { allowed: false, status: 429 }
}
if (entry && entry.lockedUntil > 0 && t >= entry.lockedUntil) {
delete map[ip]
dirty = true
}
return null
}
function recordIpFailure(map: Record<string, IpEntry>, ip: string): IpEntry {
const t = now()
let entry = map[ip]
if (!entry) {
entry = { failures: 0, lockedUntil: 0, firstFailureAt: t }
map[ip] = entry
}
const firstFailureAt = entry.firstFailureAt || t
if (entry.lockedUntil <= 0 && t - firstFailureAt > IP_FAILURE_WINDOW_MS) {
entry.failures = 0
entry.firstFailureAt = t
} else if (!entry.firstFailureAt) {
entry.firstFailureAt = firstFailureAt
}
entry.failures++
return entry
}
export function checkPassword(ip: string): CheckResult {
const global = checkGlobalLimits()
if (global) return global
// Check both maps — IP locked by either password or token = blocked
const ipLock = checkIpLock(ip, state.passwordIpMap) || checkIpLock(ip, state.tokenIpMap)
if (ipLock) return ipLock
state.globalMinuteCount++
dirty = true
schedulePersist()
return { allowed: true }
}
export function checkToken(ip: string): CheckResult {
const global = checkGlobalLimits()
if (global) return global
// Check both maps — IP locked by either password or token = blocked
const ipLock = checkIpLock(ip, state.tokenIpMap) || checkIpLock(ip, state.passwordIpMap)
if (ipLock) return ipLock
state.globalMinuteCount++
dirty = true
schedulePersist()
return { allowed: true }
}
export function recordPasswordFailure(ip: string): void {
const entry = recordIpFailure(state.passwordIpMap, ip)
state.globalTotalFailures++
dirty = true
if (entry.failures >= IP_MAX_FAILURES) {
entry.lockedUntil = now() + IP_LOCK_DURATION_MS
persistStateSync()
return
}
if (state.globalTotalFailures >= GLOBAL_MAX_TOTAL_FAILURES) {
state.globalLockedUntil = now() + GLOBAL_LOCK_DURATION_MS
persistStateSync()
return
}
pruneIpMap(state.passwordIpMap)
schedulePersist()
}
export function recordTokenFailure(ip: string): void {
const entry = recordIpFailure(state.tokenIpMap, ip)
state.globalTotalFailures++
dirty = true
if (entry.failures >= IP_MAX_FAILURES) {
entry.lockedUntil = now() + IP_LOCK_DURATION_MS
persistStateSync()
return
}
if (state.globalTotalFailures >= GLOBAL_MAX_TOTAL_FAILURES) {
state.globalLockedUntil = now() + GLOBAL_LOCK_DURATION_MS
persistStateSync()
return
}
pruneIpMap(state.tokenIpMap)
schedulePersist()
}
export function recordPasswordSuccess(ip: string): void {
if (state.passwordIpMap[ip]) {
delete state.passwordIpMap[ip]
state.globalTotalFailures = 0
dirty = true
schedulePersist()
}
}
export function reset(): void {
state = {
passwordIpMap: {}, tokenIpMap: {},
globalMinuteCount: 0, globalMinuteWindow: 0,
globalTotalFailures: 0, globalLockedUntil: 0,
}
dirty = true
schedulePersist()
}
export interface LockedIpInfo {
ip: string
type: 'password' | 'token'
failures: number
lockedUntil: number
}
export function getLockedIps(): LockedIpInfo[] {
const t = now()
const result: LockedIpInfo[] = []
for (const [ip, entry] of Object.entries(state.passwordIpMap)) {
if (entry.lockedUntil > 0 && t < entry.lockedUntil) {
result.push({ ip, type: 'password', failures: entry.failures, lockedUntil: entry.lockedUntil })
}
}
for (const [ip, entry] of Object.entries(state.tokenIpMap)) {
if (entry.lockedUntil > 0 && t < entry.lockedUntil) {
result.push({ ip, type: 'token', failures: entry.failures, lockedUntil: entry.lockedUntil })
}
}
return result
}
export function unlockIp(ip: string): boolean {
let found = false
if (state.passwordIpMap[ip]) {
delete state.passwordIpMap[ip]
found = true
}
if (state.tokenIpMap[ip]) {
delete state.tokenIpMap[ip]
found = true
}
if (found) {
dirty = true
persistStateSync()
}
return found
}
export function unlockAll(): number {
const count = getLockedIps().length
state.passwordIpMap = {}
state.tokenIpMap = {}
state.globalTotalFailures = 0
state.globalLockedUntil = 0
dirty = true
persistStateSync()
return count
}
export { extractIp }
export async function initLoginLimiter(): Promise<void> {
await loadState()
const t = now()
let changed = false
for (const [ip, entry] of Object.entries(state.passwordIpMap)) {
if (entry.lockedUntil > 0 && t >= entry.lockedUntil) {
delete state.passwordIpMap[ip]
changed = true
}
}
for (const [ip, entry] of Object.entries(state.tokenIpMap)) {
if (entry.lockedUntil > 0 && t >= entry.lockedUntil) {
delete state.tokenIpMap[ip]
changed = true
}
}
if (state.globalLockedUntil > 0 && t >= state.globalLockedUntil) {
state.globalLockedUntil = 0
state.globalTotalFailures = 0
changed = true
}
if (changed) {
dirty = true
await persistState()
}
}