#2-1 에서 JWT의 기본적인 개념 및 Access Token에 관한 설명을 다루었다.
해당 포스팅은 Refresh Token과 JWT 예외처리에 관련한 포스팅으로 2-1 포스팅을 보고 오는 것을 추천한다.
앞선 포스팅에서 서버의 요청에 Access Token을 요청 헤더에 같이 보내 디코딩 작업 과정을 살펴보았다.
그런데, Token의 만료, 변조, 없는 경우 서버에서의 대처 방안에 대해 다뤄 보겠다.
JwtProvider.java
private Claims getClaims(String key, String token, boolean isRefresh) {
try {
return Jwts.parser()
.setSigningKey(key.getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token)
.getBody();
} catch (SignatureException | MalformedJwtException | MissingClaimException ex) {
if (isRefresh) {
throw new CustomException(MODULATION_REFRESH);
}
throw new CustomException(MODULATION_ACCESS);
} catch (ExpiredJwtException ex) {
throw new CustomException(EXPIRATION_ACCESS);
}
}
JWT 관련 예외처리 코드이다.
JWT 디코딩 작업을 통해 Claim을 꺼낼 때, 만료 혹은 변조의 예외를 발생시켰다.
Access Token이든 Refresh Token이든 유효 시간이 존재한다. 그로 인해 만료된 Token을 통해 서버에 요청을 보내는 경우가 존재하는데 서버에선 2가지 대처 방안이 존재한다.
1번의 경우, 응답이 매번 바뀌게 되며 클라이언트 측에선 Access Token을 매번 들여다 보며 있을 경우 로컬 스토리지에 새 AccessToken을 갈아끼워야 하는 번거로움이 존재한다.
위 상황을 고려해 2번을 채택했는데 추가로 설명을 덧붙이자면 Refresh Token을 통해 Access Token을 발급 받는 과정도 하나의 기능이라 생각해 API로 만들게 되었다.
요청에 대한 인증을 위한 부분이므로 임의로 변조시킨 JWT는 통과시키면 안 되기에 무조건적으로 예외를 발생시켰다.
첫번째 트러블 슈팅이었다.
정상적으로 예외는 만들었는데 Advice
가 핸들링을 못 하는 문제점이 생겼다. 한참을 헤매고 답을 알지 못 했다.
처음으로 돌아가
Controller
를 통과하는지 살펴볼 필요가 있다 생각했고Controller
통과 후 로그를 찍게 해보았다.
하지만, 예상과 달리 로그가 찍히지 않았다. 즉Controller
단에 진입하기 전이어서Advice
가 핸들링을 못 하는 문제점이었다.
즉,Filter
단에서 예외 처리가 이루어져야 했다.
JwtFailureFilter.java
public class JwtFailureFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (CustomException e) {
responseJwtError(response, e);
}
}
public void responseJwtError(HttpServletResponse response, CustomException e) throws IOException {
response.setStatus(e.getResponseCode().getHttpStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Response<?> responseValue = Response.create(e.getResponseCode(), null);
new ObjectMapper().writeValue(response.getOutputStream(), responseValue);
}
}
그렇게 추가된 JwtFailureFilter
이다.
Security Filter 단을 통과하는 도중 서버에서 발생시킨 예외가 존재할 경우 Filter 통과를 중단하고 예외를 핸들링 시켰다.
위 1,2번과는 예외 처리에 있어 다른 양상을 띠기에 분리해두었다.
상황을 이해하기 위해선 Security의 기본적인 흐름을 이해할 필요가 있다.
서버 입장에선 요청 헤더에 담긴 Access Token을 디코딩하고 얻은 Claim으로 AuthUser란 객체로 인증된 유저란걸 Security에 알려주고 Filter를 통과 시켰다.
즉, Access Token이 존재하지 않는 경우 AuthUser란 객체를 생성하지 못 해 Security의 Authentication에서 통과가 되지 않아 Security의 예외 처리가 응답으로 내려오게 된다.
위 상황도 서버와 클라이언트 양 쪽에서 후처리가 필요했다.
SecurityConfig.java
http
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint());
Security의 AuthenticationEntryPoint를 직접 구현해서 처리해주어야 한다.
CustomAuthenticationEntryPoint.java
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setStatus(NOT_EXIST_TOKEN.getHttpStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Response<?> responseValue = Response.create(NOT_EXIST_TOKEN, null);
new ObjectMapper().writeValue(response.getOutputStream(), responseValue);
}
}
AuthenticationEntryPoint를 직접 구현한 객체로 토큰이 필요한 요청에 없는 경우에 응답을 커스텀해서 클라이언트에 요청을 보내게 된다.
Access Token 만료된 경우 서버에선 만료되었다는 응답을 주기로 결정되었다. 그럼 Refresh Token을 통해 새로운 Access Token을 발급 받는 과정과 Refresh Token의 저장 장소에 관한 내용을 다루어보자.
Refresh Token을 응답 해주는 시기는 언제가 적절한가?
로그인 시 같이 응답을 해주고 사용자 브라우저에 심어두는게 가장 적절하다.
여기서도 2가지의 방법이 존재한다.
기획 당시 프론트와 논의를 거쳤으며 둘 다 쿠키에 담아두자 라는 결론이 나왔다. 그로 인해 2번을 채택했다.
쿠키는 클라이언트에서 자바스크립트로 조회할 수 있기 때문에, 해커들은 자바스크립트로 쿠키를 가로채고자 시도를 하게 된다. 가장 대표적인 공격 중 하나가 CSS(Cross Site Scripting) 공격이다. 외부에서 가로챌 수 있는 방법이 존재하기에 서버에서 직접 Http Only를 걸어두고 외부에서 자바스크립트를 통해 제 3자에 의한 가로챌 수 없도록 설정하기 위함이다.
CookieUtil.java
private static void setCookie(String value, long maxAge, HttpServletResponse response) {
ResponseCookie cookie = ResponseCookie.from(REFRESH_HEADER, value)
.httpOnly(true)
.secure(true)
.sameSite("None")
.maxAge(maxAge)
.domain(domain)
.path("/")
.build();
response.addHeader(SET_COOKIE, cookie.toString());
}
해당 설정을 통해 로그인 한 유저의 브라우저에 서버가 직접 Http Only 설정을 걸어둔 쿠키를 심어주는 형태이다.
JwtController.java
@GetMapping("/refresh")
public ResponseEntity<?> reIssueToken(HttpServletRequest request, HttpServletResponse response) {
JwtResponse refresh = jwtService.refresh(CookieUtil.getRefreshToken(request));
CookieUtil.setCookieRefreshToken(response, refresh.getRefreshToken());
ResponseCode successRefresh = ResponseCode.SUCCESS_REFRESH_TOKEN;
return new ResponseEntity<>(Response.create(successRefresh, refresh), successRefresh.getHttpStatus());
}
JwtService.java
public JwtResponse refresh(String refreshToken) {
if (refreshToken == null) {
throw new CustomException(ResponseCode.NOT_EXIST_TOKEN_COOKIE);
}
RefreshToken refreshTokenEntity = getRefreshTokenEntity(refreshToken);
if (jwtProvider.checkRenewRefreshToken(refreshToken)) {
return createToken(refreshTokenEntity.getUserId());
}
return JwtResponse.create(jwtFactory.createAccessToken(refreshTokenEntity.getUserId()), refreshToken);
}
private RefreshToken getRefreshTokenEntity(String refreshToken) {
return jwtRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new CustomException(ResponseCode.NOT_FOUNT_REFRESH));
}
요청 헤더에서 Cookie 안의 Refresh Token을 가져오고 디코딩 작업을 거치고 새로운 Access Token을 응답해주는 과정이다.
checkRenewRefreshToken()
을 살펴봐야한다.
public boolean checkRenewRefreshToken(String refreshToken) {
try {
getClaims(refreshKey, refreshToken, true);
} catch (CustomException e) {
if (e.getResponseCode().equals(EXPIRATION_ACCESS)) return true;
else throw e;
}
return false;
}
디코딩 작업을 거칠 때 Refresh Token의 만료 여부를 알 수 있다.
Refresh Token이 만료될 경우 새로운 Refresh Token을 만들고 DB에 저장하는 형식으로 서버 코드 흐름을 잡아두었다.