feat(admin): 添加管理员密钥验证功能

- 新增管理员密钥设置及验证流程
- 实现前端验证弹窗及本地存储加密
- 修改评论提交接口支持管理员验证
- 添加相关样式和文档说明
This commit is contained in:
anghunk
2026-01-20 16:46:42 +08:00
parent a41d783326
commit ab67bfb40e
14 changed files with 554 additions and 12 deletions

View File

@@ -22,7 +22,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
if (!data || typeof data !== 'object') {
return c.json({ message: '无效的请求体' }, 400);
}
const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url } = data;
const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data;
const parentId = (data as any).parent_id ?? (data as any).parentId ?? null;
if (!post_slug || typeof post_slug !== 'string') {
return c.json({ message: 'post_slug 必填' }, 400);
@@ -44,6 +44,44 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
// 1. 获取 IP (Worker 获取 IP 的标准方式)
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
// 1.5 管理员身份验证
const adminEmail = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?').bind('comment_admin_email').first<string>('value');
if (adminEmail && email === adminEmail) {
const adminKey = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?').bind('comment_admin_key_hash').first<string>('value');
if (adminKey) {
const lockKey = `admin_lock:${ip}`;
const isLocked = await c.env.CWD_AUTH_KV.get(lockKey);
if (isLocked) {
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403);
}
if (!adminToken) {
return c.json({ message: "请输入管理员密钥", requireAuth: true }, 401);
}
if (adminToken !== adminKey) {
const failKey = `admin_fail:${ip}`;
const failsStr = await c.env.CWD_AUTH_KV.get(failKey);
let fails = failsStr ? parseInt(failsStr) : 0;
fails++;
if (fails >= 3) {
await c.env.CWD_AUTH_KV.put(lockKey, '1', { expirationTtl: 1800 });
await c.env.CWD_AUTH_KV.delete(failKey);
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403);
} else {
await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 });
return c.json({ message: "密钥错误" }, 401);
}
}
// 验证成功,清除失败记录
await c.env.CWD_AUTH_KV.delete(`admin_fail:${ip}`);
}
}
// 2. 检查评论频率控制 (对应 canPostComment)
// 这里建议使用 D1 查最近一条评论的时间,或者直接放行(如果使用了 Cloudflare WAF
const lastComment = await c.env.CWD_DB.prepare(

View File

@@ -0,0 +1,49 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const verifyAdminKey = async (c: Context<{ Bindings: Bindings }>) => {
const data = await c.req.json();
const { adminToken } = data;
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
if (!adminToken) {
return c.json({ message: "请输入管理员密钥" }, 401);
}
// Check lock
const lockKey = `admin_lock:${ip}`;
const isLocked = await c.env.CWD_AUTH_KV.get(lockKey);
if (isLocked) {
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403);
}
const adminKey = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?').bind('comment_admin_key_hash').first<string>('value');
if (!adminKey) {
// If no key set, verification is technically successful or not needed?
// If key is not set, we can't verify. Return success?
// Requirement says "Provide admin key setting...".
return c.json({ message: "未设置管理员密钥" }, 200);
}
if (adminToken !== adminKey) {
// Handle failure
const failKey = `admin_fail:${ip}`;
const failsStr = await c.env.CWD_AUTH_KV.get(failKey);
let fails = failsStr ? parseInt(failsStr) : 0;
fails++;
if (fails >= 3) {
await c.env.CWD_AUTH_KV.put(lockKey, '1', { expirationTtl: 1800 }); // 30 mins
await c.env.CWD_AUTH_KV.delete(failKey);
return c.json({ message: "验证失败次数过多请30分钟后再试" }, 403);
} else {
await c.env.CWD_AUTH_KV.put(failKey, fails.toString(), { expirationTtl: 3600 }); // 1 hour reset
return c.json({ message: "密钥错误" }, 401);
}
}
// Success
await c.env.CWD_AUTH_KV.delete(`admin_fail:${ip}`);
return c.json({ message: "验证通过" });
};

View File

@@ -7,9 +7,11 @@ import {
loadEmailNotificationSettings,
saveEmailNotificationSettings
} from './utils/email';
import packageJson from '../package.json';
import { getComments } from './api/public/getComments';
import { postComment } from './api/public/postComment';
import { verifyAdminKey } from './api/public/verifyAdminKey';
import { adminLogin } from './api/admin/login';
import { deleteComment } from './api/admin/deleteComment';
import { listComments } from './api/admin/listComments';
@@ -21,13 +23,15 @@ import { setAdminEmail } from './api/admin/setAdminEmail';
import { testEmail } from './api/admin/testEmail';
const app = new Hono<{ Bindings: Bindings }>();
const VERSION = 'v0.0.1';
const VERSION = `v${packageJson.version}`;
const COMMENT_ADMIN_EMAIL_KEY = 'comment_admin_email';
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
const COMMENT_ADMIN_ENABLED_KEY = 'comment_admin_enabled';
const COMMENT_ALLOWED_DOMAINS_KEY = 'comment_allowed_domains';
const COMMENT_ADMIN_KEY_HASH_KEY = 'comment_admin_key_hash';
async function loadCommentSettings(env: Bindings) {
await env.CWD_DB.prepare(
@@ -38,10 +42,11 @@ async function loadCommentSettings(env: Bindings) {
COMMENT_ADMIN_BADGE_KEY,
COMMENT_AVATAR_PREFIX_KEY,
COMMENT_ADMIN_ENABLED_KEY,
COMMENT_ALLOWED_DOMAINS_KEY
COMMENT_ALLOWED_DOMAINS_KEY,
COMMENT_ADMIN_KEY_HASH_KEY
];
const { results } = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?)'
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?, ?, ?)'
)
.bind(...keys)
.all<{ key: string; value: string }>();
@@ -67,7 +72,9 @@ async function loadCommentSettings(env: Bindings) {
adminBadge: map.get(COMMENT_ADMIN_BADGE_KEY) ?? null,
avatarPrefix: map.get(COMMENT_AVATAR_PREFIX_KEY) ?? null,
adminEnabled,
allowedDomains
allowedDomains,
adminKey: map.get(COMMENT_ADMIN_KEY_HASH_KEY) ?? null,
adminKeySet: !!map.get(COMMENT_ADMIN_KEY_HASH_KEY)
};
}
@@ -79,12 +86,18 @@ async function saveCommentSettings(
avatarPrefix?: string;
adminEnabled?: boolean;
allowedDomains?: string[];
adminKey?: string;
}
) {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
let adminKeyValue: string | undefined;
if (settings.adminKey !== undefined) {
adminKeyValue = settings.adminKey;
}
const entries: { key: string; value: string | null | undefined }[] = [
{ key: COMMENT_ADMIN_EMAIL_KEY, value: settings.adminEmail },
{ key: COMMENT_ADMIN_BADGE_KEY, value: settings.adminBadge },
@@ -101,6 +114,10 @@ async function saveCommentSettings(
{
key: COMMENT_ALLOWED_DOMAINS_KEY,
value: settings.allowedDomains ? settings.allowedDomains.join(',') : undefined
},
{
key: COMMENT_ADMIN_KEY_HASH_KEY,
value: adminKeyValue
}
];
@@ -152,6 +169,7 @@ app.get('/', (c) => {
app.get('/api/comments', getComments);
app.post('/api/comments', postComment);
app.post('/api/verify-admin', verifyAdminKey);
app.get('/api/config/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);
@@ -215,6 +233,7 @@ app.put('/admin/settings/comments', async (c) => {
const rawAvatarPrefix = typeof body.avatarPrefix === 'string' ? body.avatarPrefix : '';
const rawAdminEnabled = body.adminEnabled;
const rawAllowedDomains = Array.isArray(body.allowedDomains) ? body.allowedDomains : [];
const rawAdminKey = typeof body.adminKey === 'string' ? body.adminKey : undefined;
const adminEmail = rawAdminEmail.trim();
const adminBadge = rawAdminBadge.trim();
@@ -226,6 +245,7 @@ app.put('/admin/settings/comments', async (c) => {
const allowedDomains = rawAllowedDomains
.map((d: any) => (typeof d === 'string' ? d.trim() : ''))
.filter(Boolean);
const adminKey = rawAdminKey; // Can be undefined or empty string
if (adminEmail && !isValidEmail(adminEmail)) {
return c.json({ message: '邮箱格式不正确' }, 400);
@@ -236,7 +256,8 @@ app.put('/admin/settings/comments', async (c) => {
adminBadge,
avatarPrefix,
adminEnabled,
allowedDomains
allowedDomains,
adminKey
});
return c.json({ message: '保存成功' });

View File

@@ -0,0 +1,8 @@
export async function hashKey(key: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(key);
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}