Skip to content

Commit

Permalink
Feat: #80 로그인 코드 로직을 전체적으로 수정
Browse files Browse the repository at this point in the history
  • Loading branch information
Yoonyesol committed Aug 31, 2024
1 parent af57761 commit 1cee168
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 55 deletions.
6 changes: 6 additions & 0 deletions src/constants/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { MB } from '@constants/units';

export const AUTH_SETTINGS = Object.freeze({
// ACCESS_TOKEN_EXPIRATION: 3000, // 테스트용 3초
ACCESS_TOKEN_EXPIRATION: 15 * 60 * 1000, // 15분
REFRESH_TOKEN_EXPIRATION: 7 * 24 * 60 * 60 * 1000, // 7일
});

export const USER_SETTINGS = Object.freeze({
MAX_IMAGE_SIZE: 2 * MB,
MAX_LINK_COUNT: 5,
Expand Down
55 changes: 31 additions & 24 deletions src/mocks/services/authServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { http, HttpResponse } from 'msw';
import { AUTH_SETTINGS } from '@/constants/settings';

const BASE_URL = import.meta.env.VITE_BASE_URL;

// TODO: 타입 분리하기
type Tokens = {
accessTokenIssuedAt: number;
refreshTokenIssuedAt: number;
accessToken: string;
accessTokenExpiresAt: number;
refreshToken: string;
refreshTokenExpiresAt: number;
};

// 토큰 만료 시간
const ACCESS_TOKEN_EXPIRATION = 15 * 60 * 1000; // 15분
const REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7일

// 토큰 및 발급 시간 저장
let tokens: Tokens;

type LoginRequestBody = {
username: string;
password: string;
};

let tokens: Tokens | null = null;

const authServiceHandler = [
// 로그인 API
http.post(`${BASE_URL}/user/login`, async ({ request }) => {
Expand All @@ -29,49 +28,57 @@ const authServiceHandler = [
const refreshToken = 'mockedRefreshToken';
const currentTime = Date.now();

// 토큰 발급 시간 저장
// 토큰 발급 시간 저장
tokens = {
accessTokenIssuedAt: currentTime,
refreshTokenIssuedAt: currentTime,
accessToken,
accessTokenExpiresAt: currentTime + AUTH_SETTINGS.ACCESS_TOKEN_EXPIRATION,
refreshToken,
refreshTokenExpiresAt: currentTime + AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION,
};

return HttpResponse.json(null, {
return new HttpResponse(null, {
status: 200,
headers: {
Authorization: `Bearer ${accessToken}`,
'Set-Cookie': `refreshToken=${refreshToken}; Max-Age=${REFRESH_TOKEN_EXPIRATION / 1000}; HttpOnly; SameSite=Strict; Secure`,
'Set-Cookie': `refreshToken=${refreshToken}; HttpOnly; SameSite=Strict; Secure; Path=/`,
},
});
}
return new HttpResponse(JSON.stringify({ message: '아이디 또는 비밀번호가 잘못되었습니다.' }), { status: 400 });
return HttpResponse.json({ message: '아이디 또는 비밀번호가 잘못되었습니다.' }, { status: 400 });
}),

// access token 갱신 API
// 액세스 토큰 갱신 API
http.post(`${BASE_URL}/user/login/refresh`, async ({ cookies }) => {
const { refreshToken } = cookies;

if (refreshToken === 'mockedRefreshToken') {
if (tokens === null) {
return HttpResponse.json({ message: '로그인 세션이 존재하지 않습니다.' }, { status: 401 });
}

// 리프레시 토큰 검증
if (refreshToken === tokens.refreshToken) {
const currentTime = Date.now();

// 리프레시 토큰 만료 체크
const isTokenExpired = currentTime - tokens.refreshTokenIssuedAt > REFRESH_TOKEN_EXPIRATION;
if (isTokenExpired) {
return new HttpResponse(JSON.stringify({ message: '리프레시 토큰이 만료되었습니다.' }), { status: 401 });
if (currentTime >= tokens.refreshTokenExpiresAt) {
tokens = null;
return HttpResponse.json({ message: '리프레시 토큰이 만료되었습니다.' }, { status: 401 });
}

// 액세스 토큰 갱신
const newAccessToken = `${currentTime};newMockedAccessToken`;
tokens.accessTokenIssuedAt = currentTime;
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}; Max-Age=${REFRESH_TOKEN_EXPIRATION / 1000}; HttpOnly; SameSite=Strict; Secure`,
'Set-Cookie': `refreshToken=${tokens.refreshToken}; HttpOnly; SameSite=Strict; Secure; Path=/`,
},
});
}
return new HttpResponse(JSON.stringify({ message: '리프레시 토큰이 유효하지 않습니다.' }), { status: 400 });
return HttpResponse.json({ message: '리프레시 토큰이 유효하지 않습니다.' }, { status: 401 });
}),
];

Expand Down
9 changes: 4 additions & 5 deletions src/pages/user/SignInPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { useMutation } from '@tanstack/react-query';
import type { UserSignInForm } from '@/types/UserType';
import { authAxios } from '@/services/axiosProvider';
import useToast from '@/hooks/useToast';
import { useAuthStore } from '@/stores/useAuthStore';
import { login } from '@/services/authService';

export default function SignInPage() {
const { Login } = useAuthStore();
const { onLogin } = useAuthStore();
const { toastError } = useToast();
const navigate = useNavigate();

const {
register,
handleSubmit,
Expand All @@ -37,13 +37,12 @@ export default function SignInPage() {
const accessToken = response.headers.authorization;
if (!accessToken) return toastError('로그인에 실패했습니다.');

authAxios.defaults.headers.Authorization = accessToken;
Login(accessToken.replace('Bearer ', ''));
onLogin(accessToken.split(' ')[1]);

navigate('/', { replace: true });
},
onError: (error: Error) => {
if (axios.isAxiosError(error) && error.response?.status === 400) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
return toastError('아이디와 비밀번호를 한번 더 확인해 주세요.');
}
toastError(`로그인 도중 오류가 발생했습니다: ${error}`);
Expand Down
25 changes: 20 additions & 5 deletions src/services/authService.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { authAxios } from '@services/axiosProvider';
import type { User, UserSignInForm } from '@/types/UserType';
import { defaultAxios } from '@services/axiosProvider';

import type { AxiosRequestConfig } from 'axios';
import type { UserSignInForm } from '@/types/UserType';

/**
* 사용자 로그인 API
*
* @export
* @async
* @param {UserSignInForm} loginForm - 로그인 폼 데이터
* @returns {Promise<AxiosResponse<User>>}
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse>}
*/
export async function login(loginForm: UserSignInForm, axiosConfig: AxiosRequestConfig = {}) {
return defaultAxios.post('user/login', loginForm, { ...axiosConfig, withCredentials: true });
}

/**
* 사용자 액세스 토큰 갱신 API
*
* @export
* @async
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse>}
*/
export async function login(loginForm: UserSignInForm) {
return authAxios.post<User>('user/login', loginForm, { withCredentials: true });
export async function getAccessToken(axiosConfig: AxiosRequestConfig = {}) {
return defaultAxios.post('user/login/refresh', null, { ...axiosConfig, withCredentials: true });
}
36 changes: 19 additions & 17 deletions src/services/axiosProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { JWT_TOKEN_DUMMY } from '@mocks/mockData';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/stores/useAuthStore';
import useToast from '@/hooks/useToast';
import { getAccessToken } from './authService';

const BASE_URL = import.meta.env.VITE_BASE_URL;
const defaultConfigOptions: AxiosRequestConfig = {
Expand All @@ -29,9 +30,9 @@ export const authAxios = axiosProvider({
// 요청 인터셉터
authAxios.interceptors.request.use(
(config) => {
const modifiedConfig = { ...config };
const { accessToken } = useAuthStore();

const { accessToken } = useAuthStore.getState();
const modifiedConfig = { ...config };
if (accessToken) modifiedConfig.headers.Authorization = `Bearer ${accessToken}`;

return modifiedConfig;
Expand All @@ -45,33 +46,34 @@ authAxios.interceptors.request.use(
authAxios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
const { toastError } = useToast();

// Access token 만료 시 처리
// 액세스 토큰 만료 시 처리
if (error.response?.status === 401) {
const { toastError } = useToast();
const { onLogout, setAccessToken } = useAuthStore();

// 에러 객체의 설정 객체 추출
const originalRequest = error.config;

try {
// Refresh token을 이용해 새로운 Access token 발급
const refreshResponse = await defaultAxios.post('user/login/refresh', null, { withCredentials: true });
const newAccessToken = refreshResponse.headers.Authorization; // 응답값: `Bearer ${newAccessToken}`
// 리프레시 토큰을 이용해 새로운 액세스 토큰 발급
const refreshResponse = await getAccessToken();
const newAccessToken = refreshResponse.headers.Authorization; // 응답값: `Bearer newAccessToken`

if (!newAccessToken) {
toastError('토큰 발급에 실패했습니다. 다시 로그인 해주세요.');
useAuthStore.getState().Logout();
onLogout();
return;
}

authAxios.defaults.headers.Authorization = newAccessToken;
useAuthStore.getState().setAccessToken(newAccessToken.replace('Bearer ', ''));
setAccessToken(newAccessToken.split(' ')[1]);

// 기존 요청에 새로운 Access token 적용
// 기존 설정 객체에 새로운 액세스 토큰 적용
originalRequest.headers.Authorization = newAccessToken;
return await axios(originalRequest);
return await authAxios(originalRequest);
} catch (refreshError) {
// Refresh token 에러 시 처리
console.error('Refresh token error:', refreshError);
// 리프레시 토큰 에러 시 처리
toastError('로그인 정보가 만료되었습니다. 다시 로그인 해주세요.');
useAuthStore.getState().Logout();
onLogout();
return Promise.reject(refreshError);
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/stores/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ type AuthStore = {
accessToken: string | null;
setAccessToken: (token: string) => void;
// clearAccessToken: () => void;
Login: (token: string) => void;
Logout: () => void;
onLogin: (token: string) => void;
onLogout: () => void;
};

export const useAuthStore = create<AuthStore>((set) => ({
isAuthenticated: false,
accessToken: null,
setAccessToken: (token: string) => set({ accessToken: token }),
// clearAccessToken: () => set({ accessToken: null }),
Login: (token: string) => {
onLogin: (token: string) => {
set({ isAuthenticated: true, accessToken: token });
},
Logout: () => {
onLogout: () => {
set({ isAuthenticated: false, accessToken: null });
},
}));

0 comments on commit 1cee168

Please sign in to comment.