// slice/authSlice.js
export const signin = createAsyncThunk(
"auth/signin",
async (_user, thunkAPI) => {
try {
const res = await axios.post("/auth/signin", _user);
const { accessToken, user } = res.data;
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("user", JSON.stringify(user));
return res.data;
} catch (err) {
return thunkAPI.rejectWithValue(err.response.data);
}
}
);
사용자가 로그인하면 localStorage.setItem('accessToken', accessToken);
즉, localStorage를 통해 accessToken을 저장하고 이를 헤더에 담아 요청하는 방식을 사용하였다. 이는 페이지를 리프레시하거나 창을 닫고 다시 접속할 때도 로그인 정보가 이어지도록 브라우저에 저장하는 방식이다. 하지만, 이런 방식을 사용하는 경우에 문제점이 있다.
웹 응용프로그램에 존재하는 취약점을 기반으로 웹 서버와 클라이언트 간 통신 방식인 HTTP 프로토콜 동작과정 중에 발생한다. XSS 공격은 웹사이트 관리자가 아닌 이가 JavaScript를 통해 사이트의 글로벌 변숫값을 가져오거나 그 값을 이용해 사이트인척 API 콜을 요청할 수도 있다.
쿠키에 별도로 설정을 가하지 않는다면, 크롬을 제외한 브라우저들은 모든 HTTP 요청에 대해서 쿠키를 전송하게 되는데, 그 요청에는 HTML 문서 요청, HTML 문서에 포함된 이미지 요청, XHR 혹은 Form을 이용한 HTTP 요청 등 모든 요청이 포함된다.
accessToken
을 저장해 인증에 이용하는 구조에 CSRF 취약점이 있다면 인증 정보가 쿠키에 담겨 보내진다. 공격자는 유저 권한으로 정보를 가져오거나 액션을 수행할 수 있다.accessToken
을 담는 방식을 사용하였다.httpOnly
, secure
로 저장된 refreshToken
를 사용해 다시 accessToken
을 가져와 저장할 수 있도록 로직을 구성하여 휘발되는 문제를 해결했다.httpOnly
: 스크립트 상에서 접근이 불가능하도록 한다.secure
: 패킷 감청을 막기 위해 https 통신 시에만 해당 쿠키를 사용하도록 한다.// store/apis/authApiSlice.ts
export const authApiSlice = apiSlice.injectEndpoints({
endpoints: (builder) => ({
signin: builder.mutation<AccessTokenProps, SignUpProps>({
query: (credentials) => ({
url: '/auth/signin',
method: 'POST',
body: { ...credentials },
}),
}),
}),
});
// store/slices/authSlice.ts
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setCredentials: (state, action: PayloadAction<TokenProps>) => {
const { accessToken } = action.payload;
state.token = accessToken;
}
}
})
accessToken
을 받아와서 로그인 페이지에서 dispatch(setCredential({ accessToken })
을 통해 token
상태에 저장하도록 하였다.backend/controllers/auth.js
res.cookie("jwt", newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: "None",
maxAge: 24 * 60 * 60 * 1000,
});
// store/apis/authApiSlice.ts
export const authApiSlice = apiSlice.injectEndpoints({
endpoints: (builder) => ({
signin: builder.mutation<AccessTokenProps, SignUpProps>({
query: (credentials) => ({
url: '/auth/signin',
method: 'POST',
body: { ...credentials },
}),
}),
refresh: builder.query<AccessTokenProps, void>({
query: () => ({
url: '/refresh',
}),
async onQueryStarted(args, { dispatch, queryFulfilled }) {
try {
const { data: { accessToken } } = await queryFulfilled;
dispatch(setCredentials({ accessToken }));
} catch (err) {}
},
}),
}),
});
export const { useSigninMutation, useLazyRefreshQuery } = authApiSlice;
/refresh
로 요청할 쿼리를 만들고, 이를 사용해 사용자 로그인 상태를 유지하려고 한다.import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Outlet } from 'react-router-dom';
import { useLazyRefreshQuery } from 'store/apis/authApiSlice';
import { selectCurrentToken } from 'store/slices/authSlice';
export const PersistLogin = () => {
const token = useSelector(selectCurrentToken);
const [refresh] = useLazyRefreshQuery();
useEffect(() => {
const verifyRefreshToken = async () => {
try {
await refresh();
} catch (err) {
console.error(err);
}
};
if (!token) verifyRefreshToken();
}, []);
return <Outlet />;
};
verifyRefreshToken
이 실행되고, refreshToken 쿠키를 가지고 서버에서 해당 유저의 정보를 확인 후 다시 accessToken
을 넘겨준다.accessToken
, refreshToken
을 받아온다. accessToken
은 메모리(변수)에 저장한다.refreshToken
은 httpOnly
, secure
옵션으로 지정해 보내준다.Authorization
에 accessToken
을 담아 요청한다.accessToken
이 만료되었거나, 다른 페이지로 잠시 이동하고 돌아왔을 경우, 쿠키에 저장된 refreshToken
을 통해 /refresh
API 요청을 하고, 다시 accessToken
을 메모리(변수)에 저장한다.import jwtDecode, { JwtPayload } from 'jwt-decode';
import { selectCurrentToken } from 'store/slices/authSlice';
import ROLES from 'config/roles';
import { useAppSelector } from './useAppStore';
type TokenProps = {
id: string;
roles: number[];
};
export const useAuth = () => {
const token = useAppSelector(selectCurrentToken);
if (token) {
const { id, roles } = jwtDecode<TokenProps>(token);
return { id, roles };
}
return { id: '', roles: [] };
};
/refresh
요청을 통해 사용자 로그인 정보가 들어간 객체가 반환될 것이다.