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`으로 이동합니다.

#### 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
14 changes: 0 additions & 14 deletions apps/web/src/lib/zustand/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,7 +57,6 @@ const useAuthStore = create<AuthState>()(
serverRole: null,
clientRole: null,
isAuthenticated: false,
isNeedLogin: false,
isLoading: false,
isInitialized: false,
refreshStatus: "idle",
Expand All @@ -74,7 +70,6 @@ const useAuthStore = create<AuthState>()(
serverRole,
clientRole: resolveClientRole(serverRole, state.clientRole),
isAuthenticated: true,
isNeedLogin: false,
isLoading: false,
isInitialized: true,
refreshStatus: "success",
Expand All @@ -88,7 +83,6 @@ const useAuthStore = create<AuthState>()(
serverRole: null,
clientRole: null,
isAuthenticated: false,
isNeedLogin: false,
isLoading: false,
isInitialized: true,
refreshStatus: "idle",
Expand All @@ -105,14 +99,6 @@ const useAuthStore = create<AuthState>()(
});
},

setNeedLogin: (needLogin) => {
set({ isNeedLogin: needLogin });
},

clearNeedLogin: () => {
set({ isNeedLogin: false });
},

setLoading: (loading) => {
set({ isLoading: loading });
},
Expand Down
53 changes: 0 additions & 53 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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)) {
Expand All @@ -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;

Expand All @@ -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();
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 Restore pre-entry auth guard for protected /my flows

By removing the protected-route branch in middleware and always falling through to NextResponse.next(), logged-out users can now enter protected pages like /my/password and /my/apply-mentor (see apps/web/src/app/my/password/page.tsx and apps/web/src/app/my/apply-mentor/page.tsx) that do not perform an immediate auth check on mount. In this state, users can complete multi-step forms and only get redirected on submit when an authenticated API call hits the axios interceptor, causing avoidable data loss and a regression from the previous immediate login redirect behavior.

Useful? React with 👍 / 👎.

}
export const config = {
Expand Down
Loading
Loading