diff --git a/apps/web/AUTHENTICATION.md b/apps/web/AUTHENTICATION.md index fe9ddd02..9ee799d5 100644 --- a/apps/web/AUTHENTICATION.md +++ b/apps/web/AUTHENTICATION.md @@ -1,129 +1,60 @@ # Authentication & Authorization -## Login Redirect Flow +## Overview -### Overview +웹 인증은 **클라이언트 재발급/인터셉터 중심**으로 동작합니다. -The application implements a comprehensive login redirect system that ensures users are properly authenticated before accessing protected pages. +- 서버 진입 시 middleware에서 보호 경로를 `/login`으로 선리다이렉트하지 않습니다. +- 인증 실패 시점은 페이지 렌더/데이터 요청 단계에서 결정됩니다. -### Protected Pages +## Current Flow -The following pages require authentication: -- `/mentor/*` - All mentor-related pages -- `/my/*` - All user profile pages -- `/community` and `/community/*` - Entire community experience, including board lists, post detail, creation, and modification +### 1. App Initialization -### How It Works +- `ReissueProvider`에서 앱 최초 진입 시 `/auth/reissue`를 시도합니다. +- 성공 시 access token이 스토어에 반영되고, 실패 시 비로그인 상태를 유지합니다. -#### 1. Middleware Detection +### 2. Request Interceptor -The middleware (`apps/web/src/middleware.ts`) checks for authentication on every request: +- `axiosInstance` 요청 인터셉터가 access token 만료 여부를 검사합니다. +- 토큰이 없거나 만료된 경우 재발급을 시도하고, 실패하면 로그인 이동을 유도합니다. -```typescript -const loginNeedPages = ["/mentor", "/my", "/community"]; -const needLogin = loginNeedPages.some((path) => { - return url.pathname === path || url.pathname.startsWith(`${path}/`); -}); -``` +### 3. Response Interceptor -#### 2. Community Redirect Reason +- API 응답이 401이면 재발급 1회 후 원요청을 재시도합니다. +- 재시도 실패 시 로그인 페이지로 이동합니다. -When an unauthenticated user tries to access a protected page: -- Middleware redirects to `/login` -- Community routes include a reason marker so the login page can explain why access was blocked -- Example: `/login?reason=community-members-only` +### 4. Page-level Guards -```typescript -if (needLogin && !refreshToken) { - const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/"); - url.pathname = "/login"; - if (isCommunityRoute) { - url.searchParams.set("reason", "community-members-only"); - } - return NextResponse.redirect(url); -} -``` +- 인증이 필요한 UI(예: 멘토 페이지)는 클라이언트에서 재발급/토큰 상태를 확인합니다. +- 필요한 경우 페이지 내부 로직에서 `/login`으로 이동합니다. -#### 3. Toast Notification +## Middleware Responsibility -The login page displays a one-time toast message when users are redirected from community routes: +`apps/web/src/middleware.ts`는 현재 아래만 담당합니다. -```typescript -// apps/web/src/app/login/LoginContent.tsx -useEffect(() => { - const reason = searchParams.get("reason"); - if (reason === "community-members-only") { - toast.info("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요."); - router.replace(pathname); - } -}, [pathname, router, searchParams]); -``` +- stage 환경 `robots.txt` 제어 +- 스캐너/프로브 경로 차단 (`.php`, `/.git`, `/wp-admin` 등) +- 정적 리소스 경로 제외 matcher 유지 -#### 4. Post-Login Redirect +즉, middleware는 더 이상 인증 선검증(보호 경로 강제 로그인 리다이렉트)을 수행하지 않습니다. -After successful authentication, users continue to be redirected to `/`. +## Tokens -### Configuration +### Refresh Token -Authentication is cookie-based: -- Refresh token: HTTP-only cookie -- Middleware: 보호 페이지 접근 시 refresh token 존재 여부 확인 -- 로그인 성공 후: 메인(`/`)으로 이동 +- HTTP-only 쿠키로 관리 +- 재발급 API 호출 시 서버가 검증 -### Token Management +### Access Token -#### Refresh Token -- Stored in HTTP-only cookie (secure) -- Used for authentication checks in middleware -- Automatically renewed on valid requests +- Zustand store 기반으로 관리 +- API 인증 헤더에 사용 +- 만료 시 재발급을 통해 갱신 -#### Access Token -- Stored in Zustand store or localStorage -- Used for API requests -- Short-lived for security +## Related Files -### Adding New Protected Routes - -To protect a new route: - -1. Add to `loginNeedPages` array in middleware: -```typescript -const loginNeedPages = ["/mentor", "/my", "/new-route"]; -``` - -2. Or add custom logic for sub-routes: -```typescript -const isNewRouteSubPath = url.pathname.startsWith("/new-route/"); -const needLogin = loginNeedPages.some(...) || isNewRouteSubPath; -``` - -### Troubleshooting - -#### Redirect not working? -- Verify refresh token exists in cookies -- Check middleware matcher pattern excludes static files - -#### Toast not showing? -- Ensure `reason=community-members-only` query parameter is present for community access -- Check `LoginContent.tsx` useEffect is running -- Verify toast store is initialized - -### Security Considerations - -1. **HTTP-Only Cookies**: Refresh tokens are never accessible to JavaScript -2. **Middleware Protection**: Server-side check before page renders -3. **Token Expiry**: Short-lived access tokens minimize exposure -4. **Scoped Login Reasons**: Community-only messaging is controlled by a fixed internal `reason` value - -### Related Files - -- `apps/web/src/middleware.ts` - Authentication middleware -- `apps/web/src/app/login/LoginContent.tsx` - Login page with redirect handling -- `apps/web/src/lib/zustand/useAuthStore.ts` - Auth state management -- `apps/web/.env` - Configuration - -### Issue Reference - -This implementation resolves issue #302: "로그인 필요 페이지 분리 작업 + proxy 에서 리디렉션 처리" - -The login reason marker and toast notification help users understand why community access was blocked. +- `apps/web/src/lib/zustand/useAuthStore.ts` +- `apps/web/src/utils/axiosInstance.ts` +- `apps/web/src/components/layout/ReissueProvider/index.tsx` +- `apps/web/src/middleware.ts` diff --git a/apps/web/src/app/login/LoginContent.tsx b/apps/web/src/app/login/LoginContent.tsx index 2104d8bd..4a8d1493 100644 --- a/apps/web/src/app/login/LoginContent.tsx +++ b/apps/web/src/app/login/LoginContent.tsx @@ -2,14 +2,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; -import { toast } from "react-hot-toast"; import { z } from "zod"; import { usePostEmailAuth } from "@/apis/Auth"; -import { infoToastOptions } from "@/lib/toast/options"; -import useAuthStore from "@/lib/zustand/useAuthStore"; import { IconSolidConnectionFullBlackLogo } from "@/public/svgs"; import { IconAppleLogo, IconEmailIcon, IconKakaoLogo } from "@/public/svgs/auth"; import { appleLogin, kakaoLogin } from "@/utils/authUtils"; @@ -23,36 +19,8 @@ const loginSchema = z.object({ type LoginFormData = z.infer; -const COMMUNITY_LOGIN_REASON = "community-members-only"; -const NEED_LOGIN_COOKIE_KEY = "isNeedLogin"; - -const hasCookie = (cookieKey: string): boolean => { - if (typeof document === "undefined") { - return false; - } - - return document.cookie - .split(";") - .map((item) => item.trim()) - .some((item) => item.startsWith(`${cookieKey}=`)); -}; - -const clearCookie = (cookieKey: string) => { - if (typeof document === "undefined") { - return; - } - - // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API 미지원 브라우저 대응을 위한 안전한 fallback입니다. - document.cookie = `${cookieKey}=; path=/; max-age=0; SameSite=Lax`; -}; - const LoginContent = () => { const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const hasShownCommunityOnlyToast = useRef(false); - const hasShownNeedLoginToast = useRef(false); - const { isNeedLogin, setNeedLogin, clearNeedLogin } = useAuthStore(); const { mutate: postEmailAuth, isPending } = usePostEmailAuth(); const { showPasswordField, handleEmailChange } = useInputHandler(); @@ -79,37 +47,6 @@ const LoginContent = () => { } }; - useEffect(() => { - const reason = searchParams.get("reason"); - - if (reason !== COMMUNITY_LOGIN_REASON || hasShownCommunityOnlyToast.current) { - return; - } - - hasShownCommunityOnlyToast.current = true; - toast("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요.", infoToastOptions); - router.replace(pathname); - }, [pathname, router, searchParams]); - - useEffect(() => { - if (!hasCookie(NEED_LOGIN_COOKIE_KEY)) { - return; - } - - setNeedLogin(true); - clearCookie(NEED_LOGIN_COOKIE_KEY); - }, [setNeedLogin]); - - useEffect(() => { - if (!isNeedLogin || hasShownNeedLoginToast.current) { - return; - } - - hasShownNeedLoginToast.current = true; - toast("로그인이 필요합니다. 다시 로그인해주세요.", infoToastOptions); - clearNeedLogin(); - }, [clearNeedLogin, isNeedLogin]); - return (
diff --git a/apps/web/src/app/mentor/_ui/MentorClient/index.tsx b/apps/web/src/app/mentor/_ui/MentorClient/index.tsx index 5dd4bce6..59dad579 100644 --- a/apps/web/src/app/mentor/_ui/MentorClient/index.tsx +++ b/apps/web/src/app/mentor/_ui/MentorClient/index.tsx @@ -1,67 +1,59 @@ "use client"; +import type { AxiosError } from "axios"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { postReissueToken } from "@/apis/Auth"; +import { useEffect } from "react"; +import { useGetMyInfo } from "@/apis/MyPage"; import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { UserRole } from "@/types/mentor"; -import { isTokenExpired } from "@/utils/jwtUtils"; import MenteePage from "./_ui/MenteePage"; import MentorPage from "./_ui/MentorPage"; const MentorClient = () => { const router = useRouter(); - const { isLoading, accessToken, clientRole, isInitialized, refreshStatus, setRefreshStatus } = useAuthStore(); - const [isRefreshing, setIsRefreshing] = useState(false); - const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken)); + const clientRole = useAuthStore((state) => state.clientRole); + const { data: myInfo, isLoading, isFetching, isError, error, refetch } = useGetMyInfo(); + const role = myInfo?.role; + const status = (error as AxiosError | null)?.response?.status; + const isUnauthorized = status === 401 || status === 403; + const isAuthResolving = isLoading || (isFetching && !role); - // 토큰 재발급 로직 useEffect(() => { - const attemptTokenRefresh = async () => { - // 이미 실패한 경우 재시도하지 않음 (무한 루프 방지) - if (refreshStatus === "failed") { - return; - } + if (isAuthResolving) return; + if (isUnauthorized || (!isError && !role)) { + router.replace("/login"); + } + }, [isAuthResolving, isUnauthorized, isError, role, router]); - // 초기화 이후 유효한 access token이 없을 때만 재발급 시도 - if (!isInitialized || hasValidAccessToken || isRefreshing || refreshStatus === "refreshing") { - return; - } - - setIsRefreshing(true); - setRefreshStatus("refreshing"); - - try { - await postReissueToken(); - setRefreshStatus("success"); - } catch { - // 재발급 실패 시 로그인 페이지로 리다이렉트 - setRefreshStatus("failed"); - router.push("/login"); - } finally { - setIsRefreshing(false); - } - }; - - attemptTokenRefresh(); - }, [isInitialized, hasValidAccessToken, isRefreshing, refreshStatus, setRefreshStatus, router]); - - // 초기화 전이거나 로딩 중이거나 재발급 중일 때 스피너 표시 - if (!isInitialized || isLoading || refreshStatus === "refreshing" || isRefreshing) { + if (isAuthResolving) { return ; } - // 초기화 완료 후에도 토큰이 없으면 리다이렉트 (useEffect에서 처리되지만 fallback) - if (!hasValidAccessToken) { + if (isUnauthorized || (!isError && !role)) { return ; } - if (!clientRole) { - return ; + if (isError) { + return ( +
+

멘토 페이지 정보를 불러오지 못했어요.

+ +
+ ); + } + + if (role === UserRole.ADMIN) { + return clientRole === UserRole.MENTEE ? : ; } - return clientRole === UserRole.MENTOR ? : ; + return role === UserRole.MENTOR ? : ; }; export default MentorClient; diff --git a/apps/web/src/lib/zustand/useAuthStore.ts b/apps/web/src/lib/zustand/useAuthStore.ts index dff27ddc..3c5e5647 100644 --- a/apps/web/src/lib/zustand/useAuthStore.ts +++ b/apps/web/src/lib/zustand/useAuthStore.ts @@ -39,15 +39,12 @@ 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; @@ -60,7 +57,6 @@ const useAuthStore = create()( serverRole: null, clientRole: null, isAuthenticated: false, - isNeedLogin: false, isLoading: false, isInitialized: false, refreshStatus: "idle", @@ -74,7 +70,6 @@ const useAuthStore = create()( serverRole, clientRole: resolveClientRole(serverRole, state.clientRole), isAuthenticated: true, - isNeedLogin: false, isLoading: false, isInitialized: true, refreshStatus: "success", @@ -88,7 +83,6 @@ const useAuthStore = create()( serverRole: null, clientRole: null, isAuthenticated: false, - isNeedLogin: false, isLoading: false, isInitialized: true, refreshStatus: "idle", @@ -105,14 +99,6 @@ 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 b2d69091..d63c548c 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,8 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지 -const NEED_LOGIN_COOKIE_KEY = "isNeedLogin"; const blockedExactPaths = new Set([ "/database.php", "/db.php", @@ -14,7 +12,6 @@ const blockedExactPaths = new Set([ const blockedPathPrefixes = ["/wp-admin", "/phpmyadmin", "/pma", "/.env", "/.git", "/vendor"]; const isStageHostname = (hostname: string) => hostname.includes("stage"); -const isLocalHostname = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1"; const isProbePath = (pathname: string) => { if (blockedExactPaths.has(pathname)) { @@ -28,39 +25,6 @@ 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 pathname = request.nextUrl.pathname; @@ -84,23 +48,6 @@ export function middleware(request: NextRequest) { }); } - // local 개발 환경에서는 서버 도메인 쿠키와 분리되어 refreshToken을 신뢰할 수 없으므로 로그인 가드를 스킵한다. - if (isLocalHostname(request.nextUrl.hostname)) { - return NextResponse.next(); - } - - // HTTP-only 쿠키의 refreshToken 확인 - const refreshToken = request.cookies.get("refreshToken")?.value; - - // 정확한 경로 매칭 - const needLogin = loginNeedPages.some((path) => { - return pathname === path || pathname.startsWith(`${path}/`); - }); - - if (needLogin && !refreshToken) { - return buildLoginRedirectResponse(request); - } - return NextResponse.next(); } export const config = { diff --git a/docs/auth-refresh-edge-cases.md b/docs/auth-refresh-edge-cases.md index a2d0b73c..d6b6697f 100644 --- a/docs/auth-refresh-edge-cases.md +++ b/docs/auth-refresh-edge-cases.md @@ -1,22 +1,23 @@ # Access/Refresh 토큰 엣지케이스 정리 -이 문서는 웹 앱의 로그인 유지 로직에서 자주 발생하는 토큰 상태별 동작을 정리합니다. -특히 `멘토(/mentor*)`, `커뮤니티(/community*)` 경로에서 발생 빈도가 높은 케이스를 우선 다룹니다. +이 문서는 로그인 유지 로직의 토큰 상태별 동작을 정리합니다. +현재 기준 인증 제어는 middleware 선리다이렉트가 아닌 **클라이언트 재발급/인터셉터 중심**입니다. ## 1. 현재 인증 판단 경계 -- 서버 진입(Next middleware): `refreshToken` 쿠키 유효성으로 1차 진입 제어 -- 클라이언트 API 요청(axios interceptor): `accessToken` 유효성 + 필요 시 `/auth/reissue` 재발급 -- 멘토 진입 페이지: 렌더 전에 access 유효성 확인 후 필요 시 재발급 +- 서버 진입(Next middleware): 인증 선리다이렉트 없음 (robots/probe 차단만 수행) +- 앱 초기화(`ReissueProvider`): `/auth/reissue` 1회 시도 +- 클라이언트 API 요청(axios interceptor): `accessToken` 유효성 확인 + 필요 시 재발급 +- 멘토 진입 페이지: 렌더 전 access 유효성 확인 후 필요 시 재발급 - 채팅 소켓 연결: access 유효성 확인 후 연결 ## 2. 토큰 상태별 케이스 매트릭스 | 케이스 | 토큰 상태 | 주로 발생 화면 | 기대 동작 | 현재 처리 | | --- | --- | --- | --- | --- | -| A | refresh 없음, access 없음 | `/mentor`, `/community`, `/my` 직접 진입 | 즉시 로그인 이동 | middleware에서 로그인 리다이렉트 | -| B | refresh 만료/손상, access 없음 | `/mentor`, `/community` 새로고침 | 즉시 로그인 이동 | middleware에서 만료 refresh 차단 | -| C | refresh 유효, access 없음 | 멘토 첫 진입, 커뮤니티 글쓰기 직전 | 백그라운드 재발급 후 계속 진행 | interceptor/멘토 클라에서 재발급 | +| A | refresh 없음, access 없음 | `/mentor`, `/community`, `/my` 직접 진입 | 인증 필요 화면은 로그인 이동, 공개 화면은 접근 허용 | 클라이언트 재발급 실패/요청 실패 시 로그인 이동 | +| B | refresh 만료/손상, access 없음 | `/mentor`, `/community` 새로고침 | 재발급 실패 후 로그인 이동 | `refreshStatus=failed` + 인터셉터/페이지 리다이렉트 | +| C | refresh 유효, access 없음 | 멘토 첫 진입, 커뮤니티 인증 액션 직전 | 백그라운드 재발급 후 계속 진행 | interceptor/멘토 클라에서 재발급 | | D | refresh 유효, access 만료 | 멘토 목록/채팅, 커뮤니티 작성/수정 | 만료 access 폐기 -> 재발급 -> 요청 진행 | 만료 access 선제 정리 + 재발급 | | E | refresh 유효, access 유효하지만 서버에서 401(폐기/불일치) | 멘토 API, 커뮤니티 mutation | 재발급 1회 후 원요청 재시도 | response interceptor에서 1회 retry | | F | refresh 유효, access 없음 + 동시 다중 요청 | 멘토 페이지 초기 렌더 | 재발급 요청 1회만 수행 | `reissuePromise` 락으로 중복 방지 | @@ -25,24 +26,22 @@ ## 3. 멘토/커뮤니티에서 자주 터지는 이유 -1. 두 경로 모두 보호 페이지로 분류되어 진입 시점의 인증 상태 흔들림이 바로 노출됨 -2. 멘토는 초기 렌더 시 인증 의존 API가 많아, access 만료 시 체감 문제가 빠르게 발생 -3. 커뮤니티는 목록 조회는 public이지만 작성/수정/댓글은 인증 API라, 클릭 시점에 문제 노출 +1. 멘토는 초기 렌더 시 인증 의존 API가 많아, access 만료 시 체감 문제가 빠르게 발생 +2. 커뮤니티는 목록 조회는 public이지만 작성/수정/댓글은 인증 API라 클릭 시점에 문제 노출 +3. 인터셉터/페이지 가드가 동시에 개입하는 구간에서 상태 전이 타이밍 이슈가 발생하기 쉬움 -## 4. 이번 보완 포인트 +## 4. 현재 보완 포인트 -- middleware에서 `refreshToken`의 단순 존재가 아니라 **만료 여부까지 검사** -- axios request interceptor에서 **만료된 access를 즉시 폐기**하고 재발급 경로로 전환 -- axios response interceptor에서 401 발생 시 **재발급 1회 후 원요청 재시도** +- axios request interceptor에서 **만료된 access 즉시 폐기** 후 재발급 경로 전환 +- axios response interceptor에서 401 시 **재발급 1회 후 원요청 재시도** - 멘토 클라이언트 렌더 분기에서 **만료 access를 유효 토큰으로 취급하지 않도록 보정** - 소켓 연결 훅에서 **만료 access로 연결 시도 금지** ## 5. 수동 검증 체크리스트 -1. refresh 없음 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동 -2. refresh 만료 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동 -3. refresh 유효 + access 없음 상태에서 `/mentor` 진입 시 화면 유지 후 정상 렌더 -4. refresh 유효 + access 만료 상태에서 `/mentor` 진입 시 로그인 튕김 없이 복구 -5. refresh 유효 + access 만료 상태에서 커뮤니티 글 작성/댓글 시도 시 재발급 후 정상 요청 +1. refresh 없음 상태에서 `/mentor` 진입 시 middleware 즉시 리다이렉트 없이, 클라이언트 인증 처리 후 로그인 이동 +2. refresh 없음 상태에서 `/my` 진입 후 인증 API 호출 시 로그인 이동 +3. `/community` 진입은 유지되고, 인증 필요한 액션에서만 로그인 이동 +4. refresh 유효 + access 없음 상태에서 `/mentor` 진입 시 재발급 후 정상 렌더 +5. refresh 유효 + access 만료 상태에서 커뮤니티 작성/댓글 시도 시 재발급 후 정상 요청 6. access가 서버에서 무효 처리된 상태(401)에서 요청 시 1회 재시도 후 실패 시 로그인 이동 -