diff --git a/apps/web/src/lib/zustand/useAuthStore.ts b/apps/web/src/lib/zustand/useAuthStore.ts
index 3c5e5647..dff27ddc 100644
--- a/apps/web/src/lib/zustand/useAuthStore.ts
+++ b/apps/web/src/lib/zustand/useAuthStore.ts
@@ -39,12 +39,15 @@ interface AuthState {
serverRole: UserRole | null;
clientRole: ClientRole | null;
isAuthenticated: boolean;
+ isNeedLogin: boolean;
isLoading: boolean;
isInitialized: boolean;
refreshStatus: RefreshStatus;
setAccessToken: (token: string) => void;
clearAccessToken: () => void;
setClientRole: (role: ClientRole) => void;
+ setNeedLogin: (needLogin: boolean) => void;
+ clearNeedLogin: () => void;
setLoading: (loading: boolean) => void;
setInitialized: (initialized: boolean) => void;
setRefreshStatus: (status: RefreshStatus) => void;
@@ -57,6 +60,7 @@ const useAuthStore = create
()(
serverRole: null,
clientRole: null,
isAuthenticated: false,
+ isNeedLogin: false,
isLoading: false,
isInitialized: false,
refreshStatus: "idle",
@@ -70,6 +74,7 @@ const useAuthStore = create()(
serverRole,
clientRole: resolveClientRole(serverRole, state.clientRole),
isAuthenticated: true,
+ isNeedLogin: false,
isLoading: false,
isInitialized: true,
refreshStatus: "success",
@@ -83,6 +88,7 @@ const useAuthStore = create()(
serverRole: null,
clientRole: null,
isAuthenticated: false,
+ isNeedLogin: false,
isLoading: false,
isInitialized: true,
refreshStatus: "idle",
@@ -99,6 +105,14 @@ const useAuthStore = create()(
});
},
+ setNeedLogin: (needLogin) => {
+ set({ isNeedLogin: needLogin });
+ },
+
+ clearNeedLogin: () => {
+ set({ isNeedLogin: false });
+ },
+
setLoading: (loading) => {
set({ isLoading: loading });
},
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index 4062638a..7646dbf5 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,7 +1,9 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
+import { isTokenExpired } from "@/utils/jwtUtils";
const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지
+const NEED_LOGIN_COOKIE_KEY = "isNeedLogin";
const blockedExactPaths = new Set([
"/database.php",
"/db.php",
@@ -26,11 +28,43 @@ const isProbePath = (pathname: string) => {
return blockedPathPrefixes.some((prefix) => pathname.startsWith(prefix));
};
+const buildLoginRedirectResponse = (
+ request: NextRequest,
+ options: {
+ clearRefreshToken?: boolean;
+ } = {},
+) => {
+ const { clearRefreshToken = false } = options;
+ const redirectUrl = request.nextUrl.clone();
+ redirectUrl.pathname = "/login";
+ redirectUrl.search = "";
+
+ const response = NextResponse.redirect(redirectUrl);
+ response.cookies.set({
+ name: NEED_LOGIN_COOKIE_KEY,
+ value: "true",
+ path: "/",
+ sameSite: "lax",
+ maxAge: 60,
+ });
+
+ if (clearRefreshToken) {
+ response.cookies.set({
+ name: "refreshToken",
+ value: "",
+ path: "/",
+ expires: new Date(0),
+ maxAge: 0,
+ });
+ }
+
+ return response;
+};
+
export function middleware(request: NextRequest) {
- const url = request.nextUrl.clone();
- const pathname = url.pathname;
+ const pathname = request.nextUrl.pathname;
- if (pathname === "/robots.txt" && isStageHostname(url.hostname)) {
+ if (pathname === "/robots.txt" && isStageHostname(request.nextUrl.hostname)) {
return new NextResponse("User-agent: *\nDisallow: /\n", {
status: 200,
headers: {
@@ -64,9 +98,11 @@ export function middleware(request: NextRequest) {
});
if (needLogin && !refreshToken) {
- url.pathname = "/login";
- url.searchParams.delete("reason");
- return NextResponse.redirect(url);
+ return buildLoginRedirectResponse(request);
+ }
+
+ if (needLogin && isTokenExpired(refreshToken ?? null)) {
+ return buildLoginRedirectResponse(request, { clearRefreshToken: true });
}
return NextResponse.next();
diff --git a/apps/web/src/utils/jwtUtils.ts b/apps/web/src/utils/jwtUtils.ts
index b7955405..5d39c2a1 100644
--- a/apps/web/src/utils/jwtUtils.ts
+++ b/apps/web/src/utils/jwtUtils.ts
@@ -5,15 +5,31 @@ interface JwtPayload {
exp: number;
}
+const decodeJwtPayload = (token: string): JwtPayload | null => {
+ try {
+ const payloadSegment = token.split(".")[1];
+ if (!payloadSegment) {
+ return null;
+ }
+
+ const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
+ return JSON.parse(atob(padded)) as JwtPayload;
+ } catch {
+ return null;
+ }
+};
+
export const isTokenExpired = (token: string | null): boolean => {
if (!token) return true;
- try {
- const payload = JSON.parse(atob(token.split(".")[1])) as JwtPayload;
- const currentTime = Math.floor(Date.now() / 1000);
- return payload.exp < currentTime;
- } catch (error) {
+
+ const payload = decodeJwtPayload(token);
+ if (!payload?.exp) {
return true; // 토큰이 유효하지 않으면 만료된 것으로 간주
}
+
+ const currentTime = Math.floor(Date.now() / 1000);
+ return payload.exp < currentTime;
};
export const tokenParse = (token: string | null): JwtPayload | null => {
@@ -21,10 +37,5 @@ export const tokenParse = (token: string | null): JwtPayload | null => {
if (!token) return null;
- try {
- const payload = JSON.parse(atob(token.split(".")[1])) as JwtPayload;
- return payload;
- } catch (error) {
- return null;
- }
+ return decodeJwtPayload(token);
};