feat: SSL 证书管家 —— 后台面板 + 专属角色 + 国内机放行
集成在 api.200181.xyz(同一个 Worker),国内机 writeapi.usj.cc 同步可用。 新增第三档后台角色 'ssl': - 只拿证书管家钥匙,看不到评论/文章/用户等模块 - 判定正着枚举放行(canManageSSL = admin|ssl),不用排除法, 免得以后新增角色静默获得私钥权限 - 鉴权只认 Bearer 会话,绝不走 isAdminRequest —— 后者有 Artalk 老客户端的 query 兜底,一旦进来的就是 TLS 私钥 数据(复用 RSS_KV,前缀 certkeeper:): - 凭据一条一键,避开 KV 读-改-写无事务导致的并发丢数据 - 私钥/AK-SK 一律 AES-GCM 密文(TOKEN_SECRET 经 PBKDF2 派生) - 列表接口只回显前 4 后 4 位,明文不进内存 - 配置读失败抛错而非返回空,避免一次保存覆盖线上配置 只读监控: - probeTls 走 cloudflare:sockets 拿证书正文,实现到期分级 与「库里记录 vs 线上实测」对比(match/mismatch/live-only/unreachable) - 到期提醒邮件(HTML 已转义) 国内机 editor-api: - identify 识别 ssl 角色;业务分支前白名单,ssl 只能碰 /health、 /admin/session、/admin/logout 与 /api/v2/ssl* - /api/v2/ssl* 反代放行 admin + ssl(RSS 仍是 admin-only) - posts.mjs 越权兜底方向修正:owns() 从「非 editor 即放行」改为 「只有 admin 不受限」,漏进来的 ssl 被当受限编辑而非管理员全放行 - createPost 显式拒绝非写作角色 验证:typecheck ✓ / selftest:ssl 117 项 ✓ / 浏览器 37 项 ✓
This commit is contained in:
1 parent
ca2fb7163d
commit
a40a92f526
16 files changed
+2791
-59
No files matched your search
+70
-9
@@ -138,7 +138,7 @@ function loadSessions() {
|
||||
sessions.set(sid, {
|
||||
uid: String(v.uid || ''),
|
||||
name: String(v.name || v.user || cfg.adminUser),
|
||||
role: v.role === 'editor' ? 'editor' : 'admin',
|
||||
role: normalizeRole(v.role),
|
||||
exp: v.exp,
|
||||
});
|
||||
}
|
||||
@@ -207,8 +207,8 @@ function decodeHeader(v) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 谁在操作 —— 返回 { uid, name, role } 或 null。role 只有 'admin' / 'editor'
|
||||
* 两种(普通评论用户进不来写作后台)。
|
||||
* 谁在操作 —— 返回 { uid, name, role } 或 null。
|
||||
* role 有三档:'admin'(全部)/ 'editor'(只有自己的文章)/ 'ssl'(只有证书管家)。
|
||||
*
|
||||
* 两条通道:
|
||||
* ① Cloudflare Worker 反代:带了共享令牌 X-Editor-Token(浏览器永远拿不到),
|
||||
@@ -228,7 +228,7 @@ function identify(req) {
|
||||
return {
|
||||
uid: String(s.uid || ''),
|
||||
name: String(s.name || cfg.adminUser),
|
||||
role: s.role === 'editor' ? 'editor' : 'admin',
|
||||
role: normalizeRole(s.role),
|
||||
};
|
||||
}
|
||||
// Worker 反代通道:由 Cloudflare 发出,不带浏览器 Cookie,只有令牌 + 身份头
|
||||
@@ -236,12 +236,19 @@ function identify(req) {
|
||||
return {
|
||||
uid: String(req.headers['x-editor-uid'] || '').trim(),
|
||||
name: decodeHeader(req.headers['x-editor-user']),
|
||||
role: String(req.headers['x-editor-role'] || '').trim() === 'editor' ? 'editor' : 'admin',
|
||||
role: normalizeRole(req.headers['x-editor-role']),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 角色归一化:只有这三个值合法;空/未知一律按最高权限处理(保持改造前语义) */
|
||||
function normalizeRole(v) {
|
||||
const r = String(v || '').trim();
|
||||
if (r === 'editor' || r === 'ssl') return r;
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 直连后台登录时,把账号密码交给 Cloudflare 上的用户表验证。
|
||||
*
|
||||
@@ -280,8 +287,16 @@ async function loginViaCf(identifier, password) {
|
||||
const u = d && d.user;
|
||||
if (!u) return { denied: true };
|
||||
|
||||
const role = u.role === 'editor' ? 'editor' : u.is_admin || u.role === 'admin' ? 'admin' : '';
|
||||
if (!role) return { denied: true }; // 普通评论用户没有写作后台权限
|
||||
// ★ 三种角色都要认。早期版本只认 editor/admin,SSL 管理员登进来会被当成
|
||||
// 普通评论用户直接拒掉 —— 那它就没法从国内线路用证书管家了。
|
||||
const role = u.is_admin
|
||||
? 'admin'
|
||||
: u.role === 'editor'
|
||||
? 'editor'
|
||||
: u.role === 'ssl'
|
||||
? 'ssl'
|
||||
: '';
|
||||
if (!role) return { denied: true }; // 普通评论用户没有后台权限
|
||||
return { ident: { uid: String(u.id), name: String(u.name || identifier), role } };
|
||||
}
|
||||
|
||||
@@ -337,6 +352,23 @@ const RSS_PATH_PREFIXES = (env.RSS_PATHS || '/api/feeds,/api/links,/api/link-app
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* 需要转给**证书管家**的路径前缀(后台的证书管家模块用)。
|
||||
*
|
||||
* 为什么证书管家的实现放在 Cloudflare 而不在本机:
|
||||
* · 它握着 AES-GCM 保险箱的密钥(TOKEN_SECRET)和全部云厂商凭据,
|
||||
* 再多一处副本就多一个失守点;
|
||||
* · 逻辑(探测、ACME、部署)与 artalk-cf 共用同一套 KV 与后台会话。
|
||||
* 本机只做一跳转发,语义与 rssProxy 完全一致。
|
||||
*
|
||||
* ★ 与 RSS 的区别:**SSL 管理员也要能走**(证件管家就是给它用的),
|
||||
* 所以这条路径的放行判定是 role ∈ {admin, ssl},不是 admin-only。
|
||||
*/
|
||||
const SSL_PATH_PREFIXES = (env.SSL_PATHS || '/api/v2/ssl')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* 把订阅/友链请求原样转发到订阅服务(Cloudflare 上的 rss-api),带上共享令牌。
|
||||
* 用途:国内机后台的订阅源 / 友链模块 —— 请求由本机出去,前端不需要知道
|
||||
@@ -526,7 +558,7 @@ const server = http.createServer(async (req, res) => {
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
mode: 'direct',
|
||||
user: { id: s.uid || null, name: s.name || cfg.adminUser, role: s.role === 'editor' ? 'editor' : 'admin' },
|
||||
user: { id: s.uid || null, name: s.name || cfg.adminUser, role: normalizeRole(s.role) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -602,7 +634,9 @@ const server = http.createServer(async (req, res) => {
|
||||
res.end('handoff rejected');
|
||||
};
|
||||
if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad();
|
||||
if (role !== 'admin' && role !== 'editor') return bad();
|
||||
// ★ 三种角色都要收 —— 早期只认 admin/editor,SSL 管理员点「国内线路」
|
||||
// 会被这里静默拒掉(403 handoff rejected),看起来像「按钮坏了」。
|
||||
if (role !== 'admin' && role !== 'editor' && role !== 'ssl') return bad();
|
||||
if (!name) return bad();
|
||||
const expect = hmacHex(cfg.token, `${ts}\n${uid}\n${name}\n${role}`);
|
||||
if (!expect || !safeEqual(sig, expect)) return bad();
|
||||
@@ -629,6 +663,21 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// SSL 管理员只该碰证书管家。白名单式放行:除 /ssl 相关的转发路径外一律拒。
|
||||
// ★ 放在所有业务分支之前,且用「允许清单」而不是「排除清单」——
|
||||
// 将来新增文章相关路由时,SSL 角色默认是**被拒**的,不会悄悄放行。
|
||||
if (me.role === 'ssl') {
|
||||
const allow =
|
||||
url.pathname === '/health' ||
|
||||
url.pathname === '/admin/session' ||
|
||||
url.pathname === '/admin/logout' ||
|
||||
SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'));
|
||||
if (!allow) {
|
||||
json(res, 403, { error: 'SSL 管理员只能使用证书管家' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。
|
||||
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
|
||||
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
|
||||
@@ -641,6 +690,18 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 证书管家:同样反代到 Cloudflare(实现和密钥都在那边)。
|
||||
// ★ 放行 admin + ssl —— 这条路径本来就是为了让 SSL 管理员用的。
|
||||
// 注意判定是**枚举放行**,不是 `!== 'editor'`:以后加角色不会静默放权。
|
||||
if (SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) {
|
||||
if (me.role !== 'admin' && me.role !== 'ssl') {
|
||||
json(res, 403, { error: '需要管理员或 SSL 管理员权限' });
|
||||
return;
|
||||
}
|
||||
await rssProxy(req, res, url);
|
||||
return;
|
||||
}
|
||||
|
||||
const hit = match(req.method, url.pathname);
|
||||
if (!hit) {
|
||||
json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname });
|
||||
|
||||
@@ -123,12 +123,19 @@ export function makePosts(cfg) {
|
||||
// ------------------------------------------------------------ 归属判定
|
||||
//
|
||||
// viewer = { uid, name, role } —— 由 server.mjs 从请求头/会话里解出来。
|
||||
// admin 不受限;editor 只能碰自己的。
|
||||
// 三档角色(与 Cloudflare 侧 users.role 一一对应):
|
||||
// admin 管理员 —— 不受限
|
||||
// editor 编辑 —— 只能碰自己写的文章
|
||||
// ssl SSL 管理员 —— ★ 压根不该出现在这里:文章相关的路由入口处
|
||||
// 已经按角色拦掉了(见 server.mjs)。万一漏了,
|
||||
// 下面的判定会把它当 editor 处理 —— 「只看得见自己
|
||||
// 名下的东西」,而不是当 admin 全量放行。
|
||||
// 判定的写法是「谁受限」而不是「谁不受限」,就是为了这一刻的兜底方向正确。
|
||||
// viewer 为 null/undefined = 内部调用(不做限制),只用于本文件内部互相调用。
|
||||
|
||||
function owns(item, viewer) {
|
||||
if (!item || !viewer) return true;
|
||||
if (viewer.role !== 'editor') return true; // 管理员(或未知角色走不到这儿)
|
||||
if (viewer.role === 'admin') return true; // 只有管理员不受限
|
||||
const id = String(item.authorId || '');
|
||||
if (id) return id === String(viewer.uid);
|
||||
// 老文章的兜底:没有 author_id 时按昵称认领(改造前 write-server 写的那些)
|
||||
@@ -142,7 +149,7 @@ export function makePosts(cfg) {
|
||||
|
||||
/** 编辑自己在仓库里的文章目录(git pathspec),管理员返回 null 表示「全量」 */
|
||||
function ownedPaths(viewer) {
|
||||
if (!viewer || viewer.role !== 'editor') return null;
|
||||
if (!viewer || viewer.role === 'admin') return null;
|
||||
return findPostDirs()
|
||||
.filter((dir) => owns(toListItem(dir), viewer))
|
||||
.map(relOf);
|
||||
@@ -151,8 +158,9 @@ export function makePosts(cfg) {
|
||||
function listPosts({ q = '', page = 1, perPage = 20 } = {}, viewer) {
|
||||
let rows = findPostDirs().map(toListItem);
|
||||
|
||||
// 编辑角色:列表只显示自己写的(连「别人有几篇」都不让它知道)
|
||||
if (viewer && viewer.role === 'editor') rows = rows.filter((r) => owns(r, viewer));
|
||||
// 编辑角色(以及任何非管理员):列表只显示自己写的
|
||||
// (连「别人有几篇」都不让它知道)
|
||||
if (viewer && viewer.role !== 'admin') rows = rows.filter((r) => owns(r, viewer));
|
||||
|
||||
// slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
|
||||
const seen = new Map();
|
||||
@@ -374,6 +382,13 @@ export function makePosts(cfg) {
|
||||
}
|
||||
|
||||
function createPost({ frontMatter = {}, content = '' } = {}, viewer) {
|
||||
// ★ 只有管理员和编辑能新建。SSL 管理员没有写作权限 —— 它在 server.mjs
|
||||
// 的路由层就该被拦住;这里再卡一道,是因为「新建」会在仓库里落文件,
|
||||
// 一旦漏放不是「看不到」而是「写进去了」,后果比读取严重得多。
|
||||
if (viewer && viewer.role !== 'admin' && viewer.role !== 'editor') {
|
||||
throw bad('当前角色没有创建文章的权限', 403);
|
||||
}
|
||||
|
||||
const title = String(frontMatter.title || '').trim();
|
||||
if (!title) throw bad('缺少标题');
|
||||
const slug = String(frontMatter.slug || '').trim() || makeSlug();
|
||||
|
||||
Reference in new issue
Block a user