Skip to content

Commit

Permalink
Feat: #80 로컬 로그인 기능 구현 (#100)
Browse files Browse the repository at this point in the history
* 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 훅 적용
  • Loading branch information
Yoonyesol authored Sep 6, 2024
1 parent d5dba81 commit c7f85bc
Show file tree
Hide file tree
Showing 15 changed files with 323 additions and 47 deletions.
8 changes: 7 additions & 1 deletion src/components/user/auth-form/ProfileImageContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HTMLInputElement>) => {
const file = e.target.files?.[0];

Expand Down
8 changes: 7 additions & 1 deletion src/constants/settings.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
12 changes: 8 additions & 4 deletions src/hooks/useAxios.ts
Original file line number Diff line number Diff line change
@@ -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<T, P extends unknown[]> = (...args: P) => Promise<AxiosResponse<T>>;

Expand All @@ -12,21 +12,24 @@ type PromiseCallback<T, P extends unknown[]> = (...args: P) => Promise<AxiosResp
* @template {unknown[]} P - API 함수에 전달되는 매개변수의 가변인자 타입 배열
* @param {PromiseCallback<T, P>} fetchCallback - API 요청을 수행하는 함수
* @returns {{
* headers: AxiosResponseHeaders | RawAxiosResponseHeaders | undefined; // API 요청의 응답 헤더
* data: T | undefined; // API 요청의 응답 데이터
* error: Error | null; // API 요청 중 발생한 에러
* loading: boolean; // 데이터 로딩 중인지 여부
* fetchData: (...args: P) => Promise<void>; // API 요청을 호출하는 함수
* }}
* @example
* const { data, error, loading, fetchData } = useAxios(fetchCallback) // fetchCallback에서 타입을 반환한다면, 자동 타입 추론이 가능
* const { data, error, loading, fetchData } = useAxios<User[], Parameters<typeof fetchCallback>>(fetchCallback);
* const { headers, data, error, loading, fetchData } = useAxios(fetchCallback) // fetchCallback에서 타입을 반환한다면, 자동 타입 추론이 가능
* const { headers, data, error, loading, fetchData } = useAxios<User[], Parameters<typeof fetchCallback>>(fetchCallback);
*/
export default function useAxios<T, P extends unknown[]>(fetchCallback: PromiseCallback<T, P>) {
const [headers, setHeaders] = useState<AxiosResponseHeaders | RawAxiosResponseHeaders>();
const [data, setData] = useState<T>();
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);

const clearData = useCallback(() => {
setHeaders(undefined);
setData(undefined);
setError(null);
setLoading(false);
Expand All @@ -37,6 +40,7 @@ export default function useAxios<T, P extends unknown[]>(fetchCallback: PromiseC
try {
setLoading(true);
const response = await fetchCallback(...params);
setHeaders(response.headers);
setData(response.data);
} catch (error: unknown) {
setError(error as Error);
Expand All @@ -48,5 +52,5 @@ export default function useAxios<T, P extends unknown[]>(fetchCallback: PromiseC
[fetchCallback],
);

return { data, error, loading, clearData, fetchData };
return { data, headers, error, loading, clearData, fetchData };
}
2 changes: 2 additions & 0 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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,
...teamServiceHandler,
...projectServiceHandler,
...taskServiceHandler,
...statusServiceHandler,
...authServiceHandler,
];

export default handlers;
32 changes: 16 additions & 16 deletions src/mocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]',
nickname: 'momoco',
profileUrl: '',
Expand All @@ -37,7 +37,7 @@ export const USER_INFO_DUMMY = {
export const USER_DUMMY: User[] = [
{
userId: 1,
id: null,
username: null,
email: '[email protected]',
provider: 'GOOGLE',
nickname: '판다',
Expand All @@ -47,7 +47,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 2,
id: null,
username: null,
email: '[email protected]',
provider: 'KAKAO',
nickname: '카멜레온',
Expand All @@ -57,7 +57,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 3,
id: null,
username: null,
email: '[email protected]',
provider: 'GOOGLE',
nickname: '랫서판다',
Expand All @@ -67,7 +67,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 4,
id: null,
username: null,
email: '[email protected]',
provider: 'KAKAO',
nickname: '북금곰',
Expand All @@ -77,7 +77,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 5,
id: null,
username: null,
email: '[email protected]',
provider: 'KAKAO',
nickname: '호랑이',
Expand All @@ -87,7 +87,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 6,
id: null,
username: null,
email: '[email protected]',
provider: 'GOOGLE',
nickname: '나무늘보',
Expand All @@ -97,7 +97,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 7,
id: null,
username: null,
email: '[email protected]',
provider: 'KAKAO',
nickname: '웜뱃',
Expand All @@ -107,7 +107,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 8,
id: null,
username: null,
email: '[email protected]',
provider: 'GOOGLE',
nickname: '벨루가',
Expand All @@ -117,7 +117,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 9,
id: null,
username: null,
email: '[email protected]',
provider: 'KAKAO',
nickname: '펭귄',
Expand All @@ -127,7 +127,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 10,
id: null,
username: null,
email: '[email protected]',
provider: 'GOOGLE',
nickname: '비버',
Expand All @@ -137,7 +137,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 11,
id: 'eleven',
username: 'eleven',
email: '[email protected]',
provider: 'LOCAL',
nickname: '판다아빠',
Expand All @@ -147,7 +147,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 12,
id: 'twelve',
username: 'twelve',
email: '[email protected]',
provider: 'LOCAL',
nickname: '판다엄마',
Expand All @@ -157,7 +157,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 13,
id: 'thirteen',
username: 'thirteen',
email: '[email protected]',
provider: 'LOCAL',
nickname: '판다형',
Expand All @@ -167,7 +167,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 14,
id: 'fourteen',
username: 'fourteen',
email: '[email protected]',
provider: 'LOCAL',
nickname: '판다누나',
Expand All @@ -177,7 +177,7 @@ export const USER_DUMMY: User[] = [
},
{
userId: 15,
id: 'fifteen',
username: 'fifteen',
email: '[email protected]',
provider: 'LOCAL',
nickname: '판다동생',
Expand Down
87 changes: 87 additions & 0 deletions src/mocks/services/authServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 4 additions & 4 deletions src/pages/setting/UserSettingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function UserSettingPage() {
const methods = useForm<EditUserInfoForm>({
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,
Expand All @@ -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: 폼 제출 로직 작성
Expand All @@ -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)}
/>

{/* 이메일 */}
Expand Down
6 changes: 3 additions & 3 deletions src/pages/user/SearchPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function SearchPasswordPage() {
} = useForm<SearchPasswordForm>({
mode: 'onChange',
defaultValues: {
id: '',
username: '',
email: '',
code: '',
},
Expand All @@ -28,8 +28,8 @@ export default function SearchPasswordPage() {
{/* 아이디 */}
<ValidationInput
placeholder="아이디"
errors={errors.id?.message}
register={register('id', USER_AUTH_VALIDATION_RULES.ID)}
errors={errors.username?.message}
register={register('username', USER_AUTH_VALIDATION_RULES.ID)}
/>

{/* 이메일 */}
Expand Down
Loading

0 comments on commit c7f85bc

Please sign in to comment.