Commit d14cf33e authored by xuchentao's avatar xuchentao

feat: add admin password change

parent 17154010
Pipeline #459 passed with stage
in 15 seconds
# 本地开发配置:复制为 .env 后使用。 # 本地开发配置:复制为 .env 后使用。
# 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。 # 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。
# 后台主密码始终有效,用于登录和找回;在后台修改的用户密码会另存于 CMS_DATA_DIR。
CMS_PORT=8789 CMS_PORT=8789
CMS_PASSWORD=replace-with-a-strong-password CMS_PASSWORD=replace-with-a-strong-password
CMS_SECRET=replace-with-openssl-rand-hex-32 CMS_SECRET=replace-with-openssl-rand-hex-32
......
...@@ -35,7 +35,7 @@ npm start ...@@ -35,7 +35,7 @@ npm start
启动命令会自动读取项目根目录的 `.env`,其中包含: 启动命令会自动读取项目根目录的 `.env`,其中包含:
- `CMS_PORT`:官网与后台共用的服务端口,默认 `8789`(斯嘉丽项目端口 `8788` + 1) - `CMS_PORT`:官网与后台共用的服务端口,默认 `8789`(斯嘉丽项目端口 `8788` + 1)
- `CMS_PASSWORD`:后台登录密码 - `CMS_PASSWORD`:后台主密码;用户在后台修改密码后,该密码仍可用于登录和找回(修改 `.env` 后需重启应用)
- `CMS_SECRET`:后台会话签名密钥 - `CMS_SECRET`:后台会话签名密钥
- `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token - `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token
- `CMS_DATA_DIR`:生产环境的用户数据目录,建议设置为仓库外的绝对路径 - `CMS_DATA_DIR`:生产环境的用户数据目录,建议设置为仓库外的绝对路径
...@@ -50,6 +50,7 @@ npm start ...@@ -50,6 +50,7 @@ npm start
| 会话 | `GET /api/cms/session` | 查询登录状态 | | 会话 | `GET /api/cms/session` | 查询登录状态 |
| 会话 | `POST /api/cms/session` | 登录后台 | | 会话 | `POST /api/cms/session` | 登录后台 |
| 会话 | `DELETE /api/cms/session` | 退出登录 | | 会话 | `DELETE /api/cms/session` | 退出登录 |
| 账号 | `PUT /api/cms/account/password` | 修改后台用户密码并注销现有会话 |
| 分类 | `GET /api/cms/categories` | 分类列表与文章数量 | | 分类 | `GET /api/cms/categories` | 分类列表与文章数量 |
| 分类 | `POST /api/cms/categories` | 新建分类 | | 分类 | `POST /api/cms/categories` | 新建分类 |
| 分类 | `PATCH /api/cms/categories` | 重命名分类 | | 分类 | `PATCH /api/cms/categories` | 重命名分类 |
......
...@@ -65,6 +65,46 @@ $("#login-form").addEventListener("submit", async (event) => { ...@@ -65,6 +65,46 @@ $("#login-form").addEventListener("submit", async (event) => {
$("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); }); $("#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 = { const statusMap = {
"new-unsaved": ["尚未保存", "new-draft", "填写完成后先保存草稿,线上网站不会发生变化。"], "new-unsaved": ["尚未保存", "new-draft", "填写完成后先保存草稿,线上网站不会发生变化。"],
"new-draft": ["新建未发布", "new-draft", "这篇文章目前只有草稿版本,发布后才会出现在官网。"], "new-draft": ["新建未发布", "new-draft", "这篇文章目前只有草稿版本,发布后才会出现在官网。"],
......
...@@ -125,6 +125,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(22,174,156,. ...@@ -125,6 +125,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(22,174,156,.
.modal-card > .modal-head { padding: 18px 20px; } .modal-card > .modal-head { padding: 18px 20px; }
.modal-head > div { display: grid; gap: 4px; } .modal-head > div { display: grid; gap: 4px; }
.modal-head small { color: var(--muted); font-size: 12px; font-weight: 400; } .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; } .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 { display: flex; gap: 9px; }
.manager-create input { min-width: 0; } .manager-create input { min-width: 0; }
......
...@@ -22,9 +22,9 @@ import { ...@@ -22,9 +22,9 @@ import {
writeArticle, writeArticle,
} from "./article-store"; } from "./article-store";
import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import"; import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { currentSessionVersion, savePassword, verifyPassword } from "./cms-auth";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs"; 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 SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || ""; const API_KEY = process.env.CMS_API_KEY || "";
const CAPTCHA_TTL = 5 * 60 * 1000; const CAPTCHA_TTL = 5 * 60 * 1000;
...@@ -50,7 +50,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => { ...@@ -50,7 +50,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
return left.length === right.length && crypto.timingSafeEqual(left, right); return left.length === right.length && crypto.timingSafeEqual(left, right);
}; };
const sessionToken = () => crypto.createHmac("sha256", SECRET).update("sumeiqiao-cms-v1").digest("hex"); const sessionToken = async () => crypto.createHmac("sha256", SECRET)
.update(`sumeiqiao-cms-v2:${await currentSessionVersion()}`)
.digest("hex");
function cookies(request: Request): Record<string, string> { function cookies(request: Request): Record<string, string> {
return Object.fromEntries((request.headers.get("cookie") || "").split(";").filter(Boolean).map((part) => { return Object.fromEntries((request.headers.get("cookie") || "").split(";").filter(Boolean).map((part) => {
...@@ -66,8 +68,12 @@ function apiKeyValid(request: Request): boolean { ...@@ -66,8 +68,12 @@ function apiKeyValid(request: Request): boolean {
return key ? safeEqual(key, API_KEY) : false; return key ? safeEqual(key, API_KEY) : false;
} }
const authed = (request: Request): boolean => const sessionAuthed = async (request: Request): Promise<boolean> => {
Boolean(cookies(request).cms_session && safeEqual(cookies(request).cms_session, sessionToken())) || apiKeyValid(request); 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>> { async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try { return await request.json(); } catch { return {}; } try { return await request.json(); } catch { return {}; }
...@@ -212,7 +218,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -212,7 +218,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
} }
if (route === "session") { if (route === "session") {
if (method === "GET") return json({ authed: authed(request) }); if (method === "GET") return json({ authed: await authed(request) });
if (method === "POST") { if (method === "POST") {
const blockedFor = loginBlockedFor(clientAddress); const blockedFor = loginBlockedFor(clientAddress);
if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429); if (blockedFor) return json({ error: `尝试过于频繁,请 ${Math.ceil(blockedFor / 60)} 分钟后再试` }, 429);
...@@ -221,20 +227,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -221,20 +227,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
if (!captchaValid(request, body.captcha)) { if (!captchaValid(request, body.captcha)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha }); return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
} }
if (!safeEqual(body.password || "", PASSWORD)) { if (!await verifyPassword(body.password)) {
loginFailed(clientAddress); loginFailed(clientAddress);
return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha }); return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha });
} }
loginAttempts.delete(clientAddress); loginAttempts.delete(clientAddress);
const headers = new Headers(); const headers = new Headers();
headers.append("Set-Cookie", clearCaptcha); 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); return json({ ok: true }, 200, headers);
} }
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) }); 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 (route === "categories") {
if (method === "GET") return json({ categories: await getCategoryNames(), stats: await getCategoryStats() }); 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> {
// 环境变量密码始终作为主密码和找回密码保留。
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) {
// 可选密码文件损坏时,仍允许使用环境变量密码登录和找回。
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 @@ ...@@ -30,6 +30,7 @@
<div class="top-actions"> <div class="top-actions">
<span id="pending-badge" class="pending-badge hidden"></span> <span id="pending-badge" class="pending-badge hidden"></span>
<a class="button ghost" href="/articles/" target="_blank" rel="noopener">查看前台</a> <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> <button id="logout" class="ghost">退出</button>
</div> </div>
</header> </header>
...@@ -95,6 +96,19 @@ ...@@ -95,6 +96,19 @@
</main> </main>
</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="category-modal" class="modal hidden"> <div id="category-modal" class="modal hidden">
<div class="modal-card category-manager"> <div class="modal-card category-manager">
<div class="modal-head"><div><strong>文章分类管理</strong><small>重命名会同步更新该分类下的文章</small></div><button id="close-category-modal" class="ghost small" type="button">关闭</button></div> <div class="modal-head"><div><strong>文章分类管理</strong><small>重命名会同步更新该分类下的文章</small></div><button id="close-category-modal" class="ghost small" 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(), "sumeiqiao-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 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