feat: add IP-based login brute-force protection (#531)

* feat: add IP-based login brute-force protection

- Per-IP rate limiting: 3 failed login attempts locks the IP for 1 hour
- Separate counters for password login and token auth
- Global safety net: 20 req/min, hard lock after 50 total failures
- Persistent lock state to ~/.hermes-web-ui/.login-lock.json (survives restarts)
- Manual unlock: edit or delete the lock file
- Frontend handles 429/503 responses with localized error messages
- i18n support for 8 languages

* feat: add locked IP management endpoint and UI

- GET /api/auth/locked-ips: list all currently locked IPs (protected)
- DELETE /api/auth/locked-ips/:ip: unlock a specific IP (protected)
- DELETE /api/auth/locked-ips: unlock all IPs (protected)
- AccountSettings: shows locked IPs with remaining time, unlock buttons
- i18n support for 8 languages
- Clean up stale .js artifacts, add .gitignore rule

* fix: cross-type IP lock and IPv6-compatible unlock route

- Password and token login now share IP lock state: if an IP is locked
  by either method, ALL auth methods are blocked for that IP
- Changed unlock endpoint from path param to query param (?ip=xxx) to
  support IPv6 addresses containing colons
- Merged unlockIp and unlockAll into a single handler

* chore: increase global login rate limit from 20 to 100 requests per minute

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: ekko <fqsy1416@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ccc
2026-05-08 18:29:43 +08:00
committed by GitHub
parent 6291f0d589
commit 4859c32045
17 changed files with 644 additions and 3 deletions
+28 -1
View File
@@ -19,7 +19,9 @@ export async function loginWithPassword(username: string, password: string): Pro
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Login failed')
const err: any = new Error(data.error || 'Login failed')
err.status = res.status
throw err
}
const data = await res.json()
return data.token
@@ -51,3 +53,28 @@ export async function removePassword(): Promise<void> {
method: 'DELETE',
})
}
export interface LockedIp {
ip: string
type: 'password' | 'token'
failures: number
lockedUntil: number
}
export async function fetchLockedIps(): Promise<LockedIp[]> {
const res = await request<{ locks: LockedIp[] }>('/api/auth/locked-ips')
return res.locks
}
export async function unlockSpecificIp(ip: string): Promise<void> {
return request(`/api/auth/locked-ips?ip=${encodeURIComponent(ip)}`, {
method: 'DELETE',
})
}
export async function unlockAllIps(): Promise<number> {
const res = await request<{ count: number }>('/api/auth/locked-ips', {
method: 'DELETE',
})
return res.count
}