feat(web): auth proxy with httpOnly cookie session + route guard
This commit is contained in:
parent
b1cb04747e
commit
25bdfda5a3
13 changed files with 156 additions and 0 deletions
15
src/app/api/auth/2fa/route.ts
Normal file
15
src/app/api/auth/2fa/route.ts
Normal 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;
|
||||
}
|
||||
12
src/app/api/auth/auth.route.test.ts
Normal file
12
src/app/api/auth/auth.route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
src/app/api/auth/forgot/route.ts
Normal file
9
src/app/api/auth/forgot/route.ts
Normal 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 });
|
||||
}
|
||||
17
src/app/api/auth/login/route.ts
Normal file
17
src/app/api/auth/login/route.ts
Normal 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;
|
||||
}
|
||||
10
src/app/api/auth/logout/route.ts
Normal file
10
src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
17
src/app/api/auth/register/route.ts
Normal file
17
src/app/api/auth/register/route.ts
Normal 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;
|
||||
}
|
||||
9
src/app/api/auth/reset/route.ts
Normal file
9
src/app/api/auth/reset/route.ts
Normal 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 });
|
||||
}
|
||||
9
src/app/api/auth/verify/route.ts
Normal file
9
src/app/api/auth/verify/route.ts
Normal 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 });
|
||||
}
|
||||
14
src/app/api/v1/[...path]/route.ts
Normal file
14
src/app/api/v1/[...path]/route.ts
Normal 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
15
src/middleware.ts
Normal 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
7
src/server/backend.ts
Normal 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" });
|
||||
}
|
||||
10
src/server/session.test.ts
Normal file
10
src/server/session.test.ts
Normal 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
12
src/server/session.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue