feat(auth): password gate replacing the IP-only middleware guard

middleware.ts previously 404'd every external request, which meant the
app was unusable once hosted. There are no per-user accounts, so this
adds a single-password gate:

- /login page + POST /api/auth/login, POST /api/auth/logout
- lib/auth.ts: HMAC-SHA256 signed session cookie (lib/auth), Web Crypto
  only so the same code runs in Edge middleware and Node handlers
- middleware now admits: the signing flow (unchanged, still public),
  /login + /api/auth/*, local requests, and valid sessions. Everything
  else redirects to /login (pages) or returns 401 JSON (API calls).
- password compared with a constant-time compare, wrong attempts
  delayed 600ms to slow online guessing
- cookie is HttpOnly + SameSite=Lax, 12h, Secure when x-forwarded-proto
  says https (Coolify serves the proxy on http today)

APP_PASSWORD / AUTH_SECRET added to .env.example, with a warning: an
unquoted # in an env value starts a comment, which silently truncated
the first generated password and made login fail.

Verified against a real built server: anon->307 /login, anon API->401,
wrong password->401, correct password->200+cookie, signed-in request
reaches PocketBase, /resign/ still 200 without a cookie, forged
cookie->401.
This commit is contained in:
sjcoetzeesj-coder 2026-10-18 18:39:27 +02:00
parent 21db4d0e64
commit b3eaeb7287
6 changed files with 295 additions and 24 deletions

View file

@ -19,6 +19,20 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# --- App password gate ------------------------------------------------------
# The app has no per-user accounts. These two values protect everything.
# APP_PASSWORD — the password you type at /login
# AUTH_SECRET — random 32+ char secret used to sign the session cookie.
# Changing it logs everyone out. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
#
# WARNING: keep the password alphanumeric. An unquoted `#` starts a comment in
# .env files, so APP_PASSWORD=abc#123 silently becomes "abc" and login fails
# with "Wrong password". If you must use special characters, wrap the value in
# double quotes: APP_PASSWORD="abc#123"
APP_PASSWORD=
AUTH_SECRET=
# --- CIPC BizPortal logins --------------------------------------------------
# Login 1 — primary; rotates to Login 3 when it hits the 100-search limit.
CIPC_LOGIN1_CODE=

View file

@ -0,0 +1,41 @@
// app/api/auth/login/route.ts
// POST — exchanges APP_PASSWORD for a signed session cookie.
//
// This is the only place the password is compared, and it happens entirely
// on the server. The password is never sent anywhere except this app.
import { NextRequest, NextResponse } from "next/server";
import { createSessionToken, secretsMatch, SESSION_COOKIE, SESSION_TTL_SECONDS, sessionCookieOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const expected = process.env.APP_PASSWORD;
const secret = process.env.AUTH_SECRET;
if (!expected || !secret) {
return NextResponse.json(
{ error: "Login is not configured. Set APP_PASSWORD and AUTH_SECRET." },
{ status: 503 }
);
}
let password = "";
try {
const body = await req.json();
if (typeof body?.password === "string") password = body.password;
} catch {
// fall through — empty password will simply fail the check
}
if (!secretsMatch(password, expected)) {
// Slow down online guessing; a wrong guess costs the caller ~0.6s.
await new Promise((r) => setTimeout(r, 600));
return NextResponse.json({ error: "Wrong password." }, { status: 401 });
}
const token = await createSessionToken(secret);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, sessionCookieOptions(req, SESSION_TTL_SECONDS));
return res;
}

View file

@ -0,0 +1,13 @@
// app/api/auth/logout/route.ts
// POST — clears the session cookie.
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, "", sessionCookieOptions(req, 0));
return res;
}

View file

@ -0,0 +1,78 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
function LoginForm() {
const router = useRouter();
const params = useSearchParams();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "Could not sign in.");
setBusy(false);
return;
}
const next = params.get("next");
// full reload so the middleware re-evaluates with the new cookie
router.replace(next && next.startsWith("/") ? next : "/");
router.refresh();
} catch {
setError("Could not sign in.");
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<form onSubmit={submit} className="w-full max-w-sm space-y-4">
<div>
<h1 className="text-2xl font-semibold text-foreground">Ysterbaard</h1>
<p className="text-sm text-muted mt-1">Enter your password to continue.</p>
</div>
<input
type="password"
name="password"
autoComplete="current-password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-foreground outline-none focus:border-accent"
/>
{error ? <p className="text-sm text-red-400">{error}</p> : null}
<button
type="submit"
disabled={busy || !password}
className="w-full rounded-lg bg-accent px-3 py-2 font-medium text-black disabled:opacity-50"
>
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div className="min-h-screen" />}>
<LoginForm />
</Suspense>
);
}

103
Workflow/lib/auth.ts Normal file
View file

@ -0,0 +1,103 @@
// lib/auth.ts
//
// Session helpers for the app password gate.
//
// Design notes:
// • Uses only Web Crypto (crypto.subtle), never node:crypto, so the same
// code runs in the Edge middleware and in Node route handlers.
// • The session cookie carries an expiry plus an HMAC-SHA256 signature.
// It is NOT a PocketBase token: the app keeps using its server-side
// PB_TOKEN, this only proves "a human who knows APP_PASSWORD got here".
// • Nothing secret is baked into the client bundle — the password is only
// ever read inside a route handler on the server.
export const SESSION_COOKIE = "wf_session";
/** 12 hours. Long enough for a working day, short enough to expire. */
export const SESSION_TTL_SECONDS = 60 * 60 * 12;
const enc = new TextEncoder();
const dec = new TextDecoder();
function b64u(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function unb64u(s: string): Uint8Array {
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
"verify",
]);
}
/** Returns "<base64url payload>.<base64url signature>". */
export async function createSessionToken(secret: string, ttlSeconds = SESSION_TTL_SECONDS): Promise<string> {
if (!secret) throw new Error("AUTH_SECRET is not set");
const payload = b64u(
enc.encode(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttlSeconds }))
);
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(payload)));
return `${payload}.${b64u(sig)}`;
}
export async function verifySessionToken(
secret: string | undefined,
token: string | undefined | null
): Promise<boolean> {
if (!secret || !token) return false;
const dot = token.lastIndexOf(".");
if (dot <= 0) return false;
const payload = token.slice(0, dot);
let sig: Uint8Array;
try {
sig = unb64u(token.slice(dot + 1));
} catch {
return false;
}
try {
// crypto.subtle.verify is a constant-time comparison for us.
const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret), sig, enc.encode(payload));
if (!ok) return false;
const { exp } = JSON.parse(dec.decode(unb64u(payload)));
return typeof exp === "number" && exp > Math.floor(Date.now() / 1000);
} catch {
return false;
}
}
/**
* Constant-time-ish string compare. Prevents an attacker from learning the
* password one character at a time by timing the response.
*/
export function secretsMatch(a: string, b: string): boolean {
const ab = enc.encode(a);
const bb = enc.encode(b);
let diff = ab.length ^ bb.length;
const max = Math.max(ab.length, bb.length);
for (let i = 0; i < max; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
return diff === 0;
}
/** Cookie options shared by the login and logout routes. */
export function sessionCookieOptions(req: { headers: Headers; nextUrl: URL }, maxAge: number) {
// Behind Traefik the browser talks https to the proxy, which forwards http
// to the container — so trust x-forwarded-proto rather than nextUrl.protocol.
const proto = req.headers.get("x-forwarded-proto") ?? req.nextUrl.protocol;
return {
httpOnly: true,
sameSite: "lax" as const,
secure: proto === "https:",
path: "/",
maxAge,
};
}

View file

@ -1,25 +1,23 @@
// middleware.ts
// Access control for the signing flow.
// Access control.
//
// WHY: this app has no login. It exposes a PocketBase proxy at
// /api/pb/records/* that carries client names, director ID numbers,
// addresses, emails and financials. If the app is put on the public
// internet (tunnel, port-forward, or Coolify) without a guard, anyone who
// guesses or observes the URL can read all of it.
// WHY this exists:
// This app has no per-user accounts. It exposes a PocketBase proxy at
// /api/pb/records/* carrying client names, director ID numbers, addresses
// and financials. Once the app is hosted (Coolify, tunnel, port-forward)
// that proxy must NOT be reachable by anyone who finds the URL.
//
// The rule implemented here:
// • The resign/ signing flow is PUBLIC — the unguessable per-resignation
// The rule:
// • /login and /api/auth/* are always reachable — otherwise you could not
// sign in in the first place.
// • The resign/ signing flow stays PUBLIC: the unguessable per-resignation
// token IS the credential, which is the whole point of that page.
// • Everything else (the registry, PocketBase proxies, reports, PB
// writes) is LOCAL ONLY. From outside, it 404s as if it does not exist.
//
// A local request — you, on this machine, via localhost:6001 — keeps full
// access to the entire app.
//
// Deploying behind Coolify later: delete this file once the app has real
// authentication, or keep it and add a session check.
// • Everything else requires a valid signed session cookie (see lib/auth).
// • A local request (your machine, via localhost) keeps full access, so
// day-to-day development is unaffected.
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth";
/** Paths a director on a phone must be able to reach. */
const PUBLIC_GET_PREFIXES = [
@ -32,6 +30,9 @@ const PUBLIC_GET_PREFIXES = [
* 15-character random string that cannot be enumerated. */
const PUBLIC_PATCH_PREFIX = "/api/resignations/";
/** The gate itself. */
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout"];
function clientIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
const first = fwd ? fwd.split(",")[0].trim() : "";
@ -50,10 +51,11 @@ function isLocalRequest(ip: string): boolean {
return false;
}
export function middleware(req: NextRequest) {
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const method = req.method.toUpperCase();
// 1. Signing flow stays public.
const isPublicGet = method === "GET" && PUBLIC_GET_PREFIXES.some((p) => pathname.startsWith(p));
const isPublicPatch =
method === "PATCH" &&
@ -64,21 +66,41 @@ export function middleware(req: NextRequest) {
return NextResponse.next();
}
// 2. The login gate itself.
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/"))) {
return NextResponse.next();
}
// 3. Local development keeps working with no password at all.
if (isLocalRequest(clientIp(req))) {
return NextResponse.next();
}
// External request for an internal route. Disguise as "not found" rather
// than "forbidden" so probing does not reveal what exists.
return new NextResponse("Not found", {
status: 404,
headers: { "Content-Type": "text/plain" },
});
// 4. Otherwise: a valid session or nothing.
const secret = process.env.AUTH_SECRET;
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (await verifySessionToken(secret, token)) {
return NextResponse.next();
}
// 5. Not signed in.
// API callers get JSON; browsers get sent to the login page.
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
const url = req.nextUrl.clone();
url.pathname = "/login";
url.search = `?next=${encodeURIComponent(pathname + req.nextUrl.search)}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: [
// Everything except Next internals and the verification image.
// Everything except Next internals and static/image files.
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
],
};