AWS, Azure 등의 서비스들은 브라우저의 localStorage에 인증 사용자 정보를 보관한다.
여러 서비스가 localStorage에 데이터를 저장함에도 불구하고, localStorage의 보안성 문제를 이야기하는 사람들도 있다. localStorage에 저장된 데이터는 브라우저 개발자 도구를 통해 쉽게 접근할 수 있으므로 크로스 사이트 스크립팅(XSS) 공격에 취약하다고 말한다. 웹사이트에 삽입된 악성 스크립트는 localStorage에 저장된 데이터에 쉽게 접근, 조작하여 민감한 사용자 정보를 손상시킬 수 있다는 것이다.
참고: https://www.reddit.com/r/webdev/comments/15g6spc/is_localstorage_secure/?rdt=60787
만약 보안 문제로 인해 localStorage 사용이 꺼려진다면 쿠키(Cookie)를 사용하거나, sessionStorage를 사용하는 것도 방법이 될 수 있다.
하지만, sessionStorage를 사용하면 불편한 점도 많고, 민감한 정보를 localStorage에 저장하는 것이 아니라면 보안에 문제가 없다고 보기 때문에 localStorage를 사용해 user 정보를 관리하기로 했다.
useContext와 같은 React의 api나 Redux 등을 사용할 수 있겠지만, 비교적 사용법이 쉬운 Zustand를 사용해서 구현하였다.
Zustand는 여러 middleware라는 것을 제공하는데, 이 api들 중 devtools, persist 두 가지를 사용하였다.
Redux에서는 redux-devtools라는 개발자 도구를 사용해 편리하게 상태를 추적할 수 있는데, Zustand의 devtools api를 사용하면 이 도구를 그대로 사용할 수 있다.
import create from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
// ...
}), { name: "CounterStore" }
)
);
이렇게 create의 매개변수를devtools의 첫 번째 매개변수로 넣어주기만 하면 된다.
두 번째 매개변수는 개발자 도구에 표시될 store의 이름 등의 옵션을 설정해준다.
이 포스트의 핵심인 persist는 상태를 브라우저의 저장소(예: localStorage, sessionStorage, IndexedDB 등)에 저장하여 페이지 새로고침이나 애플리케이션 재시작 시에도 상태를 유지할 수 있게 해준다.
이 api를 사용해 user 정보를 localStorage에 저장할 것이다.
import create from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'count-storage', // 저장소에 저장될 키 이름
storage: createJSONStorage(() => localStorage), // 사용할 저장소
partialize: (store) => ({ count: store.count }), // 로컬스토리지에 저장할 상태만 선택
}
)
);
devtools와 마찬가지로, 첫 번째 매개변수로create의 매개변수를 넣어주고, 두 번째 매개변수에는 저장소(여기서는 localStorage)에 저장될 키 이름, 사용할 저장소 등을 선택해서 필요한 옵션을 넣어주면 된다.
서버에 유저 정보를 요청하는 함수와 토큰 만료 여부를 확인하는 함수는 미리 작성해 두었다.
store의 코드는 아래와 같다.
import { getUserData } from '@/api/users';
import { parseJwt } from '@/utils';
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
// 토큰 만료 여부 확인
/** @type {(token: string) => boolean} */
function checkTokenExpiration(token) {
if (!token) return true;
const decodedToken = parseJwt(token);
const expirationTime = decodedToken.exp * 1000; // exp는 초 단위이므로 밀리초로 변환
const currentTime = Date.now();
return currentTime > expirationTime;
}
// store
export const useAuthStore = create(
devtools(
persist(
(set, get) => ({
token: null,
userInfo: null,
// 유저 로그인
loginUser: (token, userInfo) => set({ token, userInfo }),
// 유저 로그아웃
logoutUser: () => set({ token: null, userInfo: null }),
// 유저 정보 업데이트
updateUserInfo: (newUserInfo) => set({ userInfo: newUserInfo }),
// 토큰 유효성 검사
validateToken: () => {
const token = get().token;
if (checkTokenExpiration(token)) {
get().logoutUser(); // 토큰이 만료되었다면 로그아웃
return false;
}
return true;
},
// 유저 정보 요청
fetchUserInfo: async () => {
if (!get().validateToken()) return null;
const token = get().token;
try {
const userInfo = await getUserData(token);
set({ userInfo });
return userInfo;
} catch (error) {
console.error('Failed to fetch user info:', error);
return null;
}
},
// 현재 인증 상태 확인 (토큰 유효성 검사 포함)
checkSignIn: () => {
return get().validateToken() && get().userInfo;
},
}),
{
name: 'authStore', // 로컬스토리지에 저장될 키 이름
storage: createJSONStorage(() => localStorage), // 사용할 스토리지 선택
partialize: (store) => ({
token: store.token,
userInfo: store.userInfo,
}), // 로컬스토리지에 저장할 상태만 선택
}
),
{ name: 'authStore' } // devtools에 표기될 저장소 이름
)
);
우선, store에는 token과 userInfo 두 가지 상태를 저장한다.
그리고 상태를 관리할 액션 함수는 '로그인', '로그아웃', '업데이트', '토큰 유효성 검사', '유저 정보 요청', '현재 인증 상태 확인'으로 총 6가지가 있다.
로그인은 아래와 같이 user의 로그인 인증 요청 함수에서 정상적으로 응답이 왔을 때, 그 안에 담긴 토큰과 유저 정보를 store에 넣어주면 된다.
업데이트도 같은 방식으로 유저 정보 업데이트 함수에 넣어주면 된다.
// @/api/user.js
/** @type {(username: string, password: string) => Promise<any>} */
export async function userSignIn(username, password) {
const REQUEST_URL = {요청할 url};
const body = JSON.stringify({ identity: username, password });
const response = await fetch(REQUEST_URL, {
method: 'POST',
body,
...REQUEST_OPTIONS,
});
// 에러 핸들링
if (!response.ok) {
throw new Response(
JSON.stringify({ message: '서버에서 요청에 응답하지 않습니다.' }),
{ status: 500 }
);
}
const responseData = await response.json();
// 로그인 성공 시 토큰과 유저 정보를 로컬 스토리지에 저장
useAuthStore.getState().loginUser(responseData.token, responseData.record);
return responseData;
}
'로그아웃'과 '유저 정보 요청'은 이벤트 핸들링 함수나 useEffect 등에서 필요한 상황에 사용할 수 있고,
'토큰 유효성 검사'는 '현재 인증 상태 확인' 함수에 포함되어 있기 때문에 쓸 일이 거의 없다.
'현재 인증 상태 확인' 함수는 중요하게 사용되는데,
아래와 같이 로그인하지 않은 유저는 로그인 화면으로 강제 redirect하는 방식으로 사용할 수 있다.
import { useAuthStore } from '@/stores/authStore';
import { getStorage } from '@/utils';
import { memo, useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import style from './RootLayout.module.css';
function RootLayout() {
const navigate = useNavigate();
const checkSignIn = useAuthStore((store) => store.checkSignIn);
useEffect(() => {
// 로그인 되어있지 않으면 (토큰 유효성 검사 포함) demo | auth 페이지로 이동
if (!checkSignIn()) {
if (!getStorage('completeDemo')) navigate('/demo/1');
else navigate('/auth');
}
}, [navigate, checkSignIn]);
// ...
return (
<div className={style.component}>
<Outlet />
// ...
</div>
);
}
export default memo(RootLayout);