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_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=
|
||||
|
|
|
|||
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
|
||||
// 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)$).*)",
|
||||
],
|
||||
};
|
||||
Loading…
Reference in a new issue