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:
parent
21db4d0e64
commit
b3eaeb7287
6 changed files with 295 additions and 24 deletions
|
|
@ -19,6 +19,20 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
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 --------------------------------------------------
|
# --- CIPC BizPortal logins --------------------------------------------------
|
||||||
# Login 1 — primary; rotates to Login 3 when it hits the 100-search limit.
|
# Login 1 — primary; rotates to Login 3 when it hits the 100-search limit.
|
||||||
CIPC_LOGIN1_CODE=
|
CIPC_LOGIN1_CODE=
|
||||||
|
|
|
||||||
41
Workflow/app/api/auth/login/route.ts
Normal file
41
Workflow/app/api/auth/login/route.ts
Normal 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;
|
||||||
|
}
|
||||||
13
Workflow/app/api/auth/logout/route.ts
Normal file
13
Workflow/app/api/auth/logout/route.ts
Normal 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;
|
||||||
|
}
|
||||||
78
Workflow/app/login/page.tsx
Normal file
78
Workflow/app/login/page.tsx
Normal 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
103
Workflow/lib/auth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
// middleware.ts
|
// middleware.ts
|
||||||
// Access control for the signing flow.
|
// Access control.
|
||||||
//
|
//
|
||||||
// WHY: this app has no login. It exposes a PocketBase proxy at
|
// WHY this exists:
|
||||||
// /api/pb/records/* that carries client names, director ID numbers,
|
// This app has no per-user accounts. It exposes a PocketBase proxy at
|
||||||
// addresses, emails and financials. If the app is put on the public
|
// /api/pb/records/* carrying client names, director ID numbers, addresses
|
||||||
// internet (tunnel, port-forward, or Coolify) without a guard, anyone who
|
// and financials. Once the app is hosted (Coolify, tunnel, port-forward)
|
||||||
// guesses or observes the URL can read all of it.
|
// that proxy must NOT be reachable by anyone who finds the URL.
|
||||||
//
|
//
|
||||||
// The rule implemented here:
|
// The rule:
|
||||||
// • The resign/ signing flow is PUBLIC — the unguessable per-resignation
|
// • /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.
|
// token IS the credential, which is the whole point of that page.
|
||||||
// • Everything else (the registry, PocketBase proxies, reports, PB
|
// • Everything else requires a valid signed session cookie (see lib/auth).
|
||||||
// writes) is LOCAL ONLY. From outside, it 404s as if it does not exist.
|
// • A local request (your machine, via localhost) keeps full access, so
|
||||||
//
|
// day-to-day development is unaffected.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth";
|
||||||
|
|
||||||
/** Paths a director on a phone must be able to reach. */
|
/** Paths a director on a phone must be able to reach. */
|
||||||
const PUBLIC_GET_PREFIXES = [
|
const PUBLIC_GET_PREFIXES = [
|
||||||
|
|
@ -32,6 +30,9 @@ const PUBLIC_GET_PREFIXES = [
|
||||||
* 15-character random string that cannot be enumerated. */
|
* 15-character random string that cannot be enumerated. */
|
||||||
const PUBLIC_PATCH_PREFIX = "/api/resignations/";
|
const PUBLIC_PATCH_PREFIX = "/api/resignations/";
|
||||||
|
|
||||||
|
/** The gate itself. */
|
||||||
|
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout"];
|
||||||
|
|
||||||
function clientIp(req: NextRequest): string {
|
function clientIp(req: NextRequest): string {
|
||||||
const fwd = req.headers.get("x-forwarded-for");
|
const fwd = req.headers.get("x-forwarded-for");
|
||||||
const first = fwd ? fwd.split(",")[0].trim() : "";
|
const first = fwd ? fwd.split(",")[0].trim() : "";
|
||||||
|
|
@ -50,10 +51,11 @@ function isLocalRequest(ip: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const method = req.method.toUpperCase();
|
const method = req.method.toUpperCase();
|
||||||
|
|
||||||
|
// 1. Signing flow stays public.
|
||||||
const isPublicGet = method === "GET" && PUBLIC_GET_PREFIXES.some((p) => pathname.startsWith(p));
|
const isPublicGet = method === "GET" && PUBLIC_GET_PREFIXES.some((p) => pathname.startsWith(p));
|
||||||
const isPublicPatch =
|
const isPublicPatch =
|
||||||
method === "PATCH" &&
|
method === "PATCH" &&
|
||||||
|
|
@ -64,21 +66,41 @@ export function middleware(req: NextRequest) {
|
||||||
return NextResponse.next();
|
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))) {
|
if (isLocalRequest(clientIp(req))) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// External request for an internal route. Disguise as "not found" rather
|
// 4. Otherwise: a valid session or nothing.
|
||||||
// than "forbidden" so probing does not reveal what exists.
|
const secret = process.env.AUTH_SECRET;
|
||||||
return new NextResponse("Not found", {
|
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
status: 404,
|
if (await verifySessionToken(secret, token)) {
|
||||||
headers: { "Content-Type": "text/plain" },
|
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 = {
|
export const config = {
|
||||||
matcher: [
|
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)$).*)",
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
Loading…
Reference in a new issue