feat(web): auth proxy with httpOnly cookie session + route guard

This commit is contained in:
Munkherdene 2026-08-22 20:15:39 +08:00
parent b1cb04747e
commit 25bdfda5a3
13 changed files with 156 additions and 0 deletions

View file

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
import { buildSetCookie } from "../../../../server/session";
import { AuthResponseSchema } from "../../../../api/schemas";
export async function POST(req: Request) {
const body = await req.text();
const upstream = await backendFetch("/v1/auth/2fa/challenge", { method: "POST", body });
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
const parsed = AuthResponseSchema.parse(data);
const res = NextResponse.json({ user: parsed.user }); // token stripped
res.headers.set("Set-Cookie", buildSetCookie(parsed.token));
return res;
}

View file

@ -0,0 +1,12 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("../../../server/backend", () => ({ backendFetch: vi.fn(async () =>
new Response(JSON.stringify({ token: "secret", user: { id: 1, email: "a@b.c", createdAt: "2026-01-01T00:00:00Z" } }), { status: 200 })) }));
import { POST } from "./login/route";
describe("login route", () => {
it("sets cookie and strips token", async () => {
const res = await POST(new Request("http://x/api/auth/login", { method: "POST", body: JSON.stringify({ email: "a@b.c", password: "x" }) }));
const json = await res.clone().json();
expect(json.token).toBeUndefined();
expect(res.headers.get("Set-Cookie") ?? "").toMatch(/HttpOnly/i);
});
});

View file

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
export async function POST(req: Request) {
const body = await req.text();
const upstream = await backendFetch("/v1/auth/forgot", { method: "POST", body });
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
import { buildSetCookie } from "../../../../server/session";
import { AuthResponseSchema } from "../../../../api/schemas";
export async function POST(req: Request) {
const body = await req.text();
const upstream = await backendFetch("/v1/auth/login", { method: "POST", body });
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
// 2FA-enabled accounts return { twoFactor: true, challenge } — pass through, no cookie yet.
if (data?.twoFactor) return NextResponse.json({ twoFactor: true, challenge: data.challenge });
const parsed = AuthResponseSchema.parse(data);
const res = NextResponse.json({ user: parsed.user }); // token stripped
res.headers.set("Set-Cookie", buildSetCookie(parsed.token));
return res;
}

View file

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
import { buildClearCookie, readToken } from "../../../../server/session";
export async function POST(req: Request) {
const token = readToken(req);
await backendFetch("/v1/auth/logout", { method: "POST" }, token).catch(() => {});
const res = NextResponse.json({ ok: true });
res.headers.set("Set-Cookie", buildClearCookie());
return res;
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
import { buildSetCookie } from "../../../../server/session";
import { AuthResponseSchema } from "../../../../api/schemas";
export async function POST(req: Request) {
const body = await req.text();
const upstream = await backendFetch("/v1/auth/register", { method: "POST", body });
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
// 2FA-enabled accounts return { twoFactor: true, challenge } — pass through, no cookie yet.
if (data?.twoFactor) return NextResponse.json({ twoFactor: true, challenge: data.challenge });
const parsed = AuthResponseSchema.parse(data);
const res = NextResponse.json({ user: parsed.user }); // token stripped
res.headers.set("Set-Cookie", buildSetCookie(parsed.token));
return res;
}

View file

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
export async function POST(req: Request) {
const body = await req.text();
const upstream = await backendFetch("/v1/auth/reset", { method: "POST", body });
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
}

View file

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
export async function POST(req: Request) {
const body = await req.text();
const upstream = await backendFetch("/v1/auth/verify", { method: "POST", body });
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
}

View file

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch } from "../../../../server/backend";
import { readToken } from "../../../../server/session";
async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const token = readToken(req);
const search = req.nextUrl.search;
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.text();
const upstream = await backendFetch(`/v1/${path.join("/")}${search}`, { method: req.method, body }, token);
const text = await upstream.text();
return new NextResponse(text, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" } });
}
export const GET = proxy; export const POST = proxy; export const PUT = proxy; export const DELETE = proxy;

15
src/middleware.ts Normal file
View file

@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { COOKIE } from "./server/session";
const PROTECTED = ["/home", "/accounting", "/planner", "/assets", "/profile"];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const hasSession = req.cookies.has(COOKIE);
if (PROTECTED.some((p) => pathname.startsWith(p)) && !hasSession) {
return NextResponse.redirect(new URL("/login", req.url));
}
if ((pathname === "/login" || pathname === "/") && hasSession) {
return NextResponse.redirect(new URL("/home", req.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/", "/login", "/home/:path*", "/accounting/:path*", "/planner/:path*", "/assets/:path*", "/profile/:path*"] };

7
src/server/backend.ts Normal file
View file

@ -0,0 +1,7 @@
const BASE = process.env.FMS_API_URL ?? "http://localhost:8080";
export async function backendFetch(path: string, init: RequestInit = {}, token?: string | null): Promise<Response> {
const headers = new Headers(init.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
return fetch(`${BASE}${path}`, { ...init, headers, cache: "no-store" });
}

View file

@ -0,0 +1,10 @@
import { describe, it, expect } from "vitest";
import { COOKIE, buildSetCookie, buildClearCookie } from "./session";
describe("session cookie", () => {
it("sets httpOnly Secure SameSite=Lax", () => {
const c = buildSetCookie("tok123");
expect(c).toContain(`${COOKIE}=tok123`);
expect(c).toMatch(/HttpOnly/i); expect(c).toMatch(/Secure/i); expect(c).toMatch(/SameSite=Lax/i);
});
it("clear expires the cookie", () => expect(buildClearCookie()).toMatch(/Max-Age=0/i));
});

12
src/server/session.ts Normal file
View file

@ -0,0 +1,12 @@
export const COOKIE = "mercury_session";
export function buildSetCookie(token: string): string {
return `${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}`;
}
export function buildClearCookie(): string {
return `${COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
}
export function readToken(req: Request): string | null {
const cookie = req.headers.get("cookie") ?? "";
const m = cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]+)`));
return m ? decodeURIComponent(m[1]) : null;
}