Stage 2: integrate administrative login and protect dashboard

This commit is contained in:
2026-10-26 14:31:07 +00:00
parent d11c4a868e
commit 848728584e
4 changed files with 535 additions and 5 deletions
+52 -5
View File
@@ -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
+44
View File
@@ -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 (
<div>
@@ -57,6 +83,16 @@ const StoragePage = (props) => {
style={{ width: "90%", margin: "auto" }}
>
<div className="js-enabled govuk-template__body govuk-frontend-supported">
<div className="govuk-!-margin-top-4 govuk-!-text-align-right">
<button
type="button"
className="govuk-button govuk-button--secondary"
onClick={handleSignOut}
disabled={signingOut}
>
{signingOut ? "Signing out..." : "Sign out"}
</button>
</div>
<div className="govuk-tabs" data-module="govuk-tabs">
<h2 className="govuk-tabs__title">Contents</h2>
@@ -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())
+155
View File
@@ -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 (
<div>
<Head>
<title>Administrative sign in</title>
</Head>
<div id="page_wrapper">
<CookieBanner />
<Header />
<main
className="govuk-width-container govuk-main-wrapper govuk-!-padding-bottom-9"
id="main-content"
>
<div className="govuk-grid-row">
<div className="govuk-grid-column-two-thirds">
<h1 className="govuk-heading-l">
Administrative sign in
</h1>
{error && (
<div
className="govuk-error-summary"
role="alert"
aria-labelledby="admin-login-error-title"
tabIndex={-1}
>
<h2
className="govuk-error-summary__title"
id="admin-login-error-title"
>
There is a problem
</h2>
<div className="govuk-error-summary__body">
<p>{error}</p>
</div>
</div>
)}
<form onSubmit={handleSubmit} noValidate>
<div
className={`govuk-form-group${
error ? " govuk-form-group--error" : ""
}`}
>
<label
className="govuk-label govuk-label--m"
htmlFor="admin-passphrase"
>
Password
</label>
{error && (
<p
id="admin-passphrase-error"
className="govuk-error-message"
>
<span className="govuk-visually-hidden">
Error:
</span>{" "}
{error}
</p>
)}
<input
className={`govuk-input${
error ? " govuk-input--error" : ""
}`}
id="admin-passphrase"
name="passphrase"
type="password"
autoComplete="current-password"
value={passphrase}
onChange={(event) =>
setPassphrase(event.target.value)
}
aria-describedby={
error
? "admin-passphrase-error"
: undefined
}
aria-invalid={error ? "true" : undefined}
disabled={submitting}
required
/>
</div>
<button
className="govuk-button"
type="submit"
disabled={submitting}
>
{submitting ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
</div>
</main>
<Footer />
</div>
</div>
);
};
export const getServerSideProps = async ({ req }) => {
const adminSession = readAdminSessionCookie(req);
if (validateAdminSession(adminSession)) {
return {
redirect: { destination: "/admin", permanent: false }
};
}
return { props: {} };
};
export default AdminLoginPage;
@@ -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);
});
}