Files
Nexus/web/app/login.html
T
Your Name a50f17941d fix: settings 安全加固 + UX 迭代 — 10项修复
安全修复:
- S-01 SSRF: parse-subscription 加私有IP封禁+重定向限制+响应大小限制
- S-02 值校验: PUT /{key} 加 key 白名单 + INT 范围校验(1-1000/1-100/10-600)
- S-05 重登录: 改密码成功后跳转login.html+绿色提示
- S-07 审计: add_manual_ips/remove_allowlist_ip 补全审计日志
- S-09 TOTP: 已启用TOTP时改密码需输入验证码

UX改进:
- UX-01: 密码框focus ring + 设置项input type映射(number/url/text)
- UX-02: 改密码按钮loading状态(disabled+提交中...)
- UX-03: 3处空catch块→console.error+toast
- UX-04: 保存失败时reloadSettings恢复原值
- UX-05: IP格式校验(前端正则+后端Pydantic model_validator)
2026-05-30 21:58:13 +08:00

347 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus — 登录</title>
<!-- Tailwind CSS v4 CDN -->
<script src="/app/vendor/tailwindcss-browser.js"></script>
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(55% 0.2 250);
--color-brand-light: oklch(75% 0.15 250);
--color-brand-dark: oklch(35% 0.18 250);
--color-surface: oklch(98.5% 0.002 250);
}
</style>
</head>
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Logo / Brand -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-brand/20 mb-4">
<svg class="w-8 h-8 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-white">Nexus</h1>
<p class="text-slate-400 text-sm mt-1">服务器运维管理平台</p>
</div>
<!-- Login Card -->
<div id="loginCard" class="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-xl">
<!-- Step 1: Password -->
<div id="stepPassword">
<h2 class="text-lg font-semibold text-white mb-6">登录到您的账户</h2>
<form id="loginForm" class="space-y-5">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
<input type="text" id="username" name="username" required autocomplete="username"
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="请输入用户名">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
<div class="relative">
<input type="password" id="password" name="password" required autocomplete="current-password"
class="w-full px-4 py-3 pr-12 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="••••••••">
<button type="button" id="togglePwd" tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition p-1">
<!-- Eye icon (show) -->
<svg id="eyeShow" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<!-- Eye-off icon (hide) -->
<svg id="eyeHide" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<button type="submit" id="loginBtn"
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
登录
</button>
</form>
</div>
<!-- Step 2: TOTP (hidden by default) -->
<div id="stepTotp" class="hidden">
<h2 class="text-lg font-semibold text-white mb-2">两步验证</h2>
<p class="text-slate-400 text-sm mb-6">请输入验证器应用中的 6 位数字</p>
<form id="totpForm" class="space-y-5">
<div>
<input type="text" id="totpCode" name="totp_code" required maxlength="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="000000">
</div>
<button type="submit" id="totpBtn"
class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
验证
</button>
<button type="button" id="backBtn"
class="w-full py-2 text-slate-400 hover:text-white text-sm transition">
← 返回
</button>
</form>
</div>
<!-- Error Message -->
<div id="errorMsg" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center"></div>
<!-- Lockout Warning -->
<div id="lockoutMsg" class="hidden mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-sm text-center"></div>
</div>
<!-- Footer -->
<p class="text-center text-slate-600 text-xs mt-6">Nexus v6.0 · 安全连接</p>
</div>
<script>
const API = window.location.origin + '/api/auth';
let pendingUsername = '';
let pendingPassword = '';
let failCount = 0;
const MAX_FAIL_DISPLAY = 5; // Show warning after this many failures
// ── Password Visibility Toggle ──
document.getElementById('togglePwd').addEventListener('click', () => {
const pwdInput = document.getElementById('password');
const eyeShow = document.getElementById('eyeShow');
const eyeHide = document.getElementById('eyeHide');
if (pwdInput.type === 'password') {
pwdInput.type = 'text';
eyeShow.classList.add('hidden');
eyeHide.classList.remove('hidden');
} else {
pwdInput.type = 'password';
eyeShow.classList.remove('hidden');
eyeHide.classList.add('hidden');
}
});
// ── Step 1: Password Login ──
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
hideLockout();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) return;
const btn = document.getElementById('loginBtn');
btn.disabled = true;
btn.textContent = '登录中...';
try {
const res = await fetch(API + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.status === 202 && data.detail === '需要TOTP验证码') {
// TOTP required — show step 2
pendingUsername = username;
pendingPassword = password;
showTotpStep();
} else if (res.status === 429) {
// Account locked
failCount = MAX_FAIL_DISPLAY;
showError(data.detail || '登录尝试过多,账户已临时锁定');
showLockout('账户已临时锁定,请15分钟后再试。');
} else if (!res.ok) {
failCount++;
showError(data.detail || '登录失败');
// Show lockout warning after repeated failures
if (failCount >= MAX_FAIL_DISPLAY) {
showLockout(`连续登录失败 ${failCount} 次。多次失败后账户可能被临时锁定,请确认用户名和密码。`);
} else if (failCount >= 3) {
showLockout(`已失败 ${failCount} 次,请检查输入。`);
}
// Shake animation on error
const card = document.getElementById('loginCard');
card.classList.add('animate-shake');
setTimeout(() => card.classList.remove('animate-shake'), 500);
} else {
// Success — store tokens and redirect
failCount = 0;
onLoginSuccess(data);
}
} catch (err) {
showError('网络错误,请检查连接');
} finally {
btn.disabled = false;
btn.textContent = '登录';
}
});
// ── Step 2: TOTP Verification ──
document.getElementById('totpForm').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const totpCode = document.getElementById('totpCode').value.trim();
if (totpCode.length !== 6) return;
const btn = document.getElementById('totpBtn');
btn.disabled = true;
btn.textContent = '验证中...';
try {
const res = await fetch(API + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: pendingUsername,
password: pendingPassword,
totp_code: totpCode,
}),
});
const data = await res.json();
if (!res.ok) {
failCount++;
showError(data.detail || '验证码错误');
if (failCount >= 3) {
showLockout(`已失败 ${failCount} 次,请确认验证码正确。`);
}
document.getElementById('totpCode').value = '';
document.getElementById('totpCode').focus();
} else {
failCount = 0;
onLoginSuccess(data);
}
} catch (err) {
showError('网络错误,请检查连接');
} finally {
btn.disabled = false;
btn.textContent = '验证';
}
});
// Back button
document.getElementById('backBtn').addEventListener('click', () => {
// Clear sensitive data when going back
pendingUsername = '';
pendingPassword = '';
showPasswordStep();
});
// Auto-focus TOTP input
function showTotpStep() {
document.getElementById('stepPassword').classList.add('hidden');
document.getElementById('stepTotp').classList.remove('hidden');
document.getElementById('totpCode').value = '';
document.getElementById('totpCode').focus();
}
function showPasswordStep() {
document.getElementById('stepTotp').classList.add('hidden');
document.getElementById('stepPassword').classList.remove('hidden');
document.getElementById('password').focus();
}
// ── Success Handler ──
function onLoginSuccess(data) {
localStorage.setItem('access_token', data.access_token);
// Refresh token is now in HttpOnly cookie — not accessible from JS
localStorage.setItem('admin', JSON.stringify(data.admin));
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
// Clear sensitive data from memory
pendingUsername = '';
pendingPassword = '';
window.location.href = '/app/index.html';
}
// ── Error Display ──
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.classList.remove('hidden');
}
function hideError() {
document.getElementById('errorMsg').classList.add('hidden');
}
// ── Lockout Warning ──
function showLockout(msg) {
const el = document.getElementById('lockoutMsg');
el.textContent = msg;
el.classList.remove('hidden');
}
function hideLockout() {
document.getElementById('lockoutMsg').classList.add('hidden');
}
// ── Check existing session ──
(function checkSession() {
const token = localStorage.getItem('access_token');
const expires = localStorage.getItem('token_expires');
if (token && expires && Date.now() < parseInt(expires)) {
window.location.href = '/app/index.html';
return;
}
// Access token expired — try refresh via HttpOnly cookie (auto-sent by browser)
fetch(window.location.origin + '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()).then(data => {
if (data.success) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('token_expires', String(Date.now() + data.expires_in * 1000));
window.location.href = '/app/index.html';
}
}).catch(() => {});
})();
// S-05: Show message from URL parameter (e.g., after password change redirect)
(function() {
const msg = new URLSearchParams(window.location.search).get('msg');
if (msg === 'password_changed') {
const el = document.getElementById('lockoutMsg');
el.textContent = '✅ 密码已修改,请重新登录。';
el.className = 'mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm text-center';
el.classList.remove('hidden');
window.history.replaceState({}, '', window.location.pathname);
}
})();
</script>
<style>
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
</style>
</body>
</html>