FE개발자로서 로그인과 같은 인증에 관련된 부분에 대해 jwt방식으로 정리해보자
git clone https://github.com/shoveller/simple-jwt-express
(vscode에서 서버실행 'REST Client' 플러그인)
백엔드 로직이 목표가 아니기에 인증서버는 간단하게 사용한다

위 주소로 클론을 받으면 가장 필수적인 jwt인증 서버의 3가지 엔드포인트로 구성되어있다.
통행증, 즉 jwt토큰을 발급받는 용도의 엔드포인트이다.
post요청을 통해 얻어지는 jwt토큰(accessToken)은 아래의 주소에서 쉽게 검사해 볼 수있다.
https://jwt.io/
요청으로 얻어지는 (아이디와 비밀번호의 검사 등 세부적인 로직은 제외한다) 토큰은 크게 두가지로 얻어진다.
이 둘의 차이는 토큰의 정보 속의 만료시간의 차이이다(exp).
accessToken이 만료됐을 때 통행증을 갱신하기 위한 목적으로 만료시간이 더 긴 refreshToken이 존재한다.
보호된 리소스에 접근하기 위해 accessToken을 사용해서 해당 api를 호출할 수 있다.
어떻게 사용하는가?
Authorization 헤더의 Bearer스키마를 통해 토큰을 전달한다.
headers: {
Authorization: `Bearer ${accessToken}`,
},
위의 인증서버의 '/resource' 엔드포인트에 accessToken을 전달하면 userName이 리소스로 출력된다.
accessToken 만료시, refreshToken을 사용해서 (refreshToken도 유효성 검사는 필수) 새로운 accessToken을 발급한다.
위의 인증서버를 이용해서 클라이언트 인증을 구현해보자.
레시피로 react-router와 react-query를 사용한다.(선호한다)
인증 로직은 다음 그림과 같다.

하지만 모든 인증관련 요청에 매번 localStorage에서 토큰을 가져오고, 헤더의 토큰을 주입하는 등 불필요하게 중복코드가 많이 발생하게된다. 이를 자동화 하기위해 axios의 interceptor기능을 사용한다.

(나가는 값을 암시하는 axios interceptor + 성공함수)
axiosInstance.interceptors.request.use((config:InternalAxiosRequestConfig) => {
const accessToken = localStorage.getItem('accessToken')
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
(성공함수는 단순 res => res, 실패함수를 대비 async(error) => {}
axiosInstance.interceptors.response.use((res)=>res, async (error:AxiosError) => {
// 1. 이전 요청 설정을 보관한다.
const prevConfig = error.config
// 2. 통행증을 재발급 받는다 (리프레시 토큰이 필요한 상황일 때)
const refreshToken = localStorage.getItem('refreshToken') as string
const {accessToken, refreshToken: newRefreshToken} = await refresh(refreshToken)
//새로운 엑세스 토큰으로 헤더 업데이트
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', newRefreshToken)
// 3. 이전 요청 설정에 통행증을 추가해서 다시 요청한다.
return axiosInstance(prevConfig!)
})
import axios, {AxiosError, InternalAxiosRequestConfig} from 'axios'
type TokensResponseType = {
accessToken: string;
refreshToken: string;
};
type MessageResponseType = {
message: string;
};
const axiosInstance = axios.create({baseURL: "http://localhost:3000"})
//axios클라이언트(인스턴스)로 리퀘스트를 보낼 때 엑세스 토큰 주입 (나가는 값을 암시하는 axios interceptor + 성공함수)
axiosInstance.interceptors.request.use((config:InternalAxiosRequestConfig) => {
const accessToken = localStorage.getItem('accessToken')
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
//axios클라이언트(인스턴스)로 응답을 받을 때 엑세스 토큰이 있는지 확인 (성공함수는 단순 res => res, 실패함수를 대비 async(error) => {}
axiosInstance.interceptors.response.use((res)=>res, async (error:AxiosError) => {
// 1. 이전 요청 설정을 보관한다.
const prevConfig = error.config
// 2. 통행증을 재발급 받는다 (리프레시 토큰이 필요한 상황일 때)
const refreshToken = localStorage.getItem('refreshToken') as string
const {accessToken, refreshToken: newRefreshToken} = await refresh(refreshToken)
//새로운 엑세스 토큰으로 헤더 업데이트
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', newRefreshToken)
// 3. 이전 요청 설정에 통행증을 추가해서 다시 요청한다.
return axiosInstance(prevConfig!)
})
export const login = (username: string) => {
return axiosInstance.post("/login",{
headers: {
"Content-Type": "application/json",
},
username
}).then<TokensResponseType>(res => res.data)
};
export const resource = () => {
return axiosInstance.get("/resource",{
}).then<MessageResponseType>(res => res.data)
};
export const refresh = (refreshToken: string) => {
return fetch("http://localhost:3000/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken,
}),
}).then<TokensResponseType>((res) => res.json());
};
더 나아가 인증 관련 라우터들의 레이아웃으로 accessToken이 없을 경우 다른 페이지로 navigate를 하도록 전역적으로 설정할 수 있다.
const AuthLayout = () => {
const accessToken = localStorage.getItem('accessToken')
if(!accessToken) {
return <Navigate to='/login' replace={true}/>
}
return <Outlet/>
}
const router = createBrowserRouter(createRoutesFromElements(
<Route>
<Route element={<AuthLayout/>}>
<Route path='/' element={<Home/>}/>
<Route path='/login' element={<Login/>}/>
</Route>
</Route>
))
로그아웃 기능은 로컬스토리지에서 accessToken을 제거하는것으로 해결할 수 있다.
const navigate = useNavigate()
const onClick = () => {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
navigate('/login')
}
플로우를 설명하기 위해 모든 토큰을 로컬스토리지에 저장을 했지만,
실제 서비스에서 적용할때는 절대로 로컬스토리지에 저장하면 안된다.
CSRF 취약점과 XSS취약점을 대비
accessToken을 메모리에 저장하면 새로고침 시 state가 초기화되기 때문에, 이를 막기 위해
새로고침이 됐을 때 refresh토큰을 이용해서 매번 토큰을 갱신하면 해결할 수 있음
// auth/useAuth.ts
import { useAtom } from 'jotai';
import { accessTokenAtom } from '../store/auth.store';
export const useAuth = () => {
const [accessToken, setAccessToken] = useAtom(accessTokenAtom);
const initializeAuth = async () => {
try {
const { data } = await api.post('/auth/refresh');
setAccessToken(data.accessToken);
return true;
} catch (error) {
setAccessToken(null);
return false;
}
};
return { accessToken, setAccessToken, initializeAuth };
};
// App.tsx
function App() {
const { initializeAuth } = useAuth();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const init = async () => {
await initializeAuth();
setIsLoading(false);
};
init();
}, []);
if (isLoading) {
return <LoadingSpinner />;
}
return <Router>{/* 라우트 설정 */}</Router>;
}