From 4859c32045757499a3184ae0ec016a92104cb5d1 Mon Sep 17 00:00:00 2001 From: ccc <2260933+cccsaber@users.noreply.github.com> Date: Fri, 8 May 2026 18:29:43 +0800 Subject: [PATCH] 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 --------- Co-authored-by: ekko Co-authored-by: Claude Opus 4.7 --- .gitignore | 3 + packages/client/src/api/auth.ts | 29 +- .../hermes/settings/AccountSettings.vue | 132 ++++++- packages/client/src/i18n/locales/de.ts | 11 + packages/client/src/i18n/locales/en.ts | 12 + packages/client/src/i18n/locales/es.ts | 11 + packages/client/src/i18n/locales/fr.ts | 11 + packages/client/src/i18n/locales/ja.ts | 11 + packages/client/src/i18n/locales/ko.ts | 11 + packages/client/src/i18n/locales/pt.ts | 11 + packages/client/src/i18n/locales/zh.ts | 12 + packages/client/src/views/LoginView.vue | 12 +- packages/server/src/controllers/auth.ts | 41 +++ packages/server/src/index.ts | 2 + packages/server/src/routes/auth.ts | 2 + packages/server/src/services/auth.ts | 13 + packages/server/src/services/login-limiter.ts | 323 ++++++++++++++++++ 17 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/services/login-limiter.ts diff --git a/.gitignore b/.gitignore index dfcca5c..b1dd2e9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ hermes-dependencies.md *.sln *.sw? .superpowers/ +CLAUDE.md +# Client source map artifacts +packages/client/src/**/*.js \ No newline at end of file diff --git a/packages/client/src/api/auth.ts b/packages/client/src/api/auth.ts index 92bb213..7f0c608 100644 --- a/packages/client/src/api/auth.ts +++ b/packages/client/src/api/auth.ts @@ -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 { method: 'DELETE', }) } + +export interface LockedIp { + ip: string + type: 'password' | 'token' + failures: number + lockedUntil: number +} + +export async function fetchLockedIps(): Promise { + const res = await request<{ locks: LockedIp[] }>('/api/auth/locked-ips') + return res.locks +} + +export async function unlockSpecificIp(ip: string): Promise { + return request(`/api/auth/locked-ips?ip=${encodeURIComponent(ip)}`, { + method: 'DELETE', + }) +} + +export async function unlockAllIps(): Promise { + const res = await request<{ count: number }>('/api/auth/locked-ips', { + method: 'DELETE', + }) + return res.count +} diff --git a/packages/client/src/components/hermes/settings/AccountSettings.vue b/packages/client/src/components/hermes/settings/AccountSettings.vue index 038972b..7a18f50 100644 --- a/packages/client/src/components/hermes/settings/AccountSettings.vue +++ b/packages/client/src/components/hermes/settings/AccountSettings.vue @@ -2,7 +2,8 @@ import { ref, onMounted } from "vue"; import { NButton, NInput, NModal, NForm, NFormItem, NPopconfirm, useMessage } from "naive-ui"; import { useI18n } from "vue-i18n"; -import { fetchAuthStatus, setupPassword, changePassword, changeUsername, removePassword } from "@/api/auth"; +import { fetchAuthStatus, setupPassword, changePassword, changeUsername, removePassword, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth"; +import type { LockedIp } from "@/api/auth"; const { t } = useI18n(); const message = useMessage(); @@ -139,6 +140,47 @@ function openChangeUsernameModal() { newUsernameVal.value = ""; showChangeUsernameModal.value = true; } + +// Locked IPs management +const lockedIps = ref([]); +const loadingLocks = ref(false); + +async function loadLockedIps() { + loadingLocks.value = true; + try { + lockedIps.value = await fetchLockedIps(); + } catch { /* ignore */ } + finally { + loadingLocks.value = false; + } +} + +async function handleUnlockIp(ip: string) { + try { + await unlockSpecificIp(ip); + message.success(t("settings.lockedIps.unlocked")); + await loadLockedIps(); + } catch (err: any) { + message.error(err.message || t("common.saveFailed")); + } +} + +async function handleUnlockAll() { + try { + const count = await unlockAllIps(); + message.success(t("settings.lockedIps.allUnlocked", { count })); + await loadLockedIps(); + } catch (err: any) { + message.error(err.message || t("common.saveFailed")); + } +} + +function formatTime(ts: number): string { + const remaining = Math.max(0, Math.round((ts - Date.now()) / 60000)); + return remaining > 0 ? `${remaining} min` : t("common.expired"); +} + +onMounted(() => { loadLockedIps(); });