Commit b8c474b837370d3be9911fba0863024cb108c248
1 parent
8ffb7e2b
Add minimal admin auth gate for /api/admin/* (T6)
Single shared password (.env ADMIN_PASSWORD) + in-memory HttpOnly session cookie, no accounts/roles — MVCP M.1/M.10's real account system supersedes this in period 3. Covers the existing /api/admin/library/* routes (previously unauthenticated, see architecture.md 1.2's tech-debt note) plus the new upload endpoint. admin.html gets a fetch() interceptor that redirects to the new admin-login.html on any 401 from /api/admin/*, rather than patching every call site.
Showing
5 changed files
with
240 additions
and
4 deletions
admin-auth.test.js
0 → 100644
| 1 | +// Integration test for the minimal admin auth gate on /api/admin/* | |
| 2 | +// (dev-plan.md T6). Spawns server.js on a throwaway port, same isolation | |
| 3 | +// approach as uploads-route.test.js. | |
| 4 | +const test = require("node:test"); | |
| 5 | +const assert = require("node:assert/strict"); | |
| 6 | +const { spawn } = require("node:child_process"); | |
| 7 | + | |
| 8 | +const PORT = 19934 + Math.floor(Math.random() * 1000); | |
| 9 | +const BASE = `http://localhost:${PORT}`; | |
| 10 | +const ADMIN_PASSWORD = "test-password-only-for-this-run"; | |
| 11 | + | |
| 12 | +function waitForServer(url, timeoutMs = 10000) { | |
| 13 | + const deadline = Date.now() + timeoutMs; | |
| 14 | + return (async function poll() { | |
| 15 | + while (Date.now() < deadline) { | |
| 16 | + try { | |
| 17 | + await fetch(url); | |
| 18 | + return; | |
| 19 | + } catch { | |
| 20 | + await new Promise((r) => setTimeout(r, 100)); | |
| 21 | + } | |
| 22 | + } | |
| 23 | + throw new Error("server did not come up in time"); | |
| 24 | + })(); | |
| 25 | +} | |
| 26 | + | |
| 27 | +function sessionCookie(loginRes) { | |
| 28 | + const setCookie = loginRes.headers.get("set-cookie") || ""; | |
| 29 | + return setCookie.split(";")[0]; | |
| 30 | +} | |
| 31 | + | |
| 32 | +let child; | |
| 33 | +test.before(async () => { | |
| 34 | + child = spawn(process.execPath, ["server.js"], { | |
| 35 | + cwd: __dirname, | |
| 36 | + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, | |
| 37 | + stdio: "pipe", | |
| 38 | + }); | |
| 39 | + await waitForServer(BASE); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +test.after(() => child.kill()); | |
| 43 | + | |
| 44 | +test("any /api/admin/* route returns 401 without a session cookie", async () => { | |
| 45 | + const res = await fetch(`${BASE}/api/admin/library`, { | |
| 46 | + method: "POST", | |
| 47 | + headers: { "Content-Type": "application/json" }, | |
| 48 | + body: JSON.stringify({ prompt: "x", videoUrl: "http://example.com/x.mp4" }), | |
| 49 | + }); | |
| 50 | + assert.equal(res.status, 401); | |
| 51 | +}); | |
| 52 | + | |
| 53 | +test("logging in with the wrong password is rejected and sets no cookie", async () => { | |
| 54 | + const res = await fetch(`${BASE}/api/admin/login`, { | |
| 55 | + method: "POST", | |
| 56 | + headers: { "Content-Type": "application/json" }, | |
| 57 | + body: JSON.stringify({ password: "definitely-wrong" }), | |
| 58 | + }); | |
| 59 | + assert.equal(res.status, 401); | |
| 60 | + assert.equal(res.headers.get("set-cookie"), null); | |
| 61 | +}); | |
| 62 | + | |
| 63 | +test("logging in with the right password grants a session that unlocks /api/admin/*, and logout revokes it", async () => { | |
| 64 | + const loginRes = await fetch(`${BASE}/api/admin/login`, { | |
| 65 | + method: "POST", | |
| 66 | + headers: { "Content-Type": "application/json" }, | |
| 67 | + body: JSON.stringify({ password: ADMIN_PASSWORD }), | |
| 68 | + }); | |
| 69 | + assert.equal(loginRes.status, 200); | |
| 70 | + const cookie = sessionCookie(loginRes); | |
| 71 | + assert.match(cookie, /^admin_session=.+/); | |
| 72 | + | |
| 73 | + const authedRes = await fetch(`${BASE}/api/admin/library`, { | |
| 74 | + method: "POST", | |
| 75 | + headers: { "Content-Type": "application/json", Cookie: cookie }, | |
| 76 | + body: JSON.stringify({ prompt: "authed test entry", videoUrl: "http://example.com/x.mp4" }), | |
| 77 | + }); | |
| 78 | + assert.equal(authedRes.status, 200); | |
| 79 | + const created = await authedRes.json(); | |
| 80 | + | |
| 81 | + // clean up the entry this test created in the real library.json | |
| 82 | + await fetch(`${BASE}/api/admin/library/${created.id}`, { method: "DELETE", headers: { Cookie: cookie } }); | |
| 83 | + | |
| 84 | + const logoutRes = await fetch(`${BASE}/api/admin/logout`, { method: "POST", headers: { Cookie: cookie } }); | |
| 85 | + assert.equal(logoutRes.status, 200); | |
| 86 | + | |
| 87 | + const afterLogout = await fetch(`${BASE}/api/admin/library`, { | |
| 88 | + method: "POST", | |
| 89 | + headers: { "Content-Type": "application/json", Cookie: cookie }, | |
| 90 | + body: JSON.stringify({ prompt: "should be rejected", videoUrl: "http://example.com/x.mp4" }), | |
| 91 | + }); | |
| 92 | + assert.equal(afterLogout.status, 401); | |
| 93 | +}); | ... | ... |
admin-login.html
0 → 100644
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="UTF-8" /> | |
| 5 | +<title>管理员登录</title> | |
| 6 | +<link rel="stylesheet" href="style.css" /> | |
| 7 | +<style> | |
| 8 | + .login-wrap { max-width: 360px; margin: 15vh auto; padding: 24px; } | |
| 9 | + .login-wrap h1 { font-size: 18px; margin-bottom: 16px; } | |
| 10 | + .login-wrap input { width: 100%; margin-bottom: 12px; } | |
| 11 | + .login-wrap .err { color: #c0392b; font-size: 13px; min-height: 18px; margin-bottom: 8px; } | |
| 12 | +</style> | |
| 13 | +</head> | |
| 14 | +<body> | |
| 15 | +<div class="login-wrap"> | |
| 16 | + <h1>管理员登录</h1> | |
| 17 | + <input type="password" id="password" placeholder="管理员口令" autofocus /> | |
| 18 | + <div class="err" id="err"></div> | |
| 19 | + <button id="loginBtn">登录</button> | |
| 20 | +</div> | |
| 21 | +<script> | |
| 22 | + const params = new URLSearchParams(location.search); | |
| 23 | + const next = params.get("next") || "/admin.html"; | |
| 24 | + | |
| 25 | + async function login() { | |
| 26 | + const password = document.getElementById("password").value; | |
| 27 | + const errBox = document.getElementById("err"); | |
| 28 | + errBox.textContent = ""; | |
| 29 | + try { | |
| 30 | + const res = await fetch("/api/admin/login", { | |
| 31 | + method: "POST", | |
| 32 | + headers: { "Content-Type": "application/json" }, | |
| 33 | + body: JSON.stringify({ password }), | |
| 34 | + }); | |
| 35 | + if (!res.ok) { | |
| 36 | + errBox.textContent = "口令错误"; | |
| 37 | + return; | |
| 38 | + } | |
| 39 | + location.href = next; | |
| 40 | + } catch (e) { | |
| 41 | + errBox.textContent = "登录请求失败: " + e.message; | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + document.getElementById("loginBtn").onclick = login; | |
| 46 | + document.getElementById("password").addEventListener("keydown", (e) => { | |
| 47 | + if (e.key === "Enter") login(); | |
| 48 | + }); | |
| 49 | +</script> | |
| 50 | +</body> | |
| 51 | +</html> | ... | ... |
admin.html
| ... | ... | @@ -80,6 +80,17 @@ |
| 80 | 80 | </main> |
| 81 | 81 | |
| 82 | 82 | <script> |
| 83 | +// Redirect to login whenever any /api/admin/* call comes back 401 (T6 auth | |
| 84 | +// gate) — a single interceptor instead of touching every fetch call site. | |
| 85 | +const _fetch = window.fetch; | |
| 86 | +window.fetch = async (...args) => { | |
| 87 | + const res = await _fetch(...args); | |
| 88 | + if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) { | |
| 89 | + location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname); | |
| 90 | + } | |
| 91 | + return res; | |
| 92 | +}; | |
| 93 | + | |
| 83 | 94 | let library = []; |
| 84 | 95 | let editingId = null; |
| 85 | 96 | let sortKey = "createdAt"; | ... | ... |
server.js
| ... | ... | @@ -6,6 +6,7 @@ const http = require("http"); |
| 6 | 6 | const https = require("https"); |
| 7 | 7 | const fs = require("fs"); |
| 8 | 8 | const path = require("path"); |
| 9 | +const crypto = require("crypto"); | |
| 9 | 10 | |
| 10 | 11 | try { |
| 11 | 12 | process.loadEnvFile(path.join(__dirname, ".env")); |
| ... | ... | @@ -90,6 +91,37 @@ function readJsonBody(req) { |
| 90 | 91 | }); |
| 91 | 92 | } |
| 92 | 93 | |
| 94 | +// Minimal admin auth (dev-plan.md T6): one shared password from .env, an | |
| 95 | +// HttpOnly session cookie, no accounts/roles/DB — the account system in | |
| 96 | +// MVCP M.1/M.10 supersedes this in period 3. Sessions are in-memory only, | |
| 97 | +// so a process restart logs everyone out (acceptable for this stopgap). | |
| 98 | +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; | |
| 99 | +const ADMIN_SESSION_COOKIE = "admin_session"; | |
| 100 | +const ADMIN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, matches MVCP M.10's session TTL | |
| 101 | +const adminSessions = new Map(); // token -> expiresAt | |
| 102 | + | |
| 103 | +function parseCookies(req) { | |
| 104 | + const header = req.headers.cookie || ""; | |
| 105 | + const cookies = {}; | |
| 106 | + for (const pair of header.split(";")) { | |
| 107 | + const idx = pair.indexOf("="); | |
| 108 | + if (idx === -1) continue; | |
| 109 | + cookies[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim()); | |
| 110 | + } | |
| 111 | + return cookies; | |
| 112 | +} | |
| 113 | + | |
| 114 | +function isAdminAuthed(req) { | |
| 115 | + const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; | |
| 116 | + if (!token) return false; | |
| 117 | + const expiresAt = adminSessions.get(token); | |
| 118 | + if (!expiresAt || expiresAt < Date.now()) { | |
| 119 | + adminSessions.delete(token); | |
| 120 | + return false; | |
| 121 | + } | |
| 122 | + return true; | |
| 123 | +} | |
| 124 | + | |
| 93 | 125 | function h3ConsoleFetch(pathAndQuery, options) { |
| 94 | 126 | return new Promise((resolve, reject) => { |
| 95 | 127 | const proxyReq = http.request( |
| ... | ... | @@ -469,6 +501,46 @@ http |
| 469 | 501 | |
| 470 | 502 | const pathname = req.url.split("?")[0]; |
| 471 | 503 | |
| 504 | + if (pathname === "/api/admin/login" && req.method === "POST") { | |
| 505 | + readJsonBody(req) | |
| 506 | + .then((body) => { | |
| 507 | + if (!ADMIN_PASSWORD || body.password !== ADMIN_PASSWORD) { | |
| 508 | + res.writeHead(401, { "Content-Type": "application/json" }); | |
| 509 | + res.end(JSON.stringify({ error: "invalid password" })); | |
| 510 | + return; | |
| 511 | + } | |
| 512 | + const token = crypto.randomUUID(); | |
| 513 | + adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS); | |
| 514 | + res.writeHead(200, { | |
| 515 | + "Content-Type": "application/json", | |
| 516 | + "Set-Cookie": `${ADMIN_SESSION_COOKIE}=${token}; HttpOnly; Path=/; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; SameSite=Lax`, | |
| 517 | + }); | |
| 518 | + res.end(JSON.stringify({ ok: true })); | |
| 519 | + }) | |
| 520 | + .catch(() => { | |
| 521 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 522 | + res.end(JSON.stringify({ error: "invalid JSON body" })); | |
| 523 | + }); | |
| 524 | + return; | |
| 525 | + } | |
| 526 | + | |
| 527 | + if (pathname === "/api/admin/logout" && req.method === "POST") { | |
| 528 | + const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; | |
| 529 | + if (token) adminSessions.delete(token); | |
| 530 | + res.writeHead(200, { | |
| 531 | + "Content-Type": "application/json", | |
| 532 | + "Set-Cookie": `${ADMIN_SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`, | |
| 533 | + }); | |
| 534 | + res.end(JSON.stringify({ ok: true })); | |
| 535 | + return; | |
| 536 | + } | |
| 537 | + | |
| 538 | + if (pathname.startsWith("/api/admin/") && !isAdminAuthed(req)) { | |
| 539 | + res.writeHead(401, { "Content-Type": "application/json" }); | |
| 540 | + res.end(JSON.stringify({ error: "unauthorized" })); | |
| 541 | + return; | |
| 542 | + } | |
| 543 | + | |
| 472 | 544 | if (pathname === "/api/library") { |
| 473 | 545 | const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; |
| 474 | 546 | const lib = getLibrary().slice(); | ... | ... |
uploads-route.test.js
| ... | ... | @@ -18,6 +18,8 @@ const TINY_VALID_PNG = Buffer.from( |
| 18 | 18 | |
| 19 | 19 | const PORT = 18934 + Math.floor(Math.random() * 1000); |
| 20 | 20 | const BASE = `http://localhost:${PORT}`; |
| 21 | +const ADMIN_PASSWORD = "test-password-only-for-this-run"; | |
| 22 | +let cookie; | |
| 21 | 23 | |
| 22 | 24 | function waitForServer(url, timeoutMs = 10000) { |
| 23 | 25 | const deadline = Date.now() + timeoutMs; |
| ... | ... | @@ -41,10 +43,17 @@ test.before(async () => { |
| 41 | 43 | // H3_CONSOLE_BASE cleared: this test only exercises the upload route, |
| 42 | 44 | // no need for the spawned copy to also hit the real H3 Console / |
| 43 | 45 | // race the live pm2 instance on library.json's periodic sync. |
| 44 | - env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "" }, | |
| 46 | + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, | |
| 45 | 47 | stdio: "pipe", |
| 46 | 48 | }); |
| 47 | 49 | await waitForServer(BASE); |
| 50 | + | |
| 51 | + const loginRes = await fetch(`${BASE}/api/admin/login`, { | |
| 52 | + method: "POST", | |
| 53 | + headers: { "Content-Type": "application/json" }, | |
| 54 | + body: JSON.stringify({ password: ADMIN_PASSWORD }), | |
| 55 | + }); | |
| 56 | + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0]; | |
| 48 | 57 | }); |
| 49 | 58 | |
| 50 | 59 | test.after(() => { |
| ... | ... | @@ -59,7 +68,7 @@ test("uploading a real PNG returns a URL that GET /uploads/* serves back with th |
| 59 | 68 | const fd = new FormData(); |
| 60 | 69 | fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png"); |
| 61 | 70 | |
| 62 | - const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }); | |
| 71 | + const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 63 | 72 | assert.equal(uploadRes.status, 200); |
| 64 | 73 | const { url } = await uploadRes.json(); |
| 65 | 74 | assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.png$/); |
| ... | ... | @@ -74,7 +83,7 @@ test("a zip disguised as .jpg is rejected regardless of filename/content-type cl |
| 74 | 83 | const fd = new FormData(); |
| 75 | 84 | fd.append("file", new Blob([zipMagic], { type: "image/jpeg" }), "totally-a-photo.jpg"); |
| 76 | 85 | |
| 77 | - const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }); | |
| 86 | + const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 78 | 87 | assert.equal(res.status, 400); |
| 79 | 88 | }); |
| 80 | 89 | |
| ... | ... | @@ -89,7 +98,7 @@ test("a file over 8MB is rejected", async () => { |
| 89 | 98 | fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg"); |
| 90 | 99 | |
| 91 | 100 | await assert.rejects( |
| 92 | - () => fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }), | |
| 101 | + () => fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }), | |
| 93 | 102 | /fetch failed|ECONNRESET|terminated/i |
| 94 | 103 | ); |
| 95 | 104 | }); | ... | ... |