프로젝트에서 JWT(JSON Web Token)를 이용한 인증 시스템을 구현하면서 배운 내용을 정리하고자 한다.
JWT는 당사자 간 정보를 JSON 객체로 안전하게 전송하기 위한 독립적인 방식을 정의하는 표준이다.
여기에 Access Token과 Refresh Token을 추가로 활용하여 보안성을 더욱 강화했다.
우리 프로젝트에서 구현한 JWT 인증 시스템의 주요 컴포넌트는 다음과 같다:
1. FilterConfiguration: 인증 필터를 등록하고 설정
2. AuthFilter: 실제 인증 검사를 수행하는 필터
3. JWTUtil: JWT 토큰의 생성과 검증을 담당
4. Application.properties: JWT 관련 설정 값 관리
5. MemberController: 토큰 관리와 관련된 엔드포인트 제공
각 컴포넌트의 상세한 구현 내용과 전체 코드는 아래 깃허브 링크에서 확인할 수 있다.
깃허브 링크
사용자가 처음 로그인할 때, 서버는 두 가지 토큰을 생성한다.
Access Token(AT) : 실제 API 요청에 사용되는 인증 토큰
Refresh Token(RT) : AT가 만료되었을 때 새로운 AT를 발급받기 위한 토큰
서버는 RT를 데이터베이스에 저장하고, 두 토큰 모두를 클라이언트에게 전달한다. 클라이언트는 이 토큰들을 안전한 저장소에 보관한다.
클라이언트가 보호된 리소스에 접근하기 위해 API 요청을 할 때마다, Authorization 헤더에 "Bearer {Access Token}"형식으로 AT를 포함시켜 전송한다.
서버의 인증 필터(AuthFilter)는 모든 요청에 대해 이 AT의 유효성을 검증한다.
토큰이 유효하다면 요청된 리소스에 대한 접근을 허용하고, 만료되었다면 401 Unauthorized 응답을 반환한다.
AT가 만료되어 서버로부터 401 응답을 받으면,
클라이언트는 저장해둔 RT를 사용하여 새로운 AT 발급을 요청한다.
서버는 전달받은 RT의 유효성을 검증하고, 데이터베이스에 저장된 RT와 비교한다.
검증이 성공하면 서버는 새로운 AT와 RT를 생성하여 클라이언트에게 전달하고, 새로운 RT를 데이터베이스에 저장합니다.
RT마저 만료된 경우에는 사용자에게 재로그인을 요청한다.
AT가 만료되어 여러 API 요청이 동시에 실패하는 경우, 각각의 요청이 개별적으로 토큰 갱신을 시도하면 불필요한 중복 요청이 발생할 수 있다.
이를 방지하기 위해 토큰 갱신 요청을 큐에 저장하고 관리하는 방식을 사용했다.
첫 번째 토큰 갱신 요청만 서버로 전송되고, 이후의 요청들은 큐에서 대기한다. 갱신이 성공하면 큐에 있는 모든 요청에 새로운 토큰이 적용되며, 실패하면 모든 요청이 함께 실패 처리된다.
토큰 만료로 인한 401 응답 시, 브라우저의 CORS 정책으로 인해 에러가 발생하는 문제가 있었다.
이를 해결하기 위해 인증 필터에서 모든 응답에 적절한 CORS 헤더를 추가했다. 특히 프리플라이트 요청(OPTIONS)에 대해서도 올바른 CORS 헤더를 포함하여 응답하도록 구현했다.
// CORS 헤더를 모든 응답에 추가하는 메소드
private void addCorsHeaders(HttpServletRequest request, HttpServletResponse response) {
String origin = request.getHeader("Origin");
if (origin != null) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Max-Age", "3600");
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// CORS 헤더를 모든 응답에 추가
addCorsHeaders(httpRequest, httpResponse);
// CORS Preflight 요청 처리
if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
httpResponse.setStatus(HttpServletResponse.SC_OK);
return;
}
// ... 이후 인증 로직
}