From c7f85bcf1d32781cb61dfab560b2b32e4b100134 Mon Sep 17 00:00:00 2001 From: Yoonyesol <51500821+Yoonyesol@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:54:15 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20#80=20=EB=A1=9C=EC=BB=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: #80 로그인 기능 초안 작성 * Test: #80 로그인 모킹 함수 작성 * Feat: #80 로그인 관리 저장소 로직 일부 수정 * Feat: #80 로그인 로직, 모킹 함수 일부 수정 및 요청 인터셉트 추가 * Feat: #80 axiosProvider에 응답 인터셉터 추가 * Feat: #80 access token 저장 위치 변경 - 기존 cookie에서 zustand store로 저장 위치 변경 * Chore: #80 불필요한 패키지 제거, 설치 및 유저아이디 변수명 변경 * Feat: #80 refresh token 갱신 모킹 함수 작성 * Feat: #80 refresh token 만료 시 에러 처리 구현 * Feat: #80 프로필 이미지 URL 해제 코드 적용 * Feat: #80 인터셉터 로직 일부 수정 및 로그인 API 호출 로직에 useMutation 훅 적용 * Feat: #80 모킹 함수에 토큰 발급 시간 관련 로직 추가 * Feat: #80 zustand authStore에서 persist 미들웨어 제거 * Feat: #80 로그인 코드 로직을 전체적으로 수정 * Refactor: #80 URL revoke를 위한 로직 변경 및 파일 경로 수정, 시간 상수 활용 코드 변경 * Feat: #80 로그인 API의 withCredentials 옵션 삭제, 토큰 발급 실패 시 에러 처리 방식 변경 * Feat: #80 로그인 API 호출 방식 변경 * Feat: #80 authStore에 AT 만료 시간 추가 * Feat: #80 authStore AT 자동 만료 기능 추가 * Fix: #80 Invalid hook call 에러 해결 * Formatting: #80 import 경로 정리 * Feat: #80 핸들러 타입 처리 및 이미지 컨테이너의 불필요한 revoke 코드 삭제 * Feat: #80 로그인 API 호출 시 useAxios 훅 적용 --- .../user/auth-form/ProfileImageContainer.tsx | 8 +- src/constants/settings.ts | 8 +- src/hooks/useAxios.ts | 12 ++- src/mocks/handlers.ts | 2 + src/mocks/mockData.ts | 32 +++---- src/mocks/services/authServiceHandler.ts | 87 +++++++++++++++++++ src/pages/setting/UserSettingPage.tsx | 8 +- src/pages/user/SearchPasswordPage.tsx | 6 +- src/pages/user/SignInPage.tsx | 44 ++++++++-- src/pages/user/SignUpPage.tsx | 14 +-- src/services/authService.ts | 29 +++++++ src/services/axiosProvider.ts | 53 +++++++++++ src/stores/useAuthStore.ts | 30 +++++++ src/types/UserType.tsx | 6 +- yarn.lock | 31 ++++++- 15 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 src/mocks/services/authServiceHandler.ts create mode 100644 src/services/authService.ts create mode 100644 src/stores/useAuthStore.ts diff --git a/src/components/user/auth-form/ProfileImageContainer.tsx b/src/components/user/auth-form/ProfileImageContainer.tsx index 6d516b84..496d1275 100644 --- a/src/components/user/auth-form/ProfileImageContainer.tsx +++ b/src/components/user/auth-form/ProfileImageContainer.tsx @@ -4,6 +4,7 @@ import { useFormContext } from 'react-hook-form'; import { convertBytesToString } from '@utils/converter'; import { USER_SETTINGS } from '@constants/settings'; import useToast from '@hooks/useToast'; +import { useEffect } from 'react'; type ProfileImageContainerProps = { imageUrl: string; @@ -14,7 +15,12 @@ export default function ProfileImageContainer({ imageUrl, setImageUrl }: Profile const { setValue } = useFormContext(); const { toastWarn } = useToast(); - // 이미지 관련 코드 + useEffect(() => { + return () => { + if (imageUrl) URL.revokeObjectURL(imageUrl); + }; + }, [imageUrl]); + const handleChangeImg = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/src/constants/settings.ts b/src/constants/settings.ts index 240b5b7e..75573579 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -1,4 +1,10 @@ -import { MB } from '@constants/units'; +import { DAY, MB, MINUTE, SECOND } from '@constants/units'; + +export const AUTH_SETTINGS = Object.freeze({ + // ACCESS_TOKEN_EXPIRATION: 5 * SECOND, // 테스트용 5초 + ACCESS_TOKEN_EXPIRATION: 15 * MINUTE, // 15분 + REFRESH_TOKEN_EXPIRATION: 7 * DAY, // 7일 +}); export const USER_SETTINGS = Object.freeze({ MAX_IMAGE_SIZE: 2 * MB, diff --git a/src/hooks/useAxios.ts b/src/hooks/useAxios.ts index bd2d1762..d52c3a41 100644 --- a/src/hooks/useAxios.ts +++ b/src/hooks/useAxios.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import errorHandler from '@hooks/errorHandler'; -import type { AxiosResponse } from 'axios'; +import type { AxiosResponse, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios'; type PromiseCallback = (...args: P) => Promise>; @@ -12,21 +12,24 @@ type PromiseCallback = (...args: P) => Promise} fetchCallback - API 요청을 수행하는 함수 * @returns {{ + * headers: AxiosResponseHeaders | RawAxiosResponseHeaders | undefined; // API 요청의 응답 헤더 * data: T | undefined; // API 요청의 응답 데이터 * error: Error | null; // API 요청 중 발생한 에러 * loading: boolean; // 데이터 로딩 중인지 여부 * fetchData: (...args: P) => Promise; // API 요청을 호출하는 함수 * }} * @example - * const { data, error, loading, fetchData } = useAxios(fetchCallback) // fetchCallback에서 타입을 반환한다면, 자동 타입 추론이 가능 - * const { data, error, loading, fetchData } = useAxios>(fetchCallback); + * const { headers, data, error, loading, fetchData } = useAxios(fetchCallback) // fetchCallback에서 타입을 반환한다면, 자동 타입 추론이 가능 + * const { headers, data, error, loading, fetchData } = useAxios>(fetchCallback); */ export default function useAxios(fetchCallback: PromiseCallback) { + const [headers, setHeaders] = useState(); const [data, setData] = useState(); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const clearData = useCallback(() => { + setHeaders(undefined); setData(undefined); setError(null); setLoading(false); @@ -37,6 +40,7 @@ export default function useAxios(fetchCallback: PromiseC try { setLoading(true); const response = await fetchCallback(...params); + setHeaders(response.headers); setData(response.data); } catch (error: unknown) { setError(error as Error); @@ -48,5 +52,5 @@ export default function useAxios(fetchCallback: PromiseC [fetchCallback], ); - return { data, error, loading, clearData, fetchData }; + return { data, headers, error, loading, clearData, fetchData }; } diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0f621704..1ed3731d 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -3,6 +3,7 @@ import teamServiceHandler from '@mocks/services/teamServiceHandler'; import projectServiceHandler from '@mocks/services/projectServiceHandler'; import taskServiceHandler from '@mocks/services/taskServiceHandler'; import statusServiceHandler from '@mocks/services/statusServiceHandler'; +import authServiceHandler from '@mocks/services/authServiceHandler'; const handlers = [ ...userServiceHandler, @@ -10,6 +11,7 @@ const handlers = [ ...projectServiceHandler, ...taskServiceHandler, ...statusServiceHandler, + ...authServiceHandler, ]; export default handlers; diff --git a/src/mocks/mockData.ts b/src/mocks/mockData.ts index d77fe293..58cfa955 100644 --- a/src/mocks/mockData.ts +++ b/src/mocks/mockData.ts @@ -25,7 +25,7 @@ export const JWT_TOKEN_DUMMY = 'mocked-header.mocked-payload-4.mocked-signature' export const USER_INFO_DUMMY = { provider: 'LOCAL', userId: 1, - id: 'test123', + username: 'test123', email: 'momoco@gmail.com', nickname: 'momoco', profileUrl: '', @@ -37,7 +37,7 @@ export const USER_INFO_DUMMY = { export const USER_DUMMY: User[] = [ { userId: 1, - id: null, + username: null, email: 'one@naver.com', provider: 'GOOGLE', nickname: '판다', @@ -47,7 +47,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 2, - id: null, + username: null, email: 'two@naver.com', provider: 'KAKAO', nickname: '카멜레온', @@ -57,7 +57,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 3, - id: null, + username: null, email: 'three@naver.com', provider: 'GOOGLE', nickname: '랫서판다', @@ -67,7 +67,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 4, - id: null, + username: null, email: 'four@naver.com', provider: 'KAKAO', nickname: '북금곰', @@ -77,7 +77,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 5, - id: null, + username: null, email: 'five@naver.com', provider: 'KAKAO', nickname: '호랑이', @@ -87,7 +87,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 6, - id: null, + username: null, email: 'six@naver.com', provider: 'GOOGLE', nickname: '나무늘보', @@ -97,7 +97,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 7, - id: null, + username: null, email: 'seven@naver.com', provider: 'KAKAO', nickname: '웜뱃', @@ -107,7 +107,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 8, - id: null, + username: null, email: 'eight@naver.com', provider: 'GOOGLE', nickname: '벨루가', @@ -117,7 +117,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 9, - id: null, + username: null, email: 'nine@naver.com', provider: 'KAKAO', nickname: '펭귄', @@ -127,7 +127,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 10, - id: null, + username: null, email: 'ten@naver.com', provider: 'GOOGLE', nickname: '비버', @@ -137,7 +137,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 11, - id: 'eleven', + username: 'eleven', email: 'eleven@naver.com', provider: 'LOCAL', nickname: '판다아빠', @@ -147,7 +147,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 12, - id: 'twelve', + username: 'twelve', email: 'twelve@naver.com', provider: 'LOCAL', nickname: '판다엄마', @@ -157,7 +157,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 13, - id: 'thirteen', + username: 'thirteen', email: 'thirteen@naver.com', provider: 'LOCAL', nickname: '판다형', @@ -167,7 +167,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 14, - id: 'fourteen', + username: 'fourteen', email: 'fourteen@naver.com', provider: 'LOCAL', nickname: '판다누나', @@ -177,7 +177,7 @@ export const USER_DUMMY: User[] = [ }, { userId: 15, - id: 'fifteen', + username: 'fifteen', email: 'fifteen@naver.com', provider: 'LOCAL', nickname: '판다동생', diff --git a/src/mocks/services/authServiceHandler.ts b/src/mocks/services/authServiceHandler.ts new file mode 100644 index 00000000..cec84b50 --- /dev/null +++ b/src/mocks/services/authServiceHandler.ts @@ -0,0 +1,87 @@ +import { http, HttpResponse } from 'msw'; +import { AUTH_SETTINGS } from '@constants/settings'; +import { UserSignInForm } from '@/types/UserType'; + +const BASE_URL = import.meta.env.VITE_BASE_URL; + +// TODO: 타입 분리하기 +type Tokens = { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; +}; + +// TODO: 토큰 관리 변수 분리 +let tokens: Tokens | null = null; + +const accessTokenExpiryDate = new Date(Date.now() + AUTH_SETTINGS.ACCESS_TOKEN_EXPIRATION).toISOString(); +const refreshTokenExpiryDate = new Date(Date.now() + AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION).toISOString(); + +const authServiceHandler = [ + // 로그인 API + http.post(`${BASE_URL}/user/login`, async ({ request }) => { + const { username, password } = (await request.json()) as UserSignInForm; + + if (username === 'test' && password === 'test@123') { + const accessToken = 'mockedAccessToken'; + const refreshToken = 'mockedRefreshToken'; + const currentTime = Date.now(); + + // 토큰 및 발급 시간 저장 + tokens = { + accessToken, + accessTokenExpiresAt: currentTime + AUTH_SETTINGS.ACCESS_TOKEN_EXPIRATION, + refreshToken, + refreshTokenExpiresAt: currentTime + AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION, + }; + + return new HttpResponse(null, { + status: 200, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Set-Cookie': `refreshToken=${refreshToken}; HttpOnly; SameSite=Strict; Secure; Path=/; Expires=${refreshTokenExpiryDate}; Max-Age=${AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION / 1000}`, + }, + }); + } + return HttpResponse.json({ message: '아이디 또는 비밀번호가 잘못되었습니다.' }, { status: 401 }); + }), + + // TODO: 액세스 토큰 발급 방식이 확정지어지면 수정하기 + // 액세스 토큰 갱신 API + http.post(`${BASE_URL}/user/login/refresh`, async ({ cookies }) => { + const { refreshToken } = cookies; + + // TODO: 토큰 관리 변수 분리 + if (tokens === null) { + return HttpResponse.json({ message: '로그인 세션이 존재하지 않습니다.' }, { status: 401 }); + } + + // 리프레시 토큰 검증 + if (refreshToken === tokens.refreshToken) { + const currentTime = Date.now(); + + // 리프레시 토큰 만료 체크 + if (currentTime >= tokens.refreshTokenExpiresAt) { + tokens = null; + return HttpResponse.json({ message: '리프레시 토큰이 만료되었습니다.' }, { status: 401 }); + } + + // 액세스 토큰 갱신 + const newAccessToken = 'newMockedAccessToken'; + tokens.accessToken = newAccessToken; + tokens.accessTokenExpiresAt = currentTime + AUTH_SETTINGS.ACCESS_TOKEN_EXPIRATION; + + return new HttpResponse(null, { + status: 200, + headers: { + Authorization: `Bearer ${newAccessToken}`, + 'Set-Cookie': `refreshToken=${refreshToken}; HttpOnly; SameSite=Strict; Secure; Path=/; Expires=${refreshTokenExpiryDate}; Max-Age=${AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION / 1000}`, + }, + }); + } + return HttpResponse.json({ message: '리프레시 토큰이 유효하지 않습니다.' }, { status: 401 }); + }), +]; + +export default authServiceHandler; diff --git a/src/pages/setting/UserSettingPage.tsx b/src/pages/setting/UserSettingPage.tsx index a18041de..c5bc983d 100644 --- a/src/pages/setting/UserSettingPage.tsx +++ b/src/pages/setting/UserSettingPage.tsx @@ -12,7 +12,7 @@ export default function UserSettingPage() { const methods = useForm({ mode: 'onChange', defaultValues: { - id: USER_INFO_DUMMY.id, + username: USER_INFO_DUMMY.username, email: USER_INFO_DUMMY.email, nickname: USER_INFO_DUMMY.nickname, bio: USER_INFO_DUMMY.bio, @@ -23,7 +23,7 @@ export default function UserSettingPage() { // form 전송 함수 const onSubmit = async (data: EditUserInfoForm) => { - const { id, email, profileUrl, ...filteredData } = data; + const { username, email, profileUrl, ...filteredData } = data; console.log(data); // TODO: 폼 제출 로직 작성 @@ -41,8 +41,8 @@ export default function UserSettingPage() { disabled label="아이디" required={false} - errors={methods.formState.errors.id?.message} - register={methods.register('id', USER_AUTH_VALIDATION_RULES.ID)} + errors={methods.formState.errors.username?.message} + register={methods.register('username', USER_AUTH_VALIDATION_RULES.ID)} /> {/* 이메일 */} diff --git a/src/pages/user/SearchPasswordPage.tsx b/src/pages/user/SearchPasswordPage.tsx index 620e8844..e202c733 100644 --- a/src/pages/user/SearchPasswordPage.tsx +++ b/src/pages/user/SearchPasswordPage.tsx @@ -13,7 +13,7 @@ export default function SearchPasswordPage() { } = useForm({ mode: 'onChange', defaultValues: { - id: '', + username: '', email: '', code: '', }, @@ -28,8 +28,8 @@ export default function SearchPasswordPage() { {/* 아이디 */} {/* 이메일 */} diff --git a/src/pages/user/SignInPage.tsx b/src/pages/user/SignInPage.tsx index 413a2452..f3594c07 100644 --- a/src/pages/user/SignInPage.tsx +++ b/src/pages/user/SignInPage.tsx @@ -5,9 +5,21 @@ import ValidationInput from '@components/common/ValidationInput'; import { USER_AUTH_VALIDATION_RULES } from '@constants/formValidationRules'; import FooterLinks from '@components/user/auth-form/FooterLinks'; import AuthFormLayout from '@layouts/AuthFormLayout'; +import { useNavigate } from 'react-router-dom'; +import { AxiosError } from 'axios'; +import useToast from '@hooks/useToast'; +import { login } from '@services/authService'; +import { useEffect } from 'react'; import type { UserSignInForm } from '@/types/UserType'; +import { useAuthStore } from '@/stores/useAuthStore'; +import useAxios from '@/hooks/useAxios'; export default function SignInPage() { + const { onLogin } = useAuthStore(); + const { toastError } = useToast(); + const navigate = useNavigate(); + const { error, fetchData, headers, loading } = useAxios(login); + const { register, handleSubmit, @@ -15,23 +27,45 @@ export default function SignInPage() { } = useForm({ mode: 'onChange', defaultValues: { - id: '', + username: '', password: '', }, }); - const onSubmit = (data: UserSignInForm) => { - console.log(data); + const onSubmit = async (formData: UserSignInForm) => { + await fetchData(formData); }; + useEffect(() => { + if (headers) { + const accessToken = headers.authorization; + if (!accessToken) { + toastError('로그인에 실패했습니다.'); + return; + } + + onLogin(accessToken.split(' ')[1]); + navigate('/', { replace: true }); + return; + } + + if (error instanceof AxiosError) { + if (error.response?.status === 401) { + toastError('아이디와 비밀번호를 한번 더 확인해 주세요.'); + return; + } + toastError(`로그인 도중 오류가 발생했습니다: ${error.message}`); + } + }, [headers, error]); + return ( <> {/* 아이디 */} {/* 비밀번호 */} diff --git a/src/pages/user/SignUpPage.tsx b/src/pages/user/SignUpPage.tsx index 55cd3fe2..fc9a4a6b 100644 --- a/src/pages/user/SignUpPage.tsx +++ b/src/pages/user/SignUpPage.tsx @@ -21,7 +21,7 @@ export default function SignUpPage() { const methods = useForm({ mode: 'onChange', defaultValues: { - id: '', + username: '', email: '', code: '', nickname: '', @@ -34,7 +34,7 @@ export default function SignUpPage() { // form 전송 함수 const onSubmit = async (data: UserSignUpForm) => { - const { id, code, checkPassword, ...filteredData } = data; + const { username, code, checkPassword, ...filteredData } = data; console.log(data); const verifyResult = verifyCode(methods.watch('code'), methods.setError); @@ -43,8 +43,8 @@ export default function SignUpPage() { // TODO: 폼 제출 로직 수정 필요 try { // 회원가입 폼 - const formData = { ...filteredData, id, code }; - const registrationResponse = await axios.post(`http://localhost:8080/api/v1/user/${id}`, formData); + const formData = { ...filteredData, username, code }; + const registrationResponse = await axios.post(`http://localhost:8080/api/v1/user/${username}`, formData); if (registrationResponse.status !== 200) return toastError('회원가입에 실패했습니다. 다시 시도해 주세요.'); // 이미지 폼 @@ -54,7 +54,7 @@ export default function SignUpPage() { const jpeg = await reduceImageSize(imageUrl); const file = new File([jpeg], new Date().toISOString(), { type: 'image/jpeg' }); imgFormData.append('profileUrl', file); - imgFormData.append('id', id ?? ''); + imgFormData.append('username', username ?? ''); const imageResponse = await axios.post(`http://localhost:8080/api/v1/users/file`, imgFormData, { headers: { 'Content-Type': 'multipart/form-data' }, @@ -80,8 +80,8 @@ export default function SignUpPage() { {/* 아이디 */} {/* 이메일 */} diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 00000000..ee4d7f84 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,29 @@ +import { defaultAxios } from '@services/axiosProvider'; + +import type { AxiosRequestConfig } from 'axios'; +import type { UserSignInForm } from '@/types/UserType'; + +/** + * 사용자 로그인 API + * + * @export + * @async + * @param {UserSignInForm} loginForm - 로그인 폼 데이터 + * @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체 + * @returns {Promise} + */ +export async function login(loginForm: UserSignInForm, axiosConfig: AxiosRequestConfig = {}) { + return defaultAxios.post('user/login', loginForm, axiosConfig); +} + +/** + * 사용자 액세스 토큰 갱신 API + * + * @export + * @async + * @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체 + * @returns {Promise} + */ +export async function getAccessToken(axiosConfig: AxiosRequestConfig = {}) { + return defaultAxios.post('user/login/refresh', null, { ...axiosConfig, withCredentials: true }); +} diff --git a/src/services/axiosProvider.ts b/src/services/axiosProvider.ts index d3aba721..527d1de3 100644 --- a/src/services/axiosProvider.ts +++ b/src/services/axiosProvider.ts @@ -2,6 +2,9 @@ import axios from 'axios'; import { SECOND } from '@constants/units'; import { JWT_TOKEN_DUMMY } from '@mocks/mockData'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import useToast from '@hooks/useToast'; +import { getAccessToken } from '@services/authService'; +import { useAuthStore } from '@/stores/useAuthStore'; const BASE_URL = import.meta.env.VITE_BASE_URL; const defaultConfigOptions: AxiosRequestConfig = { @@ -23,3 +26,53 @@ export const authAxios = axiosProvider({ }, withCredentials: true, }); + +// 요청 인터셉터 +authAxios.interceptors.request.use( + (config) => { + const { accessToken } = useAuthStore.getState(); + + const modifiedConfig = { ...config }; + if (accessToken) modifiedConfig.headers.Authorization = `Bearer ${accessToken}`; + + return modifiedConfig; + }, + (error) => { + return Promise.reject(error); + }, +); + +// 응답 인터셉터 +authAxios.interceptors.response.use( + (response) => response, + async (error) => { + // 액세스 토큰 만료 시 처리 + if (error.response?.status === 401) { + const { toastError } = useToast(); + const { onLogout, setAccessToken } = useAuthStore.getState(); + + // 에러 객체의 설정 객체 추출 + const originalRequest = error.config; + + try { + // 리프레시 토큰을 이용해 새로운 액세스 토큰 발급 + const refreshResponse = await getAccessToken(); + const newAccessToken = refreshResponse.headers.Authorization; // 응답값: `Bearer newAccessToken` + + if (!newAccessToken) throw new Error('토큰 발급에 실패했습니다.'); + + setAccessToken(newAccessToken.split(' ')[1]); + + // 기존 설정 객체에 새로운 액세스 토큰 적용 + originalRequest.headers.Authorization = newAccessToken; + return await authAxios(originalRequest); + } catch (refreshError) { + // 리프레시 토큰 에러 시 처리 + toastError('로그인 정보가 만료되었습니다. 다시 로그인 해주세요.'); + onLogout(); + return Promise.reject(refreshError); + } + } + return Promise.reject(error); + }, +); diff --git a/src/stores/useAuthStore.ts b/src/stores/useAuthStore.ts new file mode 100644 index 00000000..76aba1e8 --- /dev/null +++ b/src/stores/useAuthStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import { AUTH_SETTINGS } from '@constants/settings'; + +type AuthStore = { + isAuthenticated: boolean; + accessToken: string | null; + + setAccessToken: (token: string) => void; + onLogin: (token: string) => void; + onLogout: () => void; +}; + +export const useAuthStore = create((set) => ({ + isAuthenticated: false, + accessToken: null, + + setAccessToken: (token: string) => set({ accessToken: token }), + + onLogin: (token: string) => { + set({ isAuthenticated: true, accessToken: token }); + + setTimeout(() => { + set({ isAuthenticated: false, accessToken: null }); + }, AUTH_SETTINGS.ACCESS_TOKEN_EXPIRATION); + }, + + onLogout: () => { + set({ isAuthenticated: false, accessToken: null }); + }, +})); diff --git a/src/types/UserType.tsx b/src/types/UserType.tsx index a0e67421..11a1ce63 100644 --- a/src/types/UserType.tsx +++ b/src/types/UserType.tsx @@ -2,7 +2,7 @@ import type { Role } from '@/types/RoleType'; export type User = { userId: number; - id: string | null; + username: string | null; email: string; provider: 'LOCAL' | 'KAKAO' | 'GOOGLE'; nickname: string; @@ -21,13 +21,13 @@ export type UserSignUpForm = Omit & { checkPassword: string; }; -export type UserSignInForm = Pick & { +export type UserSignInForm = Pick & { password: string; }; export type EmailVerificationForm = Pick & { code: string }; -export type SearchPasswordForm = Pick & { code: string }; +export type SearchPasswordForm = Pick & { code: string }; export type EditPasswordForm = { password: string; diff --git a/yarn.lock b/yarn.lock index 43d64155..03454170 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5093,7 +5093,16 @@ string-argv@0.3.2: resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5174,7 +5183,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5804,7 +5820,7 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -5822,6 +5838,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"