feat: 实现 Token 认证系统

- 后端:Lua 实现 Token 验证、权限控制、限流、审计日志
- API:创建/列出/查看/更新/删除 Token
- Web UI:Token 管理界面(列表、创建、详情、编辑、删除弹框)
- 向后兼容:保留 X-Password 超级管理员认证
- 安全增强:失败次数限制、过期检测、双因素验证(敏感操作)

需求文档:TOKEN_AUTH_REQUIREMENT.md
This commit is contained in:
K-hermes committed 2026-09-13 23:40:42 +08:00
1 parent 1567a3dea3
commit 2cf807b59c
4 files changed
+1870 -15

No files matched your search

+633 -2
View File
@@ -479,6 +479,40 @@
.method-get { background: #28a745; color: white; }
.method-post { background: #007bff; color: white; }
.method-delete { background: #dc3545; color: white; }
/* Navigation tabs */
.nav-tabs {
display: flex;
gap: 10px;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 0;
margin-top: 20px;
}
.nav-tab {
padding: 12px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: #666;
transition: all 0.3s;
}
.nav-tab:hover {
color: #667eea;
}
.nav-tab.active {
color: #667eea;
border-bottom-color: #667eea;
}
#token-page {
display: none;
}
</style>
</head>
<body>
@@ -487,8 +521,16 @@
<div class="header">
<h1>🔄 通用数据同步服务</h1>
<p class="subtitle">Cookie / Token / Credential 管理面板</p>
<!-- Navigation Tabs -->
<div class="nav-tabs" style="margin-top: 20px;">
<button class="nav-tab active" onclick="switchPage('data')">📋 数据管理</button>
<button class="nav-tab" onclick="switchPage('tokens')">🔑 Token 管理</button>
</div>
</div>
<!-- Data Management Page -->
<div id="data-page">
<!-- Stats -->
<div class="stats">
<div class="stat-card">
@@ -555,6 +597,55 @@
<code>/api/data/copy/:tag</code> - 获取复制配置
</div>
</div>
</div>
<!-- End Data Management Page -->
<!-- Token Management Page -->
<div id="token-page" style="display: none;">
<!-- Stats -->
<div class="stats">
<div class="stat-card">
<div class="stat-icon">🔑</div>
<div class="stat-label">总 Token</div>
<div class="stat-value" id="token-total-count">-</div>
</div>
<div class="stat-card">
<div class="stat-icon">✅</div>
<div class="stat-label">启用</div>
<div class="stat-value" id="token-active-count">-</div>
</div>
<div class="stat-card">
<div class="stat-icon">🚫</div>
<div class="stat-label">禁用</div>
<div class="stat-value" id="token-disabled-count">-</div>
</div>
<div class="stat-card">
<div class="stat-icon">⏰</div>
<div class="stat-label">已过期</div>
<div class="stat-value" id="token-expired-count">-</div>
</div>
</div>
<!-- Token List -->
<div class="data-list">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">🔑 Token 列表</h2>
<button class="btn btn-primary" onclick="openCreateTokenModal()">+ 创建 Token</button>
</div>
<!-- Search -->
<div class="search-box">
<input type="text" class="search-input" id="token-search-input" placeholder="🔍 搜索 Token 名称..." oninput="filterTokens()">
<span class="search-icon">🔍</span>
</div>
<div id="token-container">
<div class="loading">正在加载...</div>
</div>
</div>
</div>
<!-- End Token Management Page -->
</div>
<!-- Refresh Button -->
@@ -594,6 +685,172 @@
</div>
</div>
<!-- Create Token Modal -->
<div class="modal-overlay" id="create-token-modal">
<div class="modal" style="width: 500px;">
<div class="modal-header">
<div class="modal-title">创建 Token</div>
<button class="modal-close" onclick="closeModal('create-token-modal')">&times;</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 名称 *</label>
<input type="text" id="token-name" placeholder="如:生产服务器A" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
<p style="color: #999; font-size: 12px; margin-top: 5px;">用于识别客户端</p>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">权限范围 *</label>
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="token-permission" value="readonly" checked> 只读(仅查询数据)
</label>
<label style="display: block; cursor: pointer;">
<input type="radio" name="token-permission" value="readwrite"> 读写(可上传、删除数据)
</label>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">过期设置 *</label>
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="token-expiry" value="0" checked> 永久有效
</label>
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="token-expiry" value="2592000"> 30天后过期
</label>
<label style="display: block; cursor: pointer;">
<input type="radio" name="token-expiry" value="7776000"> 90天后过期
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('create-token-modal')">取消</button>
<button class="btn btn-primary" onclick="submitCreateToken()">创建</button>
</div>
</div>
</div>
<!-- Token Details Modal -->
<div class="modal-overlay" id="token-details-modal">
<div class="modal" style="width: 900px;">
<div class="modal-header">
<div class="modal-title">Token 详情</div>
<button class="modal-close" onclick="closeModal('token-details-modal')">&times;</button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
<div style="margin-bottom: 25px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 值</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="password" id="token-value-display" readonly style="flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-family: monospace; background: #f8f9fa;">
<button class="btn btn-secondary" onclick="toggleTokenVisibility()" id="toggle-token-btn">👁 显示</button>
<button class="btn btn-success" onclick="copyTokenValue()">📋 复制</button>
</div>
</div>
<div style="margin-bottom: 25px;">
<h3 style="margin-bottom: 10px;">基本信息</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<p style="margin-bottom: 8px;"><strong>名称:</strong> <span id="token-detail-name">-</span></p>
<p style="margin-bottom: 8px;"><strong>权限:</strong> <span id="token-detail-permission">-</span></p>
<p style="margin-bottom: 8px;"><strong>状态:</strong> <span id="token-detail-status">-</span></p>
<p style="margin-bottom: 8px;"><strong>创建时间:</strong> <span id="token-detail-created">-</span></p>
<p><strong>过期时间:</strong> <span id="token-detail-expires">-</span></p>
</div>
</div>
<div style="margin-bottom: 25px;">
<h3 style="margin-bottom: 10px;">使用统计</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<p style="margin-bottom: 8px;"><strong>总请求数:</strong> <span id="token-detail-requests">-</span></p>
<p style="margin-bottom: 8px;"><strong>最后使用:</strong> <span id="token-detail-last-used">-</span></p>
<p style="margin-bottom: 8px;"><strong>最后 IP:</strong> <span id="token-detail-last-ip">-</span></p>
<p><strong>失败次数:</strong> <span id="token-detail-failed">-</span></p>
</div>
</div>
<div>
<h3 style="margin-bottom: 10px;">访问日志(最近 20 条)</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: #f8f9fa; border-bottom: 2px solid #e0e0e0;">
<th style="padding: 10px; text-align: left;">时间</th>
<th style="padding: 10px; text-align: left;">IP</th>
<th style="padding: 10px; text-align: left;">方法</th>
<th style="padding: 10px; text-align: left;">路径</th>
<th style="padding: 10px; text-align: left;">状态</th>
</tr>
</thead>
<tbody id="token-access-log">
<tr><td colspan="5" style="padding: 20px; text-align: center; color: #999;">暂无访问记录</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('token-details-modal')">关闭</button>
<button class="btn btn-primary" onclick="openEditTokenModal()">修改</button>
<button class="btn btn-danger" onclick="confirmDeleteToken()">删除</button>
</div>
</div>
</div>
<!-- Edit Token Modal -->
<div class="modal-overlay" id="edit-token-modal">
<div class="modal" style="width: 500px;">
<div class="modal-header">
<div class="modal-title">编辑 Token</div>
<button class="modal-close" onclick="closeModal('edit-token-modal')">&times;</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Token 名称 *</label>
<input type="text" id="edit-token-name" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">状态</label>
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<input type="radio" name="edit-token-status" value="active" checked> 启用
</label>
<label style="display: block; cursor: pointer;">
<input type="radio" name="edit-token-status" value="disabled"> 禁用
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('edit-token-modal')">取消</button>
<button class="btn btn-primary" onclick="submitEditToken()">保存</button>
</div>
</div>
</div>
<!-- Delete Token Confirmation Modal -->
<div class="modal-overlay confirm-modal" id="delete-token-modal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">⚠️ 删除 Token</div>
<button class="modal-close" onclick="closeModal('delete-token-modal')">&times;</button>
</div>
<div class="modal-body">
<p class="confirm-message" style="margin-bottom: 15px;">确认要删除以下 Token 吗?</p>
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
<p style="margin-bottom: 8px;"><strong>名称:</strong> <span id="delete-token-name">-</span></p>
<p><strong>Token:</strong> <span id="delete-token-value">-</span></p>
</div>
<p style="color: #dc3545; margin-bottom: 15px;">⚠️ 此操作不可恢复,使用此 Token 的客户端将无法访问</p>
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 500;">输入主密码以确认:</label>
<input type="password" id="delete-token-password" placeholder="admin123123123" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 6px;">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('delete-token-modal')">取消</button>
<button class="btn btn-danger" onclick="submitDeleteToken()">确认删除</button>
</div>
</div>
</div>
<script>
let currentViewData = null;
let deleteCallback = null;
@@ -993,10 +1250,384 @@
}
});
});
// ============= Token Management Functions =============
let allTokens = [];
let currentToken = null;
let tokenVisible = false;
// Page switching
function switchPage(page) {
document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
if (page === 'data') {
document.querySelector('[onclick*="data"]').classList.add('active');
document.getElementById('data-page').style.display = 'block';
document.getElementById('token-page').style.display = 'none';
loadData();
} else if (page === 'tokens') {
document.querySelector('[onclick*="tokens"]').classList.add('active');
document.getElementById('data-page').style.display = 'none';
document.getElementById('token-page').style.display = 'block';
loadTokens();
}
}
// Load tokens
async function loadTokens() {
try {
const response = await fetch('/api/tokens', {
headers: { 'X-Password': 'admin123123123' }
});
if (!response.ok) {
throw new Error('Failed to load tokens');
}
const data = await response.json();
allTokens = data.tokens || [];
renderTokenList(allTokens);
updateTokenStats(allTokens);
} catch (error) {
console.error('Load tokens error:', error);
showToast('加载 Token 失败', 'error');
}
}
// Update token stats
function updateTokenStats(tokens) {
let activeCount = 0;
let disabledCount = 0;
let expiredCount = 0;
tokens.forEach(token => {
if (token.status === 'active') activeCount++;
else if (token.status === 'disabled') disabledCount++;
else if (token.status === 'expired') expiredCount++;
});
document.getElementById('token-total-count').textContent = tokens.length;
document.getElementById('token-active-count').textContent = activeCount;
document.getElementById('token-disabled-count').textContent = disabledCount;
document.getElementById('token-expired-count').textContent = expiredCount;
}
// Render token list
function renderTokenList(tokens) {
const container = document.getElementById('token-container');
if (tokens.length === 0) {
container.innerHTML = '<div class="empty">📭 暂无 Token</div>';
return;
}
let html = '';
tokens.forEach(token => {
const statusClass = token.status === 'active' ? 'status-valid' :
token.status === 'disabled' ? 'status-expired' : 'status-warning';
const statusText = token.status === 'active' ? '启用' :
token.status === 'disabled' ? '禁用' : '已过期';
const permissionText = token.permission === 'readonly' ? '只读' : '读写';
html += `
<div class="data-item">
<div class="data-info">
<div class="data-tag">
${token.name}
<span class="status-badge ${statusClass}">${statusText}</span>
</div>
<div class="data-meta">
Token: ${token.token} | 权限: ${permissionText} |
创建: ${token.created_at} |
请求数: ${token.stats.total_requests || 0}
</div>
</div>
<div>
<button class="btn btn-primary" onclick='viewTokenDetails("${token.token_full}")'>详情</button>
${token.status === 'active'
? `<button class="btn btn-secondary" onclick='toggleTokenStatus("${token.token_full}", "disabled")'>禁用</button>`
: `<button class="btn btn-success" onclick='toggleTokenStatus("${token.token_full}", "active")'>启用</button>`
}
</div>
</div>
`;
});
container.innerHTML = html;
}
// Filter tokens
function filterTokens() {
const searchTerm = document.getElementById('token-search-input').value.trim().toLowerCase();
if (!searchTerm) {
renderTokenList(allTokens);
return;
}
const filtered = allTokens.filter(token =>
token.name.toLowerCase().includes(searchTerm)
);
renderTokenList(filtered);
}
// Open create token modal
function openCreateTokenModal() {
document.getElementById('token-name').value = '';
document.querySelector('input[name="token-permission"][value="readonly"]').checked = true;
document.querySelector('input[name="token-expiry"][value="0"]').checked = true;
openModal('create-token-modal');
}
// Submit create token
async function submitCreateToken() {
const name = document.getElementById('token-name').value.trim();
const permission = document.querySelector('input[name="token-permission"]:checked').value;
const expiresIn = parseInt(document.querySelector('input[name="token-expiry"]:checked').value);
if (!name) {
showToast('请输入 Token 名称', 'error');
return;
}
try {
const response = await fetch('/api/tokens', {
method: 'POST',
headers: {
'X-Password': 'admin123123123',
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
permission: permission,
expires_in: expiresIn || null
})
});
const result = await response.json();
if (response.ok) {
showToast('✅ Token 创建成功');
closeModal('create-token-modal');
await loadTokens();
viewTokenDetails(result.token);
} else {
showToast('❌ ' + (result.error || 'Token 创建失败'), 'error');
}
} catch (error) {
showToast('❌ 请求失败: ' + error.message, 'error');
}
}
// View token details
async function viewTokenDetails(token) {
try {
const response = await fetch(`/api/tokens/${token}`, {
headers: { 'X-Password': 'admin123123123' }
});
if (!response.ok) {
throw new Error('Failed to load token details');
}
const tokenData = await response.json();
currentToken = tokenData;
tokenVisible = false;
document.getElementById('token-value-display').value = tokenData.token;
document.getElementById('token-value-display').type = 'password';
document.getElementById('toggle-token-btn').textContent = '👁 显示';
document.getElementById('token-detail-name').textContent = tokenData.name;
document.getElementById('token-detail-permission').textContent =
tokenData.permission === 'readonly' ? '只读' : '读写';
document.getElementById('token-detail-status').textContent =
tokenData.status === 'active' ? '🟢 启用中' :
tokenData.status === 'disabled' ? '🔴 已禁用' : '⏰ 已过期';
document.getElementById('token-detail-created').textContent = tokenData.created_at;
document.getElementById('token-detail-expires').textContent = tokenData.expires_at || '永久有效';
const stats = tokenData.stats || {};
document.getElementById('token-detail-requests').textContent = stats.total_requests || 0;
document.getElementById('token-detail-last-used').textContent = stats.last_used_at || '从未使用';
document.getElementById('token-detail-last-ip').textContent = stats.last_ip || '-';
document.getElementById('token-detail-failed').textContent = stats.failed_attempts || 0;
// Render access log
const logBody = document.getElementById('token-access-log');
if (!tokenData.access_log || tokenData.access_log.length === 0) {
logBody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #999;">暂无访问记录</td></tr>';
} else {
logBody.innerHTML = tokenData.access_log.map(log => `
<tr style="border-bottom: 1px solid #e0e0e0;">
<td style="padding: 10px;">${log.timestamp}</td>
<td style="padding: 10px;">${log.ip}</td>
<td style="padding: 10px;">${log.method}</td>
<td style="padding: 10px; font-size: 12px;">${log.path}</td>
<td style="padding: 10px;">${log.status}</td>
</tr>
`).join('');
}
openModal('token-details-modal');
} catch (error) {
showToast('加载 Token 详情失败', 'error');
}
}
// Toggle token visibility
function toggleTokenVisibility() {
const input = document.getElementById('token-value-display');
const btn = document.getElementById('toggle-token-btn');
if (tokenVisible) {
input.type = 'password';
btn.textContent = '👁 显示';
tokenVisible = false;
} else {
input.type = 'text';
btn.textContent = '🙈 隐藏';
tokenVisible = true;
}
}
// Copy token value
function copyTokenValue() {
const tokenValue = document.getElementById('token-value-display').value;
copyToClipboard(tokenValue).then(success => {
if (success) {
showToast('Token 已复制到剪贴板');
} else {
showToast('复制失败,请手动选择复制', 'error');
}
});
}
// Open edit token modal
function openEditTokenModal() {
if (!currentToken) return;
document.getElementById('edit-token-name').value = currentToken.name;
document.querySelector(`input[name="edit-token-status"][value="${currentToken.status}"]`).checked = true;
closeModal('token-details-modal');
openModal('edit-token-modal');
}
// Submit edit token
async function submitEditToken() {
if (!currentToken) return;
const name = document.getElementById('edit-token-name').value.trim();
const status = document.querySelector('input[name="edit-token-status"]:checked').value;
if (!name) {
showToast('请输入 Token 名称', 'error');
return;
}
try {
const response = await fetch(`/api/tokens/${currentToken.token}`, {
method: 'PATCH',
headers: {
'X-Password': 'admin123123123',
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, status })
});
const result = await response.json();
if (response.ok) {
showToast('✅ Token 已更新');
closeModal('edit-token-modal');
await loadTokens();
viewTokenDetails(currentToken.token);
} else {
showToast('❌ ' + (result.error || '更新失败'), 'error');
}
} catch (error) {
showToast('❌ 请求失败: ' + error.message, 'error');
}
}
// Toggle token status
async function toggleTokenStatus(token, newStatus) {
try {
const response = await fetch(`/api/tokens/${token}`, {
method: 'PATCH',
headers: {
'X-Password': 'admin123123123',
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: newStatus })
});
if (response.ok) {
showToast(newStatus === 'active' ? '✅ Token 已启用' : '✅ Token 已禁用');
loadTokens();
} else {
showToast('❌ 操作失败', 'error');
}
} catch (error) {
showToast('❌ 请求失败: ' + error.message, 'error');
}
}
// Confirm delete token
function confirmDeleteToken() {
if (!currentToken) return;
document.getElementById('delete-token-name').textContent = currentToken.name;
document.getElementById('delete-token-value').textContent = currentToken.token.substring(0, 12) + '...';
document.getElementById('delete-token-password').value = '';
closeModal('token-details-modal');
openModal('delete-token-modal');
}
// Submit delete token
async function submitDeleteToken() {
if (!currentToken) return;
const password = document.getElementById('delete-token-password').value;
if (!password) {
showToast('请输入主密码', 'error');
return;
}
try {
const response = await fetch(`/api/tokens/${currentToken.token}`, {
method: 'DELETE',
headers: {
'X-Password': 'admin123123123',
'Content-Type': 'application/json'
},
body: JSON.stringify({ confirm_password: password })
});
const result = await response.json();
if (response.ok) {
showToast('✅ Token 已删除');
closeModal('delete-token-modal');
currentToken = null;
loadTokens();
} else {
showToast('❌ ' + (result.error || '删除失败'), 'error');
}
} catch (error) {
showToast('❌ 请求失败: ' + error.message, 'error');
}
}
// ============= End Token Management Functions =============
// 页面加载时获取数据
loadData();
// 每30秒自动刷新
setInterval(loadData, 30000);
</script>