😎 restful..
이번 프로젝트에서는 보안과 restful 에 대해 고민이 있었습니다.
완벽하진 않더라도 최대한 restful한 구조를 가질 수 있도록 노력했고
그 과정에서 이러한 상황이 발생했습니다.
A : 로그인 요청에 대한 응답은 토큰만 발급해주어야하지 않을까?
B : 맞는것 같읍니다.
A : 그럼 로그인 요청은 토큰을 발급하고 토큰이 있으면 유저정보를 가져올 수 있게
B : 맞는 것 같읍니다.
그런데 이렇게 플로우가 흐르게되면 문제점이 조금 생깁니다.
토큰을 발급받는것은 로그인 이후의 상황이라는 것이 문제가 되는데 그말인즉슨
1. 로그인 요청을 한다
2. 로그인 요청에 대한 응답을 액세스토큰과 함께 받는다.
3. 유저정보를 get하기 위해 2에서 받은 토큰을 요청헤더에 담아서 보낸다.
4. 유저정보를 받아온다.
5. 유저정보를 받아오는 것에 성공하면 화면에 유저정보를 뿌려준다.
이러한 플로우로 로그인이 진행되어야 한다는 것을 뜻합니다.
앞선 로그인 요청에서 받아온 정보가 뒤의 유저정보 요청에 반드시 필요한 흐름이기때문에
저는 이 흐름을 비동기요청을 동기/블로킹 형태로 진행해야하는 흐름이라고 판단했고
이를 위해 구현할 수 있는 방법을 몇가지 고려해보았습니다.
사용한 기술은 axios와 react-query 입니다.
우선 리액트쿼리는 이러한 케이스를 유연하게 처리할 수 있는 유틸들을 충분히 제공해줍니다.
여러가지 쿼리를 병렬적으로 처리할 수 있는 queries 함수는 물론
특정 요청이 성공,실패,완료되었을때 실행시킬 콜백함수 onSuccess / onError / onSettled 옵션도 줄 수 있습니다.
또한 쿼리를 수동적으로 조작할 수 있게 도와주는 enabled 옵션과 refetch, invalidateQueries 와 같은 기능도 제공하죠
이 기능들을 적절히 활용하면 구현이 될 것 같습니다.
https://2ham-s.tistory.com/407
stale, cache, refetch에 대한 개념이 명확하지 않으신 분들은 위 포스트를 추천드립니다.
또한 axios를 사용하고 있기 때문에 axios에서 제공해주는 문법을 활용하면
헤더를 손쉽게 설정해줄 수 있습니다.
import instance from '@/queries/axiosinstance';
const setAccessTokenToHeader = (token: string) => {
instance.defaults.headers['Authorization'] = token;
};
export default setAccessTokenToHeader;
저는 이렇게 액세스토큰을 set 하는 함수를 간단하게 만들어서 사용하고있습니다.
그러면 이제 세부적인 구현이 명확해집니다.
1. 로그인 요청한다.
2. 성공하면 응답값에서 토큰을 빼와서 axios 헤더에 부착한다.
3. 성공하면 유저정보를 요청한다.
그 이전에 원활한 디버깅을 위해서 axios 헤더에 토큰을 잘 부착했겠지만
부착되지 않았을 경우를 대비해 axios 헤더의 토큰이 잘 세팅되었는지 비교해보고
boolean을 뱉어주는 함수가 있으면 편할 것 같습니다.
import instance from '@/queries/axiosinstance';
const getAuthorizationHeader = () => instance.defaults.headers['Authorization'];
export default getAuthorizationHeader;
import { EMPTY_TOKEN } from '@/datas/constants';
import { AxiosHeaderValue } from 'axios';
const isValidToken = (token: AxiosHeaderValue) => {
return token === EMPTY_TOKEN || token === undefined || token === null || !token;
};
export default isValidToken;
입력받은 토큰값을 기반으로 검증을 하는 유틸함수와
액세스토큰을 가져오는 get 함수를 만들어주었습니다.
사실 이정도되면 클래스로 관리하는 게 더 깔끔할 수도 있겠다는 생각이 듭니다만
우선은 이상태로 사용해도 충분할 것 같습니다.
const postLogin = async (loginData: LoginType) => {
const response = await instance.post(
'/api/users/login',
{
username: loginData.username,
password: loginData.password,
},
{
withCredentials: true,
}
);
const ACCESS_TOKEN = response.headers['authorization'];
setAccessTokenToHeader(ACCESS_TOKEN);
return {
response,
ACCESS_TOKEN,
};
};
const useLoginMutation = () => {
const toast = useToast();
const navigate = useNavigate();
const dispatchAccesstoken = useSetAccessToken();
const mutateHandler = useSuccessFailToast();
const loginMutation = useMutation({
mutationFn: (loginData: LoginType) => postLogin(loginData),
onSuccess(data, variables, context) {
navigate('/');
dispatchAccesstoken({ accesstoken: data.ACCESS_TOKEN });
toast({
content: '로그인에 성공했습니다.',
type: 'success',
});
},
onError: mutateHandler.onError('로그인에 실패했습니다.'),
});
return loginMutation;
};
export default useLoginMutation;
이제 로그인을 수행할 함수를 만들어줍니다.
mutation요청이 성공한 경우
홈화면으로 돌아가면서 리덕스에 토큰을 저장해주는 형태로 구현했습니다.
리덕스에 토큰을 저장하고
const useInquireUsersQuery = () => {
const getUsers = async () => {
const response = await instance.get<User>('/api/users', {
withCredentials: true,
});
return response;
};
const inquireUsers = useQuery({
queryKey: ['users'],
queryFn: getUsers,
suspense: true,
retry: 3,
enabled: false,
});
return inquireUsers;
};
export default useInquireUsersQuery;
유저정보를 요청하는 함수를 새로 만들어주었습니다.
withCredentials 옵션을 true로 주어 인증된요청을 주고받도록 설정해줍니다.
suspense 옵션은 suspense 기능을 사용할 것인지를 묻는 옵션입니다.
retry는 재시도 횟수를 의미합니다. 실패했을 경우 재시도 하는 것을 의미하는데
간혹 서버상태에 따라 단순히 재시도하면 해결되는 경우가 존재해서 3회정도 재시도하도록 코드를 작성해뒀습니다.
enabled 옵션은 false가 되어있으면 useQuery가 첫마운트되는 시점에 실행되는 fetch를 막아줍니다.
즉 useQuery를 수동적으로 조작하기 위해서 주어야하는 옵션입니다.
const inquireQuery = useInquireUsersQuery();
const isLogin = useSelector((state: RootState) => state.auth.accesstoken);
useEffect(() => {
if (isLogin) {
inquireQuery.refetch();
}
}, [isLogin]);
그리고 정보가 필요한 컴포넌트에서 이런식으로 refetch를 날려주었는데
const loginMutation = useMutation({
mutationFn: (loginData: LoginType) => postLogin(loginData),
onSuccess(data, variables, context) {
navigate('/');
inquireQuery.refetch().then(() => inquireQuery.refetch());
dispatchAccesstoken({ accesstoken: data.ACCESS_TOKEN });
toast({
content: '로그인에 성공했습니다.',
type: 'success',
});
},
그 대신 성공했을때 refetch를 두번 날려주는 형태로도 구현이 되긴 합니다.
refetch를 두번 날리는 식으로 구현한게 조금 찝찝하긴 하지만
쿼리와 관련된 로직이 컴포넌트에 들어가있는 형태가 되면
추후 유지보수하기가 어렵다고 판단했습니다.
실제로도 어려울 뻔 했어요
다른 방법도 많지만 우선은 동작하는 방식을 두고 나중에 필요하면 건드리는 식으로 하고자 했습니다.
😙새로고침하면 로그인이 풀리는 현상 해결
xss 공격에 대한 안전성을 마련하고자 토큰 관리 전략을 아래와 같이 세웠습니다.
1. 리프레쉬토큰 - http only 쿠키
2. 액세스토큰 - in-memory
그런데 이렇게 관리를 하면 당연하게도 새로고침하는 순간 자바스크립트는 처음부터 새로 실행되고
모든 변수들이 초기화된 상태가 됩니다.
따라서 로그인을 통해 발급받은 액세스토큰 역시 날아가게 되는데요
const initialState: InitialStataType = {
isLogin: false,
accesstoken: EMPTY_TOKEN,
};
저는 리덕스를 통해 로그인 상태를 위와 같은 형태로 관리하고 있었습니다.
초기상태는 isLogin이 false인 상태로 만들어둔 것이지요
이것을 활용하면 로컬스토리지에 토큰을 저장하지 않고도 로그인 유지기능을 구현할 수 있을 것 같았습니다.
class LoginLocalStorage {
private readonly keyword = 'wasLogin';
public get getWasLoginFromLocalStorage() {
const wasLogin = window.localStorage.getItem(this.keyword);
if (wasLogin === 'true') return true;
return false;
}
public setWasLoginToTrue() {
window.localStorage.setItem(this.keyword, 'true');
}
public setRemoveWasLoginFromLocalStorage() {
window.localStorage.removeItem(this.keyword);
}
}
const loginLocalStorage = new LoginLocalStorage();
export default loginLocalStorage;
로컬스토리지에 토큰을 저장하는 게 아니라 로컬스토리지에
이전에 로그인을 한 적이 있는지에 대한 내용을 넣어두면 해결할 수 있을 것이라 판단했습니다.
즉 사용자가 로그인에 성공하면 setWasLoginToTrue 메서드를 호출하는 프로세스를 더해주는것이지요
그리고 로그아웃을 시도할때는 반대로 setRemoveWasLoginFromLocalStorage 메서드를 호출하여
로컬스토리지를 비워줍니다.
이렇게하면 사용자가 로그아웃을 명시적으로 시도하지 않은 경우에는 로컬스토리지에
로그인 상태가 남아있을 것입니다.
const useGetAccessTokenQuery = () => {
const inquireQuery = useInquireUsersQuery();
const dispatchAccessToken = useSetAccessToken();
const wasLogin = loginLocalStorage.getWasLoginFromLocalStorage;
const isLogin = useSelector((state: RootState) => state.auth.isLogin);
const getAccessTokenQuery = useQuery({
queryKey: ['getAccessToken'],
queryFn: getAccessTokenAxios,
suspense: true,
retry: 1,
enabled: wasLogin && !isLogin,
staleTime: 1000 * 60 * 10,
onSuccess: (response) => {
inquireQuery.refetch().then(() => inquireQuery.refetch());
dispatchAccessToken({ accesstoken: response.headers['authorization'] });
},
});
return getAccessTokenQuery;
};
이제 리액트쿼리에 enabled 속성을 이용하여
wasLogin이 존재하면서 / isLogin 상태는 falsy할때에만
액세스토큰을 요청하는 로직을 작성하는 것으로 문제를 해결할 수 있었습니다.
😐마치며
사실 이 코드에서 고민이 조금 있는데
inquireQuery를 통해 유저정보를 페칭해오고 , dispatchAccessToken을 통해 리덕스에 토큰을 저장하는
일련의 로직은 항상 짝궁처럼 붙어다니게 됩니다.
그래서 이 두 로직이 한번에 실행될 수 있도록 도와주는 커스텀훅을 작성하면 유용할 것 같기도하면서
조금 애매한 부분이 있네요
'프로젝트 진행기' 카테고리의 다른 글
[연픽] 성공적인 프론트엔드 리팩토링을 위한 사전 준비 (3) | 2023.09.24 |
---|---|
[Plip] 이메일 요청은 되도록 한번만 보내주세요 (0) | 2023.07.24 |
너무 복잡도가 높은 컴포넌트는 어떡하면 좋을까?(답변) (0) | 2023.07.07 |
너무 복잡도가 높은 컴포넌트는 어떡하면 좋을까? (1) | 2023.07.05 |
[Solo Project] 재사용성을 고려한 설계 (1) | 2023.05.19 |