Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 36 additions & 105 deletions apps/web/AUTHENTICATION.md
Original file line number Diff line number Diff line change
@@ -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`으로 이동합니다.
Comment on lines +27 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

1. 페이지 가드 설명도 role 기반 분기로 적어 두는 편이 정확합니다.

지금 멘토 페이지는 토큰 상태를 컴포넌트가 직접 읽는 구조가 아니라 useGetMyInfo().role와 에러 상태로 분기합니다. 여기 표현이 그대로면 문서만 봤을 때는 여전히 “페이지가 토큰을 직접 검사한다”로 이해되기 쉬워요.

문구 예시
-- 인증이 필요한 UI(예: 멘토 페이지)는 클라이언트에서 재발급/토큰 상태를 확인합니다.
-- 필요한 경우 페이지 내부 로직에서 `/login`으로 이동합니다.
+- 인증이 필요한 UI(예: 멘토 페이지)는 관련 프로필 API 응답과 에러 상태로 접근 권한을 판단합니다.
+- 토큰 재발급과 401 재시도는 interceptor가 처리하고, 페이지는 권한 미충족 시 `/login`으로 이동합니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 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`으로 이동합니다.
### 4. Page-level Guards
- 인증이 필요한 UI(예: 멘토 페이지)는 관련 프로필 API 응답과 에러 상태로 접근 권한을 판단합니다.
- 토큰 재발급과 401 재시도는 interceptor가 처리하고, 페이지는 권한 미충족 시 `/login`으로 이동합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/AUTHENTICATION.md` around lines 27 - 30, 문서의 "4. Page-level Guards"
설명을 토큰 직접 검사 대신 role 기반 분기임을 명확히 하도록 바꿔주세요: 현재 멘토 페이지는 컴포넌트가 토큰을 직접 읽는 구조가 아니라
useGetMyInfo().role과 에러 상태로 분기하여 필요한 경우 /login으로 리다이렉트하므로 해당 로직(예:
useGetMyInfo().role, 에러 체크, /login으로의 이동)을 명시적으로 기술하고 역할(role)별 접근 제어 흐름을 예시로
추가하세요.


#### 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`
65 changes: 1 addition & 64 deletions apps/web/src/app/login/LoginContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,36 +19,8 @@ const loginSchema = z.object({

type LoginFormData = z.infer<typeof loginSchema>;

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();
Expand All @@ -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 (
<div>
<div className="mt-[-56px] h-[77px] border-b border-bg-200 py-[21px] pl-5">
Expand Down
76 changes: 34 additions & 42 deletions apps/web/src/app/mentor/_ui/MentorClient/index.tsx
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +16 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate auth before trusting cached myInfo role

이 분기는 useGetMyInfo()의 캐시 데이터만으로 인증/역할을 확정하기 때문에, 이전 로그인에서 채워진 프로필 캐시가 남아 있으면(해당 쿼리는 staleTime: Infinity 설정) 현재 토큰이 만료되었거나 refresh가 없는 상태에서도 /mentor 진입 시 로그인 리다이렉트 없이 멘토/멘티 화면이 바로 렌더될 수 있습니다. 기존 구현은 토큰 유효성/재발급 결과를 먼저 확인했지만, 현재 로직은 캐시된 role이 있으면 인증 검증을 우회하므로 세션 만료 후에도 보호 UI가 노출되는 회귀가 발생합니다.

Useful? React with 👍 / 👎.


// 토큰 재발급 로직
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 <CloudSpinnerPage />;
}

// 초기화 완료 후에도 토큰이 없으면 리다이렉트 (useEffect에서 처리되지만 fallback)
if (!hasValidAccessToken) {
if (isUnauthorized || (!isError && !role)) {
return <CloudSpinnerPage />;
}

if (!clientRole) {
return <CloudSpinnerPage />;
if (isError) {
return (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-4 text-center">
<p className="text-k-700 typo-medium-2">멘토 페이지 정보를 불러오지 못했어요.</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-full bg-primary px-4 py-2 text-white typo-medium-2"
>
다시 시도
</button>
</div>
);
}

if (role === UserRole.ADMIN) {
return clientRole === UserRole.MENTEE ? <MenteePage /> : <MentorPage />;
}

return clientRole === UserRole.MENTOR ? <MentorPage /> : <MenteePage />;
return role === UserRole.MENTOR ? <MentorPage /> : <MenteePage />;
};

export default MentorClient;
Loading
Loading