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:
@@ -31,3 +31,6 @@ hermes-dependencies.md
|
||||
*.sln
|
||||
*.sw?
|
||||
.superpowers/
|
||||
CLAUDE.md
|
||||
# Client source map artifacts
|
||||
packages/client/src/**/*.js
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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をロック解除しました',
|
||||
},
|
||||
},
|
||||
|
||||
// プラットフォームチャンネル設定
|
||||
|
||||
@@ -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 잠금 해제됨',
|
||||
},
|
||||
},
|
||||
|
||||
// 플랫폼 채널 설정
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
// 平台频道设置
|
||||
|
||||
@@ -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) {
|
||||
if (err.status === 429 || err.status === 503) {
|
||||
errorMsg.value = t("login.tooManyAttempts");
|
||||
} else {
|
||||
errorMsg.value = err.message || t("login.invalidCredentials");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials'
|
||||
import { getToken } from '../services/auth'
|
||||
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
@@ -27,8 +28,17 @@ export async function login(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
const ip = extractIp(ctx)
|
||||
const result = checkPassword(ip)
|
||||
if (!result.allowed) {
|
||||
ctx.status = result.status
|
||||
ctx.body = { error: 'Too many login attempts, please try again later' }
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await verifyCredentials(username, password)
|
||||
if (!valid) {
|
||||
recordPasswordFailure(ip)
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Invalid username or password' }
|
||||
return
|
||||
@@ -41,6 +51,7 @@ export async function login(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPasswordSuccess(ip)
|
||||
ctx.body = { token }
|
||||
}
|
||||
|
||||
@@ -150,3 +161,33 @@ export async function removePassword(ctx: Context) {
|
||||
await deleteCredentials()
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/locked-ips
|
||||
* List all currently locked IPs (protected).
|
||||
*/
|
||||
export async function listLockedIps(ctx: Context) {
|
||||
const locks = getLockedIps()
|
||||
ctx.body = { locks }
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/locked-ips?ip=xxx
|
||||
* Unlock a specific IP. No ip param = unlock all.
|
||||
*/
|
||||
export async function unlockIpHandler(ctx: Context) {
|
||||
const ip = ctx.query.ip as string
|
||||
if (ip) {
|
||||
const found = unlockIp(ip)
|
||||
if (!found) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'IP not locked' }
|
||||
return
|
||||
}
|
||||
ctx.body = { success: true }
|
||||
return
|
||||
}
|
||||
// No IP specified — unlock all
|
||||
const count = unlockAll()
|
||||
ctx.body = { success: true, count }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mkdir } from 'fs/promises'
|
||||
import { readFileSync } from 'fs'
|
||||
import { config } from './config'
|
||||
import { getToken, requireAuth } from './services/auth'
|
||||
import { initLoginLimiter } from './services/login-limiter'
|
||||
import { initGatewayManager, getGatewayManagerInstance } from './services/gateway-bootstrap'
|
||||
import { bindShutdown } from './services/shutdown'
|
||||
import { setupTerminalWebSocket } from './routes/hermes/terminal'
|
||||
@@ -78,6 +79,7 @@ export async function bootstrap() {
|
||||
await mkdir(config.dataDir, { recursive: true })
|
||||
|
||||
const authToken = await getToken()
|
||||
await initLoginLimiter()
|
||||
const app = new Koa()
|
||||
|
||||
await initGatewayManager()
|
||||
|
||||
@@ -12,3 +12,5 @@ authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword)
|
||||
authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword)
|
||||
authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername)
|
||||
authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword)
|
||||
authProtectedRoutes.get('/api/auth/locked-ips', ctrl.listLockedIps)
|
||||
authProtectedRoutes.delete('/api/auth/locked-ips', ctrl.unlockIpHandler)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { checkToken, recordTokenFailure, extractIp } from './login-limiter'
|
||||
|
||||
const APP_HOME = join(homedir(), '.hermes-web-ui')
|
||||
const TOKEN_FILE = join(APP_HOME, '.token')
|
||||
@@ -56,6 +57,18 @@ export function requireAuth(token: string | null) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
// Check rate limiter for token auth failures (separate IP counters from password login)
|
||||
const ip = extractIp(ctx)
|
||||
const result = checkToken(ip)
|
||||
if (!result.allowed) {
|
||||
ctx.status = result.status
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = { error: 'Too many login attempts, please try again later' }
|
||||
return
|
||||
}
|
||||
|
||||
recordTokenFailure(ip)
|
||||
ctx.status = 401
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = { error: 'Unauthorized' }
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
const APP_HOME = join(homedir(), '.hermes-web-ui')
|
||||
const LOCK_FILE = join(APP_HOME, '.login-lock.json')
|
||||
|
||||
// Per-IP settings
|
||||
const IP_MAX_FAILURES = 3
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!state.passwordIpMap[ip]) {
|
||||
state.passwordIpMap[ip] = { failures: 0, lockedUntil: 0 }
|
||||
}
|
||||
const entry = state.passwordIpMap[ip]
|
||||
entry.failures++
|
||||
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 {
|
||||
if (!state.tokenIpMap[ip]) {
|
||||
state.tokenIpMap[ip] = { failures: 0, lockedUntil: 0 }
|
||||
}
|
||||
const entry = state.tokenIpMap[ip]
|
||||
entry.failures++
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user