Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator, apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock the address or the name for 15 minutes. Logins, logouts and every refused request go to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are listed in routes.ts with their kind and capability; writes stay refused for now. Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan is in docs/hub-management-plan.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
/*
|
|
Login rate limiting. Failures are counted per client address and per user name: after
|
|
MAX_FAILURES within WINDOW_MS, further attempts are refused for LOCK_MS. A success clears
|
|
the user's counter. In memory; `sweep` drops what has expired.
|
|
*/
|
|
export const MAX_FAILURES = 5
|
|
export const WINDOW_MS = 15 * 60_000
|
|
export const LOCK_MS = 15 * 60_000
|
|
|
|
interface Entry {
|
|
failures: number[]
|
|
lockedUntil: number
|
|
}
|
|
|
|
const userKey = (user: string): string => user.trim().toLowerCase().slice(0, 128)
|
|
|
|
export class LoginLimiter {
|
|
#now: () => number
|
|
#ips = new Map<string, Entry>()
|
|
#users = new Map<string, Entry>()
|
|
|
|
constructor(now: () => number = Date.now) {
|
|
this.#now = now
|
|
}
|
|
|
|
/** Milliseconds left of a lock on this address or this user, or 0 when attempts are allowed. */
|
|
lockedFor(ip: string, user: string): number {
|
|
const now = this.#now()
|
|
const until = Math.max(this.#ips.get(ip)?.lockedUntil ?? 0, this.#users.get(userKey(user))?.lockedUntil ?? 0)
|
|
return until > now ? until - now : 0
|
|
}
|
|
|
|
failure(ip: string, user: string): void {
|
|
this.#record(this.#ips, ip)
|
|
this.#record(this.#users, userKey(user))
|
|
this.sweep()
|
|
}
|
|
|
|
/** A good login clears the user's counter (the address keeps its own). */
|
|
success(user: string): void {
|
|
this.#users.delete(userKey(user))
|
|
}
|
|
|
|
#record(map: Map<string, Entry>, key: string): void {
|
|
const now = this.#now()
|
|
const entry = map.get(key) ?? { failures: [], lockedUntil: 0 }
|
|
entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS)
|
|
entry.failures.push(now)
|
|
if (entry.failures.length >= MAX_FAILURES) {
|
|
entry.lockedUntil = now + LOCK_MS
|
|
entry.failures = []
|
|
}
|
|
map.set(key, entry)
|
|
}
|
|
|
|
sweep(): void {
|
|
const now = this.#now()
|
|
for (const map of [this.#ips, this.#users]) {
|
|
for (const [key, entry] of map) {
|
|
const recent = entry.failures.some((t) => now - t < WINDOW_MS)
|
|
if (entry.lockedUntil <= now && !recent) map.delete(key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Technitium-like message for a locked attempt. */
|
|
export const lockedMessage = (ms: number): string => {
|
|
const minutes = Math.max(1, Math.ceil(ms / 60_000))
|
|
return `Too many failed logins. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`
|
|
}
|