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
19 changes: 15 additions & 4 deletions src/domains/login/components/ClientInitHook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@

import { useFetchInterceptor } from '@/shared/hook/useFetchInterceptor';
import { useIdleLogout } from '../hook/useIdleLogout';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useAuthStore } from '@/domains/shared/store/auth';

function ClientInitHook() {
const checkAuth = useAuthStore((state) => state.checkAuth);
const { isAuthChecked } = useAuthStore();
const checkAuthRef = useRef(useAuthStore.getState().checkAuth);

useIdleLogout();

useFetchInterceptor();

useEffect(() => {
checkAuth();
}, [checkAuth]);
// ref를 최신 함수로 업데이트
checkAuthRef.current = useAuthStore.getState().checkAuth;
});

useEffect(() => {
if (!isAuthChecked) {
checkAuthRef.current();
}
}, [isAuthChecked]);

return null;
}

export default ClientInitHook;
6 changes: 3 additions & 3 deletions src/domains/login/hook/useLoginRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const useLoginRedirect = () => {

if (user && preLoginPath === '/login') {
router.replace('/');
removeCookie('preLoginPath');
setTimeout(() => removeCookie('preLoginPath'), 500);
return;
}

Expand All @@ -42,14 +42,14 @@ export const useLoginRedirect = () => {
} else if (pathname.startsWith('/login/user/success')) {
toastSuccess(`${user.nickname}님 \n 로그인 성공 🎉`);
router.replace(preLoginPath);
removeCookie('preLoginPath');
setTimeout(() => removeCookie('preLoginPath'), 500);
}
}, [pathname, user, loading, router, toastSuccess]);

const handleCloseWelcomeModal = () => {
setWelcomeModalOpen(false);
const preLoginPath = getCookie('preLoginPath') || '/';
removeCookie('preLoginPath');
setTimeout(() => removeCookie('preLoginPath'), 500);
router.replace(preLoginPath);
};

Expand Down
65 changes: 47 additions & 18 deletions src/domains/shared/store/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// auth.ts
import { getApi } from '@/app/api/config/appConfig';
import { create } from 'zustand';

Expand All @@ -13,43 +14,47 @@ export interface User {
interface AuthState {
user: User | null;
isLoggedIn: boolean;
isAuthChecked: boolean;

setUser: (user: User) => void;
logout: () => Promise<void>;
loginWithProvider: (provider: User['provider']) => void;

updateUser: () => Promise<User | null>;
checkAuth: () => Promise<User | null>;
}

export const useAuthStore = create<AuthState>()((set) => ({
export const useAuthStore = create<AuthState>()((set, get) => ({
user: null,
isLoggedIn: false,
isAuthChecked: false,

loginWithProvider: (provider) => {
window.location.href = `${getApi}/oauth2/authorization/${provider}`;
},

setUser: (user) => {
const updatedUser = { ...user, abv_degree: user.abv_degree ?? 5.0 };
set({ user: updatedUser, isLoggedIn: true });
set({
user: updatedUser,
isLoggedIn: true,
isAuthChecked: true,
});
},

// 로그아웃
logout: async () => {
try {
await fetch(`${getApi}/user/auth/logout`, {
method: 'POST',
credentials: 'include',
});
set({ user: null, isLoggedIn: false });

set({ user: null, isLoggedIn: false, isAuthChecked: true });
} catch (err) {
console.error('로그아웃 실패', err);
} finally {
set({ user: null, isLoggedIn: false });
set({ user: null, isLoggedIn: false, isAuthChecked: true });
}
},

// idle + refresh 시 호출
updateUser: async () => {
try {
const res = await fetch(`${getApi}/user/auth/refresh`, {
Expand All @@ -58,15 +63,22 @@ export const useAuthStore = create<AuthState>()((set) => ({
headers: { 'Content-Type': 'application/json' },
});

if (!res.ok) throw new Error('토큰 갱신 실패');
// 200 응답 기대
if (!res.ok) {
set({ user: null, isLoggedIn: false });
return null;
}

const data = await res.json();
const userInfo = data?.data?.user;

if (userInfo) {
set({ user: userInfo, isLoggedIn: true });
return userInfo;
const updatedUser = { ...userInfo, abv_degree: userInfo.abv_degree ?? 5.0 };
set({ user: updatedUser, isLoggedIn: true });
return updatedUser;
}

set({ user: null, isLoggedIn: false });
return null;
} catch (err) {
console.error('updateUser 실패', err);
Expand All @@ -75,24 +87,41 @@ export const useAuthStore = create<AuthState>()((set) => ({
}
},

// 시작 시 로그인 상태 확인
checkAuth: async () => {
const { isAuthChecked } = get();

// 이미 체크했으면 현재 user 반환
if (isAuthChecked) {
return get().user;
}

try {
const res = await fetch(`${getApi}/user/auth/me`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) throw new Error('인증 실패');

// 항상 200 응답 기대
if (!res.ok) {
set({ user: null, isLoggedIn: false, isAuthChecked: true });
return null;
}

const data = await res.json();
const userInfo = data?.data?.user;
const userInfo = data?.data?.user || null;

if (userInfo) {
set({ user: userInfo, isLoggedIn: true });
return userInfo;
const updatedUser = { ...userInfo, abv_degree: userInfo.abv_degree ?? 5.0 };
set({ user: updatedUser, isLoggedIn: true, isAuthChecked: true });
return updatedUser;
}

// user가 null이어도 정상 응답
set({ user: null, isLoggedIn: false, isAuthChecked: true });
return null;
} catch {
set({ user: null, isLoggedIn: false });
} catch (err) {
console.error('checkAuth 실패', err);
set({ user: null, isLoggedIn: false, isAuthChecked: true });
return null;
}
},
Expand Down
27 changes: 27 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// middleware.ts
import { NextResponse, NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
const authCookie = request.cookies.get('accessToken')?.value;
const { pathname } = request.nextUrl;

const PUBLIC_PATHS = ['/', '/login', '/community', '/recipe', '/recommend'];

const isPublicPath = PUBLIC_PATHS.some((path) => pathname.startsWith(path));

// 로그인한 사용자가 로그인 페이지 접근 → 홈으로
if (authCookie && pathname === '/login') {
return NextResponse.redirect(new URL('/', request.url));
}

// 공개 경로가 아니면 로그인 필요
if (!authCookie && !isPublicPath) {
return NextResponse.redirect(new URL('/login', request.url));
}

return NextResponse.next();
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
13 changes: 13 additions & 0 deletions src/shared/hook/useFetchInterceptor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,34 @@ export const useFetchInterceptor = () => {
const response = await originalFetch(input, { ...init, credentials: 'include' });

if (response.status === 401) {
// URL 문자열 추출
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;

// refresh API 자체가 401이면 무한루프 방지
if (url.includes('/user/auth/refresh')) {
return response;
}

try {
const refreshRes = await originalFetch(`${getApi}/user/auth/refresh`, {
method: 'POST',
credentials: 'include',
});

if (refreshRes.ok) {
// 토큰 갱신 성공 시 원래 요청 재시도
return originalFetch(input, { ...init, credentials: 'include' });
} else {
// refresh 실패 → 로그인 페이지로
toastInfo('로그인 인증 만료로 다시 로그인해주세요.');
router.push('/login');
return response;
}
} catch {
toastInfo('로그인 인증 만료로 다시 로그인해주세요.');
router.push('/login');
return response;
}
}
return response;
Expand Down