From 848728584eb00c56d8f9539b933b900717143a99 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 26 Oct 2026 14:31:07 +0000 Subject: [PATCH] Stage 2: integrate administrative login and protect dashboard --- docs/administrative-authentication.md | 57 +++- pages/admin/index.js | 44 +++ pages/admin/login.js | 155 ++++++++++ .../phase21/admin-login-integration.test.cjs | 284 ++++++++++++++++++ 4 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 pages/admin/login.js create mode 100644 tests/phase21/admin-login-integration.test.cjs diff --git a/docs/administrative-authentication.md b/docs/administrative-authentication.md index e71f25a4..e9f8c871 100644 --- a/docs/administrative-authentication.md +++ b/docs/administrative-authentication.md @@ -1,11 +1,22 @@ # Administrative authentication foundation -## Stage 1 scope +## Integrated administrative sign-in -This foundation is independent of NextAuth, Prisma, CRM, the existing `/admin` -page restriction, and draft deletion. It provides shared-credential login, -logout, and session-status API handlers only. It is not wired into `/admin` or -any storage API in this stage. +Administrators sign in at `/admin/login` using the shared credential configured +as `ADMIN_AUTH_SECRET`. The page submits the credential directly to +`POST /api/admin/auth/login`; it does not place the credential in a URL or store +it in browser storage. + +Successful authentication creates the administrative session cookie and sends +the browser to `/admin`. The `/admin` server-side loader validates that cookie +before loading administrative data. A valid session is not sufficient by +itself: the existing `/admin` IP allowlist remains an additional restriction, +and a valid administrator outside the allowed network is sent to `/403`. + +The administrative interface provides a **Sign out** control which calls +`POST /api/admin/auth/logout`, clears the cookie, and returns the browser to +`/admin/login`. Sessions expire after 20 minutes and expired, invalid, or +missing sessions are redirected to the login page. ## Required server-side configuration @@ -38,6 +49,42 @@ administrative secrets fail closed. continue verifying signature and expiry on every request. - This is shared-credential authentication. A valid token proves knowledge of the shared credential, not the identity of a named administrator. +- Protecting `/admin` prevents its server-side Prisma, storage, and CRM data + loading from running without a valid administrative session. It does not by + itself protect browser-addressable API routes. + +## Administrative API protection limitations + +The current administrative UI directly uses API routes that do not yet enforce +the administrative session, including: + +- `/api/admin/getnewappeals_api` +- `/api/admin/getlatestdocuments_api` +- `/api/admin/getStatusCountsByAppeal_api` +- `/api/admin/getStatusCountsByAppealAndLPA_api` + +The appeals and documents routes expose paging operations used after the page +has loaded. The status routes expose administrative dashboard reads. These +routes retain their existing relay token and HMAC behaviour but have no +independent administrative-session authorization. A later authorised stage +must enforce the administrative session on privileged API routes and account +for both browser calls and the existing server-side bootstrap callers. + +Storage download, representation editing, queue actions, and draft deletion +also retain their existing authorization behaviour in this stage. In +particular, the original cross-container deletion risk is not remediated by the +administrative page login and remains a separate mitigation boundary. + +Directly observed storage routes include: + +- `/api/file/getrepsblobproxy`, which forwards a caller-supplied container using + server relay credentials and path signing. +- `/api/file/editRepJson`, which performs a direct blob mutation and has no + visible administrative-session or HMAC check. +- `/api/file/downloadblob` and `/api/file/createrepcompletemessage_api`, which + enforce existing path-HMAC checks but not administrative authorization. +- `/api/file/deleteblobcase` and `/api/file/deleteblobrep`, whose existing + cross-container deletion behaviour is intentionally unchanged in this stage. ## Deployment requirements before production use diff --git a/pages/admin/index.js b/pages/admin/index.js index 4f06b04b..ed58e8d4 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -1,6 +1,7 @@ // pages/admin/storage.js import { useState } from "react"; import Head from "next/head"; +import { useRouter } from "next/router"; import CookieBanner from "../../components/cookieBanner"; import Header from "../../components/header"; import Footer from "../../components/footer"; @@ -29,6 +30,10 @@ import { setSearchResults } from "../../store/searchOutput/action"; import { setCurrentPage } from "../../store/currentView/action"; +import { + readAdminSessionCookie, + validateAdminSession +} from "../../lib/admin/adminAuth"; const StoragePage = (props) => { const { @@ -42,6 +47,27 @@ const StoragePage = (props) => { statusLPAObj } = props; const [whichTab, setWhichTab] = useState("appealDashboard"); + const [signingOut, setSigningOut] = useState(false); + const router = useRouter(); + + const handleSignOut = async () => { + setSigningOut(true); + + try { + const response = await fetch("/api/admin/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin" + }); + + if (response.ok) { + await router.replace("/admin/login"); + return; + } + } finally { + setSigningOut(false); + } + }; return (
@@ -57,6 +83,16 @@ const StoragePage = (props) => { style={{ width: "90%", margin: "auto" }} >
+
+ +

Contents

@@ -199,6 +235,14 @@ const StoragePage = (props) => { export const getServerSideProps = wrapper.getServerSideProps( (store) => async (ctx) => { const { query, req, res } = ctx; + const adminSession = readAdminSessionCookie(req); + + if (!validateAdminSession(adminSession)) { + return { + redirect: { destination: "/admin/login", permanent: false } + }; + } + // IP restriction check const ALLOWED_IPS = process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(",").map((ip) => ip.trim()) diff --git a/pages/admin/login.js b/pages/admin/login.js new file mode 100644 index 00000000..9a9029ff --- /dev/null +++ b/pages/admin/login.js @@ -0,0 +1,155 @@ +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import CookieBanner from "../../components/cookieBanner"; +import Footer from "../../components/footer"; +import Header from "../../components/header"; +import { + readAdminSessionCookie, + validateAdminSession +} from "../../lib/admin/adminAuth"; + +const AdminLoginPage = () => { + const router = useRouter(); + const [passphrase, setPassphrase] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + setError(""); + setSubmitting(true); + + try { + const response = await fetch("/api/admin/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ passphrase }) + }); + + if (!response.ok) { + setError("Sign in failed. Check the details and try again."); + return; + } + + await router.replace("/admin"); + } catch (_error) { + setError("Sign in failed. Check the details and try again."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + Administrative sign in + +
+ +
+
+
+
+

+ Administrative sign in +

+ + {error && ( +
+

+ There is a problem +

+
+

{error}

+
+
+ )} + +
+
+ + {error && ( +

+ + Error: + {" "} + {error} +

+ )} + + setPassphrase(event.target.value) + } + aria-describedby={ + error + ? "admin-passphrase-error" + : undefined + } + aria-invalid={error ? "true" : undefined} + disabled={submitting} + required + /> +
+ +
+
+
+
+
+
+ ); +}; + +export const getServerSideProps = async ({ req }) => { + const adminSession = readAdminSessionCookie(req); + + if (validateAdminSession(adminSession)) { + return { + redirect: { destination: "/admin", permanent: false } + }; + } + + return { props: {} }; +}; + +export default AdminLoginPage; \ No newline at end of file diff --git a/tests/phase21/admin-login-integration.test.cjs b/tests/phase21/admin-login-integration.test.cjs new file mode 100644 index 00000000..de1cf8ee --- /dev/null +++ b/tests/phase21/admin-login-integration.test.cjs @@ -0,0 +1,284 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const rootDir = path.resolve(__dirname, "..", ".."); +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const readSource = (relativePath) => + fs.readFileSync(path.join(rootDir, relativePath), "utf8"); + +const loadAdminPageLoader = (overrides = {}) => { + const filePath = path.join(rootDir, "pages", "admin", "index.js"); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.slice(source.indexOf("export const getServerSideProps")); + source = source.replace( + /export\s+const\s+getServerSideProps\s*=\s*/, + "const getServerSideProps = " + ); + source = source.replace(/const mapDispatchToProps[\s\S]*$/, ""); + source += "\nmodule.exports = { getServerSideProps };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + wrapper: { + getServerSideProps: (factory) => async (ctx) => { + const store = { + dispatch: () => {}, + getState: () => ({}) + }; + return factory(store)(ctx); + } + }, + readAdminSessionCookie: () => "valid-token", + validateAdminSession: () => true, + fetchAdminStorageData: async () => ({ + data: [], + userData: [], + repCompleteCount: 0 + }), + getNewAppeals: async () => ({ value: [] }), + getStatusByAppeal: async () => [], + getStatusByAppealLPA: async () => [], + getSearchDetails: async () => ({}), + setSearchResults: () => ({}), + setSearchDetails: () => ({}), + setCurrentPage: () => ({}), + process: { + env: { + ALLOWED_IPS: "203.0.113.10" + } + }, + ...overrides + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const loadAdminLoginLoader = (overrides = {}) => { + const filePath = path.join(rootDir, "pages", "admin", "login.js"); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.slice(source.indexOf("export const getServerSideProps")); + source = source.replace( + /export\s+const\s+getServerSideProps\s*=\s*/, + "const getServerSideProps = " + ); + source = source.replace(/export default[\s\S]*$/, ""); + source += "\nmodule.exports = { getServerSideProps };\n"; + + const context = { + module: { exports: {} }, + exports: {}, + readAdminSessionCookie: () => null, + validateAdminSession: () => false, + ...overrides + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const createAdminContext = (ip = "203.0.113.10") => ({ + query: {}, + req: { + headers: { "x-forwarded-for": ip }, + socket: { remoteAddress: ip } + }, + res: {} +}); + +test("unauthenticated admin access redirects before privileged data loads", async () => { + let dataLoads = 0; + const mod = loadAdminPageLoader({ + readAdminSessionCookie: () => null, + validateAdminSession: () => false, + fetchAdminStorageData: async () => { + dataLoads += 1; + return { data: [], userData: [], repCompleteCount: 0 }; + }, + getNewAppeals: async () => { + dataLoads += 1; + return { value: [] }; + } + }); + + const result = await mod.getServerSideProps(createAdminContext()); + + assert.strictEqual(result.redirect.destination, "/admin/login"); + assert.strictEqual(result.redirect.permanent, false); + assert.strictEqual(dataLoads, 0); +}); + +test("invalid or expired administrative sessions cannot access admin", async () => { + for (const token of ["invalid-token", "expired-token"]) { + const mod = loadAdminPageLoader({ + readAdminSessionCookie: () => token, + validateAdminSession: () => false + }); + const result = await mod.getServerSideProps(createAdminContext()); + assert.strictEqual(result.redirect.destination, "/admin/login"); + } +}); + +test("valid administrative session and allowed IP load existing admin data", async () => { + const calls = []; + const mod = loadAdminPageLoader({ + fetchAdminStorageData: async () => { + calls.push("storage"); + return { data: ["storage"], userData: ["user"], repCompleteCount: 1 }; + }, + getNewAppeals: async () => { + calls.push("appeals"); + return { value: [] }; + }, + getStatusByAppeal: async () => { + calls.push("status"); + return ["status"]; + }, + getStatusByAppealLPA: async () => { + calls.push("lpa-status"); + return ["lpa-status"]; + }, + getSearchDetails: async () => { + calls.push("search-details"); + return {}; + } + }); + + const result = await mod.getServerSideProps(createAdminContext()); + + assert.deepStrictEqual(calls, [ + "storage", + "appeals", + "status", + "lpa-status", + "search-details" + ]); + assert.strictEqual(result.props.repCompleteCount, 1); +}); + +test("valid administrative session and disallowed IP retains 403 redirect", async () => { + let dataLoads = 0; + const mod = loadAdminPageLoader({ + fetchAdminStorageData: async () => { + dataLoads += 1; + return { data: [], userData: [], repCompleteCount: 0 }; + } + }); + + const result = await mod.getServerSideProps( + createAdminContext("198.51.100.25") + ); + + assert.strictEqual(result.redirect.destination, "/403"); + assert.strictEqual(dataLoads, 0); +}); + +test("missing administrative configuration fails closed at the page boundary", async () => { + const mod = loadAdminPageLoader({ validateAdminSession: () => false }); + const result = await mod.getServerSideProps(createAdminContext()); + assert.strictEqual(result.redirect.destination, "/admin/login"); +}); + +test("login page remains available for missing or invalid sessions", async () => { + const mod = loadAdminLoginLoader(); + const result = await mod.getServerSideProps({ req: { headers: {} } }); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(result)), { props: {} }); +}); + +test("login page redirects an already authenticated administrator to admin", async () => { + const mod = loadAdminLoginLoader({ + readAdminSessionCookie: () => "valid-token", + validateAdminSession: () => true + }); + const result = await mod.getServerSideProps({ req: { headers: {} } }); + + assert.strictEqual(result.redirect.destination, "/admin"); + assert.strictEqual(result.redirect.permanent, false); +}); + +test("login page uses the existing login API and generic error handling", () => { + const source = readSource("pages/admin/login.js"); + + assert.match(source, /fetch\("\/api\/admin\/auth\/login"/); + assert.match(source, /method:\s*"POST"/); + assert.match(source, /JSON\.stringify\(\{ passphrase \}\)/); + assert.match(source, /type="password"/); + assert.match(source, /htmlFor="admin-passphrase"/); + assert.match(source, /Sign in failed\. Check the details and try again\./); + assert.match(source, /router\.replace\("\/admin"\)/); + assert.doesNotMatch(source, /localStorage|sessionStorage/); +}); + +test("admin logout uses the existing endpoint and returns to login", () => { + const source = readSource("pages/admin/index.js"); + + assert.match(source, /fetch\("\/api\/admin\/auth\/logout"/); + assert.match(source, /method:\s*"POST"/); + assert.match(source, /router\.replace\("\/admin\/login"\)/); + assert.match(source, />\s*\{signingOut \? "Signing out\.\.\." : "Sign out"\}/); + assert.doesNotMatch(source, /document\.cookie/); +}); + +test("admin login integration adds no NextAuth, Prisma, or CRM authentication dependency", () => { + for (const relativePath of [ + "pages/admin/login.js", + "lib/admin/adminAuth.js", + "pages/api/admin/auth/login.js", + "pages/api/admin/auth/logout.js", + "pages/api/admin/auth/session.js" + ]) { + const source = readSource(relativePath); + assert.doesNotMatch(source, /next-auth|@prisma|Prisma|getPortalLogin|CRM/i); + } +}); + +test("existing signing and document service files remain unchanged", () => { + const protectedPaths = [ + "pages/api/endpoint/gethash_api.js", + "actions/services/documentDirectService.js" + ]; + const changedPaths = require("child_process") + .execFileSync("git", ["diff", "--name-only", "HEAD"], { + cwd: rootDir, + encoding: "utf8" + }) + .trim() + .split("\n") + .filter(Boolean); + + for (const protectedPath of protectedPaths) { + assert.ok(!changedPaths.includes(protectedPath)); + } +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Administrative login integration tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} \ No newline at end of file