Commit 61fd98b5 authored by xuchentao's avatar xuchentao

feat: support admin password changes

parent 74052cff
Pipeline #456 passed with stage
in 21 seconds
# 复制为 .env 后使用。生产环境请生成独立密钥。
CMS_PORT=8791
# 后台主密码始终有效,用于登录和找回;在后台修改的用户密码会另存于 CMS_DATA_DIR。
CMS_PASSWORD=replace-with-a-strong-password
CMS_SECRET=replace-with-openssl-rand-hex-32
CMS_API_KEY=replace-with-openssl-rand-hex-32
......
......@@ -35,7 +35,7 @@ npm start
启动命令会自动读取项目根目录的 `.env`,其中包含:
- `CMS_PORT`:官网与后台共用的服务端口,默认 `8791`
- `CMS_PASSWORD`:后台登录密码
- `CMS_PASSWORD`:后台主密码;用户在后台修改密码后,该密码仍可用于登录和找回(修改 `.env` 后需重启应用)
- `CMS_SECRET`:后台会话签名密钥
- `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token
- `CMS_DATA_DIR`:生产环境的用户数据目录,建议设置为仓库外的绝对路径
......@@ -50,6 +50,7 @@ npm start
| 会话 | `GET /api/cms/session` | 查询登录状态 |
| 会话 | `POST /api/cms/session` | 登录后台 |
| 会话 | `DELETE /api/cms/session` | 退出登录 |
| 账号 | `PUT /api/cms/account/password` | 修改后台用户密码并注销现有会话 |
| 分类 | `GET /api/cms/categories` | 分类列表与文章数量 |
| 分类 | `POST /api/cms/categories` | 新建分类 |
| 分类 | `PATCH /api/cms/categories` | 重命名分类 |
......
......@@ -66,6 +66,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", "这篇文章目前只有草稿版本,发布后才会出现在官网。"],
......
......@@ -123,6 +123,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(209,37,26,.1
.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,9 +22,9 @@ import {
writeArticle,
} from "./article-store";
import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
const PASSWORD = process.env.CMS_PASSWORD || "admin";
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;
......@@ -50,7 +50,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
return left.length === right.length && crypto.timingSafeEqual(left, right);
};
const sessionToken = () => crypto.createHmac("sha256", SECRET).update("yinzhuang-cms-v1").digest("hex");
const sessionToken = async () => crypto.createHmac("sha256", SECRET)
.update(`yinzhuang-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) => {
......@@ -66,8 +68,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 {}; }
......@@ -212,7 +218,7 @@ 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") {
const blockedFor = loginBlockedFor(clientAddress);
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
......@@ -221,20 +227,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
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 < 10) throw new StoreError("新密码至少需要 10 个字符");
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 || "admin";
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> {
// Check the .env credential first so it remains a recovery password even if
// the optional persisted password file is missing or damaged.
if (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 currentSessionVersion(): Promise<number> {
try {
return (await readPasswordState())?.sessionVersion || 0;
} catch (error) {
// Keep .env login and recovery available if the optional file is corrupt.
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>
......@@ -101,6 +102,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="10" maxlength="128" required /></label>
<label>确认新密码<input id="confirm-password" type="password" autocomplete="new-password" minlength="10" maxlength="128" required /></label>
<p class="password-hint">新密码至少 10 个字符。.env 中的管理密码仍可用于登录和找回。</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(), "yinzhuang-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 .env password remains valid", async () => {
const initialLogin = await login("environment-master-password");
assert.equal(initialLogin.response.status, 200);
assert.ok(initialLogin.cookie);
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 = "second-user-password";
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