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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user