우리 서비스에서는 다른 서비스들도 그러하듯이 JWT 방식의 access token(AT)와 refresh token을 통해 사용자를 관리한다.
따라서 다음의 로직을 사용한다.
@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = resolveToken(servletRequest);
//만약 토큰에 이상이 있다면 오류가 발생한다.
if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) {
//tokenProvider에서 jwt를 가져가 Authentication 객체생성
Authentication authentication = this.tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
//이상이 없다면 계속 진행
filterChain.doFilter(servletRequest, servletResponse);
} catch (JwtException e) {
//토큰에 오류가 있다면 401에러를 응답한다.
log.error("[JWTExceptionHandlerFilter] "+e.getMessage());
servletResponse.setStatus(401);
servletResponse.setContentType("application/json;charset=UTF-8");
}
}
해당 메서드는 OncePerRequestFilter 클래스를 extend 한 클래스 내부에 있다. 따라서 매번 요청이 있을때마다 토큰을 확인하여 이를 검증한다.
refresh token의 존재 이유는 access token을 재발급 받기 위해서이다. 원래 AT의 만료시간은 30분~1시간으로 짧으므로 RT를 통해 이를 재발급 받을 수 있어야 한다. 따라서 다음의 코드를 사용했다.
public RefreshTokenResponseDto reissueAccessToken(Member member, String refreshToken) {
// TODO : 레디스 반영시 access token 을 블랙리스트에 넣는? && 기존의 RT는 만료시켜야하거나 폐기
String uniqueId = tokenProvider.getUniqueId(refreshToken);
if (member.getUniqueId().equals(uniqueId)) {
String accessToken = tokenProvider.createAccessToken(member.getName(), member.getUniqueId());
String newRefreshToken = tokenProvider.createRefreshToken(member.getName(), member.getUniqueId());
member.updateRefreshToken(newRefreshToken);
Member member = getMemberByUniqueId(uniqueId);
return RefreshTokenResponseDto.fromRefreshToken(accessToken, newRefreshToken);
} else {
throw new BusinessExceptionHandler("사용자와 토큰이 일치하지 않음", ErrorCode.BAD_REQUEST_ERROR);
}
return RefreshTokenResponseDto.fromRefreshToken(accessToken, newRefreshToken);
}
단순히 사용자가 refresh token값을 담아서 요청하면 해당 토큰으로부터 uniqueId(우리 서비스에서 사용하는 고유 아이디) 를 추출하고 사용자를 찾은 다음, 사용자와 토큰이 일치한다면 AT와 RT를 모두 재발급해주는 로직이다.
생각해보면, 사용자의 AT는 이미 만료가 돼서 더이상 쓸 수 없는 상태이다. 따라서 refresh token 발급 요청을 보내더라도 위의 OncePerRequest 클래스를 extend한 코드에 의해 AT는 만료되었다는 에러를 발생시킨다. 이를 해결하기 위한 방법은 두개가 있었다.
OncePerRequest 를 extend 한 클래스 내에서 토큰의 유무를 확인 하는 방법.OncePerRequest 를 그냥 통과하도록 하는 방법.결론적으로 2번의 방법을 사용했다.
@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = resolveToken(servletRequest);
// 액세스 토큰이 있는 경우
if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) {
// 유효한 액세스 토큰인 경우 Authentication 객체 생성
Authentication authentication = this.tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(servletRequest, servletResponse);
} else {
// 액세스 토큰이 없거나 만료된 경우 리프레시 토큰 처리
String refreshToken = getRefreshTokenFromCookie(servletRequest);
if (StringUtils.hasText(refreshToken) && tokenProvider.validateRefreshToken(refreshToken)) {
// 유효한 리프레시 토큰인 경우 새 액세스 토큰 발급
RefreshTokenResponseDto newTokens = memberService.reissueAccessToken(refreshToken);
// 새 리프레시 토큰을 쿠키에 설정
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", newTokens.getRefreshToken())
.httpOnly(true)
.path("/")
.secure(true) // HTTPS를 사용할 경우에만 true로 설정
.maxAge(14 * 24 * 60 * 60) // 2주
.sameSite("None")
.build();
servletResponse.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
// 새 액세스 토큰을 응답 본문에 설정
servletResponse.setContentType("application/json;charset=UTF-8");
servletResponse.getWriter().write("{\"accessToken\": \"" + newTokens.getAccessToken() + "\"}");
servletResponse.setStatus(HttpServletResponse.SC_OK);
} else {
// 리프레시 토큰도 유효하지 않은 경우 401 응답
throw new JwtException("Invalid refresh token");
}
}
} catch (JwtException e) {
// 토큰에 오류가 있다면 401 에러를 응답
log.error("[JWTExceptionHandlerFilter] " + e.getMessage());
servletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
servletResponse.setContentType("application/json;charset=UTF-8");
servletResponse.getWriter().write("{\"error\": \"" + e.getMessage() + "\"}");
}
}
요청이 들어왔을때 jwt 토큰이 없다면 바로 재발급해주는 로직을 추가하는 것이다. 이렇게 되면 프론트에서 만료되었다는 에러 메세지를 통해 다시 재발급 요청을 보낼 필요 없이 백엔드에서 바로 처리할 수 있게 된다.
실제로 이 방법을 사용하고 싶었으나, 프론트 상의 코드 변화가 클 것 같아 두번째 방법을 쓰기로 했다.
@Transactional
public RefreshTokenResponseDto reissueAccessToken(String refreshToken) {
// TODO : 레디스 반영시 access token 을 블랙리스트에 넣는? && 기존의 RT는 만료시켜야하거나 폐기
String uniqueId = tokenProvider.getUniqueId(refreshToken);
Member member = getMemberByUniqueId(uniqueId);
String accessToken = tokenProvider.createAccessToken(member.getName(), member.getUniqueId());
String newRefreshToken = tokenProvider.createRefreshToken(member.getName(), member.getUniqueId());
member.updateRefreshToken(newRefreshToken);
// 새로운 액세스 토큰으로 인증 객체 생성 및 설정
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
return RefreshTokenResponseDto.fromRefreshToken(accessToken, newRefreshToken);
}
일단 사용자의 AT를 받지 않는다. 당연히 만료 되었으므로 쓸 수가 없다. 그래서 기존의 RT만으로 재발급을 진행했다.
그런데 얘도 request 인데 OncePerRequest 를 어떻게 피할까?
다음과 같이 편법을 썼다.
private static final List<String> WHITELIST = Arrays.asList(
"/api/v1/member/reissue"
);
@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws ServletException, IOException {
String requestURI = servletRequest.getRequestURI();
// 화이트리스트에 포함된 요청은 필터를 통과시키지 않고 계속 진행
if (WHITELIST.contains(requestURI)) {
System.out.println("requestURI = " + requestURI);
filterChain.doFilter(servletRequest, servletResponse);
return;
}
try {
String jwt = resolveToken(servletRequest);
//만약 토큰에 이상이 있다면 오류가 발생한다.
if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) {
//tokenProvider에서 jwt를 가져가 Authentication 객체생성
Authentication authentication = this.tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
//이상이 없다면 계속 진행
filterChain.doFilter(servletRequest, servletResponse);
} catch (JwtException e) {
//토큰에 오류가 있다면 401에러를 응답한다.
log.error("[JWTExceptionHandlerFilter] "+e.getMessage());
servletResponse.setStatus(401);
servletResponse.setContentType("application/json;charset=UTF-8");
}
}
이렇게 WHITE_LIST 를 통해 AT가 없어도 통과시켜줄 엔드포인트들을 정의해두고 통과하도록 사용했다.
사실 1번의 방법이 제대로 된 방법인 것 같지만 프로젝트가 진행되는데 방해가 되면 안되므로 2번 방법도 괜찮은 방법 인 것 같다.
두가지 방법을 모두 알아두는게 좋을 듯 하다.