From b3eaeb7287b1226bd930d03ace81071f58dcd222 Mon Sep 17 00:00:00 2001 From: sjcoetzeesj-coder Date: Sun, 18 Oct 2026 18:39:27 +0200 Subject: [PATCH] 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. --- Workflow/.env.example | 14 ++++ Workflow/app/api/auth/login/route.ts | 41 ++++++++++ Workflow/app/api/auth/logout/route.ts | 13 ++++ Workflow/app/login/page.tsx | 78 +++++++++++++++++++ Workflow/lib/auth.ts | 103 ++++++++++++++++++++++++++ Workflow/middleware.ts | 70 +++++++++++------ 6 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 Workflow/app/api/auth/login/route.ts create mode 100644 Workflow/app/api/auth/logout/route.ts create mode 100644 Workflow/app/login/page.tsx create mode 100644 Workflow/lib/auth.ts diff --git a/Workflow/.env.example b/Workflow/.env.example index e35ac97..d5a0935 100644 --- a/Workflow/.env.example +++ b/Workflow/.env.example @@ -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= diff --git a/Workflow/app/api/auth/login/route.ts b/Workflow/app/api/auth/login/route.ts new file mode 100644 index 0000000..e70c415 --- /dev/null +++ b/Workflow/app/api/auth/login/route.ts @@ -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; +} \ No newline at end of file diff --git a/Workflow/app/api/auth/logout/route.ts b/Workflow/app/api/auth/logout/route.ts new file mode 100644 index 0000000..46f08b7 --- /dev/null +++ b/Workflow/app/api/auth/logout/route.ts @@ -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; +} \ No newline at end of file diff --git a/Workflow/app/login/page.tsx b/Workflow/app/login/page.tsx new file mode 100644 index 0000000..a5c737d --- /dev/null +++ b/Workflow/app/login/page.tsx @@ -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(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 ( +
+
+
+

Ysterbaard

+

Enter your password to continue.

+
+ + 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 ?

{error}

: null} + + +
+
+ ); +} + +export default function LoginPage() { + return ( + }> + + + ); +} \ No newline at end of file diff --git a/Workflow/lib/auth.ts b/Workflow/lib/auth.ts new file mode 100644 index 0000000..2223a77 --- /dev/null +++ b/Workflow/lib/auth.ts @@ -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 { + return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [ + "sign", + "verify", + ]); +} + +/** Returns ".". */ +export async function createSessionToken(secret: string, ttlSeconds = SESSION_TTL_SECONDS): Promise { + 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 { + 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, + }; +} \ No newline at end of file diff --git a/Workflow/middleware.ts b/Workflow/middleware.ts index 449eb6f..a39506f 100644 --- a/Workflow/middleware.ts +++ b/Workflow/middleware.ts @@ -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)$).*)", ], }; \ No newline at end of file