Skip to content

Commit 5e6e9eb

Browse files
authored
Merge pull request #516 from solid-connection/fix/mentor-api-auth-gating-pr
fix(web): mentor auth gating by API response
2 parents 5d9ccfc + 052d86a commit 5e6e9eb

6 files changed

Lines changed: 91 additions & 299 deletions

File tree

apps/web/AUTHENTICATION.md

Lines changed: 36 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,60 @@
11
# Authentication & Authorization
22

3-
## Login Redirect Flow
3+
## Overview
44

5-
### Overview
5+
웹 인증은 **클라이언트 재발급/인터셉터 중심**으로 동작합니다.
66

7-
The application implements a comprehensive login redirect system that ensures users are properly authenticated before accessing protected pages.
7+
- 서버 진입 시 middleware에서 보호 경로를 `/login`으로 선리다이렉트하지 않습니다.
8+
- 인증 실패 시점은 페이지 렌더/데이터 요청 단계에서 결정됩니다.
89

9-
### Protected Pages
10+
## Current Flow
1011

11-
The following pages require authentication:
12-
- `/mentor/*` - All mentor-related pages
13-
- `/my/*` - All user profile pages
14-
- `/community` and `/community/*` - Entire community experience, including board lists, post detail, creation, and modification
12+
### 1. App Initialization
1513

16-
### How It Works
14+
- `ReissueProvider`에서 앱 최초 진입 시 `/auth/reissue`를 시도합니다.
15+
- 성공 시 access token이 스토어에 반영되고, 실패 시 비로그인 상태를 유지합니다.
1716

18-
#### 1. Middleware Detection
17+
### 2. Request Interceptor
1918

20-
The middleware (`apps/web/src/middleware.ts`) checks for authentication on every request:
19+
- `axiosInstance` 요청 인터셉터가 access token 만료 여부를 검사합니다.
20+
- 토큰이 없거나 만료된 경우 재발급을 시도하고, 실패하면 로그인 이동을 유도합니다.
2121

22-
```typescript
23-
const loginNeedPages = ["/mentor", "/my", "/community"];
24-
const needLogin = loginNeedPages.some((path) => {
25-
return url.pathname === path || url.pathname.startsWith(`${path}/`);
26-
});
27-
```
22+
### 3. Response Interceptor
2823

29-
#### 2. Community Redirect Reason
24+
- API 응답이 401이면 재발급 1회 후 원요청을 재시도합니다.
25+
- 재시도 실패 시 로그인 페이지로 이동합니다.
3026

31-
When an unauthenticated user tries to access a protected page:
32-
- Middleware redirects to `/login`
33-
- Community routes include a reason marker so the login page can explain why access was blocked
34-
- Example: `/login?reason=community-members-only`
27+
### 4. Page-level Guards
3528

36-
```typescript
37-
if (needLogin && !refreshToken) {
38-
const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/");
39-
url.pathname = "/login";
40-
if (isCommunityRoute) {
41-
url.searchParams.set("reason", "community-members-only");
42-
}
43-
return NextResponse.redirect(url);
44-
}
45-
```
29+
- 인증이 필요한 UI(예: 멘토 페이지)는 클라이언트에서 재발급/토큰 상태를 확인합니다.
30+
- 필요한 경우 페이지 내부 로직에서 `/login`으로 이동합니다.
4631

47-
#### 3. Toast Notification
32+
## Middleware Responsibility
4833

49-
The login page displays a one-time toast message when users are redirected from community routes:
34+
`apps/web/src/middleware.ts`는 현재 아래만 담당합니다.
5035

51-
```typescript
52-
// apps/web/src/app/login/LoginContent.tsx
53-
useEffect(() => {
54-
const reason = searchParams.get("reason");
55-
if (reason === "community-members-only") {
56-
toast.info("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요.");
57-
router.replace(pathname);
58-
}
59-
}, [pathname, router, searchParams]);
60-
```
36+
- stage 환경 `robots.txt` 제어
37+
- 스캐너/프로브 경로 차단 (`.php`, `/.git`, `/wp-admin` 등)
38+
- 정적 리소스 경로 제외 matcher 유지
6139

62-
#### 4. Post-Login Redirect
40+
즉, middleware는 더 이상 인증 선검증(보호 경로 강제 로그인 리다이렉트)을 수행하지 않습니다.
6341

64-
After successful authentication, users continue to be redirected to `/`.
42+
## Tokens
6543

66-
### Configuration
44+
### Refresh Token
6745

68-
Authentication is cookie-based:
69-
- Refresh token: HTTP-only cookie
70-
- Middleware: 보호 페이지 접근 시 refresh token 존재 여부 확인
71-
- 로그인 성공 후: 메인(`/`)으로 이동
46+
- HTTP-only 쿠키로 관리
47+
- 재발급 API 호출 시 서버가 검증
7248

73-
### Token Management
49+
### Access Token
7450

75-
#### Refresh Token
76-
- Stored in HTTP-only cookie (secure)
77-
- Used for authentication checks in middleware
78-
- Automatically renewed on valid requests
51+
- Zustand store 기반으로 관리
52+
- API 인증 헤더에 사용
53+
- 만료 시 재발급을 통해 갱신
7954

80-
#### Access Token
81-
- Stored in Zustand store or localStorage
82-
- Used for API requests
83-
- Short-lived for security
55+
## Related Files
8456

85-
### Adding New Protected Routes
86-
87-
To protect a new route:
88-
89-
1. Add to `loginNeedPages` array in middleware:
90-
```typescript
91-
const loginNeedPages = ["/mentor", "/my", "/new-route"];
92-
```
93-
94-
2. Or add custom logic for sub-routes:
95-
```typescript
96-
const isNewRouteSubPath = url.pathname.startsWith("/new-route/");
97-
const needLogin = loginNeedPages.some(...) || isNewRouteSubPath;
98-
```
99-
100-
### Troubleshooting
101-
102-
#### Redirect not working?
103-
- Verify refresh token exists in cookies
104-
- Check middleware matcher pattern excludes static files
105-
106-
#### Toast not showing?
107-
- Ensure `reason=community-members-only` query parameter is present for community access
108-
- Check `LoginContent.tsx` useEffect is running
109-
- Verify toast store is initialized
110-
111-
### Security Considerations
112-
113-
1. **HTTP-Only Cookies**: Refresh tokens are never accessible to JavaScript
114-
2. **Middleware Protection**: Server-side check before page renders
115-
3. **Token Expiry**: Short-lived access tokens minimize exposure
116-
4. **Scoped Login Reasons**: Community-only messaging is controlled by a fixed internal `reason` value
117-
118-
### Related Files
119-
120-
- `apps/web/src/middleware.ts` - Authentication middleware
121-
- `apps/web/src/app/login/LoginContent.tsx` - Login page with redirect handling
122-
- `apps/web/src/lib/zustand/useAuthStore.ts` - Auth state management
123-
- `apps/web/.env` - Configuration
124-
125-
### Issue Reference
126-
127-
This implementation resolves issue #302: "로그인 필요 페이지 분리 작업 + proxy 에서 리디렉션 처리"
128-
129-
The login reason marker and toast notification help users understand why community access was blocked.
57+
- `apps/web/src/lib/zustand/useAuthStore.ts`
58+
- `apps/web/src/utils/axiosInstance.ts`
59+
- `apps/web/src/components/layout/ReissueProvider/index.tsx`
60+
- `apps/web/src/middleware.ts`

apps/web/src/app/login/LoginContent.tsx

Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@
22

33
import { zodResolver } from "@hookform/resolvers/zod";
44
import Link from "next/link";
5-
import { usePathname, useRouter, useSearchParams } from "next/navigation";
6-
import { useEffect, useRef } from "react";
5+
import { useRouter } from "next/navigation";
76
import { useForm } from "react-hook-form";
8-
import { toast } from "react-hot-toast";
97
import { z } from "zod";
108
import { usePostEmailAuth } from "@/apis/Auth";
11-
import { infoToastOptions } from "@/lib/toast/options";
12-
import useAuthStore from "@/lib/zustand/useAuthStore";
139
import { IconSolidConnectionFullBlackLogo } from "@/public/svgs";
1410
import { IconAppleLogo, IconEmailIcon, IconKakaoLogo } from "@/public/svgs/auth";
1511
import { appleLogin, kakaoLogin } from "@/utils/authUtils";
@@ -23,36 +19,8 @@ const loginSchema = z.object({
2319

2420
type LoginFormData = z.infer<typeof loginSchema>;
2521

26-
const COMMUNITY_LOGIN_REASON = "community-members-only";
27-
const NEED_LOGIN_COOKIE_KEY = "isNeedLogin";
28-
29-
const hasCookie = (cookieKey: string): boolean => {
30-
if (typeof document === "undefined") {
31-
return false;
32-
}
33-
34-
return document.cookie
35-
.split(";")
36-
.map((item) => item.trim())
37-
.some((item) => item.startsWith(`${cookieKey}=`));
38-
};
39-
40-
const clearCookie = (cookieKey: string) => {
41-
if (typeof document === "undefined") {
42-
return;
43-
}
44-
45-
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API 미지원 브라우저 대응을 위한 안전한 fallback입니다.
46-
document.cookie = `${cookieKey}=; path=/; max-age=0; SameSite=Lax`;
47-
};
48-
4922
const LoginContent = () => {
5023
const router = useRouter();
51-
const pathname = usePathname();
52-
const searchParams = useSearchParams();
53-
const hasShownCommunityOnlyToast = useRef(false);
54-
const hasShownNeedLoginToast = useRef(false);
55-
const { isNeedLogin, setNeedLogin, clearNeedLogin } = useAuthStore();
5624

5725
const { mutate: postEmailAuth, isPending } = usePostEmailAuth();
5826
const { showPasswordField, handleEmailChange } = useInputHandler();
@@ -79,37 +47,6 @@ const LoginContent = () => {
7947
}
8048
};
8149

82-
useEffect(() => {
83-
const reason = searchParams.get("reason");
84-
85-
if (reason !== COMMUNITY_LOGIN_REASON || hasShownCommunityOnlyToast.current) {
86-
return;
87-
}
88-
89-
hasShownCommunityOnlyToast.current = true;
90-
toast("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요.", infoToastOptions);
91-
router.replace(pathname);
92-
}, [pathname, router, searchParams]);
93-
94-
useEffect(() => {
95-
if (!hasCookie(NEED_LOGIN_COOKIE_KEY)) {
96-
return;
97-
}
98-
99-
setNeedLogin(true);
100-
clearCookie(NEED_LOGIN_COOKIE_KEY);
101-
}, [setNeedLogin]);
102-
103-
useEffect(() => {
104-
if (!isNeedLogin || hasShownNeedLoginToast.current) {
105-
return;
106-
}
107-
108-
hasShownNeedLoginToast.current = true;
109-
toast("로그인이 필요합니다. 다시 로그인해주세요.", infoToastOptions);
110-
clearNeedLogin();
111-
}, [clearNeedLogin, isNeedLogin]);
112-
11350
return (
11451
<div>
11552
<div className="mt-[-56px] h-[77px] border-b border-bg-200 py-[21px] pl-5">
Lines changed: 34 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,59 @@
11
"use client";
22

3+
import type { AxiosError } from "axios";
34
import { useRouter } from "next/navigation";
4-
import { useEffect, useState } from "react";
5-
import { postReissueToken } from "@/apis/Auth";
5+
import { useEffect } from "react";
6+
import { useGetMyInfo } from "@/apis/MyPage";
67
import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage";
78
import useAuthStore from "@/lib/zustand/useAuthStore";
89
import { UserRole } from "@/types/mentor";
9-
import { isTokenExpired } from "@/utils/jwtUtils";
1010
import MenteePage from "./_ui/MenteePage";
1111
import MentorPage from "./_ui/MentorPage";
1212

1313
const MentorClient = () => {
1414
const router = useRouter();
15-
const { isLoading, accessToken, clientRole, isInitialized, refreshStatus, setRefreshStatus } = useAuthStore();
16-
const [isRefreshing, setIsRefreshing] = useState(false);
17-
const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken));
15+
const clientRole = useAuthStore((state) => state.clientRole);
16+
const { data: myInfo, isLoading, isFetching, isError, error, refetch } = useGetMyInfo();
17+
const role = myInfo?.role;
18+
const status = (error as AxiosError | null)?.response?.status;
19+
const isUnauthorized = status === 401 || status === 403;
20+
const isAuthResolving = isLoading || (isFetching && !role);
1821

19-
// 토큰 재발급 로직
2022
useEffect(() => {
21-
const attemptTokenRefresh = async () => {
22-
// 이미 실패한 경우 재시도하지 않음 (무한 루프 방지)
23-
if (refreshStatus === "failed") {
24-
return;
25-
}
23+
if (isAuthResolving) return;
24+
if (isUnauthorized || (!isError && !role)) {
25+
router.replace("/login");
26+
}
27+
}, [isAuthResolving, isUnauthorized, isError, role, router]);
2628

27-
// 초기화 이후 유효한 access token이 없을 때만 재발급 시도
28-
if (!isInitialized || hasValidAccessToken || isRefreshing || refreshStatus === "refreshing") {
29-
return;
30-
}
31-
32-
setIsRefreshing(true);
33-
setRefreshStatus("refreshing");
34-
35-
try {
36-
await postReissueToken();
37-
setRefreshStatus("success");
38-
} catch {
39-
// 재발급 실패 시 로그인 페이지로 리다이렉트
40-
setRefreshStatus("failed");
41-
router.push("/login");
42-
} finally {
43-
setIsRefreshing(false);
44-
}
45-
};
46-
47-
attemptTokenRefresh();
48-
}, [isInitialized, hasValidAccessToken, isRefreshing, refreshStatus, setRefreshStatus, router]);
49-
50-
// 초기화 전이거나 로딩 중이거나 재발급 중일 때 스피너 표시
51-
if (!isInitialized || isLoading || refreshStatus === "refreshing" || isRefreshing) {
29+
if (isAuthResolving) {
5230
return <CloudSpinnerPage />;
5331
}
5432

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

60-
if (!clientRole) {
61-
return <CloudSpinnerPage />;
37+
if (isError) {
38+
return (
39+
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-4 text-center">
40+
<p className="text-k-700 typo-medium-2">멘토 페이지 정보를 불러오지 못했어요.</p>
41+
<button
42+
type="button"
43+
onClick={() => refetch()}
44+
className="rounded-full bg-primary px-4 py-2 text-white typo-medium-2"
45+
>
46+
다시 시도
47+
</button>
48+
</div>
49+
);
50+
}
51+
52+
if (role === UserRole.ADMIN) {
53+
return clientRole === UserRole.MENTEE ? <MenteePage /> : <MentorPage />;
6254
}
6355

64-
return clientRole === UserRole.MENTOR ? <MentorPage /> : <MenteePage />;
56+
return role === UserRole.MENTOR ? <MentorPage /> : <MenteePage />;
6557
};
6658

6759
export default MentorClient;

0 commit comments

Comments
 (0)