diff --git a/src/api/service/auth-service/index.ts b/src/api/service/auth-service/index.ts index 63ae05e6..f170f5f2 100644 --- a/src/api/service/auth-service/index.ts +++ b/src/api/service/auth-service/index.ts @@ -1,33 +1,37 @@ -import type { AxiosError } from 'axios'; - -import { baseAPI } from '@/api/core'; -import { LoginRequest, LoginResponse, SignupRequest, SignupResponse } from '@/types/service/auth'; -import { CommonErrorResponse } from '@/types/service/common'; - -export const isProblemDetailError = (error: unknown): error is AxiosError => { - return ( - typeof error === 'object' && - error !== null && - 'isAxiosError' in error && - (error as AxiosError).isAxiosError === true - ); -}; +import { api } from '@/api/core'; +import { clearAccessToken, setAccessToken } from '@/lib/auth/token'; +import { + LoginRequest, + LoginResponse, + RefreshResponse, + SignupRequest, + SignupResponse, +} from '@/types/service/auth'; export const authServiceRemote = () => ({ // 로그인 login: async (payload: LoginRequest): Promise => { - const { data } = await baseAPI.post('/api/v1/auth/login', payload); + const data = await api.post('/auth/login', payload); + + setAccessToken(data.accessToken, data.expiresIn); return data; }, // 회원가입 - signup: async (payload: SignupRequest): Promise => { - const { data } = await baseAPI.post('/api/v1/auth/signup', payload); - return data; - }, + signup: async (payload: SignupRequest): Promise => + api.post(`/auth/signup`, payload), // 로그아웃 logout: async (): Promise => { - await baseAPI.post('/api/v1/auth/logout'); + await api.post('/auth/logout'); + clearAccessToken(); + }, + + // 액세스 토큰 재발급 + refresh: async (): Promise => { + const data = await api.post('/auth/refresh'); + + setAccessToken(data.accessToken, data.expiresIn); + return data; }, }); diff --git a/src/components/pages/login/login-form/index.tsx b/src/components/pages/login/login-form/index.tsx index c0e3b142..ce14eeec 100644 --- a/src/components/pages/login/login-form/index.tsx +++ b/src/components/pages/login/login-form/index.tsx @@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation'; import { type AnyFieldApi, useForm } from '@tanstack/react-form'; import { API } from '@/api'; -import { isProblemDetailError } from '@/api/service'; import { FormInput } from '@/components/shared'; import { Button } from '@/components/ui'; import { loginSchema } from '@/lib/schema/auth'; +import { CommonErrorResponse } from '@/types/service/common'; const getHintMessage = (field: AnyFieldApi) => { const { @@ -47,15 +47,10 @@ export const LoginForm = () => { formApi.reset(); router.push('/'); } catch (error) { - if (isProblemDetailError(error) && error.response?.data) { - const problem = error.response.data; - - console.error('[LOGIN ERROR]', problem.errorCode, problem.detail); - alert(problem.detail || '로그인에 실패했습니다.'); - } else { - console.error(error); - alert('알 수 없는 오류가 발생했습니다.'); - } + const err = error as CommonErrorResponse; + + console.error('[LOGIN ERROR]', err.errorCode, err.detail); + alert(err.detail || '로그인에 실패했습니다.'); } }, }); diff --git a/src/components/pages/signup/signup-form/index.tsx b/src/components/pages/signup/signup-form/index.tsx index efede055..32cc7893 100644 --- a/src/components/pages/signup/signup-form/index.tsx +++ b/src/components/pages/signup/signup-form/index.tsx @@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation'; import { type AnyFieldApi, useForm } from '@tanstack/react-form'; import { API } from '@/api'; -import { isProblemDetailError } from '@/api/service'; import { FormInput } from '@/components/shared'; import { Button } from '@/components/ui'; import { signupSchema } from '@/lib/schema/auth'; +import { CommonErrorResponse } from '@/types/service/common'; const getHintMessage = (field: AnyFieldApi) => { const { @@ -50,15 +50,10 @@ export const SignupForm = () => { formApi.reset(); router.push('/login'); } catch (error) { - if (isProblemDetailError(error) && error.response?.data) { - const problem = error.response.data; - - console.error('[SIGNUP ERROR]', problem.errorCode, problem.detail); - alert(problem.detail || '회원가입에 실패했습니다.'); - } else { - console.error(error); - alert('알 수 없는 오류가 발생했습니다.'); - } + const err = error as CommonErrorResponse; + + console.error('[SIGNUP ERROR]', err.errorCode, err.detail); + alert(err.detail || '회원가입에 실패했습니다.'); } }, }); diff --git a/src/lib/auth/token.ts b/src/lib/auth/token.ts new file mode 100644 index 00000000..2b2f49e7 --- /dev/null +++ b/src/lib/auth/token.ts @@ -0,0 +1,19 @@ +const ACCESS_TOKEN_KEY = 'accessToken'; + +export const setAccessToken = (token: string, maxAgeSeconds?: number) => { + if (typeof document === 'undefined') return; + + const parts = [`${ACCESS_TOKEN_KEY}=${encodeURIComponent(token)}`, 'path=/']; + + if (typeof maxAgeSeconds === 'number' && maxAgeSeconds > 0) { + parts.push(`Max-Age=${maxAgeSeconds}`); + } + + document.cookie = parts.join('; '); +}; + +export const clearAccessToken = () => { + if (typeof document === 'undefined') return; + + document.cookie = `${ACCESS_TOKEN_KEY}=; Max-Age=0; path=/`; +}; diff --git a/src/mock/service/auth/auth-handlers.ts b/src/mock/service/auth/auth-handlers.ts index b940e398..6f7c03f1 100644 --- a/src/mock/service/auth/auth-handlers.ts +++ b/src/mock/service/auth/auth-handlers.ts @@ -1,8 +1,14 @@ import { http, HttpResponse } from 'msw'; -import { LoginRequest, LoginResponse, SignupRequest } from '@/types/service/auth'; -import { CommonErrorResponse } from '@/types/service/common'; +import { + LoginRequest, + LoginResponse, + RefreshResponse, + SignupRequest, + SignupResponse, +} from '@/types/service/auth'; +import { createMockErrorResponse, createMockSuccessResponse } from '../common/common-mock'; import { createLoginResponse, createSignupResponse, @@ -10,57 +16,89 @@ import { isNicknameTaken, } from './auth-utils'; +// 회원가입 const signupMock = http.post('*/api/v1/auth/signup', async ({ request }) => { const body = (await request.json()) as SignupRequest; if (isEmailTaken(body.email)) { - const errorBody: CommonErrorResponse = { - type: 'https://example.com/errors/email-duplicate', - title: 'EMAIL_DUPLICATE', - status: 400, - detail: '이미 존재하는 이메일입니다.', - instance: '/api/v1/auth/signup', - errorCode: 'A002', - }; - return HttpResponse.json(errorBody, { status: 400 }); + return HttpResponse.json( + createMockErrorResponse({ + status: 400, + detail: '이미 존재하는 이메일입니다.', + errorCode: 'A002', + }), + { status: 400 }, + ); } if (isNicknameTaken(body.nickName)) { - const errorBody: CommonErrorResponse = { - type: 'https://example.com/errors/nickname-duplicate', - title: 'NICKNAME_DUPLICATE', - status: 400, - detail: '이미 존재하는 닉네임입니다.', - instance: '/api/v1/auth/signup', - errorCode: 'A003', - }; - return HttpResponse.json(errorBody, { status: 400 }); + return HttpResponse.json( + createMockErrorResponse({ + status: 400, + detail: '이미 존재하는 닉네임입니다.', + errorCode: 'A003', + }), + { status: 400 }, + ); } + const response = createSignupResponse(body.email, body.nickName, body.password); - return HttpResponse.json(response, { status: 201 }); + + return HttpResponse.json(createMockSuccessResponse(response)); }); +// 로그인 const loginMock = http.post('*/api/v1/auth/login', async ({ request }) => { const body = (await request.json()) as LoginRequest; try { const response = createLoginResponse(body.email, body.password); - return HttpResponse.json(response, { status: 200 }); + + return HttpResponse.json(createMockSuccessResponse(response), { + headers: { + 'Set-Cookie': 'refreshToken=mock-refresh-token; Path=/; HttpOnly; SameSite=Strict; Secure', + }, + }); } catch { - const errorBody: CommonErrorResponse = { - type: 'https://example.com/errors/invalid-credentials', - title: 'INVALID_CREDENTIALS', - status: 400, - detail: '이메일 또는 비밀번호가 올바르지 않습니다.', - instance: '/api/v1/auth/login', - errorCode: 'A001', - }; - return HttpResponse.json(errorBody, { status: 400 }); + return HttpResponse.json( + createMockErrorResponse({ + status: 400, + detail: '이메일 또는 비밀번호가 올바르지 않습니다.', + errorCode: 'A001', + }), + { status: 400 }, + ); } }); -const logoutMock = http.post('*/api/v1/auth/logout', async ({}) => { - return new HttpResponse(null, { status: 204 }); +// 로그아웃 +const logoutMock = http.post('*/api/v1/auth/logout', async () => { + return HttpResponse.json(createMockSuccessResponse(undefined)); +}); + +// 액세스 토큰 재발급 +const refreshMock = http.post('*/api/v1/auth/refresh', async ({ cookies }) => { + const refreshToken = cookies['refreshToken']; + + if (!refreshToken) { + return HttpResponse.json( + createMockErrorResponse({ + status: 401, + detail: '리프레시 토큰이 없습니다.', + errorCode: 'A004', + }), + { status: 401 }, + ); + } + + const response: RefreshResponse = { + accessToken: 'refreshed-mock-access-token', + tokenType: 'Bearer', + expiresIn: 3600, + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + }; + + return HttpResponse.json(createMockSuccessResponse(response)); }); -export const authHandlers = [signupMock, loginMock, logoutMock]; +export const authHandlers = [signupMock, loginMock, logoutMock, refreshMock]; diff --git a/src/mock/service/auth/auth-utils.ts b/src/mock/service/auth/auth-utils.ts index 1755a060..2e8f5188 100644 --- a/src/mock/service/auth/auth-utils.ts +++ b/src/mock/service/auth/auth-utils.ts @@ -11,7 +11,6 @@ const findUserByCredentials = (email: string, password: string) => const createMockTokens = () => ({ accessToken: 'mock-access-token', - refreshToken: 'mock-refresh-token', tokenType: 'Bearer' as const, expiresIn: 3600, }); diff --git a/src/types/service/auth.ts b/src/types/service/auth.ts index 32b9bc73..a8d6fed9 100644 --- a/src/types/service/auth.ts +++ b/src/types/service/auth.ts @@ -18,7 +18,6 @@ export interface LoginRequest { export interface LoginResponse { accessToken: string; - refreshToken: string; tokenType: 'Bearer'; expiresIn: number; user: { @@ -27,3 +26,10 @@ export interface LoginResponse { nickName: string; }; } + +export interface RefreshResponse { + accessToken: string; + tokenType: 'Bearer'; + expiresIn: number; + expiresAt: string; +}