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:
Michael Mainguy 2026-09-19 11:58:20 -05:00
parent 462141aa35
commit 5d181f3b3b
40 changed files with 1689 additions and 21 deletions

View File

@ -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-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-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-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"}

View File

@ -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 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 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) ### Create the admin login (recommended)

View 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();
});
});

View File

@ -10,6 +10,8 @@ const store = {
describeCredentials: vi.fn(), 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) => ({ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()), ...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord, getCameraRecord,

View File

@ -1,3 +1,4 @@
import { apiAccessDenied } from "@/lib/access";
import { resetConnection, testCredentials } from "@/lib/camera"; import { resetConnection, testCredentials } from "@/lib/camera";
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
import { import {
@ -10,6 +11,8 @@ import {
// Passwords are write-only: GET reports whether one is set, never its value. // 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">) { 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); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
try { 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">) { 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); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
@ -44,6 +49,8 @@ export async function DELETE(
_request: Request, _request: Request,
ctx: RouteContext<"/api/cameras/[id]/credentials">, ctx: RouteContext<"/api/cameras/[id]/credentials">,
) { ) {
const denied = await apiAccessDenied();
if (denied) return denied;
const target = await cameraTarget(ctx.params); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
try { try {

View File

@ -4,6 +4,8 @@ import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-r
const getCameraRecord = vi.fn(); const getCameraRecord = vi.fn();
const getCameraInfo = 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) => ({ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()), ...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord, getCameraRecord,

View File

@ -1,7 +1,10 @@
import { apiAccessDenied } from "@/lib/access";
import { getCameraInfo } from "@/lib/camera"; import { getCameraInfo } from "@/lib/camera";
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/info">) { 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); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;

View File

@ -4,6 +4,8 @@ import { ctx, LEAKY, record, target, url } from "../../../../../../test/camera-r
const getCameraRecord = vi.fn(); const getCameraRecord = vi.fn();
const getSnapshot = 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) => ({ vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()), ...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord, getCameraRecord,

View File

@ -1,7 +1,10 @@
import { apiAccessDenied } from "@/lib/access";
import { getSnapshot } from "@/lib/camera"; import { getSnapshot } from "@/lib/camera";
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
export async function GET(request: Request, ctx: RouteContext<"/api/cameras/[id]/snapshot">) { 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); const target = await cameraTarget(ctx.params);
if (target instanceof Response) return target; if (target instanceof Response) return target;
const profile = new URL(request.url).searchParams.get("profile") ?? undefined; const profile = new URL(request.url).searchParams.get("profile") ?? undefined;

View File

@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const discoverCameras = vi.fn(); const discoverCameras = vi.fn();
const recordDiscovered = 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) => ({ vi.mock("@/lib/onvif", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/onvif")>()), ...(await importOriginal<typeof import("@/lib/onvif")>()),
discoverCameras, discoverCameras,

View File

@ -1,7 +1,10 @@
import { apiAccessDenied } from "@/lib/access";
import { recordDiscovered } from "@/lib/camera-registry"; import { recordDiscovered } from "@/lib/camera-registry";
import { discoverCameras, discoverRequestSchema } from "@/lib/onvif"; import { discoverCameras, discoverRequestSchema } from "@/lib/onvif";
export async function POST(request: Request) { export async function POST(request: Request) {
const denied = await apiAccessDenied();
if (denied) return denied;
// An empty body means "use the defaults". // An empty body means "use the defaults".
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const parsed = discoverRequestSchema.safeParse(body ?? {}); const parsed = discoverRequestSchema.safeParse(body ?? {});

View 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
View 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("/");
}

View 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({});
});
});

View 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&#x27;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();
});
});

View File

@ -128,6 +128,19 @@ describe("CameraCard", () => {
expect(screen.queryByText(/Setup required/)).toBeNull(); 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", () => { describe("login", () => {
it("asks for a login when the camera rejects it, then recovers after saving", async () => { it("asks for a login when the camera rejects it, then recovers after saving", async () => {
let loggedIn = false; let loggedIn = false;

View File

@ -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" && ( {problem?.kind === "error" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600"> <div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Stopped: {problem.message}</span> <span>Stopped: {problem.message}</span>

View File

@ -7,8 +7,11 @@ import type { DiscoveredCamera } from "@/lib/onvif";
* handlers. No device logic lives here; the server decides what each response means. * 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 { export class CameraProblem extends Error {
name = "CameraProblem"; name = "CameraProblem";
@ -46,9 +49,11 @@ async function problemFrom(res: Response): Promise<CameraProblem> {
const kind: ProblemKind = const kind: ProblemKind =
data.code === "inactive" data.code === "inactive"
? "inactive" ? "inactive"
: res.status === 401 || data.code === "auth" : data.code === "auth"
? "auth" ? "auth"
: "error"; : res.status === 401
? "signed-out"
: "error";
return new CameraProblem(kind, data.error ?? `HTTP ${res.status}`); 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>; 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) => const retryPlainFailureOnce = (failures: number, err: Error) =>
failures < 1 && !(err instanceof CameraProblem && err.kind !== "error"); failures < 1 && !(err instanceof CameraProblem && err.kind !== "error");

View File

@ -6,6 +6,7 @@ vi.mock("next/font/google", () => ({
Geist_Mono: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }), Geist_Mono: (opts: { variable: string }) => ({ variable: `v(${opts.variable})` }),
})); }));
vi.mock("./globals.css", () => ({})); vi.mock("./globals.css", () => ({}));
vi.mock("./security-bar", () => ({ default: () => <div data-testid="security-bar" /> }));
vi.mock("./providers", () => ({ vi.mock("./providers", () => ({
default: ({ children }: { children: React.ReactNode }) => ( default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="providers">{children}</div> <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\) /); expect(html).toMatch(/<html lang="en" class="v\(--font-geist-sans\) v\(--font-geist-mono\) /);
}); });
it("wraps the page in the client Providers", () => { it("puts the security bar above the page, which is wrapped in the client Providers", () => {
expect(html).toContain('<div data-testid="providers"><main>page</main></div>'); expect(html).toContain(
'<div data-testid="security-bar"></div><div data-testid="providers"><main>page</main></div>',
);
}); });
it("sets the page metadata", () => { it("sets the page metadata", () => {

View File

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Providers from "./providers"; import Providers from "./providers";
import SecurityBar from "./security-bar";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -25,6 +26,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<SecurityBar />
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

View 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
View 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&apos;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>
);
}

View File

@ -1,3 +1,4 @@
import { requirePageAccess } from "@/lib/access";
import { listCameras } from "@/lib/camera-registry"; import { listCameras } from "@/lib/camera-registry";
import CameraCard from "./camera-card"; import CameraCard from "./camera-card";
import CameraScanner from "./camera-scanner"; import CameraScanner from "./camera-scanner";
@ -5,6 +6,7 @@ import RefreshRateSelect from "./refresh-rate-select";
import { refreshMsSchema } from "./refresh-rate"; import { refreshMsSchema } from "./refresh-rate";
export default async function Home({ searchParams }: PageProps<"/">) { export default async function Home({ searchParams }: PageProps<"/">) {
await requirePageAccess();
const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]); const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]);
const intervalMs = refreshMsSchema.parse(refresh); const intervalMs = refreshMsSchema.parse(refresh);

View 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
View 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
View 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&apos;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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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 });
}

View 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
View 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;
}

View 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
View 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());

View File

@ -2,29 +2,19 @@ import "server-only";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { getAdmin } from "./admin-auth"; import { getAdmin } from "./admin-auth";
import type { AdminFile } from "./admin-file"; import type { AdminFile } from "./admin-file";
import { SESSION_COOKIE, sessionCookieOptions } from "./auth-shared";
import { issueToken, readToken, refreshToken, type Session } from "./session-token"; 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 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 * 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. * 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"; 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> { async function requestIsHttps(): Promise<boolean> {
return (await headers()).get("x-forwarded-proto") === "https"; return (await headers()).get("x-forwarded-proto") === "https";
} }

View 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
View 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
View 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
View 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).*)"],
};