Enforce admin login: login, first-run setup, proxy and access checks
Completes securing the web front end (vrek gol-wqf95dq, iss-r5vrjx7). - /login: Server Action with a generic error, same-site-only redirect back to ?next=, and throttling of failed logins (10 per address and 100 overall per 15 min). The header shows "Signed in as" with Sign out (iss-nj9wmwp). - First run with no admin: instrumentation prints a one-time setup code, shared with the app through globalThis. /setup requires it, and 5 wrong codes rotate it. "Skip for now" runs unsecured for the browser session behind a red warning banner on every page (iss-9nxdndr). - src/proxy.ts: optimistic redirects to /login or /setup, 401 for the API, and the 12 h sliding session refresh. requirePageAccess() and apiAccessDenied() re-check in the page and all 6 route handlers (iss-76d5wrb). - An expired session now shows "Your session has ended" instead of the camera-login form. - README documents in-app setup, skipping and signing in. Verified with unit tests (383, 99.9% line coverage), end to end against `next start`, and manually in a browser by the user. Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
462141aa35
commit
5d181f3b3b
@ -338,3 +338,25 @@
|
||||
{"id":"evt-3zd0afwt73jz","type":"edge.added","subject":"ver-9h5hthr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-9h5hthr","to":"iss-e27nb70"},"at":"2026-09-19T14:43:48.424Z","parents":["evt-vmhgde1h8656"],"hash":"54f3637661f1a3dbefd062c1c7a7cdae0485735d6e848e425265fed528906ee9"}
|
||||
{"id":"evt-ys0ns2v7xa4g","type":"verification.recorded","subject":"ver-9h5hthr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/session-token.test.ts and src/lib/session.test.ts, 29 tests; 100% statements, branches, functions and lines for both modules (next/headers replaced by an in-memory cookie jar). Full suite 281/281, coverage 99.81%, tsc, eslint and next build clean, 2026-09-19. Nothing calls these yet: login, setup and enforcement (iss-nj9wmwp, iss-9nxdndr, iss-76d5wrb) wire them in."},"at":"2026-09-19T14:43:48.425Z","parents":["evt-3zd0afwt73jz"],"hash":"68a859ece84f4bd548a2e8590a4526b302e661785bd8f2428adcb9caf59cb5a0"}
|
||||
{"id":"evt-gxsrqxkcrrtn","type":"node.status_changed","subject":"iss-e27nb70","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T14:43:49.506Z","parents":["evt-ys0ns2v7xa4g"],"hash":"2a55c37b48d5a13eb0bacb0e76956f129d58b045bfb92b06092f572b607d1c19"}
|
||||
{"id":"evt-40tzp9w71pwf","type":"node.created","subject":"ver-pttw4js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"/login (a Server Action through useActionState) signs in with a generic error on failure, redirects only to same-site paths, and throttles failures (10 per client, 100 overall, per 15 min, keyed on x-forwarded-for, with a client-count cap); the sign-out form in the header clears the session; /login redirects to /setup with no admin and onward when already signed in.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:01.115Z","parents":["evt-gxsrqxkcrrtn"],"hash":"b58d0c7f31d5318cf00e87ed22960fbeaba9bb756e3724ee60cbcce63a1d3af9"}
|
||||
{"id":"evt-pghp99arbst3","type":"edge.added","subject":"ver-pttw4js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-pttw4js","to":"iss-nj9wmwp"},"at":"2026-09-19T14:54:01.121Z","parents":["evt-40tzp9w71pwf"],"hash":"3a3a5aa69c59bcb2ee8a2fa07b7e8e25e075f6dbd00badd169493dd7dc354fde"}
|
||||
{"id":"evt-3xm5jkh26xd1","type":"verification.recorded","subject":"ver-pttw4js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Unit: src/app/auth-actions.test.ts (login, logout), src/lib/login-throttle.test.ts, src/lib/auth-shared.test.ts (safeNextPath: //, /\\, absolute URL, control chars), src/app/auth-pages.test.tsx, src/app/auth-forms.test.tsx, src/app/security-bar.test.tsx. End to end against `next start` with a temp ADMIN_AUTH_FILE: /login → 307 /setup with no admin; the header shows 'Signed in as admin' with a valid session. 2026-09-19. Not exercised: submitting the login form in a real browser (a Server Action round trip); tracked as pending on the parent iss-r5vrjx7."},"at":"2026-09-19T14:54:01.122Z","parents":["evt-pghp99arbst3"],"hash":"6e0fc6b4722975520531a71446ff457770e3bbd2b1a8e77f3ebd289c27f8bc4f"}
|
||||
{"id":"evt-8wfcr54fxde3","type":"node.created","subject":"ver-q1ae1wj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"With no admin: the setup code is printed once at startup (instrumentation) and /setup reuses it (globalThis); /setup requires the code (constant-time; rotated after 5 wrong), validates the username, password and confirmation before spending an attempt, creates the admin, signs in, and clears the code and skip cookie; skipping sets a browser-session cookie; a red banner shows on every page while no admin exists.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:05.126Z","parents":["evt-3xm5jkh26xd1"],"hash":"e56458fbd7adc9b1ee912a5dbde6eb274874cb9456b06cebfcdae2246abe6758"}
|
||||
{"id":"evt-4z6vesr3k55h","type":"edge.added","subject":"ver-q1ae1wj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-q1ae1wj","to":"iss-9nxdndr"},"at":"2026-09-19T14:54:05.128Z","parents":["evt-8wfcr54fxde3"],"hash":"9301574565238450cc903551cce08946cb5727a0882d6ba8aafe473ede1da38a"}
|
||||
{"id":"evt-2fxckhhhs7mf","type":"verification.recorded","subject":"ver-q1ae1wj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Unit: src/lib/setup-code.test.ts, src/instrumentation.test.ts, src/app/auth-actions.test.ts (setupAdmin, skipSetup incl. an EEXIST race), src/app/auth-pages.test.tsx, src/app/security-bar.test.tsx. End to end with `next start`: exactly 1 'one-time code' block in the server log after start and still 1 after two GET /setup (so the code is shared between instrumentation and app bundles); GET / → 307 /setup; with the skip cookie → 200; the banner 'Not secured:' is rendered; with an admin, no code is printed and /setup → 307 /login. README documents the flow. 2026-09-19. Not exercised: submitting the setup and skip forms in a real browser; pending on iss-r5vrjx7."},"at":"2026-09-19T14:54:05.129Z","parents":["evt-4z6vesr3k55h"],"hash":"442901d20c4da3b276bab6966bb9f5d60ea14004e1fadb0766a7082dcb7a348a"}
|
||||
{"id":"evt-arehf43fcbj4","type":"node.created","subject":"ver-bj79asq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"src/proxy.ts: with an admin, pages without a valid session → /login?next=… (query kept), /api/* → 401 JSON, and a valid session passes and slides when older than 5 min; with no admin, the API is open and pages go to /setup unless skipped. Authoritative checks: requirePageAccess() in page.tsx and apiAccessDenied() first in all 6 route handlers.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:09.877Z","parents":["evt-2fxckhhhs7mf"],"hash":"e04de035e56bb4780c54910eb8c21dabec05bbfb74162e8eab97174dea90e3ee"}
|
||||
{"id":"evt-c5zqnqn59ad1","type":"edge.added","subject":"ver-bj79asq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-bj79asq","to":"iss-76d5wrb"},"at":"2026-09-19T14:54:09.879Z","parents":["evt-arehf43fcbj4"],"hash":"ff647282511532f16b85469596b5268d176e75b87be5d77a61e2b6b8b09bb28e"}
|
||||
{"id":"evt-dyb79cnf4jpa","type":"verification.recorded","subject":"ver-bj79asq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Unit: src/proxy.test.ts (14, real admin file, NextRequest; caught and fixed a bug where /?refresh=500 lost its query in ?next=), src/lib/access.test.ts, src/app/api/access.test.ts (each of the 6 handlers returns 401 and touches no camera or store code when signed out). End to end with `next start` + admin: GET /?refresh=500 → 307 /login?next=%2F%3Frefresh%3D500; the skip cookie doesn't bypass; API without a session → 401 {\"error\":\"Sign in required\"}; forged cookie → 401; valid session → 200 (API) and 200 (page); a 10-minute-old session gets Set-Cookie with a new token, Expires +12h, HttpOnly, SameSite=lax. Build lists 'ƒ Proxy (Middleware)'. Full suite 382/382, coverage 99.86%, tsc, eslint and build clean, 2026-09-19."},"at":"2026-09-19T14:54:09.880Z","parents":["evt-c5zqnqn59ad1"],"hash":"3a47fb0141472a72df76102f88cc73069f1d1806d5361ec681bbea98db503460"}
|
||||
{"id":"evt-1frkwn4trvyt","type":"node.created","subject":"ver-qz5sev9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser: complete /setup with the console code, sign out, sign in (including a wrong password and a redirect back to ?next=), and skip setup to see the banner.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:13.002Z","parents":["evt-dyb79cnf4jpa"],"hash":"4b5037365460d9462c678d505c391ee270def03792dc041796d88dae113b6e6c"}
|
||||
{"id":"evt-4pqk1wyfhf0s","type":"edge.added","subject":"ver-qz5sev9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-qz5sev9","to":"iss-r5vrjx7"},"at":"2026-09-19T14:54:13.003Z","parents":["evt-1frkwn4trvyt"],"hash":"b387b8062288b31bc36fda4e095e8881dc90744f4ba5e5dc2e7d52263e955fa4"}
|
||||
{"id":"evt-qa5z1v7chc02","type":"verification.recorded","subject":"ver-qz5sev9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"Server Action round trips from real browser forms haven't been run: Claude doesn't start dev servers here, and curl can't easily drive Server Actions. Everything else is verified by unit tests and `next start` + curl (see iss-nj9wmwp, iss-9nxdndr, iss-76d5wrb). Needs a manual check by the user."},"at":"2026-09-19T14:54:13.004Z","parents":["evt-4pqk1wyfhf0s"],"hash":"f31c050167824784bbd13d5b0a35e015afa8194011d1693aafb9da418f30bf9a"}
|
||||
{"id":"evt-c410xxjgthrh","type":"node.status_changed","subject":"iss-nj9wmwp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:54:14.116Z","parents":["evt-qa5z1v7chc02"],"hash":"370db1ceeadad76ad355d77dd2a0168ce3170a91f9dae928b19c2e2e7127a947"}
|
||||
{"id":"evt-0zfw9s1039e8","type":"node.status_changed","subject":"iss-9nxdndr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:54:15.515Z","parents":["evt-c410xxjgthrh"],"hash":"a26951573416be01590b0486119cde90a7c99b615690167c4f41a71e03f927f3"}
|
||||
{"id":"evt-7jmwek1hzwy0","type":"node.status_changed","subject":"iss-76d5wrb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:54:17.226Z","parents":["evt-0zfw9s1039e8"],"hash":"782451c37447ce0594da68efb324db8cf3925a1fbd9793fb8b366fe9bbfb0edd"}
|
||||
{"id":"evt-5hdbhp5bn7t0","type":"node.created","subject":"ver-xz17rpj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"When the app session ends, dashboard API calls (401 without a camera error code) show 'Your session has ended. Sign in again', not the camera-login form, and aren't retried.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:54:54.309Z","parents":["evt-7jmwek1hzwy0"],"hash":"7054ae0335f04c4300edcc3a059818725902e8fd93267f4bb7cc527e4ecc0aed"}
|
||||
{"id":"evt-xqg7nwtyhdf3","type":"edge.added","subject":"ver-xz17rpj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-xz17rpj","to":"iss-76d5wrb"},"at":"2026-09-19T14:54:54.311Z","parents":["evt-5hdbhp5bn7t0"],"hash":"2a1cec7150beff04c58cca6c76dd65701d390092e00f303e64f44a2e5a16d24b"}
|
||||
{"id":"evt-d7j19r27at24","type":"verification.recorded","subject":"ver-xz17rpj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Found after enforcement: camera-queries.ts mapped every 401 to the camera 'auth' problem, so an expired session would have shown the camera-login form. Fixed: code 'auth' means a camera login problem, and any other 401 means the new 'signed-out' kind, shown on camera-card.tsx with a /login link. Test: camera-card.test.tsx 'tells the user to sign in again…' (1 info request, no camera-login form). Suite 383/383, coverage 99.86%, tsc and eslint clean, 2026-09-19."},"at":"2026-09-19T14:54:54.312Z","parents":["evt-xqg7nwtyhdf3"],"hash":"cd46e3dfd1afd7710b28def1c9fa26177050936bad5e9e99956719907245c4fe"}
|
||||
{"id":"evt-z4k3aqs5whz1","type":"node.created","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser: complete /setup with the console code, sign out, sign in (including a wrong password and a redirect back to ?next=), and skip setup to see the banner.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T16:56:11.239Z","parents":["evt-d7j19r27at24"],"hash":"c2271075a643fce52efa076efef36cdf58601239e3b54c1f6ec588139c582927"}
|
||||
{"id":"evt-3j6gy3qctp2f","type":"edge.added","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-86zabcp","to":"iss-r5vrjx7"},"at":"2026-09-19T16:56:11.242Z","parents":["evt-z4k3aqs5whz1"],"hash":"c292ee79097215281246b9eb8f6e111ffdfadb0ed2e30764244f9ba1da799923"}
|
||||
{"id":"evt-qv7r0x32bhrq","type":"verification.recorded","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual test by the user (Michael Mainguy) on 2026-09-19, who went through the full flow and reported that everything works correctly. This resolves the earlier pending check ver-qz5sev9."},"at":"2026-09-19T16:56:11.243Z","parents":["evt-3j6gy3qctp2f"],"hash":"bcceb6a19d3adc70e59f72bb44ca0ee76d8f0d79f197018ce0f53ed841c5a122"}
|
||||
{"id":"evt-h5c9jc9hak9q","type":"node.status_changed","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T16:56:12.460Z","parents":["evt-qv7r0x32bhrq"],"hash":"8c7c6c6e0a55282116ad4421d2f7491da248227185abea89a815e4c147cbfea9"}
|
||||
|
||||
32
README.md
32
README.md
@ -14,7 +14,37 @@ file, **never** in plain text: the password is hashed with [scrypt](https://node
|
||||
|
||||
If the file doesn't exist, the app starts **unsecured**: anyone who can reach it can view your
|
||||
cameras and change their logins. To avoid ever running it that way, create the admin file
|
||||
before the first start.
|
||||
before the first start (below).
|
||||
|
||||
### Setting it up from the browser instead
|
||||
|
||||
With no admin file, the server prints a **one-time setup code** in its console at startup:
|
||||
|
||||
```
|
||||
====================================================================
|
||||
No admin login is set up: the camera dashboard is NOT secured.
|
||||
|
||||
To secure it from a browser, open /setup and enter this one-time code:
|
||||
|
||||
XLZ7-Q4MB
|
||||
...
|
||||
```
|
||||
|
||||
Opening the app sends you to `/setup`, which asks for that code plus the new admin username
|
||||
and password. Only someone who can see the server console can claim the login. After 5 wrong
|
||||
codes a new one is printed.
|
||||
|
||||
You can also choose **Skip for now and run unsecured**. Every page then shows a red warning
|
||||
banner, the API stays open to anyone on the network, and the setup prompt returns the next
|
||||
time the browser is restarted.
|
||||
|
||||
### Signing in
|
||||
|
||||
Once an admin exists, every page and API call needs a session: pages redirect to `/login`,
|
||||
and the API answers `401`. A session lasts **12 hours from your last activity**. After 10
|
||||
wrong passwords from one address (or 100 from all addresses) within 15 minutes, logins are
|
||||
paused for up to 15 minutes. **Sign out** (top right) ends the session in that browser; to
|
||||
sign out everywhere, change the password.
|
||||
|
||||
### Create the admin login (recommended)
|
||||
|
||||
|
||||
59
src/app/api/access.test.ts
Normal file
59
src/app/api/access.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Every API handler must refuse a signed-out request before doing anything else
|
||||
* (vrek iss-76d5wrb). The camera and store modules are replaced with spies that must never
|
||||
* be reached.
|
||||
*/
|
||||
const denied = vi.fn<() => Promise<Response | null>>();
|
||||
vi.mock("@/lib/access", () => ({ apiAccessDenied: denied }));
|
||||
|
||||
const touched = vi.fn();
|
||||
const trap = () => touched();
|
||||
vi.mock("@/lib/camera", () => ({
|
||||
getCameraInfo: trap,
|
||||
getSnapshot: trap,
|
||||
testCredentials: trap,
|
||||
resetConnection: trap,
|
||||
}));
|
||||
vi.mock("@/lib/camera-route", () => ({ cameraTarget: trap, cameraErrorResponse: trap }));
|
||||
vi.mock("@/lib/credential-store", () => ({
|
||||
credentialsSchema: { safeParse: trap },
|
||||
setCredentials: trap,
|
||||
deleteCredentials: trap,
|
||||
describeCredentials: trap,
|
||||
}));
|
||||
vi.mock("@/lib/onvif", () => ({ discoverCameras: trap, discoverRequestSchema: { safeParse: trap } }));
|
||||
vi.mock("@/lib/camera-registry", () => ({ recordDiscovered: trap }));
|
||||
|
||||
const info = await import("./cameras/[id]/info/route");
|
||||
const snapshot = await import("./cameras/[id]/snapshot/route");
|
||||
const credentials = await import("./cameras/[id]/credentials/route");
|
||||
const discover = await import("./discover/route");
|
||||
|
||||
const ctx = { params: Promise.resolve({ id: "11111111-2222-3333-4444-555555555555" }) };
|
||||
const req = (method = "GET") =>
|
||||
new Request("http://localhost/api/x", { method, body: method === "GET" ? undefined : "{}" });
|
||||
|
||||
const handlers: [string, () => Promise<Response>][] = [
|
||||
["GET /api/cameras/[id]/info", () => info.GET(req(), ctx)],
|
||||
["GET /api/cameras/[id]/snapshot", () => snapshot.GET(req(), ctx)],
|
||||
["GET /api/cameras/[id]/credentials", () => credentials.GET(req(), ctx)],
|
||||
["PUT /api/cameras/[id]/credentials", () => credentials.PUT(req("PUT"), ctx)],
|
||||
["DELETE /api/cameras/[id]/credentials", () => credentials.DELETE(req("DELETE"), ctx)],
|
||||
["POST /api/discover", () => discover.POST(req("POST"))],
|
||||
];
|
||||
|
||||
describe("API access control", () => {
|
||||
beforeEach(() => {
|
||||
touched.mockReset();
|
||||
denied.mockReset().mockResolvedValue(Response.json({ error: "Sign in required" }, { status: 401 }));
|
||||
});
|
||||
|
||||
it.each(handlers)("%s returns 401 and touches nothing when signed out", async (_name, call) => {
|
||||
const res = await call();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: "Sign in required" });
|
||||
expect(touched).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -10,6 +10,8 @@ const store = {
|
||||
describeCredentials: vi.fn(),
|
||||
};
|
||||
|
||||
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
|
||||
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
|
||||
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
|
||||
getCameraRecord,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { apiAccessDenied } from "@/lib/access";
|
||||
import { resetConnection, testCredentials } from "@/lib/camera";
|
||||
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
|
||||
import {
|
||||
@ -10,6 +11,8 @@ import {
|
||||
// Passwords are write-only: GET reports whether one is set, never its value.
|
||||
|
||||
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
try {
|
||||
@ -20,6 +23,8 @@ export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
|
||||
@ -44,6 +49,8 @@ export async function DELETE(
|
||||
_request: Request,
|
||||
ctx: RouteContext<"/api/cameras/[id]/credentials">,
|
||||
) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
try {
|
||||
|
||||
@ -4,6 +4,8 @@ import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-r
|
||||
const getCameraRecord = vi.fn();
|
||||
const getCameraInfo = vi.fn();
|
||||
|
||||
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
|
||||
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
|
||||
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
|
||||
getCameraRecord,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { apiAccessDenied } from "@/lib/access";
|
||||
import { getCameraInfo } from "@/lib/camera";
|
||||
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
|
||||
|
||||
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/info">) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-r
|
||||
const getCameraRecord = vi.fn();
|
||||
const getSnapshot = vi.fn();
|
||||
|
||||
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
|
||||
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
|
||||
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
|
||||
getCameraRecord,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { apiAccessDenied } from "@/lib/access";
|
||||
import { getSnapshot } from "@/lib/camera";
|
||||
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
|
||||
|
||||
export async function GET(request: Request, ctx: RouteContext<"/api/cameras/[id]/snapshot">) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
const profile = new URL(request.url).searchParams.get("profile") ?? undefined;
|
||||
|
||||
@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const discoverCameras = vi.fn();
|
||||
const recordDiscovered = vi.fn();
|
||||
|
||||
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
|
||||
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
|
||||
vi.mock("@/lib/onvif", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/onvif")>()),
|
||||
discoverCameras,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { apiAccessDenied } from "@/lib/access";
|
||||
import { recordDiscovered } from "@/lib/camera-registry";
|
||||
import { discoverCameras, discoverRequestSchema } from "@/lib/onvif";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const denied = await apiAccessDenied();
|
||||
if (denied) return denied;
|
||||
// An empty body means "use the defaults".
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = discoverRequestSchema.safeParse(body ?? {});
|
||||
|
||||
232
src/app/auth-actions.test.ts
Normal file
232
src/app/auth-actions.test.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdminFile } from "@/lib/admin-file";
|
||||
|
||||
const admin: AdminFile = { version: 1, username: "admin", passwordHash: "scrypt$hash" };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkLogin: vi.fn(),
|
||||
createAdmin: vi.fn(),
|
||||
getAdmin: vi.fn(),
|
||||
hasAdmin: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
checkSetupCode: vi.fn(),
|
||||
clearSetupCode: vi.fn(),
|
||||
cookieSet: vi.fn(),
|
||||
cookieDelete: vi.fn(),
|
||||
headers: new Map<string, string>(),
|
||||
}));
|
||||
vi.mock("@/lib/admin-auth", () => ({
|
||||
checkLogin: mocks.checkLogin,
|
||||
createAdmin: mocks.createAdmin,
|
||||
getAdmin: mocks.getAdmin,
|
||||
hasAdmin: mocks.hasAdmin,
|
||||
}));
|
||||
vi.mock("@/lib/session", () => ({
|
||||
createSession: mocks.createSession,
|
||||
deleteSession: mocks.deleteSession,
|
||||
}));
|
||||
vi.mock("@/lib/setup-code", () => ({
|
||||
checkSetupCode: mocks.checkSetupCode,
|
||||
clearSetupCode: mocks.clearSetupCode,
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: async () => ({ set: mocks.cookieSet, delete: mocks.cookieDelete }),
|
||||
headers: async () => ({ get: (n: string) => mocks.headers.get(n) ?? null }),
|
||||
}));
|
||||
class Redirect extends Error {}
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (to: string) => {
|
||||
throw new Redirect(to);
|
||||
},
|
||||
}));
|
||||
|
||||
let actions: typeof import("./auth-actions");
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(mocks)) if (typeof fn === "function") fn.mockReset();
|
||||
mocks.headers.clear();
|
||||
mocks.headers.set("x-forwarded-for", "192.168.1.50");
|
||||
mocks.getAdmin.mockResolvedValue(admin);
|
||||
mocks.hasAdmin.mockResolvedValue(false);
|
||||
mocks.createAdmin.mockResolvedValue(admin);
|
||||
// A fresh process-wide throttle for each test.
|
||||
delete (globalThis as Record<symbol, unknown>)[Symbol.for("cameras.loginThrottle")];
|
||||
vi.resetModules();
|
||||
actions = await import("./auth-actions");
|
||||
});
|
||||
|
||||
const form = (fields: Record<string, string>) => {
|
||||
const f = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) f.set(k, v);
|
||||
return f;
|
||||
};
|
||||
|
||||
/** Runs an action, returning its state or the path it redirected to. */
|
||||
async function run<T>(p: Promise<T>): Promise<T | { redirect: string }> {
|
||||
try {
|
||||
return await p;
|
||||
} catch (err) {
|
||||
if (err instanceof Redirect) return { redirect: err.message };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
describe("login", () => {
|
||||
it("signs in and redirects to the requested page", async () => {
|
||||
mocks.checkLogin.mockResolvedValue(true);
|
||||
const result = await run(
|
||||
actions.login({}, form({ username: "admin", password: PASSWORD, next: "/?refresh=500" })),
|
||||
);
|
||||
expect(result).toEqual({ redirect: "/?refresh=500" });
|
||||
expect(mocks.checkLogin).toHaveBeenCalledWith("admin", PASSWORD);
|
||||
expect(mocks.createSession).toHaveBeenCalledWith(admin);
|
||||
});
|
||||
|
||||
it("never redirects off-site", async () => {
|
||||
mocks.checkLogin.mockResolvedValue(true);
|
||||
const result = await run(
|
||||
actions.login({}, form({ username: "admin", password: PASSWORD, next: "//evil.example" })),
|
||||
);
|
||||
expect(result).toEqual({ redirect: "/" });
|
||||
});
|
||||
|
||||
it("gives one generic error for a wrong username or password", async () => {
|
||||
mocks.checkLogin.mockResolvedValue(false);
|
||||
expect(await actions.login({}, form({ username: "admin", password: "nope" }))).toEqual({
|
||||
error: "Wrong username or password.",
|
||||
});
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing fields", {}],
|
||||
["an empty password", { username: "admin", password: "" }],
|
||||
["an over-long username", { username: "a".repeat(65), password: "x" }],
|
||||
])("asks for both fields on %s, without checking the password", async (_l, fields) => {
|
||||
expect(await actions.login({}, form(fields))).toEqual({
|
||||
error: "Enter your username and password.",
|
||||
});
|
||||
expect(mocks.checkLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks a client out after 10 failures, without even checking the password", async () => {
|
||||
mocks.checkLogin.mockResolvedValue(false);
|
||||
for (let i = 0; i < 10; i++) await actions.login({}, form({ username: "admin", password: "x" }));
|
||||
mocks.checkLogin.mockClear().mockResolvedValue(true);
|
||||
|
||||
const locked = await actions.login({}, form({ username: "admin", password: PASSWORD }));
|
||||
expect(locked).toEqual({ error: "Too many failed attempts. Try again in 15 minutes." });
|
||||
expect(mocks.checkLogin).not.toHaveBeenCalled();
|
||||
|
||||
// Another address on the LAN isn't affected.
|
||||
mocks.headers.set("x-forwarded-for", "192.168.1.51");
|
||||
expect(await run(actions.login({}, form({ username: "admin", password: PASSWORD })))).toEqual({
|
||||
redirect: "/",
|
||||
});
|
||||
});
|
||||
|
||||
it("says 1 minute, not 1 minutes", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.checkLogin.mockResolvedValue(false);
|
||||
for (let i = 0; i < 10; i++) await actions.login({}, form({ username: "admin", password: "x" }));
|
||||
vi.advanceTimersByTime(14.5 * 60_000);
|
||||
expect(await actions.login({}, form({ username: "admin", password: "x" }))).toEqual({
|
||||
error: "Too many failed attempts. Try again in 1 minute.",
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("handles the admin file disappearing mid-login", async () => {
|
||||
mocks.checkLogin.mockResolvedValue(true);
|
||||
mocks.getAdmin.mockResolvedValue(null);
|
||||
expect(await actions.login({}, form({ username: "admin", password: PASSWORD }))).toEqual({
|
||||
error: "The admin login was removed. Reload the page.",
|
||||
});
|
||||
});
|
||||
|
||||
it("throttles an unknown client address too", async () => {
|
||||
mocks.headers.clear();
|
||||
mocks.checkLogin.mockResolvedValue(false);
|
||||
expect(await actions.login({}, form({ username: "a", password: "b" }))).toEqual({
|
||||
error: "Wrong username or password.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("clears the session and goes to /login", async () => {
|
||||
expect(await run(actions.logout())).toEqual({ redirect: "/login" });
|
||||
expect(mocks.deleteSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupAdmin", () => {
|
||||
const valid = { username: "admin", password: PASSWORD, confirm: PASSWORD, code: "ABCD-EFGH" };
|
||||
|
||||
it("creates the admin with a valid code, signs in, and clears the code and skip cookie", async () => {
|
||||
mocks.checkSetupCode.mockReturnValue(true);
|
||||
expect(await run(actions.setupAdmin({}, form(valid)))).toEqual({ redirect: "/" });
|
||||
expect(mocks.checkSetupCode).toHaveBeenCalledWith("ABCD-EFGH");
|
||||
expect(mocks.createAdmin).toHaveBeenCalledWith("admin", PASSWORD);
|
||||
expect(mocks.clearSetupCode).toHaveBeenCalled();
|
||||
expect(mocks.cookieDelete).toHaveBeenCalledWith("cameras_setup_skipped");
|
||||
expect(mocks.createSession).toHaveBeenCalledWith(admin);
|
||||
});
|
||||
|
||||
it("refuses once an admin exists", async () => {
|
||||
mocks.hasAdmin.mockResolvedValue(true);
|
||||
expect(await run(actions.setupAdmin({}, form(valid)))).toEqual({ redirect: "/login" });
|
||||
expect(mocks.createAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a wrong setup code", async () => {
|
||||
mocks.checkSetupCode.mockReturnValue(false);
|
||||
expect(await actions.setupAdmin({}, form(valid))).toEqual({
|
||||
error: "That setup code isn't right. Use the code printed in the server console.",
|
||||
});
|
||||
expect(mocks.createAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bad username", { username: "has space" }, /^Username: /],
|
||||
["a short password", { password: "short", confirm: "short" }, /^Password: At least 12/],
|
||||
["mismatched passwords", { confirm: "something else!!" }, /don't match/],
|
||||
])("rejects %s before using up a setup-code attempt", async (_l, override, message) => {
|
||||
const result = await actions.setupAdmin({}, form({ ...valid, ...override }));
|
||||
expect((result as { error: string }).error).toMatch(message);
|
||||
expect(mocks.checkSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("goes to /login if an admin appears between the check and the write", async () => {
|
||||
mocks.checkSetupCode.mockReturnValue(true);
|
||||
mocks.createAdmin.mockRejectedValue(Object.assign(new Error("exists"), { code: "EEXIST" }));
|
||||
expect(await run(actions.setupAdmin({}, form(valid)))).toEqual({ redirect: "/login" });
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces unexpected write errors", async () => {
|
||||
mocks.checkSetupCode.mockReturnValue(true);
|
||||
mocks.createAdmin.mockRejectedValue(new Error("EACCES"));
|
||||
await expect(actions.setupAdmin({}, form(valid))).rejects.toThrow("EACCES");
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipSetup", () => {
|
||||
it("sets a browser-session skip cookie and goes home", async () => {
|
||||
expect(await run(actions.skipSetup())).toEqual({ redirect: "/" });
|
||||
expect(mocks.cookieSet).toHaveBeenCalledWith("cameras_setup_skipped", "1", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing but redirect once an admin exists", async () => {
|
||||
mocks.hasAdmin.mockResolvedValue(true);
|
||||
expect(await run(actions.skipSetup())).toEqual({ redirect: "/" });
|
||||
expect(mocks.cookieSet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
99
src/app/auth-actions.ts
Normal file
99
src/app/auth-actions.ts
Normal file
@ -0,0 +1,99 @@
|
||||
"use server";
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { checkLogin, createAdmin, getAdmin, hasAdmin } from "@/lib/admin-auth";
|
||||
import { passwordSchema, usernameSchema } from "@/lib/admin-file";
|
||||
import { safeNextPath, SETUP_SKIP_COOKIE } from "@/lib/auth-shared";
|
||||
import { clientKey, loginThrottle } from "@/lib/login-throttle";
|
||||
import { createSession, deleteSession } from "@/lib/session";
|
||||
import { checkSetupCode, clearSetupCode } from "@/lib/setup-code";
|
||||
|
||||
/**
|
||||
* Login, sign-out and first-run setup. Server Actions are public POST endpoints, so each
|
||||
* one validates its own input (vrek pri-m1csgrm) and expected failures come back as
|
||||
* values for useActionState rather than thrown errors (pri-qqrp49f).
|
||||
*/
|
||||
|
||||
export interface FormState {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const text = (form: FormData, key: string) => {
|
||||
const value = form.get(key);
|
||||
return typeof value === "string" ? value : "";
|
||||
};
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
|
||||
export async function login(_prev: FormState, form: FormData): Promise<FormState> {
|
||||
const next = safeNextPath(text(form, "next"));
|
||||
const parsed = loginSchema.safeParse({
|
||||
username: text(form, "username"),
|
||||
password: text(form, "password"),
|
||||
});
|
||||
if (!parsed.success) return { error: "Enter your username and password." };
|
||||
|
||||
const client = clientKey((await headers()).get("x-forwarded-for"));
|
||||
const wait = loginThrottle.retryAfter(client);
|
||||
if (wait > 0) {
|
||||
const minutes = Math.ceil(wait / 60_000);
|
||||
return { error: `Too many failed attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.` };
|
||||
}
|
||||
|
||||
if (!(await checkLogin(parsed.data.username, parsed.data.password))) {
|
||||
loginThrottle.recordFailure(client);
|
||||
return { error: "Wrong username or password." };
|
||||
}
|
||||
loginThrottle.recordSuccess(client);
|
||||
|
||||
const admin = await getAdmin();
|
||||
if (!admin) return { error: "The admin login was removed. Reload the page." };
|
||||
await createSession(admin);
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await deleteSession();
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
export async function setupAdmin(_prev: FormState, form: FormData): Promise<FormState> {
|
||||
if (await hasAdmin()) redirect("/login");
|
||||
|
||||
const username = usernameSchema.safeParse(text(form, "username"));
|
||||
if (!username.success) return { error: `Username: ${username.error.issues[0].message}.` };
|
||||
const password = passwordSchema.safeParse(text(form, "password"));
|
||||
if (!password.success) return { error: `Password: ${password.error.issues[0].message}.` };
|
||||
if (text(form, "confirm") !== password.data) return { error: "The passwords don't match." };
|
||||
|
||||
if (!checkSetupCode(text(form, "code"))) {
|
||||
return { error: "That setup code isn't right. Use the code printed in the server console." };
|
||||
}
|
||||
|
||||
let admin;
|
||||
try {
|
||||
admin = await createAdmin(username.data, password.data);
|
||||
} catch (err) {
|
||||
// Someone else (or the CLI) created an admin a moment ago.
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") redirect("/login");
|
||||
throw err;
|
||||
}
|
||||
clearSetupCode();
|
||||
(await cookies()).delete(SETUP_SKIP_COOKIE);
|
||||
await createSession(admin);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
/** Proceeds without an admin. The prompt returns when the browser restarts. */
|
||||
export async function skipSetup(): Promise<void> {
|
||||
if (!(await hasAdmin())) {
|
||||
// No expiry: a browser-session cookie.
|
||||
(await cookies()).set(SETUP_SKIP_COOKIE, "1", { httpOnly: true, sameSite: "lax", path: "/" });
|
||||
}
|
||||
redirect("/");
|
||||
}
|
||||
86
src/app/auth-forms.test.tsx
Normal file
86
src/app/auth-forms.test.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const login = vi.fn();
|
||||
const setupAdmin = vi.fn();
|
||||
vi.mock("./auth-actions", () => ({ login, setupAdmin }));
|
||||
|
||||
const { default: LoginForm } = await import("./login/login-form");
|
||||
const { default: SetupForm } = await import("./setup/setup-form");
|
||||
|
||||
afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
login.mockReset();
|
||||
setupAdmin.mockReset();
|
||||
});
|
||||
|
||||
const submitted = (fn: typeof login) => Object.fromEntries((fn.mock.calls[0][1] as FormData).entries());
|
||||
|
||||
describe("LoginForm", () => {
|
||||
it("submits the username, password and destination, then shows the returned error", async () => {
|
||||
login.mockResolvedValue({ error: "Wrong username or password." });
|
||||
render(<LoginForm next="/?refresh=500" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "admin" } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter22hunter22" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("Wrong username or password.");
|
||||
expect(submitted(login)).toEqual({
|
||||
next: "/?refresh=500",
|
||||
username: "admin",
|
||||
password: "hunter22hunter22",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the button while signing in", async () => {
|
||||
let finish!: (s: object) => void;
|
||||
login.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
render(<LoginForm next="/" />);
|
||||
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "a" } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "b" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "Signing in…" });
|
||||
expect(button.disabled).toBe(true);
|
||||
finish({});
|
||||
await screen.findByRole("button", { name: "Sign in" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetupForm", () => {
|
||||
it("submits the code, username and both passwords, then shows the returned error", async () => {
|
||||
setupAdmin.mockResolvedValue({ error: "The passwords don't match." });
|
||||
render(<SetupForm />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Setup code (from the server console)"), { target: { value: "abcd-efgh" } });
|
||||
fireEvent.change(screen.getByLabelText("Password (at least 12 characters)"), { target: { value: "first password!" } });
|
||||
fireEvent.change(screen.getByLabelText("Repeat password"), { target: { value: "second password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create admin and sign in" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("The passwords don't match.");
|
||||
expect(submitted(setupAdmin)).toEqual({
|
||||
code: "abcd-efgh",
|
||||
username: "admin",
|
||||
password: "first password!",
|
||||
confirm: "second password",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows progress while creating the admin", async () => {
|
||||
let finish!: (s: object) => void;
|
||||
setupAdmin.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
render(<SetupForm />);
|
||||
for (const [label, value] of [
|
||||
["Setup code (from the server console)", "ABCD-EFGH"],
|
||||
["Password (at least 12 characters)", "correct horse battery"],
|
||||
["Repeat password", "correct horse battery"],
|
||||
]) {
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
}
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create admin and sign in" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Securing…" })).toBeTruthy());
|
||||
finish({});
|
||||
});
|
||||
});
|
||||
89
src/app/auth-pages.test.tsx
Normal file
89
src/app/auth-pages.test.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authState = vi.fn();
|
||||
const hasAdmin = vi.fn();
|
||||
const ensureSetupCode = vi.fn();
|
||||
vi.mock("@/lib/session", () => ({ authState }));
|
||||
vi.mock("@/lib/admin-auth", () => ({ hasAdmin }));
|
||||
vi.mock("@/lib/setup-code", () => ({ ensureSetupCode }));
|
||||
vi.mock("./auth-actions", () => ({
|
||||
login: async () => ({}),
|
||||
setupAdmin: async () => ({}),
|
||||
skipSetup: async () => {},
|
||||
}));
|
||||
class Redirect extends Error {}
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (to: string) => {
|
||||
throw new Redirect(to);
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: LoginPage } = await import("./login/page");
|
||||
const { default: SetupPage } = await import("./setup/page");
|
||||
|
||||
const redirectOf = (p: Promise<unknown>) =>
|
||||
p.then(
|
||||
() => null,
|
||||
(e) => (e instanceof Redirect ? e.message : Promise.reject(e)),
|
||||
);
|
||||
const loginPage = (next?: string) =>
|
||||
LoginPage({ params: Promise.resolve({}), searchParams: Promise.resolve(next ? { next } : {}) });
|
||||
|
||||
beforeEach(() => {
|
||||
authState.mockReset();
|
||||
hasAdmin.mockReset();
|
||||
ensureSetupCode.mockReset();
|
||||
});
|
||||
|
||||
describe("/login", () => {
|
||||
it("renders the form, carrying a safe destination", async () => {
|
||||
authState.mockResolvedValue("signed-out");
|
||||
const html = renderToStaticMarkup(await loginPage("/?refresh=500"));
|
||||
expect(html).toContain("Sign in");
|
||||
expect(html).toContain('name="next" value="/?refresh=500"');
|
||||
expect(html).toContain("npm run admin:create -- --force");
|
||||
});
|
||||
|
||||
it("drops an off-site destination", async () => {
|
||||
authState.mockResolvedValue("signed-out");
|
||||
const html = renderToStaticMarkup(await loginPage("https://evil.example"));
|
||||
expect(html).toContain('name="next" value="/"');
|
||||
});
|
||||
|
||||
it("sends an already signed-in admin on to their destination", async () => {
|
||||
authState.mockResolvedValue("signed-in");
|
||||
expect(await redirectOf(loginPage("/?refresh=250"))).toBe("/?refresh=250");
|
||||
});
|
||||
|
||||
it("sends visitors to /setup when there's no admin yet", async () => {
|
||||
authState.mockResolvedValue("no-admin");
|
||||
expect(await redirectOf(loginPage())).toBe("/setup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/setup", () => {
|
||||
it("makes sure a setup code has been printed, and explains where to find it", async () => {
|
||||
hasAdmin.mockResolvedValue(false);
|
||||
const html = renderToStaticMarkup(await SetupPage());
|
||||
expect(ensureSetupCode).toHaveBeenCalled();
|
||||
expect(html).toContain("Secure the camera dashboard");
|
||||
expect(html).toContain("printed in the server's console");
|
||||
expect(html).toContain("npm run admin:create");
|
||||
expect(html).toContain('name="code"');
|
||||
});
|
||||
|
||||
it("shows the severe warning next to the skip option", async () => {
|
||||
hasAdmin.mockResolvedValue(false);
|
||||
const html = renderToStaticMarkup(await SetupPage());
|
||||
expect(html).toContain("Skip at your own risk");
|
||||
expect(html).toContain("anyone on your network");
|
||||
expect(html).toContain("Skip for now and run unsecured");
|
||||
});
|
||||
|
||||
it("sends visitors to /login once an admin exists", async () => {
|
||||
hasAdmin.mockResolvedValue(true);
|
||||
expect(await redirectOf(SetupPage())).toBe("/login");
|
||||
expect(ensureSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -128,6 +128,19 @@ describe("CameraCard", () => {
|
||||
expect(screen.queryByText(/Setup required/)).toBeNull();
|
||||
});
|
||||
|
||||
it("tells the user to sign in again when the app session ends, not to fix the camera login", async () => {
|
||||
const fetchMock = stubFetch({
|
||||
[`GET ${base}/info`]: () => json({ error: "Sign in required" }, 401),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
expect(await screen.findByText("Your session has ended.")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Sign in again" }).getAttribute("href")).toBe("/login");
|
||||
expect(screen.queryByText("Camera login")).toBeNull();
|
||||
// Not retried: a session problem needs the user.
|
||||
expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("asks for a login when the camera rejects it, then recovers after saving", async () => {
|
||||
let loggedIn = false;
|
||||
|
||||
@ -246,6 +246,15 @@ export default function CameraCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{problem?.kind === "signed-out" && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
|
||||
<span>Your session has ended.</span>
|
||||
<a href="/login" className="underline">
|
||||
Sign in again
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{problem?.kind === "error" && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
|
||||
<span>Stopped: {problem.message}</span>
|
||||
|
||||
@ -7,8 +7,11 @@ import type { DiscoveredCamera } from "@/lib/onvif";
|
||||
* handlers. No device logic lives here; the server decides what each response means.
|
||||
*/
|
||||
|
||||
/** What the UI acts on: "inactive" shows setup steps, "auth" asks for a login. */
|
||||
export type ProblemKind = "inactive" | "auth" | "error";
|
||||
/**
|
||||
* What the UI acts on: "inactive" shows setup steps, "auth" asks for the camera's login,
|
||||
* "signed-out" means this app's own session ended (a 401 without a camera error code).
|
||||
*/
|
||||
export type ProblemKind = "inactive" | "auth" | "signed-out" | "error";
|
||||
|
||||
export class CameraProblem extends Error {
|
||||
name = "CameraProblem";
|
||||
@ -46,8 +49,10 @@ async function problemFrom(res: Response): Promise<CameraProblem> {
|
||||
const kind: ProblemKind =
|
||||
data.code === "inactive"
|
||||
? "inactive"
|
||||
: res.status === 401 || data.code === "auth"
|
||||
: data.code === "auth"
|
||||
? "auth"
|
||||
: res.status === 401
|
||||
? "signed-out"
|
||||
: "error";
|
||||
return new CameraProblem(kind, data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
@ -64,7 +69,7 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return (await request(url, init)).json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Only plain failures are worth one retry; a login or setup problem needs the user. */
|
||||
/** Only plain failures are worth one retry; a login, session or setup problem needs the user. */
|
||||
const retryPlainFailureOnce = (failures: number, err: Error) =>
|
||||
failures < 1 && !(err instanceof CameraProblem && err.kind !== "error");
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ vi.mock("next/font/google", () => ({
|
||||
Geist_Mono: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }),
|
||||
}));
|
||||
vi.mock("./globals.css", () => ({}));
|
||||
vi.mock("./security-bar", () => ({ default: () => <div data-testid="security-bar" /> }));
|
||||
vi.mock("./providers", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="providers">{children}</div>
|
||||
@ -25,8 +26,10 @@ describe("RootLayout", () => {
|
||||
expect(html).toMatch(/<html lang="en" class="v\(--font-geist-sans\) v\(--font-geist-mono\) /);
|
||||
});
|
||||
|
||||
it("wraps the page in the client Providers", () => {
|
||||
expect(html).toContain('<div data-testid="providers"><main>page</main></div>');
|
||||
it("puts the security bar above the page, which is wrapped in the client Providers", () => {
|
||||
expect(html).toContain(
|
||||
'<div data-testid="security-bar"></div><div data-testid="providers"><main>page</main></div>',
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the page metadata", () => {
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
import SecurityBar from "./security-bar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -25,6 +26,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<SecurityBar />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
src/app/login/login-form.tsx
Normal file
43
src/app/login/login-form.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { login, type FormState } from "../auth-actions";
|
||||
|
||||
const inputClass =
|
||||
"rounded border border-zinc-300 bg-transparent px-2 py-1.5 dark:border-zinc-700";
|
||||
|
||||
export default function LoginForm({ next }: { next: string }) {
|
||||
const [state, action, pending] = useActionState<FormState, FormData>(login, {});
|
||||
|
||||
return (
|
||||
<form action={action} className="mt-6 flex flex-col gap-3 text-sm">
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<label className="flex flex-col gap-1">
|
||||
Username
|
||||
<input name="username" autoComplete="username" required className={inputClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
Password
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
{state.error && (
|
||||
<p role="alert" className="text-red-600">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{pending ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
24
src/app/login/page.tsx
Normal file
24
src/app/login/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { safeNextPath } from "@/lib/auth-shared";
|
||||
import { authState } from "@/lib/session";
|
||||
import LoginForm from "./login-form";
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
|
||||
const [state, { next }] = await Promise.all([authState(), searchParams]);
|
||||
const destination = safeNextPath(next);
|
||||
if (state === "no-admin") redirect("/setup");
|
||||
if (state === "signed-in") redirect(destination);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-sm flex-1 px-4 py-16 font-sans">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in with the camera dashboard's admin login.
|
||||
</p>
|
||||
<LoginForm next={destination} />
|
||||
<p className="mt-6 text-xs text-zinc-500">
|
||||
Forgot the password? On the server, run <code>npm run admin:create -- --force</code>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { requirePageAccess } from "@/lib/access";
|
||||
import { listCameras } from "@/lib/camera-registry";
|
||||
import CameraCard from "./camera-card";
|
||||
import CameraScanner from "./camera-scanner";
|
||||
@ -5,6 +6,7 @@ import RefreshRateSelect from "./refresh-rate-select";
|
||||
import { refreshMsSchema } from "./refresh-rate";
|
||||
|
||||
export default async function Home({ searchParams }: PageProps<"/">) {
|
||||
await requirePageAccess();
|
||||
const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]);
|
||||
const intervalMs = refreshMsSchema.parse(refresh);
|
||||
|
||||
|
||||
41
src/app/security-bar.test.tsx
Normal file
41
src/app/security-bar.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getAdmin = vi.fn();
|
||||
const verifySession = vi.fn();
|
||||
vi.mock("@/lib/admin-auth", () => ({ getAdmin }));
|
||||
vi.mock("@/lib/session", () => ({ verifySession }));
|
||||
vi.mock("./auth-actions", () => ({ logout: async () => {} }));
|
||||
|
||||
const { default: SecurityBar } = await import("./security-bar");
|
||||
const render = async () => renderToStaticMarkup((await SecurityBar()) ?? <></>);
|
||||
|
||||
beforeEach(() => {
|
||||
getAdmin.mockReset();
|
||||
verifySession.mockReset().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("SecurityBar", () => {
|
||||
it("shows a severe warning with a setup link while no admin exists", async () => {
|
||||
getAdmin.mockResolvedValue(null);
|
||||
const html = await render();
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Not secured:");
|
||||
expect(html).toContain("anyone on your network can view your cameras");
|
||||
expect(html).toContain('href="/setup"');
|
||||
});
|
||||
|
||||
it("shows who is signed in, with a sign-out button", async () => {
|
||||
getAdmin.mockResolvedValue({ username: "admin" });
|
||||
verifySession.mockResolvedValue({ username: "admin", expiresAt: 0 });
|
||||
const html = await render();
|
||||
expect(html).toContain("Signed in as <strong>admin</strong>");
|
||||
expect(html).toContain("Sign out");
|
||||
expect(html).not.toContain("Not secured");
|
||||
});
|
||||
|
||||
it("shows nothing on the login page before signing in", async () => {
|
||||
getAdmin.mockResolvedValue({ username: "admin" });
|
||||
expect(await render()).toBe("");
|
||||
});
|
||||
});
|
||||
40
src/app/security-bar.tsx
Normal file
40
src/app/security-bar.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { getAdmin } from "@/lib/admin-auth";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { logout } from "./auth-actions";
|
||||
|
||||
/**
|
||||
* Top of every page: a severe warning while no admin exists (vrek dec-nw2hvff), or who is
|
||||
* signed in with a sign-out button.
|
||||
*/
|
||||
export default async function SecurityBar() {
|
||||
const [admin, session] = await Promise.all([getAdmin(), verifySession()]);
|
||||
|
||||
if (!admin) {
|
||||
return (
|
||||
<div role="alert" className="flex flex-wrap items-center justify-between gap-3 bg-red-700 px-4 py-3 text-sm text-white">
|
||||
<p>
|
||||
<strong>Not secured:</strong> anyone on your network can view your cameras and change
|
||||
their logins.
|
||||
</p>
|
||||
<Link href="/setup" className="rounded bg-white px-3 py-1 font-medium text-red-700">
|
||||
Set up admin login
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 border-b border-zinc-200 px-4 py-2 text-xs text-zinc-600 dark:border-zinc-800 dark:text-zinc-400">
|
||||
<span>
|
||||
Signed in as <strong>{session.username}</strong>
|
||||
</span>
|
||||
<form action={logout}>
|
||||
<button type="submit" className="underline">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/app/setup/page.tsx
Normal file
40
src/app/setup/page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { hasAdmin } from "@/lib/admin-auth";
|
||||
import { ensureSetupCode } from "@/lib/setup-code";
|
||||
import { skipSetup } from "../auth-actions";
|
||||
import SetupForm from "./setup-form";
|
||||
|
||||
export default async function SetupPage() {
|
||||
if (await hasAdmin()) redirect("/login");
|
||||
// Make sure a code exists and has been printed (e.g. after a restart or a rotation).
|
||||
ensureSetupCode();
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-md flex-1 px-4 py-12 font-sans">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Secure the camera dashboard</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Create the admin login. Everyone will need it to view cameras or change their settings.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
The <strong>setup code</strong> is printed in the server's console, so only someone
|
||||
with access to the server can claim this login. Prefer the command line? Run{" "}
|
||||
<code>npm run admin:create</code> on the server instead.
|
||||
</p>
|
||||
|
||||
<SetupForm />
|
||||
|
||||
<div className="mt-10 rounded-md border-2 border-red-600 p-4 text-sm">
|
||||
<p className="font-semibold text-red-700 dark:text-red-400">Skip at your own risk</p>
|
||||
<p className="mt-1">
|
||||
Without an admin login, <strong>anyone on your network</strong> can view your cameras,
|
||||
change their logins, and use the API.
|
||||
</p>
|
||||
<form action={skipSetup} className="mt-3">
|
||||
<button type="submit" className="rounded border border-red-600 px-3 py-1.5 text-red-700 dark:text-red-400">
|
||||
Skip for now and run unsecured
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
64
src/app/setup/setup-form.tsx
Normal file
64
src/app/setup/setup-form.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { setupAdmin, type FormState } from "../auth-actions";
|
||||
|
||||
const inputClass =
|
||||
"rounded border border-zinc-300 bg-transparent px-2 py-1.5 dark:border-zinc-700";
|
||||
|
||||
export default function SetupForm() {
|
||||
const [state, action, pending] = useActionState<FormState, FormData>(setupAdmin, {});
|
||||
|
||||
return (
|
||||
<form action={action} className="mt-6 flex flex-col gap-3 text-sm">
|
||||
<label className="flex flex-col gap-1">
|
||||
Setup code (from the server console)
|
||||
<input
|
||||
name="code"
|
||||
autoComplete="off"
|
||||
placeholder="XXXX-XXXX"
|
||||
required
|
||||
className={`${inputClass} font-mono uppercase`}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
Admin username
|
||||
<input name="username" defaultValue="admin" autoComplete="username" required className={inputClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
Password (at least 12 characters)
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
Repeat password
|
||||
<input
|
||||
name="confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
{state.error && (
|
||||
<p role="alert" className="text-red-600">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{pending ? "Securing…" : "Create admin and sign in"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
46
src/instrumentation.test.ts
Normal file
46
src/instrumentation.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureSetupCode = vi.fn();
|
||||
vi.mock("./lib/setup-code", () => ({ ensureSetupCode }));
|
||||
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(os.tmpdir(), "instr-"));
|
||||
vi.stubEnv("ADMIN_AUTH_FILE", path.join(dir, "admin.json"));
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
ensureSetupCode.mockReset();
|
||||
});
|
||||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
describe("instrumentation register", () => {
|
||||
it("prints the setup code at startup when no admin exists", async () => {
|
||||
await register();
|
||||
expect(ensureSetupCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays quiet when an admin exists", async () => {
|
||||
const { createAdminRecord, writeAdminFile } = await import("./lib/admin-file");
|
||||
await writeAdminFile(process.env.ADMIN_AUTH_FILE!, await createAdminRecord("admin", "correct horse battery"));
|
||||
await register();
|
||||
expect(ensureSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a malformed admin file loudly instead of printing a code", async () => {
|
||||
await writeFile(process.env.ADMIN_AUTH_FILE!, "{broken");
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await register();
|
||||
expect(ensureSetupCode).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(expect.stringMatching(/^\[auth\] Admin file .* not valid JSON/));
|
||||
});
|
||||
|
||||
it("does nothing outside the Node.js runtime", async () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "edge");
|
||||
await register();
|
||||
expect(ensureSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
15
src/instrumentation.ts
Normal file
15
src/instrumentation.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Runs once when the server starts. If no admin login exists yet, prints the one-time
|
||||
* setup code (vrek dec-nw2hvff) so it's in the console before anyone opens /setup.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
const { adminFilePath, readAdminFile } = await import("./lib/admin-file");
|
||||
const { ensureSetupCode } = await import("./lib/setup-code");
|
||||
try {
|
||||
if (!(await readAdminFile(adminFilePath()))) ensureSetupCode();
|
||||
} catch (err) {
|
||||
// A malformed admin file: say so loudly; every request will fail until it's fixed.
|
||||
console.error(`[auth] ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
76
src/lib/access.test.ts
Normal file
76
src/lib/access.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthState } from "./session";
|
||||
|
||||
const authState = vi.fn<() => Promise<AuthState>>();
|
||||
vi.mock("./session", () => ({ authState }));
|
||||
|
||||
const cookieNames = new Set<string>();
|
||||
vi.mock("next/headers", () => ({ cookies: async () => ({ has: (n: string) => cookieNames.has(n) }) }));
|
||||
|
||||
class Redirect extends Error {}
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (to: string) => {
|
||||
throw new Redirect(to);
|
||||
},
|
||||
}));
|
||||
|
||||
const { access, requirePageAccess, apiAccessDenied } = await import("./access");
|
||||
|
||||
const redirectOf = (p: Promise<unknown>) =>
|
||||
p.then(
|
||||
() => null,
|
||||
(e) => (e instanceof Redirect ? e.message : Promise.reject(e)),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
cookieNames.clear();
|
||||
authState.mockReset();
|
||||
});
|
||||
|
||||
describe("access", () => {
|
||||
it.each([
|
||||
["no-admin", "unsecured"],
|
||||
["signed-in", "signed-in"],
|
||||
["signed-out", "signed-out"],
|
||||
] as const)("maps %s to %s", async (state, expected) => {
|
||||
authState.mockResolvedValue(state);
|
||||
expect(await access()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requirePageAccess", () => {
|
||||
it("lets a signed-in admin through", async () => {
|
||||
authState.mockResolvedValue("signed-in");
|
||||
expect(await redirectOf(requirePageAccess())).toBeNull();
|
||||
});
|
||||
|
||||
it("sends a signed-out visitor to /login", async () => {
|
||||
authState.mockResolvedValue("signed-out");
|
||||
expect(await redirectOf(requirePageAccess())).toBe("/login");
|
||||
});
|
||||
|
||||
it("sends visitors to /setup while no admin exists", async () => {
|
||||
authState.mockResolvedValue("no-admin");
|
||||
expect(await redirectOf(requirePageAccess())).toBe("/setup");
|
||||
});
|
||||
|
||||
it("lets a visitor through unsecured once they chose to skip setup", async () => {
|
||||
authState.mockResolvedValue("no-admin");
|
||||
cookieNames.add("cameras_setup_skipped");
|
||||
expect(await redirectOf(requirePageAccess())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiAccessDenied", () => {
|
||||
it("returns 401 when an admin exists and the caller isn't signed in", async () => {
|
||||
authState.mockResolvedValue("signed-out");
|
||||
const res = (await apiAccessDenied())!;
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: "Sign in required" });
|
||||
});
|
||||
|
||||
it.each(["signed-in", "no-admin"] as const)("allows the call when %s", async (state) => {
|
||||
authState.mockResolvedValue(state);
|
||||
expect(await apiAccessDenied()).toBeNull();
|
||||
});
|
||||
});
|
||||
36
src/lib/access.ts
Normal file
36
src/lib/access.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SETUP_SKIP_COOKIE } from "./auth-shared";
|
||||
import { authState } from "./session";
|
||||
|
||||
/**
|
||||
* The authoritative access check for pages and route handlers (Next 16 authentication
|
||||
* guide, "Creating a Data Access Layer"). proxy.ts applies the same rules optimistically;
|
||||
* these run next to the data, so a request that slips past the proxy still gets nothing.
|
||||
*
|
||||
* Rules (vrek dec-nw2hvff): with an admin, a valid session is required everywhere. With no
|
||||
* admin the app is deliberately unsecured: the API is open, and pages are open once the user
|
||||
* has chosen to skip setup.
|
||||
*/
|
||||
|
||||
export type Access = "signed-in" | "unsecured" | "signed-out";
|
||||
|
||||
export async function access(): Promise<Access> {
|
||||
const state = await authState();
|
||||
if (state === "no-admin") return "unsecured";
|
||||
return state;
|
||||
}
|
||||
|
||||
/** For pages: sends the visitor to /login or /setup unless they may see the page. */
|
||||
export async function requirePageAccess(): Promise<void> {
|
||||
const state = await access();
|
||||
if (state === "signed-out") redirect("/login");
|
||||
if (state === "unsecured" && !(await cookies()).has(SETUP_SKIP_COOKIE)) redirect("/setup");
|
||||
}
|
||||
|
||||
/** For route handlers: a 401 response to return, or null if the request may proceed. */
|
||||
export async function apiAccessDenied(): Promise<Response | null> {
|
||||
if ((await access()) !== "signed-out") return null;
|
||||
return Response.json({ error: "Sign in required" }, { status: 401 });
|
||||
}
|
||||
38
src/lib/auth-shared.test.ts
Normal file
38
src/lib/auth-shared.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safeNextPath, sessionCookieOptions } from "./auth-shared";
|
||||
|
||||
describe("safeNextPath", () => {
|
||||
it.each([
|
||||
["/", "/"],
|
||||
["/?refresh=500", "/?refresh=500"],
|
||||
["/cameras/abc", "/cameras/abc"],
|
||||
])("keeps same-site path %s", (input, output) => {
|
||||
expect(safeNextPath(input)).toBe(output);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", undefined],
|
||||
["an array", ["/a", "/b"]],
|
||||
["a relative path", "cameras"],
|
||||
["an absolute URL", "https://evil.example/"],
|
||||
["a protocol-relative URL", "//evil.example/"],
|
||||
["a backslash trick", "/\\evil.example"],
|
||||
["a control character", "/\u0000evil"],
|
||||
["a javascript: URL", "javascript:alert(1)"],
|
||||
])("falls back to / for %s", (_label, input) => {
|
||||
expect(safeNextPath(input)).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionCookieOptions", () => {
|
||||
it("is HttpOnly, SameSite=Lax, site-wide, expiring with the session", () => {
|
||||
expect(sessionCookieOptions(1_000, false)).toEqual({
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: false,
|
||||
expires: new Date(1_000),
|
||||
});
|
||||
expect(sessionCookieOptions(1_000, true).secure).toBe(true);
|
||||
});
|
||||
});
|
||||
33
src/lib/auth-shared.ts
Normal file
33
src/lib/auth-shared.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Auth constants and helpers shared by proxy.ts, Server Actions and pages. Plain module (no
|
||||
* "server-only", no next/headers) so the proxy can import it.
|
||||
*/
|
||||
|
||||
export const SESSION_COOKIE = "cameras_session";
|
||||
/** Set when the user chooses to run without an admin; lasts until the browser closes. */
|
||||
export const SETUP_SKIP_COOKIE = "cameras_setup_skipped";
|
||||
|
||||
/** Pages reachable without a session. */
|
||||
export const PUBLIC_PATHS = ["/login", "/setup"];
|
||||
|
||||
/** Session cookie attributes. Secure only over HTTPS: the app usually runs on plain LAN HTTP. */
|
||||
export function sessionCookieOptions(expiresAt: number, secure: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure,
|
||||
expires: new Date(expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to send the user after login: a same-site path only, so `?next=` can't be used to
|
||||
* redirect to another site (`//evil.example`, `/\evil.example`, `https://…`).
|
||||
*/
|
||||
export function safeNextPath(value: unknown): string {
|
||||
if (typeof value !== "string" || !value.startsWith("/")) return "/";
|
||||
if (value.startsWith("//") || value.startsWith("/\\")) return "/";
|
||||
if (/[\u0000-\u001f]/.test(value)) return "/";
|
||||
return value;
|
||||
}
|
||||
75
src/lib/login-throttle.test.ts
Normal file
75
src/lib/login-throttle.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clientKey, DEFAULT_LIMITS, LoginThrottle, loginThrottle } from "./login-throttle";
|
||||
|
||||
const MIN = 60_000;
|
||||
const T0 = 1_000_000_000;
|
||||
|
||||
describe("LoginThrottle", () => {
|
||||
it("defaults to 10 failures per client and 100 overall per 15 minutes", () => {
|
||||
expect(DEFAULT_LIMITS).toMatchObject({ windowMs: 15 * MIN, perClient: 10, global: 100 });
|
||||
});
|
||||
|
||||
it("allows 9 failures, then locks the client out until the oldest one ages out", () => {
|
||||
const t = new LoginThrottle();
|
||||
for (let i = 0; i < 9; i++) t.recordFailure("a", T0 + i * MIN);
|
||||
expect(t.retryAfter("a", T0 + 9 * MIN)).toBe(0);
|
||||
|
||||
t.recordFailure("a", T0 + 9 * MIN);
|
||||
expect(t.retryAfter("a", T0 + 9 * MIN)).toBe(6 * MIN);
|
||||
expect(t.retryAfter("a", T0 + 15 * MIN)).toBe(0);
|
||||
});
|
||||
|
||||
it("locks out only the failing client", () => {
|
||||
const t = new LoginThrottle();
|
||||
for (let i = 0; i < 10; i++) t.recordFailure("a", T0);
|
||||
expect(t.retryAfter("a", T0)).toBeGreaterThan(0);
|
||||
expect(t.retryAfter("b", T0)).toBe(0);
|
||||
});
|
||||
|
||||
it("clears a client's failures after a successful login", () => {
|
||||
const t = new LoginThrottle();
|
||||
for (let i = 0; i < 10; i++) t.recordFailure("a", T0);
|
||||
t.recordSuccess("a");
|
||||
expect(t.retryAfter("a", T0)).toBe(0);
|
||||
});
|
||||
|
||||
it("locks everyone out once the global ceiling is hit, even across spoofed addresses", () => {
|
||||
const t = new LoginThrottle({ ...DEFAULT_LIMITS, global: 5 });
|
||||
for (let i = 0; i < 5; i++) t.recordFailure(`spoofed-${i}`, T0 + i);
|
||||
expect(t.retryAfter("fresh-client", T0 + 5)).toBe(15 * MIN - 5);
|
||||
expect(t.retryAfter("fresh-client", T0 + 15 * MIN)).toBe(0);
|
||||
});
|
||||
|
||||
it("caps the number of tracked clients, dropping the least recent", () => {
|
||||
const t = new LoginThrottle({ ...DEFAULT_LIMITS, perClient: 1, global: 1_000, maxClients: 2 });
|
||||
t.recordFailure("a", T0);
|
||||
t.recordFailure("b", T0);
|
||||
t.recordFailure("a", T0); // a is now most recent
|
||||
t.recordFailure("c", T0); // evicts b
|
||||
expect(t.retryAfter("a", T0)).toBeGreaterThan(0);
|
||||
expect(t.retryAfter("b", T0)).toBe(0);
|
||||
expect(t.retryAfter("c", T0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses the current time by default", () => {
|
||||
const t = new LoginThrottle({ ...DEFAULT_LIMITS, perClient: 1 });
|
||||
t.recordFailure("a");
|
||||
expect(t.retryAfter("a")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("is one instance per process", async () => {
|
||||
const again = await import("./login-throttle");
|
||||
expect(again.loginThrottle).toBe(loginThrottle);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientKey", () => {
|
||||
it.each([
|
||||
["192.168.1.20", "192.168.1.20"],
|
||||
[" 10.0.0.5 , 172.16.0.1", "10.0.0.5"],
|
||||
["", "unknown"],
|
||||
[null, "unknown"],
|
||||
])("%j → %s", (header, key) => {
|
||||
expect(clientKey(header)).toBe(key);
|
||||
});
|
||||
});
|
||||
77
src/lib/login-throttle.ts
Normal file
77
src/lib/login-throttle.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Limits password guessing at the login form.
|
||||
*
|
||||
* Per client: 10 failures within 15 minutes locks that client out until the oldest failure
|
||||
* ages out. The client key comes from x-forwarded-for, which Next fills from the socket
|
||||
* address but a client can also send itself, so a global ceiling (100 failures per 15
|
||||
* minutes across all clients) stops an attacker who rotates fake addresses. Each attempt
|
||||
* also costs one scrypt hash (~130 ms) regardless.
|
||||
*/
|
||||
|
||||
export interface ThrottleLimits {
|
||||
windowMs: number;
|
||||
perClient: number;
|
||||
global: number;
|
||||
/** Cap on tracked clients, so spoofed addresses can't grow memory without bound. */
|
||||
maxClients: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LIMITS: ThrottleLimits = {
|
||||
windowMs: 15 * 60 * 1000,
|
||||
perClient: 10,
|
||||
global: 100,
|
||||
maxClients: 10_000,
|
||||
};
|
||||
|
||||
export class LoginThrottle {
|
||||
private readonly limits: ThrottleLimits;
|
||||
private readonly clients = new Map<string, number[]>();
|
||||
private globalFailures: number[] = [];
|
||||
|
||||
constructor(limits: ThrottleLimits = DEFAULT_LIMITS) {
|
||||
this.limits = limits;
|
||||
}
|
||||
|
||||
private recent(times: number[], now: number) {
|
||||
return times.filter((t) => now - t < this.limits.windowMs);
|
||||
}
|
||||
|
||||
/** Milliseconds until this client may try again; 0 if it may try now. */
|
||||
retryAfter(client: string, now: number = Date.now()): number {
|
||||
const { windowMs, perClient, global } = this.limits;
|
||||
this.globalFailures = this.recent(this.globalFailures, now);
|
||||
const mine = this.recent(this.clients.get(client) ?? [], now);
|
||||
|
||||
const waits = [0];
|
||||
if (mine.length >= perClient) waits.push(mine[mine.length - perClient] + windowMs - now);
|
||||
if (this.globalFailures.length >= global) {
|
||||
waits.push(this.globalFailures[this.globalFailures.length - global] + windowMs - now);
|
||||
}
|
||||
return Math.max(...waits);
|
||||
}
|
||||
|
||||
recordFailure(client: string, now: number = Date.now()): void {
|
||||
const mine = this.recent(this.clients.get(client) ?? [], now);
|
||||
mine.push(now);
|
||||
this.clients.delete(client); // re-insert so Map order tracks recency
|
||||
this.clients.set(client, mine);
|
||||
if (this.clients.size > this.limits.maxClients) {
|
||||
this.clients.delete(this.clients.keys().next().value!);
|
||||
}
|
||||
this.globalFailures.push(now);
|
||||
}
|
||||
|
||||
recordSuccess(client: string): void {
|
||||
this.clients.delete(client);
|
||||
}
|
||||
}
|
||||
|
||||
/** The key a request is throttled under: the first x-forwarded-for address. */
|
||||
export function clientKey(forwardedFor: string | null): string {
|
||||
return forwardedFor?.split(",")[0].trim() || "unknown";
|
||||
}
|
||||
|
||||
// One throttle per server process, shared by every module instance that imports it.
|
||||
const KEY = Symbol.for("cameras.loginThrottle");
|
||||
export const loginThrottle: LoginThrottle = ((globalThis as { [KEY]?: LoginThrottle })[KEY] ??=
|
||||
new LoginThrottle());
|
||||
@ -2,29 +2,19 @@ import "server-only";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { getAdmin } from "./admin-auth";
|
||||
import type { AdminFile } from "./admin-file";
|
||||
import { SESSION_COOKIE, sessionCookieOptions } from "./auth-shared";
|
||||
import { issueToken, readToken, refreshToken, type Session } from "./session-token";
|
||||
|
||||
export { SESSION_COOKIE };
|
||||
|
||||
/**
|
||||
* The admin session as seen by the app, stored in an HttpOnly cookie. verifySession() is
|
||||
* the authoritative check for pages and routes (Next 16 authentication guide, "Creating a
|
||||
* Data Access Layer"); proxy.ts only does optimistic redirects and slides the window.
|
||||
*/
|
||||
|
||||
export const SESSION_COOKIE = "cameras_session";
|
||||
|
||||
export type AuthState = "no-admin" | "signed-out" | "signed-in";
|
||||
|
||||
/** Secure only over HTTPS: the app usually runs on plain HTTP on the LAN. */
|
||||
export function sessionCookieOptions(expiresAt: number, secure: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure,
|
||||
expires: new Date(expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestIsHttps(): Promise<boolean> {
|
||||
return (await headers()).get("x-forwarded-proto") === "https";
|
||||
}
|
||||
|
||||
80
src/lib/setup-code.test.ts
Normal file
80
src/lib/setup-code.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let setup: typeof import("./setup-code");
|
||||
const log = vi.fn<(message: string) => void>();
|
||||
|
||||
beforeEach(async () => {
|
||||
delete (globalThis as Record<symbol, unknown>)[Symbol.for("cameras.setupCode")];
|
||||
log.mockReset();
|
||||
vi.resetModules();
|
||||
setup = await import("./setup-code");
|
||||
});
|
||||
|
||||
const printedCode = (call = 0) => /\s{6}([A-Z0-9]{4}-[A-Z0-9]{4})\n/.exec(log.mock.calls[call][0])![1];
|
||||
|
||||
describe("setup code", () => {
|
||||
it("creates an 8-character code once and prints it with instructions", () => {
|
||||
const code = setup.ensureSetupCode(log);
|
||||
expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/);
|
||||
expect(setup.ensureSetupCode(log)).toBe(code);
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
|
||||
const message = log.mock.calls[0][0];
|
||||
expect(message).toContain("NOT secured");
|
||||
expect(message).toContain("open /setup");
|
||||
expect(message).toContain("npm run admin:create");
|
||||
expect(printedCode()).toBe(`${code.slice(0, 4)}-${code.slice(4)}`);
|
||||
});
|
||||
|
||||
it("is shared through globalThis, as instrumentation and the app are bundled separately", async () => {
|
||||
const code = setup.ensureSetupCode(log);
|
||||
vi.resetModules();
|
||||
const other = await import("./setup-code");
|
||||
expect(other.ensureSetupCode(log)).toBe(code);
|
||||
});
|
||||
|
||||
it("accepts the code regardless of case, spaces or the dash", () => {
|
||||
setup.ensureSetupCode(log);
|
||||
const shown = printedCode();
|
||||
expect(setup.checkSetupCode(shown, log)).toBe(true);
|
||||
expect(setup.checkSetupCode(shown.toLowerCase(), log)).toBe(true);
|
||||
expect(setup.checkSetupCode(` ${shown.replace("-", " ")} `, log)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong or differently sized codes", () => {
|
||||
setup.ensureSetupCode(log);
|
||||
expect(setup.checkSetupCode("", log)).toBe(false);
|
||||
expect(setup.checkSetupCode("AAAA-AAA", log)).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the code after 5 wrong guesses and prints the new one", () => {
|
||||
const first = setup.ensureSetupCode(log);
|
||||
for (let i = 0; i < 4; i++) setup.checkSetupCode("WRONGWRONG", log);
|
||||
expect(setup.ensureSetupCode(log)).toBe(first);
|
||||
|
||||
setup.checkSetupCode("WRONGWRONG", log);
|
||||
const second = setup.ensureSetupCode(log);
|
||||
expect(second).not.toBe(first);
|
||||
expect(log).toHaveBeenCalledWith("[setup] Too many wrong setup codes; issuing a new one.");
|
||||
expect(setup.checkSetupCode(first, log)).toBe(false);
|
||||
expect(setup.checkSetupCode(second, log)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a code on first check if none exists", () => {
|
||||
expect(setup.checkSetupCode("ANYTHING", log)).toBe(false);
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets the code once cleared", () => {
|
||||
const code = setup.ensureSetupCode(log);
|
||||
setup.clearSetupCode();
|
||||
expect(setup.ensureSetupCode(log)).not.toBe(code);
|
||||
});
|
||||
|
||||
it("logs to the console by default", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
setup.ensureSetupCode();
|
||||
setup.checkSetupCode("X");
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
93
src/lib/setup-code.ts
Normal file
93
src/lib/setup-code.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { randomInt, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* The one-time code that authorizes creating the first admin from the browser (vrek
|
||||
* dec-nw2hvff). It exists only in server memory and is printed to the server console, so
|
||||
* only someone who can see the console can claim the admin account.
|
||||
*
|
||||
* Kept on globalThis because instrumentation.ts and the app are bundled separately; both
|
||||
* must see the same code. Plain module (no "server-only") so instrumentation can import it.
|
||||
*/
|
||||
|
||||
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I
|
||||
const MAX_FAILURES = 5;
|
||||
|
||||
interface SetupCodeState {
|
||||
code: string | null;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for("cameras.setupCode");
|
||||
function state(): SetupCodeState {
|
||||
const g = globalThis as { [KEY]?: SetupCodeState };
|
||||
return (g[KEY] ??= { code: null, failures: 0 });
|
||||
}
|
||||
|
||||
function generate(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) code += ALPHABET[randomInt(ALPHABET.length)];
|
||||
return code;
|
||||
}
|
||||
|
||||
const normalize = (input: string) => input.toUpperCase().replace(/[\s-]/g, "");
|
||||
const pretty = (code: string) => `${code.slice(0, 4)}-${code.slice(4)}`;
|
||||
|
||||
function announce(code: string, log: (message: string) => void) {
|
||||
const rule = "=".repeat(68);
|
||||
log(
|
||||
[
|
||||
"",
|
||||
rule,
|
||||
" No admin login is set up: the camera dashboard is NOT secured.",
|
||||
"",
|
||||
" To secure it from a browser, open /setup and enter this one-time code:",
|
||||
"",
|
||||
` ${pretty(code)}`,
|
||||
"",
|
||||
" Or create the admin without the browser: npm run admin:create",
|
||||
rule,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
/** The current code, creating and printing one if needed. */
|
||||
export function ensureSetupCode(log: (message: string) => void = console.log): string {
|
||||
const s = state();
|
||||
if (!s.code) {
|
||||
s.code = generate();
|
||||
s.failures = 0;
|
||||
announce(s.code, log);
|
||||
}
|
||||
return s.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a submitted code in constant time. After MAX_FAILURES wrong guesses the code is
|
||||
* replaced (and the new one printed), so it can't be brute-forced.
|
||||
*/
|
||||
export function checkSetupCode(
|
||||
input: string,
|
||||
log: (message: string) => void = console.log,
|
||||
): boolean {
|
||||
const code = ensureSetupCode(log);
|
||||
const given = Buffer.from(normalize(input));
|
||||
const expected = Buffer.from(code);
|
||||
const ok = given.length === expected.length && timingSafeEqual(given, expected);
|
||||
if (!ok) {
|
||||
const s = state();
|
||||
if (++s.failures >= MAX_FAILURES) {
|
||||
s.code = null;
|
||||
log("[setup] Too many wrong setup codes; issuing a new one.");
|
||||
ensureSetupCode(log);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Forgets the code once an admin exists. */
|
||||
export function clearSetupCode(): void {
|
||||
const s = state();
|
||||
s.code = null;
|
||||
s.failures = 0;
|
||||
}
|
||||
127
src/proxy.test.ts
Normal file
127
src/proxy.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdminFile } from "./lib/admin-file";
|
||||
import { issueToken, REFRESH_AFTER_MS } from "./lib/session-token";
|
||||
|
||||
const admin: AdminFile = {
|
||||
version: 1,
|
||||
username: "admin",
|
||||
passwordHash: `scrypt$65536$8$1$${"A".repeat(24)}$${"K".repeat(88)}`,
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
let proxy: typeof import("./proxy").proxy;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(os.tmpdir(), "proxy-"));
|
||||
vi.stubEnv("ADMIN_AUTH_FILE", path.join(dir, "admin.json"));
|
||||
({ proxy } = await import("./proxy"));
|
||||
});
|
||||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const withAdmin = () => writeFile(process.env.ADMIN_AUTH_FILE!, JSON.stringify(admin));
|
||||
|
||||
function request(pathname: string, cookies: Record<string, string> = {}, headers: Record<string, string> = {}) {
|
||||
const cookie = Object.entries(cookies)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://cams.local${pathname}`, {
|
||||
headers: { ...(cookie ? { cookie } : {}), ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
const redirectTo = (res: Response) => res.headers.get("location");
|
||||
const passes = (res: Response) => res.headers.get("x-middleware-next") === "1";
|
||||
|
||||
describe("proxy without an admin (unsecured)", () => {
|
||||
it("sends pages to /setup", async () => {
|
||||
expect(redirectTo(await proxy(request("/")))).toBe("http://cams.local/setup");
|
||||
});
|
||||
|
||||
it("lets /setup and /login through", async () => {
|
||||
expect(passes(await proxy(request("/setup")))).toBe(true);
|
||||
expect(passes(await proxy(request("/login")))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the API open", async () => {
|
||||
expect(passes(await proxy(request("/api/discover")))).toBe(true);
|
||||
});
|
||||
|
||||
it("lets pages through once setup was skipped", async () => {
|
||||
expect(passes(await proxy(request("/", { cameras_setup_skipped: "1" })))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy with an admin", () => {
|
||||
beforeEach(withAdmin);
|
||||
|
||||
it("sends a signed-out visitor to /login", async () => {
|
||||
expect(redirectTo(await proxy(request("/")))).toBe("http://cams.local/login");
|
||||
});
|
||||
|
||||
it("remembers where a signed-out visitor was going", async () => {
|
||||
const res = await proxy(request("/?refresh=500"));
|
||||
expect(redirectTo(res)).toBe("http://cams.local/login?next=%2F%3Frefresh%3D500");
|
||||
});
|
||||
|
||||
it("returns 401 JSON for the API without a session", async () => {
|
||||
for (const p of ["/api", "/api/discover", "/api/cameras/x/snapshot"]) {
|
||||
const res = await proxy(request(p));
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: "Sign in required" });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores the skip cookie and forged sessions", async () => {
|
||||
expect(redirectTo(await proxy(request("/", { cameras_setup_skipped: "1" })))).toContain("/login");
|
||||
expect(redirectTo(await proxy(request("/", { cameras_session: "forged" })))).toContain("/login");
|
||||
});
|
||||
|
||||
it("lets /login and /setup through so they can redirect appropriately", async () => {
|
||||
expect(passes(await proxy(request("/login")))).toBe(true);
|
||||
expect(passes(await proxy(request("/setup")))).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a signed-in admin through without rewriting a fresh cookie", async () => {
|
||||
const res = await proxy(request("/api/discover", { cameras_session: issueToken(admin) }));
|
||||
expect(passes(res)).toBe(true);
|
||||
expect(res.cookies.get("cameras_session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("slides an older session to a fresh 12-hour cookie", async () => {
|
||||
const old = issueToken(admin, Date.now() - REFRESH_AFTER_MS - 1_000);
|
||||
const res = await proxy(request("/", { cameras_session: old }));
|
||||
const cookie = res.cookies.get("cameras_session")!;
|
||||
expect(passes(res)).toBe(true);
|
||||
expect(cookie.value).not.toBe(old);
|
||||
expect(cookie).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/", secure: false });
|
||||
expect(cookie.expires!.valueOf()).toBeGreaterThan(Date.now() + 11.9 * 3600_000);
|
||||
});
|
||||
|
||||
it("marks the refreshed cookie Secure behind HTTPS", async () => {
|
||||
const old = issueToken(admin, Date.now() - REFRESH_AFTER_MS - 1_000);
|
||||
const res = await proxy(request("/", { cameras_session: old }, { "x-forwarded-proto": "https" }));
|
||||
expect(res.cookies.get("cameras_session")!.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy with a malformed admin file", () => {
|
||||
it("fails rather than letting anyone in", async () => {
|
||||
await writeFile(process.env.ADMIN_AUTH_FILE!, "{broken");
|
||||
await expect(proxy(request("/"))).rejects.toThrow(/not valid JSON/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matcher", () => {
|
||||
it("skips build assets but covers pages and the API", async () => {
|
||||
const { config } = await import("./proxy");
|
||||
const re = new RegExp(`^${config.matcher[0]}$`);
|
||||
expect(re.test("/")).toBe(true);
|
||||
expect(re.test("/api/discover")).toBe(true);
|
||||
expect(re.test("/_next/static/chunk.js")).toBe(false);
|
||||
expect(re.test("/favicon.ico")).toBe(false);
|
||||
});
|
||||
});
|
||||
55
src/proxy.ts
Normal file
55
src/proxy.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { adminFilePath, readAdminFile } from "./lib/admin-file";
|
||||
import {
|
||||
PUBLIC_PATHS,
|
||||
SESSION_COOKIE,
|
||||
SETUP_SKIP_COOKIE,
|
||||
sessionCookieOptions,
|
||||
} from "./lib/auth-shared";
|
||||
import { readToken, refreshToken } from "./lib/session-token";
|
||||
|
||||
/**
|
||||
* Optimistic auth for every request (Next 16 proxy, Node runtime): redirects to /login or
|
||||
* /setup, returns 401 for the API, and slides the 12-hour session window (vrek
|
||||
* dec-f0xar8r). Pages and routes re-check with lib/access.ts, so this is not the only
|
||||
* line of defense.
|
||||
*/
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
const isApi = pathname === "/api" || pathname.startsWith("/api/");
|
||||
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||
const admin = await readAdminFile(adminFilePath());
|
||||
|
||||
if (!admin) {
|
||||
// Unsecured by choice: the API stays open, pages once setup has been skipped.
|
||||
if (isApi || isPublic || request.cookies.has(SETUP_SKIP_COOKIE)) return NextResponse.next();
|
||||
return NextResponse.redirect(new URL("/setup", request.url));
|
||||
}
|
||||
|
||||
const session = readToken(request.cookies.get(SESSION_COOKIE)?.value, admin);
|
||||
if (!session) {
|
||||
if (isPublic) return NextResponse.next();
|
||||
if (isApi) return NextResponse.json({ error: "Sign in required" }, { status: 401 });
|
||||
const login = new URL("/login", request.url);
|
||||
const destination = pathname + search;
|
||||
if (destination !== "/") login.searchParams.set("next", destination);
|
||||
return NextResponse.redirect(login);
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
const fresh = refreshToken(session, admin);
|
||||
if (fresh) {
|
||||
const secure = request.headers.get("x-forwarded-proto") === "https";
|
||||
response.cookies.set(
|
||||
SESSION_COOKIE,
|
||||
fresh,
|
||||
sessionCookieOptions(readToken(fresh, admin)!.expiresAt, secure),
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Everything except build assets.
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user