Stage 2: integrate administrative login and protect dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user