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.
106 lines
No EOL
3.7 KiB
TypeScript
106 lines
No EOL
3.7 KiB
TypeScript
// middleware.ts
|
|
// Access control.
|
|
//
|
|
// 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:
|
|
// • /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 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 = [
|
|
"/resign/",
|
|
"/api/resignations/sign/",
|
|
];
|
|
|
|
/** The signing page saves the drawn signature with a PATCH to this path.
|
|
* It is only safe to expose because the PocketBase record id is a
|
|
* 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() : "";
|
|
const real = req.headers.get("x-real-ip") || "";
|
|
return (first || real || "").replace(/^::ffff:/, "").trim();
|
|
}
|
|
|
|
function isLocalRequest(ip: string): boolean {
|
|
if (!ip) return true; // no proxy header at all — a direct local request
|
|
if (ip === "127.0.0.1" || ip === "::1" || ip === "localhost") return true;
|
|
if (/^127\./.test(ip)) return true;
|
|
// Private LAN ranges
|
|
if (/^10\./.test(ip)) return true;
|
|
if (/^192\.168\./.test(ip)) return true;
|
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
|
return false;
|
|
}
|
|
|
|
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" &&
|
|
pathname.startsWith(PUBLIC_PATCH_PREFIX) &&
|
|
!pathname.startsWith("/api/resignations/sign/");
|
|
|
|
if (isPublicGet || isPublicPatch) {
|
|
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();
|
|
}
|
|
|
|
// 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 static/image files.
|
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
|
|
],
|
|
}; |