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,6 +80,17 @@ | ||
| 80 | </main> | 80 | </main> |
| 81 | 81 | ||
| 82 | <script> | 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 | let library = []; | 94 | let library = []; |
| 84 | let editingId = null; | 95 | let editingId = null; |
| 85 | let sortKey = "createdAt"; | 96 | let sortKey = "createdAt"; |
server.js
| @@ -6,6 +6,7 @@ const http = require("http"); | @@ -6,6 +6,7 @@ const http = require("http"); | ||
| 6 | const https = require("https"); | 6 | const https = require("https"); |
| 7 | const fs = require("fs"); | 7 | const fs = require("fs"); |
| 8 | const path = require("path"); | 8 | const path = require("path"); |
| 9 | +const crypto = require("crypto"); | ||
| 9 | 10 | ||
| 10 | try { | 11 | try { |
| 11 | process.loadEnvFile(path.join(__dirname, ".env")); | 12 | process.loadEnvFile(path.join(__dirname, ".env")); |
| @@ -90,6 +91,37 @@ function readJsonBody(req) { | @@ -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 | function h3ConsoleFetch(pathAndQuery, options) { | 125 | function h3ConsoleFetch(pathAndQuery, options) { |
| 94 | return new Promise((resolve, reject) => { | 126 | return new Promise((resolve, reject) => { |
| 95 | const proxyReq = http.request( | 127 | const proxyReq = http.request( |
| @@ -469,6 +501,46 @@ http | @@ -469,6 +501,46 @@ http | ||
| 469 | 501 | ||
| 470 | const pathname = req.url.split("?")[0]; | 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 | if (pathname === "/api/library") { | 544 | if (pathname === "/api/library") { |
| 473 | const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; | 545 | const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; |
| 474 | const lib = getLibrary().slice(); | 546 | const lib = getLibrary().slice(); |
uploads-route.test.js
| @@ -18,6 +18,8 @@ const TINY_VALID_PNG = Buffer.from( | @@ -18,6 +18,8 @@ const TINY_VALID_PNG = Buffer.from( | ||
| 18 | 18 | ||
| 19 | const PORT = 18934 + Math.floor(Math.random() * 1000); | 19 | const PORT = 18934 + Math.floor(Math.random() * 1000); |
| 20 | const BASE = `http://localhost:${PORT}`; | 20 | const BASE = `http://localhost:${PORT}`; |
| 21 | +const ADMIN_PASSWORD = "test-password-only-for-this-run"; | ||
| 22 | +let cookie; | ||
| 21 | 23 | ||
| 22 | function waitForServer(url, timeoutMs = 10000) { | 24 | function waitForServer(url, timeoutMs = 10000) { |
| 23 | const deadline = Date.now() + timeoutMs; | 25 | const deadline = Date.now() + timeoutMs; |
| @@ -41,10 +43,17 @@ test.before(async () => { | @@ -41,10 +43,17 @@ test.before(async () => { | ||
| 41 | // H3_CONSOLE_BASE cleared: this test only exercises the upload route, | 43 | // H3_CONSOLE_BASE cleared: this test only exercises the upload route, |
| 42 | // no need for the spawned copy to also hit the real H3 Console / | 44 | // no need for the spawned copy to also hit the real H3 Console / |
| 43 | // race the live pm2 instance on library.json's periodic sync. | 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 | stdio: "pipe", | 47 | stdio: "pipe", |
| 46 | }); | 48 | }); |
| 47 | await waitForServer(BASE); | 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 | test.after(() => { | 59 | test.after(() => { |
| @@ -59,7 +68,7 @@ test("uploading a real PNG returns a URL that GET /uploads/* serves back with th | @@ -59,7 +68,7 @@ test("uploading a real PNG returns a URL that GET /uploads/* serves back with th | ||
| 59 | const fd = new FormData(); | 68 | const fd = new FormData(); |
| 60 | fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png"); | 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 | assert.equal(uploadRes.status, 200); | 72 | assert.equal(uploadRes.status, 200); |
| 64 | const { url } = await uploadRes.json(); | 73 | const { url } = await uploadRes.json(); |
| 65 | assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.png$/); | 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,7 +83,7 @@ test("a zip disguised as .jpg is rejected regardless of filename/content-type cl | ||
| 74 | const fd = new FormData(); | 83 | const fd = new FormData(); |
| 75 | fd.append("file", new Blob([zipMagic], { type: "image/jpeg" }), "totally-a-photo.jpg"); | 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 | assert.equal(res.status, 400); | 87 | assert.equal(res.status, 400); |
| 79 | }); | 88 | }); |
| 80 | 89 | ||
| @@ -89,7 +98,7 @@ test("a file over 8MB is rejected", async () => { | @@ -89,7 +98,7 @@ test("a file over 8MB is rejected", async () => { | ||
| 89 | fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg"); | 98 | fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg"); |
| 90 | 99 | ||
| 91 | await assert.rejects( | 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 | /fetch failed|ECONNRESET|terminated/i | 102 | /fetch failed|ECONNRESET|terminated/i |
| 94 | ); | 103 | ); |
| 95 | }); | 104 | }); |