From a0e6da97b175ecd9856e5e91026bc4875c833c49 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 12 Apr 2026 19:30:07 +0800 Subject: [PATCH] feat(auth): restrict public routes --- middleware.ts | 55 ++++++++++++++++++++++++++++++++++++++++ src/middleware.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 middleware.ts create mode 100644 src/middleware.test.ts diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..8c55624 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { SESSION_COOKIE_NAME } from "./src/lib/authGateway"; + +const PUBLIC_EXACT_PATHS = new Set([ + "/", + "/services", + "/login", + "/register", + "/email-verification", + "/logout", + "/404", + "/500", +]); + +function isDocsPath(pathname: string): boolean { + return pathname === "/docs" || pathname.startsWith("/docs/"); +} + +function isPublicPath(pathname: string): boolean { + return PUBLIC_EXACT_PATHS.has(pathname) || isDocsPath(pathname); +} + +function buildRedirectTarget(request: NextRequest): string { + const query = request.nextUrl.search; + return `${request.nextUrl.pathname}${query}`; +} + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (isPublicPath(pathname)) { + return undefined; + } + + const token = request.cookies.get(SESSION_COOKIE_NAME)?.value?.trim(); + if (token) { + return undefined; + } + + const loginUrl = new URL("/login", request.url); + const redirect = buildRedirectTarget(request); + if (redirect && redirect !== "/login") { + loginUrl.searchParams.set("redirect", redirect); + } + + return NextResponse.redirect(loginUrl); +} + +export const config = { + matcher: [ + "/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|gif|ico|jpg|jpeg|js|map|png|svg|txt|webp|woff|woff2|xml)$).*)", + ], +}; diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..34718f3 --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { middleware } from "../middleware"; + +describe("middleware public route policy", () => { + it("keeps homepage public", () => { + const response = middleware(new NextRequest("https://console.svc.plus/")); + + expect(response).toBeUndefined(); + }); + + it("keeps docs routes public", () => { + const response = middleware( + new NextRequest("https://console.svc.plus/docs/getting-started"), + ); + + expect(response).toBeUndefined(); + }); + + it("keeps only the top-level services page public", () => { + const publicResponse = middleware( + new NextRequest("https://console.svc.plus/services"), + ); + const protectedResponse = middleware( + new NextRequest("https://console.svc.plus/services/openclaw"), + ); + + expect(publicResponse).toBeUndefined(); + expect(protectedResponse?.status).toBe(307); + expect(protectedResponse?.headers.get("location")).toContain( + "/login?redirect=%2Fservices%2Fopenclaw", + ); + }); + + it("redirects protected pages to login when no session cookie exists", () => { + const response = middleware( + new NextRequest("https://console.svc.plus/panel?tab=agent"), + ); + + expect(response?.status).toBe(307); + expect(response?.headers.get("location")).toContain( + "/login?redirect=%2Fpanel%3Ftab%3Dagent", + ); + }); + + it("allows protected pages when a session cookie exists", () => { + const request = new NextRequest("https://console.svc.plus/support"); + request.cookies.set("xc_session", "token"); + + const response = middleware(request); + + expect(response).toBeUndefined(); + }); +});