Backend/Spring
Spring boot에서 JWT 구현(3)
나는 유찌
2022. 2. 20. 16:59
이전 글
2022.02.19 - [Backend/Spring] - Spring boot에서 JWT 구현(1)
2022.02.20 - [Backend/Spring] - Spring boot에서 JWT 구현(2)
바로 전 글에서 Access token을 발급하고 Refresh token을 Redis에 저장하는 과정까지 진행해보았습니다❗
이번 포스팅에서는 Access token이 만료되었을 때 재발급하는 과정을 프론트엔드 코드와 같이 조금 살펴보겠습니다.
Access Token 만료
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String path = request.getServletPath();
if(path.startsWith("/api/auth/reissue")) {
filterChain.doFilter(request, response);
} else {
String accessToken = tokenProvider.resolveAccessToken(request);
boolean isTokenValid = tokenProvider.validateToken(accessToken, request);
if (StringUtils.hasText(accessToken) && isTokenValid) {
this.setAuthentication(accessToken);
}
filterChain.doFilter(request, response);
}
} catch (ExpiredJwtException e) {
ResponseVO responseVO = ResponseVO.builder()
.status(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus())
.message(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getMessage())
.code(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getCode())
.build();
response.setStatus(ErrorCode.JWT_ACCESS_TOKEN_EXPIRED.getStatus().value());
response.getWriter().write(new ObjectMapper().writeValueAsString(responseVO));
response.getWriter().flush();
}
}
- 전에 작성했던 JwtAuthenticationFilter 코드의 일부입니다.
- 보시면 ExpiredJwtException 처리를 하고 있는데 토큰 만료 시에 나오는 Exception으로 저의 경우 JWT_ACCESS_TOKEN_EXPIRED라는 이름으로 Custom 401 에러를 내려주었습니다
{
"status":"UNAUTHORIZED",
"message":"Access token has expired",
"response":null,
"code":"TOKEN-0001"
}
- 만료된 Access 토큰을 가지고 아무 API를 호출하고 받은 return 값입니다.
- 저는 Access 토큰 만료 에러와 Refresh 토큰 만료 에러를 구분하여 내려주었습니다.
- 재발급 API 요청을 어떤 경우에 보내야 하는지 구분하기 위함입니다.
Axios Interceptor 사용
🛠 React.js 17.0.1, Axios 0.24.0을 사용하였습니다 🛠
axios.interceptors.request.use(function(config) {
const token = localStorage.getItem('token')
if(token) {
config.headers.Authorization = process.env.REACT_APP_TOKEN_PREFIX + ' ' + token
}
return config;
})
axios.interceptors.response.use(
success => success,
async(error) => {
const errorCode = error.response.data.code
if(errorCode === 'TOKEN-0001') {
const originRequest = error.config
await axios.post('/api/auth/reissue')
.then(result => {
localStorage.setItem('token', result.data.response.accessToken)
window.location.reload()
})
.catch(error => {
localStorage.removeItem('token')
})
return Promise.reject(error)
}
}
)
- axios interceptor란 axios를 이용한 API를 요청할 때 모두 가로채 와서 해당 코드를 실행하는 코드입니다.
- axios.interceptors.response는 모든 API가 응답이 내려올 때, axios.interceptors.request는 모든 API를 보낼 때 실행되는 코드를 의미합니다😀
- 위의 코드를 설명하자면 request는 모든 API를 보내기 전에 Header 값으로 Authorization이라는 이름으로 'Bearer <token>'을 넣어줍니다.
- 모든 API의 응답에서 에러가 발생하는 경우 ErrorCode가 'TOKEN-0001'일 때 즉, Access token이 만료가 되었다는 에러일 때 토큰을 재발급하는 API를 호출합니다.
- 성공적으로 API가 요청이 되면 localstorage에 저장을 하고 reload를 통해서 원래 실행하려던 API를 다시 요청합니다.
- 만일 여기서 에러가 나는 경우 Refresh Token이 만료가 되었다고 생각하고 storage의 토큰 값을 삭제해 아예 로그아웃을 시켜버립니다.
Access Token 재발급 API
@PostMapping("/reissue")
public ResponseEntity<ResponseVO> reissue(HttpServletRequest request, HttpServletResponse response) throws IOException {
MemberDTO memberDTO = authService.reissue(request, response);
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseVO.builder()
.status(HttpStatus.OK)
.response(memberDTO)
.build());
}
@Override
public MemberDTO reissue(HttpServletRequest request, HttpServletResponse response) throws IOException {
try {
String token = tokenProvider.resolveAccessToken(request);
// 만료된 Access Token을 디코딩하여 Payload 값을 가져옴
HashMap<String, String> payloadMap = JwtUtil.getPayloadByToken(token);
String email = payloadMap.get("sub");
// Redis에 저장된 Refresh Token을 찾고 만일 없다면 401 에러를 내려줍니다
Optional<Token> refreshToken = redisRepository.findById(email);
refreshToken.orElseThrow(
() -> new CustomException(ErrorCode.UNAUTHORIZED)
);
// Refresh Token이 만료가 된 토큰인지 확인합니다
boolean isTokenValid = tokenProvider.validateToken(refreshToken.get().getValue(), request);
// Refresh Token이 만료가 되지 않은 경우
if(isTokenValid) {
Optional<Member> member = userRepository.findById(email);
if(member.isPresent()) {
// Access Token과 Refresh Token을 둘 다 새로 발급하여 Refresh Token은 새로 Redis에 저장
Token newAccessToken = tokenProvider.createAccessToken(member.get());
Token newRefreshToken = tokenProvider.createRefreshToken(member.get());
tokenProvider.setHeaderAccessToken(response, newAccessToken.getValue());
redisRepository.save(newRefreshToken);
return MemberDTO.builder()
.accessToken(newAccessToken.getValue())
.member(member.get())
.build();
}
}
} catch(ExpiredJwtException e) {
// Refresh Token 만료 Exception
throw new CustomException(ErrorCode.JWT_REFRESH_TOKEN_EXPIRED);
}
return null;
}
- 토큰을 디코딩하여 Payload를 가져오는 JwtUtil의 getPayloadByToken을 구현하였습니다.
- 아래와 같은 Jwt에 내장된 parser 기능이 있지만 굳이 Decode 코드를 추가한 이유는 이미 만료된 토큰이라 ExpiredJwtException이 발생하기 때문에 따로 Decode 하는 코드를 추가하였습니다. (만료되었지만 Payload 값은 가져오고 싶기 때문에...❗)
-
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
- Redis에 저장된 Refresh Token 만료까지 체크한 후 Access Token을 새로 만들어주고 보안을 위하여 새로운 Refresh Token을 발급해 새로 Redis에 저장함으로써 기존의 Refresh Token은 폐기합니다.
- 위의 코드는 Filter가 아닌 Service단에서 이루어지는 코드라 Exception을 Custom 처리가 가능합니다. 따라서 Refresh Token이 만료된 경우 'JWT_REFRESH_TOKEN_EXPIRED' Custom 401 에러를 내려줍니다.
public static HashMap<String, String> getPayloadByToken(String token) {
try {
String[] splitJwt = token.split("\\.");
Base64.Decoder decoder = Base64.getDecoder();
String payload = new String(decoder.decode(splitJwt[1] .getBytes()));
return new ObjectMapper().readValue(payload, HashMap.class);
} catch (JsonProcessingException e) {
log.error(e.getMessage());
return null;
}
}
- 제가 구현한 Token을 Decode 하는 코드입니다😀😀
마치며
블로그에 글을 정리하면서 보니 구현하면서도 보안적으로 좀 다듬어야 할 부분들이 있다고 생각했는데 정말 크게 와닿네요😂😂
Github에 나중에 JWT 레포지터리를 따로 만들 생각인데 그때 보완해야 될 부분을 보완해서 올려야겠습니다ㅎㅎㅠ