Commit d87904df authored by xuchentao's avatar xuchentao

feat: 支持后台修改登录密码

parent b8a11003
Pipeline #458 passed with stage
in 20 seconds
......@@ -32,7 +32,7 @@ npm start
- 分类:`src/content/categories.json`
- 上传图片:`public/uploads/`
生产环境建议设置 `CMS_DATA_DIR` 指向部署目录之外的持久化路径。后台支持资讯新建、保存草稿、预览、发布、下架、删除、图片上传和分类管理。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。
生产环境建议设置 `CMS_DATA_DIR` 指向部署目录之外的持久化路径。后台支持资讯新建、保存草稿、预览、发布、下架、删除、图片上传、分类管理和修改后台密码。修改后的密码以哈希形式保存在 `CMS_DATA_DIR/auth/password.json``CMS_PASSWORD` 始终保留为登录和找回密码。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。
## 必需环境变量
......
......@@ -64,6 +64,46 @@ $("#login-form").addEventListener("submit", async (event) => {
$("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); });
function closePasswordModal() {
hide("#password-modal");
$("#password-form").reset();
$("#password-error").textContent = "";
}
$("#change-password").addEventListener("click", () => {
$("#password-form").reset();
$("#password-error").textContent = "";
show("#password-modal");
$("#current-password").focus();
});
$("#close-password-modal").addEventListener("click", closePasswordModal);
$("#cancel-password").addEventListener("click", closePasswordModal);
$("#password-form").addEventListener("submit", async (event) => {
event.preventDefault();
const currentPassword = $("#current-password").value;
const newPassword = $("#new-password").value;
const confirmPassword = $("#confirm-password").value;
const submit = event.submitter;
$("#password-error").textContent = "";
if (newPassword !== confirmPassword) {
$("#password-error").textContent = "两次输入的新密码不一致";
return;
}
if (submit) submit.disabled = true;
try {
await api("/account/password", { method: "PUT", body: { currentPassword, newPassword } });
closePasswordModal();
alert("密码修改成功,请重新登录后台");
showLogin();
} catch (error) {
$("#password-error").textContent = error.message;
} finally {
if (submit) submit.disabled = false;
}
});
const statusMap = {
"new-unsaved": ["尚未保存", "new-draft", "填写完成后先保存草稿,线上网站不会发生变化。"],
"new-draft": ["新建未发布", "new-draft", "这篇资讯目前只有草稿版本,发布后才会出现在官网。"],
......
......@@ -122,6 +122,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(255,125,65,.
.modal-card > .modal-head { padding: 18px 20px; }
.modal-head > div { display: grid; gap: 4px; }
.modal-head small { color: var(--muted); font-size: 12px; font-weight: 400; }
.password-card { width: min(500px, 100%); }
.modal-card > .password-form { padding: 20px; display: grid; gap: 14px; border: 0; }
.password-form label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.password-hint { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.password-form .error { margin: -4px 0 0; }
.password-actions { display: flex; justify-content: flex-end; gap: 9px; }
.modal-card > .category-manager-body { max-height: 68vh; padding: 20px; display: block; overflow-y: auto; border: 0; }
.manager-create { display: flex; gap: 9px; }
.manager-create input { min-width: 0; }
......
......@@ -22,8 +22,8 @@ import {
} from "./article-store";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
import { importWordArticle } from "./word-import";
import { currentSessionVersion, passwordConfigured, savePassword, verifyPassword } from "./cms-auth";
const PASSWORD = process.env.CMS_PASSWORD || "";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || "";
const CAPTCHA_TTL = 5 * 60 * 1000;
......@@ -46,7 +46,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
return left.length === right.length && crypto.timingSafeEqual(left, right);
};
const sessionToken = () => crypto.createHmac("sha256", SECRET).update("jiayun-cms-v1").digest("hex");
const sessionToken = async () => crypto.createHmac("sha256", SECRET)
.update(`jiayun-cms-v2:${await currentSessionVersion()}`)
.digest("hex");
function cookies(request: Request): Record<string, string> {
return Object.fromEntries((request.headers.get("cookie") || "").split(";").filter(Boolean).map((part) => {
......@@ -62,8 +64,12 @@ function apiKeyValid(request: Request): boolean {
return key ? safeEqual(key, API_KEY) : false;
}
const authed = (request: Request): boolean =>
Boolean(cookies(request).cms_session && safeEqual(cookies(request).cms_session, sessionToken())) || apiKeyValid(request);
const sessionAuthed = async (request: Request): Promise<boolean> => {
const token = cookies(request).cms_session;
return Boolean(token && safeEqual(token, await sessionToken()));
};
const authed = async (request: Request): Promise<boolean> => await sessionAuthed(request) || apiKeyValid(request);
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try { return await request.json(); } catch { return {}; }
......@@ -75,9 +81,9 @@ function cookieHeader(request: Request, name: string, value: string, maxAge: num
return `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
}
function loginAllowed(ip: string): boolean {
function loginBlockedFor(ip: string): number {
const state = loginAttempts.get(ip);
return !state || state.until < Date.now();
return state?.until && state.until > Date.now() ? Math.ceil((state.until - Date.now()) / 1000) : 0;
}
function loginFailed(ip: string): void {
......@@ -202,29 +208,43 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
}
if (route === "session") {
if (method === "GET") return json({ authed: authed(request) });
if (method === "GET") return json({ authed: await authed(request) });
if (method === "POST") {
if (!PASSWORD) throw new StoreError("资讯中心后台尚未配置管理密码", 503);
if (!loginAllowed(clientAddress)) throw new StoreError("登录尝试过于频繁,请稍后再试", 429);
if (!await passwordConfigured()) throw new StoreError("资讯中心后台尚未配置管理密码", 503);
const blockedFor = loginBlockedFor(clientAddress);
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
const body = await bodyOf(request);
const clearCaptcha = cookieHeader(request, "cms_captcha", "", 0);
if (!captchaValid(request, body.captcha)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
}
if (!safeEqual(body.password || "", PASSWORD)) {
if (!await verifyPassword(body.password)) {
loginFailed(clientAddress);
return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha });
}
loginAttempts.delete(clientAddress);
const headers = new Headers();
headers.append("Set-Cookie", clearCaptcha);
headers.append("Set-Cookie", cookieHeader(request, "cms_session", sessionToken(), 86400));
headers.append("Set-Cookie", cookieHeader(request, "cms_session", await sessionToken(), 86400));
return json({ ok: true }, 200, headers);
}
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) });
}
if (!authed(request)) throw new StoreError("请先登录资讯中心后台", 401);
if (route === "account/password" && method === "PUT") {
if (!await sessionAuthed(request)) throw new StoreError("请先登录资讯中心后台", 401);
const body = await bodyOf(request);
const currentPassword = String(body.currentPassword || "");
const newPassword = String(body.newPassword || "");
if (!await verifyPassword(currentPassword)) throw new StoreError("当前密码错误", 401);
if (newPassword.length < 5) throw new StoreError("新密码至少需要 5 个字符");
if (newPassword.length > 128) throw new StoreError("新密码不能超过 128 个字符");
if (safeEqual(currentPassword, newPassword)) throw new StoreError("新密码不能与当前密码相同");
await savePassword(newPassword);
return json({ ok: true, requiresLogin: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) });
}
if (!await authed(request)) throw new StoreError("请先登录资讯中心后台", 401);
if (route === "categories") {
if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() });
......
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
const ROOT = process.cwd();
const DATA_ROOT = process.env.CMS_DATA_DIR ? path.resolve(process.env.CMS_DATA_DIR) : path.join(ROOT, ".runtime");
const AUTH_DIR = path.join(DATA_ROOT, "auth");
const PASSWORD_FILE = path.join(AUTH_DIR, "password.json");
const ENV_PASSWORD = process.env.CMS_PASSWORD || "";
const HASH_LENGTH = 64;
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
type PasswordState = {
version: 1;
salt: string;
hash: string;
sessionVersion: number;
updatedAt: string;
};
const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
const left = Buffer.from(String(leftValue));
const right = Buffer.from(String(rightValue));
return left.length === right.length && crypto.timingSafeEqual(left, right);
};
async function readPasswordState(): Promise<PasswordState | null> {
try {
const parsed = JSON.parse(await fs.readFile(PASSWORD_FILE, "utf8")) as Partial<PasswordState>;
if (parsed.version !== 1 || typeof parsed.salt !== "string" || typeof parsed.hash !== "string" || !Number.isInteger(parsed.sessionVersion)) {
throw new Error("后台密码文件格式无效");
}
return parsed as PasswordState;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
async function hashPassword(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, HASH_LENGTH, SCRYPT_OPTIONS, (error, derivedKey) => {
if (error) reject(error);
else resolve(derivedKey);
});
});
}
export async function verifyPassword(password: unknown): Promise<boolean> {
// Keep the environment password available as a recovery credential.
if (ENV_PASSWORD && safeEqual(password || "", ENV_PASSWORD)) return true;
const state = await readPasswordState();
if (!state) return false;
const hash = await hashPassword(String(password || ""), Buffer.from(state.salt, "base64"));
return safeEqual(hash.toString("base64"), state.hash);
}
export async function passwordConfigured(): Promise<boolean> {
return Boolean(ENV_PASSWORD || await readPasswordState());
}
export async function currentSessionVersion(): Promise<number> {
try {
return (await readPasswordState())?.sessionVersion || 0;
} catch (error) {
console.error("读取后台密码文件失败:", error);
return 0;
}
}
export async function savePassword(password: string): Promise<void> {
const previous = await readPasswordState().catch(() => null);
const salt = crypto.randomBytes(16);
const hash = await hashPassword(password, salt);
const state: PasswordState = {
version: 1,
salt: salt.toString("base64"),
hash: hash.toString("base64"),
sessionVersion: (previous?.sessionVersion || 0) + 1,
updatedAt: new Date().toISOString(),
};
await fs.mkdir(AUTH_DIR, { recursive: true, mode: 0o700 });
const temporaryFile = `${PASSWORD_FILE}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
try {
await fs.writeFile(temporaryFile, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
await fs.rename(temporaryFile, PASSWORD_FILE);
} catch (error) {
await fs.rm(temporaryFile, { force: true }).catch(() => {});
throw error;
}
}
......@@ -30,6 +30,7 @@
<div class="top-actions">
<span id="pending-badge" class="pending-badge hidden"></span>
<a class="button ghost" href="/articles/" target="_blank" rel="noopener">查看前台</a>
<button id="change-password" class="ghost" type="button">修改密码</button>
<button id="logout" class="ghost">退出</button>
</div>
</header>
......@@ -99,6 +100,19 @@
</div>
</div>
</div>
<div id="password-modal" class="modal hidden">
<div class="modal-card password-card">
<div class="modal-head"><div><strong>修改后台密码</strong><small>修改成功后需要使用新密码重新登录</small></div><button id="close-password-modal" class="ghost small" type="button">关闭</button></div>
<form id="password-form" class="password-form">
<label>当前密码<input id="current-password" type="password" autocomplete="current-password" required /></label>
<label>新密码<input id="new-password" type="password" autocomplete="new-password" minlength="5" maxlength="128" required /></label>
<label>确认新密码<input id="confirm-password" type="password" autocomplete="new-password" minlength="5" maxlength="128" required /></label>
<p class="password-hint">新密码至少 5 个字符。</p>
<p id="password-error" class="error"></p>
<div class="password-actions"><button id="cancel-password" class="ghost" type="button">取消</button><button class="primary" type="submit">确认修改</button></div>
</form>
</div>
</div>
<div id="build-modal" class="modal hidden">
<div class="modal-card build-card">
<div><strong id="build-title">正在更新官网</strong><button id="close-modal" class="ghost small hidden" type="button">关闭</button></div>
......
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { after } from "node:test";
const dataDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "jiayun-auth-"));
process.env.CMS_DATA_DIR = dataDirectory;
process.env.CMS_PASSWORD = "environment-master-password";
process.env.CMS_SECRET = "auth-test-session-secret";
process.env.CMS_API_KEY = "auth-test-api-key";
const { handleCmsApi } = await import("../src/lib/cms-api");
let requestNumber = 0;
after(async () => {
await fs.rm(dataDirectory, { recursive: true, force: true });
});
function cookieFrom(response: Response, name: string): string {
const header = response.headers.get("set-cookie") || "";
const match = header.match(new RegExp(`(?:^|,\\s*)${name}=([^;]*)`));
assert.ok(match, `missing ${name} cookie in ${header}`);
return `${name}=${match[1]}`;
}
async function login(password: string): Promise<{ response: Response; cookie?: string }> {
requestNumber += 1;
const captchaResponse = await handleCmsApi(
new Request("http://localhost/api/cms/captcha"),
"captcha",
`captcha-${requestNumber}`,
);
const code = [...(await captchaResponse.text()).matchAll(/<text\b[^>]*>([^<])<\/text>/g)]
.map((match) => match[1])
.join("");
assert.equal(code.length, 4);
const response = await handleCmsApi(new Request("http://localhost/api/cms/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookieFrom(captchaResponse, "cms_captcha"),
},
body: JSON.stringify({ password, captcha: code }),
}), "session", `login-${requestNumber}`);
return {
response,
cookie: response.ok ? cookieFrom(response, "cms_session") : undefined,
};
}
async function sessionStatus(cookie: string): Promise<boolean> {
const response = await handleCmsApi(new Request("http://localhost/api/cms/session", {
headers: { Cookie: cookie },
}), "session");
return Boolean((await response.json()).authed);
}
async function changePassword(cookie: string, currentPassword: string, newPassword: string): Promise<Response> {
return handleCmsApi(new Request("http://localhost/api/cms/account/password", {
method: "PUT",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ currentPassword, newPassword }),
}), "account/password");
}
test("users can change the persisted password while the environment password remains valid", async () => {
const initialLogin = await login("environment-master-password");
assert.equal(initialLogin.response.status, 200);
assert.ok(initialLogin.cookie);
const tooShort = await changePassword(initialLogin.cookie, "environment-master-password", "abcd");
assert.equal(tooShort.status, 400);
assert.match((await tooShort.json()).error, /至少需要 5 个字符/);
assert.equal(await sessionStatus(initialLogin.cookie), true);
const firstPassword = "first-user-password";
const changed = await changePassword(initialLogin.cookie, "environment-master-password", firstPassword);
assert.equal(changed.status, 200);
assert.match(changed.headers.get("set-cookie") || "", /cms_session=;/);
assert.equal((await changed.json()).requiresLogin, true);
assert.equal(await sessionStatus(initialLogin.cookie), false);
const stored = await fs.readFile(path.join(dataDirectory, "auth", "password.json"), "utf8");
assert.doesNotMatch(stored, new RegExp(firstPassword));
const userLogin = await login(firstPassword);
assert.equal(userLogin.response.status, 200);
assert.ok(userLogin.cookie);
assert.equal((await login("environment-master-password")).response.status, 200);
const secondPassword = "abcde";
const changedAgain = await changePassword(userLogin.cookie, firstPassword, secondPassword);
assert.equal(changedAgain.status, 200);
assert.equal(await sessionStatus(userLogin.cookie), false);
assert.equal((await login(firstPassword)).response.status, 401);
assert.equal((await login(secondPassword)).response.status, 200);
assert.equal((await login("environment-master-password")).response.status, 200);
});
test("an API key alone cannot change the browser login password", async () => {
const response = await handleCmsApi(new Request("http://localhost/api/cms/account/password", {
method: "PUT",
headers: {
Authorization: "Bearer auth-test-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword: "environment-master-password",
newPassword: "api-key-must-not-change-this",
}),
}), "account/password");
assert.equal(response.status, 401);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment