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
}
@@ -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<LockedIp[]>([]);
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(); });
</script>
<template>
@@ -168,6 +210,34 @@ function openChangeUsernameModal() {
</div>
</div>
<!-- Locked IPs management -->
<div class="locked-ips-section">
<h3 class="section-title">{{ t("settings.lockedIps.title") }}</h3>
<div class="action-row" style="margin-bottom: 12px;">
<span class="action-label">{{ t("settings.lockedIps.count", { count: lockedIps.length }) }}</span>
<div class="action-buttons">
<NButton size="small" :loading="loadingLocks" @click="loadLockedIps">{{ t("common.retry") }}</NButton>
<NPopconfirm v-if="lockedIps.length > 0" @positive-click="handleUnlockAll">
<template #trigger>
<NButton size="small" type="warning">{{ t("settings.lockedIps.unlockAll") }}</NButton>
</template>
{{ t("settings.lockedIps.unlockAllConfirm") }}
</NPopconfirm>
</div>
</div>
<div v-if="lockedIps.length > 0" class="locked-list">
<div v-for="lock in lockedIps" :key="lock.ip + lock.type" class="locked-item">
<div class="locked-info">
<span class="locked-ip">{{ lock.ip }}</span>
<span class="locked-badge">{{ lock.type }}</span>
<span class="locked-ttl">{{ formatTime(lock.lockedUntil) }}</span>
</div>
<NButton size="tiny" type="error" ghost @click="handleUnlockIp(lock.ip)">{{ t("settings.lockedIps.unlock") }}</NButton>
</div>
</div>
<p v-else class="empty-hint">{{ t("settings.lockedIps.empty") }}</p>
</div>
<!-- Setup modal -->
<NModal v-model:show="showSetupModal" preset="dialog" :title="t('login.setupPassword')">
<NForm label-placement="top">
@@ -255,4 +325,64 @@ function openChangeUsernameModal() {
gap: 8px;
flex-shrink: 0;
}
.locked-ips-section {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid $border-color;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: $text-primary;
margin: 0 0 16px;
}
.locked-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.locked-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-input;
}
.locked-info {
display: flex;
align-items: center;
gap: 8px;
}
.locked-ip {
font-family: $font-code;
font-size: 13px;
color: $text-primary;
}
.locked-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: rgba($error, 0.1);
color: $error;
}
.locked-ttl {
font-size: 12px;
color: $text-muted;
}
.empty-hint {
font-size: 13px;
color: $text-muted;
margin: 0;
}
</style>
+11
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: 'Passwort',
credentialsRequired: 'Bitte Benutzername und Passwort eingeben',
invalidCredentials: 'Ungultiger Benutzername oder Passwort',
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche, bitte versuchen Sie es spater erneut',
passwordMismatch: 'Passworter stimmen nicht uberein',
passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
setupSuccess: 'Passwort-Login erfolgreich konfiguriert',
@@ -519,6 +520,16 @@ jobTriggered: 'Job ausgelost',
cors: 'CORS-Ursprunge',
corsHint: 'Erlaubte Cross-Origin-Quellen',
},
lockedIps: {
title: 'Gesperrte IPs',
count: '{count} gesperrt',
empty: 'Keine gesperrten IPs',
unlock: 'Entsperren',
unlockAll: 'Alle entsperren',
unlockAllConfirm: 'Alle gesperrten IPs entsperren?',
unlocked: 'IP entsperrt',
allUnlocked: '{count} IPs entsperrt',
},
},
// Platform channel settings
+12
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: 'Password',
credentialsRequired: 'Please enter username and password',
invalidCredentials: 'Invalid username or password',
tooManyAttempts: 'Too many failed attempts, please try again later',
passwordMismatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 6 characters',
setupSuccess: 'Password login configured successfully',
@@ -53,6 +54,7 @@ export default {
copied: 'Copied',
copy: 'Copy',
noData: 'No data',
expired: 'Expired',
fetch: 'Fetch',
add: 'Add',
enable: 'Enable',
@@ -655,6 +657,16 @@ export default {
cors: 'CORS Origins',
corsHint: 'Allowed cross-origin sources',
},
lockedIps: {
title: 'Locked IPs',
count: '{count} locked',
empty: 'No locked IPs',
unlock: 'Unlock',
unlockAll: 'Unlock All',
unlockAllConfirm: 'Unlock all locked IPs?',
unlocked: 'IP unlocked',
allUnlocked: '{count} IPs unlocked',
},
},
// Platform channel settings
+11
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: 'Contrasena',
credentialsRequired: 'Por favor, introduzca nombre de usuario y contrasena',
invalidCredentials: 'Nombre de usuario o contrasena incorrectos',
tooManyAttempts: 'Demasiados intentos fallidos, por favor intente mas tarde',
passwordMismatch: 'Las contrasenas no coinciden',
passwordTooShort: 'La contrasena debe tener al menos 6 caracteres',
setupSuccess: 'Login con contrasena configurado correctamente',
@@ -519,6 +520,16 @@ jobTriggered: 'Job ejecutado',
cors: 'Origenes CORS',
corsHint: 'Fuentes cross-origin permitidas',
},
lockedIps: {
title: 'IPs bloqueadas',
count: '{count} bloqueadas',
empty: 'Sin IPs bloqueadas',
unlock: 'Desbloquear',
unlockAll: 'Desbloquear todo',
unlockAllConfirm: 'Desbloquear todas las IPs?',
unlocked: 'IP desbloqueada',
allUnlocked: '{count} IPs desbloqueadas',
},
},
// Platform channel settings
+11
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: 'Mot de passe',
credentialsRequired: 'Veuillez entrer le nom d\'utilisateur et le mot de passe',
invalidCredentials: 'Nom d\'utilisateur ou mot de passe incorrect',
tooManyAttempts: 'Trop de tentatives echouees, veuillez reessayer plus tard',
passwordMismatch: 'Les mots de passe ne correspondent pas',
passwordTooShort: 'Le mot de passe doit contenir au moins 6 caracteres',
setupSuccess: 'Login par mot de passe configure avec succes',
@@ -519,6 +520,16 @@ jobTriggered: 'Job declenche',
cors: 'Origines CORS',
corsHint: 'Sources cross-origin autorisees',
},
lockedIps: {
title: 'IPs bloquees',
count: '{count} bloquees',
empty: 'Aucune IP bloquee',
unlock: 'Debloquer',
unlockAll: 'Tout debloquer',
unlockAllConfirm: 'Debloquer toutes les IPs?',
unlocked: 'IP debloquee',
allUnlocked: '{count} IPs debloquees',
},
},
// Platform channel settings
+11
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: 'パスワード',
credentialsRequired: 'ユーザー名とパスワードを入力してください',
invalidCredentials: 'ユーザー名またはパスワードが正しくありません',
tooManyAttempts: 'ログイン試行回数が多すぎます。しばらくしてからお試しください',
passwordMismatch: 'パスワードが一致しません',
passwordTooShort: 'パスワードは6文字以上必要です',
setupSuccess: 'パスワードログインが設定されました',
@@ -519,6 +520,16 @@ export default {
cors: 'CORS 許可元',
corsHint: '許可するクロスオリジン',
},
lockedIps: {
title: 'ロック済みIP管理',
count: '{count}件ロック中',
empty: 'ロック済みIPなし',
unlock: 'ロック解除',
unlockAll: '全て解除',
unlockAllConfirm: '全てのロック済みIPを解除しますか?',
unlocked: 'IPをロック解除しました',
allUnlocked: '{count}件のIPをロック解除しました',
},
},
// プラットフォームチャンネル設定
+11
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: '비밀번호',
credentialsRequired: '사용자 이름과 비밀번호를 입력해 주세요',
invalidCredentials: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
tooManyAttempts: '로그인 시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요',
passwordMismatch: '비밀번호가 일치하지 않습니다',
passwordTooShort: '비밀번호는 6자 이상이어야 합니다',
setupSuccess: '비밀번호 로그인이 설정되었습니다',
@@ -519,6 +520,16 @@ export default {
cors: 'CORS 출처',
corsHint: '허용된 교차 출처',
},
lockedIps: {
title: '잠긴 IP 관리',
count: '{count}개 잠김',
empty: '잠긴 IP 없음',
unlock: '잠금 해제',
unlockAll: '전체 해제',
unlockAllConfirm: '모든 잠긴 IP를 해제하시겠습니까?',
unlocked: 'IP 잠금 해제됨',
allUnlocked: '{count}개 IP 잠금 해제됨',
},
},
// 플랫폼 채널 설정
+11
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: 'Senha',
credentialsRequired: 'Por favor, insira nome de usuario e senha',
invalidCredentials: 'Nome de usuario ou senha incorretos',
tooManyAttempts: 'Muitas tentativas falhadas, por favor tente novamente mais tarde',
passwordMismatch: 'As senhas nao conferem',
passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
setupSuccess: 'Login por senha configurado com sucesso',
@@ -519,6 +520,16 @@ jobTriggered: 'Job acionado',
cors: 'Origens CORS',
corsHint: 'Fontes cross-origin permitidas',
},
lockedIps: {
title: 'IPs bloqueadas',
count: '{count} bloqueadas',
empty: 'Nenhuma IP bloqueada',
unlock: 'Desbloquear',
unlockAll: 'Desbloquear tudo',
unlockAllConfirm: 'Desbloquear todas as IPs?',
unlocked: 'IP desbloqueada',
allUnlocked: '{count} IPs desbloqueadas',
},
},
// Platform channel settings
+12
View File
@@ -14,6 +14,7 @@ export default {
passwordPlaceholder: '密码',
credentialsRequired: '请输入用户名和密码',
invalidCredentials: '用户名或密码错误',
tooManyAttempts: '登录失败次数过多,请稍后重试',
passwordMismatch: '两次密码不一致',
passwordTooShort: '密码长度至少 6 个字符',
setupSuccess: '密码登录配置成功',
@@ -53,6 +54,7 @@ export default {
update: '更新',
create: '创建',
noData: '暂无数据',
expired: '已过期',
fetch: '获取',
add: '添加',
enable: '启用',
@@ -647,6 +649,16 @@ export default {
cors: 'CORS 来源',
corsHint: '允许的跨域来源',
},
lockedIps: {
title: '锁定 IP 管理',
count: '{count} 个 IP 被锁定',
empty: '暂无锁定 IP',
unlock: '解锁',
unlockAll: '全部解锁',
unlockAllConfirm: '确认解锁所有锁定的 IP',
unlocked: 'IP 已解锁',
allUnlocked: '已解锁 {count} 个 IP',
},
},
// 平台频道设置
+11 -1
View File
@@ -67,6 +67,12 @@ async function handleTokenLogin() {
return;
}
if (res.status === 429 || res.status === 503) {
errorMsg.value = t("login.tooManyAttempts");
loading.value = false;
return;
}
setApiKey(key);
router.replace("/hermes/chat");
} catch {
@@ -90,7 +96,11 @@ async function handlePasswordLogin() {
setApiKey(sessionToken);
router.replace("/hermes/chat");
} catch (err: any) {
errorMsg.value = err.message || t("login.invalidCredentials");
if (err.status === 429 || err.status === 503) {
errorMsg.value = t("login.tooManyAttempts");
} else {
errorMsg.value = err.message || t("login.invalidCredentials");
}
} finally {
loading.value = false;
}