Skip to content
Merged
14 changes: 4 additions & 10 deletions src/api/core/auth/index.ts → src/api/core/refresh/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import axios from 'axios';

import { getAccessToken } from '@/lib/auth/token';
import { CommonErrorResponse } from '@/types/service/common';

import { createApiHelper } from '../lib/apiHelper';

const authInstance = axios.create({
const refreshInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
timeout: 20000,
withCredentials: true,
});

authInstance.interceptors.request.use(async (config) => {
refreshInstance.interceptors.request.use(async (config) => {
const isServer = typeof window === 'undefined';

if (isServer) {
Expand All @@ -23,15 +22,10 @@ authInstance.interceptors.request.use(async (config) => {
}
}

const accessToken = await getAccessToken();
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}

return config;
});

authInstance.interceptors.response.use(
refreshInstance.interceptors.response.use(
(response) => {
return response;
},
Expand All @@ -40,4 +34,4 @@ authInstance.interceptors.response.use(
},
);

export const authAPI = createApiHelper(authInstance);
export const refreshAPI = createApiHelper(refreshInstance);
20 changes: 13 additions & 7 deletions src/api/service/auth-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authAPI } from '@/api/core/auth';
import { baseAPI } from '@/api/core/base';
import { refreshAPI } from '@/api/core/refresh';
import { clearAccessToken, setAccessToken } from '@/lib/auth/token';
import {
GoogleOAuthExchangeRequest,
Expand All @@ -13,40 +14,45 @@ import {
export const authServiceRemote = () => ({
// 로그인
login: async (payload: LoginRequest) => {
const data = await authAPI.post<LoginResponse>('/api/v1/auth/login', payload);
//prettier-ignore
const data = await baseAPI.post<LoginResponse>('/api/v1/auth/login', payload, { withCredentials: true });

setAccessToken(data.accessToken, data.expiresIn);
return data;
},

// 회원가입
signup: async (payload: SignupRequest) => {
return authAPI.post<SignupResponse>(`/api/v1/auth/signup`, payload);
return baseAPI.post<SignupResponse>(`/api/v1/auth/signup`, payload, { withCredentials: true });
},

// 로그아웃
logout: async () => {
await authAPI.post<void>('/api/v1/auth/logout', null);
await baseAPI.post<void>('/api/v1/auth/logout', null, { withCredentials: true });

clearAccessToken();
},

// 액세스 토큰 재발급
refresh: async () => {
//prettier-ignore
const data = await authAPI.post<RefreshResponse>('/api/v1/auth/refresh', {});
const data = await refreshAPI.post<RefreshResponse>('/api/v1/auth/refresh', null, { withCredentials: true });

setAccessToken(data.accessToken, data.expiresIn);
return data;
},

// 회원 탈퇴
withdraw: async () => {
await authAPI.delete<void>('/api/v1/auth/withdraw');
await baseAPI.delete<void>('/api/v1/auth/withdraw', { withCredentials: true });

clearAccessToken();
},

// 구글 OAuth 코드 교환
exchangeGoogleCode: async (payload: GoogleOAuthExchangeRequest) => {
const data = await authAPI.post<GoogleOAuthExchangeResponse>('/api/v1/auth/google', payload);
//prettier-ignore
const data = await baseAPI.post<GoogleOAuthExchangeResponse>('/api/v1/auth/google', payload, { withCredentials: true });

setAccessToken(data.accessToken, data.expiresIn);
return data;
Expand Down
1 change: 1 addition & 0 deletions src/mock/service/auth/auth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const createMockTokens = () => ({
accessToken: 'mock-access-token',
tokenType: 'Bearer' as const,
expiresIn: 3600,
expiresAt: '2026-02-21T11:05:19.595700269',
});

export const createLoginResponse = (email: string, password: string): LoginResponse => {
Expand Down
25 changes: 1 addition & 24 deletions src/providers/provider-auth/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import React, { createContext, SetStateAction, useContext, useEffect, useState } from 'react';

import Cookies from 'js-cookie';

import { API } from '@/api';
import React, { createContext, SetStateAction, useContext, useState } from 'react';

interface AuthContextType {
isAuthenticated: boolean;
Expand All @@ -25,25 +21,6 @@ interface Props {
export const AuthProvider = ({ children, hasRefreshToken }: Props) => {
const [isAuthenticated, setIsAuthenticated] = useState(hasRefreshToken);

// 초기값 설정
// 페이지가 새로고침 될 때 accessToken이 없으면 refresh 시도, state update 실행
useEffect(() => {
const updateAuthenticated = async () => {
const hasAccessToken = !!Cookies.get('accessToken');
if (!hasAccessToken && hasRefreshToken) {
try {
await API.authService.refresh();
setIsAuthenticated(true);
} catch {
setIsAuthenticated(false);
}
} else if (hasAccessToken) {
setIsAuthenticated(true);
}
};
updateAuthenticated();
}, [hasRefreshToken]);

return (
<AuthContext.Provider value={{ isAuthenticated, setIsAuthenticated }}>
{children}
Expand Down
36 changes: 30 additions & 6 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';

export const proxy = (request: NextRequest) => {
import { API } from './api';

export const proxy = async (request: NextRequest) => {
const accessToken = request.cookies.get('accessToken');
const refreshToken = request.cookies.get('refreshToken');
let hasValidToken = !!accessToken;

const protectedPaths = ['/mypage', '/create-group', '/message', '/schedule', '/notification'];
const isProtected = protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path));
Expand All @@ -11,24 +14,45 @@ export const proxy = (request: NextRequest) => {
const isPublic = publicPaths.some((path) => request.nextUrl.pathname.startsWith(path));

// 인증된 사용자가 public 페이지 접근 시 홈으로
if (isPublic && (accessToken || refreshToken)) {
// refresh 중복 실행을 방지하기 위해 최상단으로 이동
if (isPublic && refreshToken) {
return NextResponse.redirect(new URL('/', request.url));
}

// 일반 응답 생성
const response = NextResponse.next();

// accessToken이 없으면 refresh 실행하여 일반 응답에 set cookie 설정
if (!accessToken && refreshToken) {
try {
const res = await API.authService.refresh();
const data = res;
hasValidToken = true;
response.cookies.set('accessToken', data.accessToken, {
httpOnly: false,
maxAge: data.expiresIn,
domain: 'wego.monster',
secure: process.env.NODE_ENV === 'production',
});
} catch {
hasValidToken = false;
}
}

// 보호되지 않은 경로는 그냥 통과
if (!isProtected) {
return NextResponse.next();
return response;
}

// 둘 다 없으면 로그인 페이지로 redirect
if (!accessToken && !refreshToken) {
// accessToken 없으면 login redirect
if (!hasValidToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('error', 'unauthorized');
loginUrl.searchParams.set('path', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}

return NextResponse.next();
return response;
};

export const config = {
Expand Down
1 change: 1 addition & 0 deletions src/types/service/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface LoginResponse {
accessToken: string;
tokenType: 'Bearer';
expiresIn: number;
expiresAt: string;
user: {
userId: number;
email: string;
Expand Down