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
37 changes: 20 additions & 17 deletions apps/web/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The application implements a comprehensive login redirect system that ensures us
The following pages require authentication:
- `/mentor/*` - All mentor-related pages
- `/my/*` - All user profile pages
- `/community/[boardCode]/*` - Community sub-routes (post creation, modification)
- `/community` and `/community/*` - Entire community experience, including board lists, post detail, creation, and modification

### How It Works

Expand All @@ -20,45 +20,48 @@ The following pages require authentication:
The middleware (`apps/web/src/middleware.ts`) checks for authentication on every request:

```typescript
const loginNeedPages = ["/mentor", "/my"];
const loginNeedPages = ["/mentor", "/my", "/community"];
const needLogin = loginNeedPages.some((path) => {
return url.pathname === path || url.pathname.startsWith(`${path}/`);
}) || isCommunitySubRoute;
});
```

#### 2. Redirect Parameter
#### 2. Community Redirect Reason

When an unauthenticated user tries to access a protected page:
- Middleware redirects to `/login`
- Original URL is preserved in the `redirect` query parameter
- Example: `/login?redirect=/mentor/chat/123`
- Community routes include a reason marker so the login page can explain why access was blocked
- Example: `/login?reason=community-members-only`

```typescript
if (needLogin && !refreshToken) {
const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/");
url.pathname = "/login";
const redirectUrl = request.nextUrl.pathname + request.nextUrl.search;
url.searchParams.set("redirect", redirectUrl);
if (isCommunityRoute) {
url.searchParams.set("reason", "community-members-only");
}
return NextResponse.redirect(url);
}
```

#### 3. Toast Notification

The login page displays a toast message when users are redirected:
The login page displays a one-time toast message when users are redirected from community routes:

```typescript
// apps/web/src/app/login/LoginContent.tsx
useEffect(() => {
const redirect = searchParams.get("redirect");
if (redirect) {
toast.info("로그인이 필요합니다.");
const reason = searchParams.get("reason");
if (reason === "community-members-only") {
toast.info("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요.");
router.replace(pathname);
}
}, [searchParams]);
}, [pathname, router, searchParams]);
```

#### 4. Post-Login Redirect

After successful authentication, users are redirected back to their original destination.
After successful authentication, users continue to be redirected to `/`.

### Configuration

Expand Down Expand Up @@ -112,7 +115,7 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath;
- Check middleware matcher pattern excludes static files

#### Toast not showing?
- Ensure `redirect` query parameter is present
- Ensure `reason=community-members-only` query parameter is present for community access
- Check `LoginContent.tsx` useEffect is running
- Verify toast store is initialized

Expand All @@ -121,7 +124,7 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath;
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. **Redirect Validation**: Only internal URLs are allowed in redirect parameter
4. **Scoped Login Reasons**: Community-only messaging is controlled by a fixed internal `reason` value

### Related Files

Expand All @@ -134,4 +137,4 @@ const needLogin = loginNeedPages.some(...) || isNewRouteSubPath;

This implementation resolves issue #302: "로그인 필요 페이지 분리 작업 + proxy 에서 리디렉션 처리"

The redirect parameter and toast notification features ensure users understand why they were redirected and can seamlessly return to their intended destination after login.
The login reason marker and toast notification help users understand why community access was blocked.
21 changes: 20 additions & 1 deletion apps/web/src/app/login/LoginContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { usePostEmailAuth } from "@/apis/Auth";
import { toast } from "@/lib/zustand/useToastStore";
import { IconSolidConnectionFullBlackLogo } from "@/public/svgs";
import { IconAppleLogo, IconEmailIcon, IconKakaoLogo } from "@/public/svgs/auth";
import { appleLogin, kakaoLogin } from "@/utils/authUtils";
Expand All @@ -19,8 +21,13 @@ const loginSchema = z.object({

type LoginFormData = z.infer<typeof loginSchema>;

const COMMUNITY_LOGIN_REASON = "community-members-only";

const LoginContent = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const hasShownCommunityOnlyToast = useRef(false);

const { mutate: postEmailAuth, isPending } = usePostEmailAuth();
const { showPasswordField, handleEmailChange } = useInputHandler();
Expand All @@ -47,6 +54,18 @@ const LoginContent = () => {
}
};

useEffect(() => {
const reason = searchParams.get("reason");

if (reason !== COMMUNITY_LOGIN_REASON || hasShownCommunityOnlyToast.current) {
return;
}

hasShownCommunityOnlyToast.current = true;
toast.info("커뮤니티는 회원 전용입니다. 로그인 후 이용해주세요.");
router.replace(pathname);
}, [pathname, router, searchParams]);

return (
<div>
<div className="mt-[-56px] h-[77px] border-b border-bg-200 py-[21px] pl-5">
Expand Down
19 changes: 11 additions & 8 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

const loginNeedPages = ["/mentor", "/my"]; // 로그인 필요페이지
const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지
const COMMUNITY_LOGIN_REASON = "community-members-only";

export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
Expand All @@ -21,17 +22,19 @@ export function middleware(request: NextRequest) {
// HTTP-only 쿠키의 refreshToken 확인
const refreshToken = request.cookies.get("refreshToken")?.value;

// /community는 통과, /community/ 하위 경로는 로그인 필요
const isCommunitySubRoute = url.pathname.startsWith("/community/");

// 정확한 경로 매칭
const needLogin =
loginNeedPages.some((path) => {
return url.pathname === path || url.pathname.startsWith(`${path}/`);
}) || isCommunitySubRoute; // /community/ 하위 경로도 로그인 필요
const needLogin = loginNeedPages.some((path) => {
return url.pathname === path || url.pathname.startsWith(`${path}/`);
});

if (needLogin && !refreshToken) {
const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/");
url.pathname = "/login";
if (isCommunityRoute) {
url.searchParams.set("reason", COMMUNITY_LOGIN_REASON);
} else {
url.searchParams.delete("reason");
}
return NextResponse.redirect(url);
}

Expand Down