后台登录改造:自建登录页 + Cookie 会话,弃用 basic auth 弹窗
- editor-api 新增 /admin/login|session|logout:HttpOnly Cookie 会话(落盘持久化)、 登录失败限速(5 次锁 5 分钟)、鉴权改为「X-Editor-Token 或 会话」双通道 - 登录页重做:品牌标识、图标输入框、密码可见切换、柔光背景,深浅色适配 - 前端以 /admin/session 探测部署模式:国内机走本机会话,CF 版走原令牌流程 - nginx:/admin/ 与 /api/v2/editor/ 去掉 basic auth 与令牌注入,登录凭据只存 Cookie - bootstrap.sh 同步 ADMIN_USER/ADMIN_PASS,保持一键迁移能力
This commit is contained in:
1 parent
96751b8143
commit
9f586d625b
5 files changed
+484
-39
No files matched your search
+174
-2
@@ -36,6 +36,15 @@ const cfg = {
|
||||
gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean),
|
||||
// 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。
|
||||
blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''),
|
||||
|
||||
// --- 后台登录(浏览器直连本机时用,与 Cloudflare 会话是两套)---------------
|
||||
// 背景:后台静态页部署在国内机,nginx 的原生 basic auth 弹窗既丑又没法做品牌,
|
||||
// 所以改成「自建登录页 + HttpOnly Cookie 会话」。令牌通道(X-Editor-Token)
|
||||
// 保持不变,供 Cloudflare Worker 侧继续使用。
|
||||
adminUser: env.ADMIN_USER || 'admin',
|
||||
adminPass: env.ADMIN_PASS || '',
|
||||
sessionFile: env.SESSION_FILE || '',
|
||||
sessionTtlMs: Number(env.SESSION_TTL_HOURS || 168) * 3600 * 1000,
|
||||
};
|
||||
|
||||
if (!cfg.token) {
|
||||
@@ -100,6 +109,109 @@ const safeName = (s) =>
|
||||
.replace(/[^\w\u4e00-\u9fff.-]+/g, '_')
|
||||
.slice(0, 120);
|
||||
|
||||
// ------------------------------------------------------------------ 后台会话
|
||||
|
||||
/**
|
||||
* 登录态存内存 + 落一个 JSON 文件(重启不掉线)。
|
||||
* 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax,
|
||||
* 前端 JS 读不到,也就没法被 XSS 偷走。
|
||||
*/
|
||||
const SESSION_FILE =
|
||||
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json');
|
||||
const sessions = new Map(); // sid -> { user, exp }
|
||||
|
||||
function loadSessions() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now();
|
||||
for (const [sid, v] of Object.entries(raw || {})) {
|
||||
if (v && v.exp > now) sessions.set(sid, v);
|
||||
}
|
||||
} catch { /* 首次运行没有这个文件,正常 */ }
|
||||
}
|
||||
|
||||
function saveSessions() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
|
||||
const now = Date.now();
|
||||
const obj = {};
|
||||
for (const [sid, v] of sessions) if (v.exp > now) obj[sid] = v;
|
||||
fs.writeFileSync(SESSION_FILE, JSON.stringify(obj), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
console.error('[editor-api] 会话落盘失败(不影响运行):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
for (const part of String(req.headers.cookie || '').split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i < 1) continue;
|
||||
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const COOKIE = 'ed_sid';
|
||||
|
||||
/** 返回会话对象或 null(顺带清掉过期项) */
|
||||
function readSession(req) {
|
||||
const sid = parseCookies(req)[COOKIE];
|
||||
if (!sid) return null;
|
||||
const s = sessions.get(sid);
|
||||
if (!s) return null;
|
||||
if (s.exp <= Date.now()) {
|
||||
sessions.delete(sid);
|
||||
saveSessions();
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setCookie(res, sid, maxAgeSec) {
|
||||
const parts = [
|
||||
COOKIE + '=' + sid,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Lax',
|
||||
'Max-Age=' + maxAgeSec,
|
||||
];
|
||||
res.setHeader('Set-Cookie', parts.join('; '));
|
||||
}
|
||||
|
||||
// 暴力破解防护:同一 IP 连续失败到阈值就冷却一段时间
|
||||
const FAIL_MAX = 5;
|
||||
const FAIL_COOL_MS = 5 * 60 * 1000;
|
||||
const failures = new Map(); // ip -> { n, until }
|
||||
|
||||
function clientIp(req) {
|
||||
return (
|
||||
String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
|
||||
(req.socket && req.socket.remoteAddress) ||
|
||||
'?'
|
||||
);
|
||||
}
|
||||
|
||||
function failState(ip) {
|
||||
const f = failures.get(ip);
|
||||
if (!f) return null;
|
||||
if (f.until && f.until > Date.now()) return f;
|
||||
if (!f.until && f.n < FAIL_MAX) return f;
|
||||
failures.delete(ip);
|
||||
return null;
|
||||
}
|
||||
|
||||
function noteFailure(ip) {
|
||||
const f = failures.get(ip) || { n: 0, until: 0 };
|
||||
f.n += 1;
|
||||
if (f.n >= FAIL_MAX) {
|
||||
f.until = Date.now() + FAIL_COOL_MS;
|
||||
f.n = 0;
|
||||
}
|
||||
failures.set(ip, f);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 路由
|
||||
|
||||
const routes = [];
|
||||
@@ -223,8 +335,60 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
|
||||
json(res, 401, { error: '未授权' });
|
||||
// ---------------------------------------------------------- 后台登录
|
||||
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能
|
||||
// 通过 nginx 的 /api/v2/admin/* 转发进来;失败限速防爆破。
|
||||
if (url.pathname === '/admin/session' && req.method === 'GET') {
|
||||
const s = readSession(req);
|
||||
if (!s) {
|
||||
json(res, 401, { ok: false, mode: 'direct', need_login: true });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { ok: true, mode: 'direct', user: { name: s.user } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/login' && req.method === 'POST') {
|
||||
if (!cfg.adminPass) {
|
||||
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
|
||||
return;
|
||||
}
|
||||
const ip = clientIp(req);
|
||||
const f = failState(ip);
|
||||
if (f && f.until) {
|
||||
json(res, 429, { error: '尝试过于频繁,请 ' + Math.ceil((f.until - Date.now()) / 1000) + ' 秒后再试' });
|
||||
return;
|
||||
}
|
||||
const body = await readJson(req, 64 * 1024);
|
||||
if (!safeEqual(body.user || '', cfg.adminUser) || !safeEqual(body.password || '', cfg.adminPass)) {
|
||||
noteFailure(ip);
|
||||
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
|
||||
json(res, 401, { error: '账号或密码不正确' });
|
||||
return;
|
||||
}
|
||||
failures.delete(ip);
|
||||
const sid = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(sid, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
|
||||
saveSessions();
|
||||
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
|
||||
json(res, 200, { ok: true, user: { name: cfg.adminUser } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/logout' && req.method === 'POST') {
|
||||
const sid = parseCookies(req)[COOKIE];
|
||||
if (sid && sessions.delete(sid)) saveSessions();
|
||||
setCookie(res, '', 0);
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 鉴权
|
||||
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
|
||||
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
|
||||
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token);
|
||||
if (!byToken && !readSession(req)) {
|
||||
json(res, 401, { error: '未授权', need_login: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,9 +412,17 @@ const server = http.createServer(async (req, res) => {
|
||||
server.headersTimeout = 30_000;
|
||||
server.requestTimeout = 120_000;
|
||||
|
||||
loadSessions();
|
||||
|
||||
server.listen(cfg.port, cfg.host, () => {
|
||||
console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port);
|
||||
console.log('[editor-api] 仓库 ' + cfg.repoRoot);
|
||||
console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', '));
|
||||
console.log('[editor-api] 回收目录 ' + cfg.trashDir);
|
||||
console.log(
|
||||
'[editor-api] 后台登录 ' +
|
||||
(cfg.adminPass
|
||||
? '已启用(账号 ' + cfg.adminUser + ',会话 ' + Math.round(cfg.sessionTtlMs / 3600000) + ' 小时,' + sessions.size + ' 个有效会话)'
|
||||
: '未配置 ADMIN_PASS —— 只能走 X-Editor-Token')
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user