1) 로그인 유지: 액세스 토큰은 일정 기간 동안만 유효, 사용자는 액세스 토큰이 만료돼도 refresh token으로 새로운 액세스 토큰을 발급받아 로그인 상태를 유지
2) 보안 강화: 액세스 토큰을 탈취당하더라도 장기적으로 액세스를 유지하는 것을 어렵게
3) 자동 갱신: 액세스 토큰이 만료되었을 때 자동으로 refresh token을 사용하여 새로운 액세스 토큰을 발급받아 사용자의 작업을 중단하지 X
4) 사용자 경험 향상: 유저가 로그인 과정을 반복하지 않아도 되므로 편리한 사용자 경험을 제공
access token은 request 마다 서버로 전송된다. 이때 access token이 부적절하거나 만료되었을 경우, 나는 오류 메시지를 리턴한다. 대강의 코드는 다음과 같다.
// JwtFilter 클래스: 로그인 이후 토큰 검증 ⭐️
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
// 디코딩할만한 토큰이 왔으면
if (token != null) {
// header의 token로 token, key를 포함하는 새로운 JwtAuthToken 만들기
AccessToken accessToken = tokenProvider.convertAuthToken(token);
// boolean validate() -> getData(): claims or null
// 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext에 저장
if (accessToken.validate()) {
// UsernamePasswordAuthenticationToken(유저, authToken, 권한)
// ⭐️⭐️⭐️⭐️⭐️
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증 성공");
}
}
filterChain.doFilter(request, response);
}
// tokenProvider.getAuthentication(accessToken) ⭐️
@Override
public Authentication getAuthentication(AccessToken authToken) {
if (authToken.validate()) {
// authToken에 담긴 데이터를 받아온다
Claims claims = authToken.getData();
// ⭐️⭐️⭐️⭐️⭐️
UserPrincipal user = (UserPrincipal) userDetailsService.loadUserByUsername(claims.getSubject());
// 권한 없으면 authenticate false => too many redirect 오류 발생
// principal, credential, role 다 쓰는 생성자 써야 super.setAuthenticated(true); 호출됨!
return new UsernamePasswordAuthenticationToken(
user,
null,
Collections.singleton(new SimpleGrantedAuthority("USER")));
} else {
throw new JwtException("token error!");
}
}
// userDetailsService.loadUserByUsername ⭐️
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ActiveGardener gardener = redisRepository.findById(Long.parseLong(username))
.orElseThrow(() -> new UsernameNotFoundException(ExceptionCode.NO_TOKEN_IN_REDIS.getCode()));
return new UserPrincipal(gardener.getGardenerId(), gardener.getName());
}
// JwtExceptionFilter
@Component
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response); // go to 'JwtAuthenticationFilter'
} catch (ExpiredJwtException e) {
setErrorResponse(response, ErrorResponse.from(ExceptionCode.ACCESS_TOKEN_EXPIRED));
} catch (MalformedJwtException e) {
log.info("Invalid JWT token");
setErrorResponse(response, ErrorResponse.from(ExceptionCode.INVALID_JWT_TOKEN));
} catch (JwtException | SecurityException | IllegalArgumentException e){
setErrorResponse(response, ErrorResponse.from(ExceptionCode.INVALID_JWT_TOKEN));
}
}
public void setErrorResponse(HttpServletResponse response, ErrorResponse errorResponse) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
이 흐름에서 access token 만료 메시지를 받은 경우, refresh token을 사용해 access token을 재발급 받는다. 이를 위해 나는 axios interceptor를 사용했다.
// axios 인스턴스 생성 ... 생략
// access token 재발급
const reissueAccessToken = async () => {
const data = {
gardenerId: localStorage.getItem("gardenerId"),
refreshToken: localStorage.getItem("refreshToken")
};
const response = await axios.post(`${process.env.REACT_APP_API_URL}/token`, data)
return response.data;
}
authAxios.interceptors.response.use(
(response) => response,
// error 발생 시
async (error) => {
const errorCode = error.response.data.code;
if (errorCode === "B001") {
// 1) 거절당한 request 저장 ⭐️
const originRequest = error?.config;
// 2) refresh token으로 access token 재발급
console.log("토큰 만료 -> reissue access token");
const accessToken = await reissueAccessToken();
localStorage.setItem("accessToken", accessToken);
// 3) 진행 중인 요청 이어하기
return authAxios({
...originRequest,
headers: {...originRequest.headers, Authorization: `Bearer ${accessToken}`},
sent: true
})
return Promise.reject(error.response.data);
}
);
export default authAxios;
access token 재발급 로직이 담긴 서비스의 메소드다.
@Override
public GardenerDto.Token refreshAccessToken(GardenerDto.Refresh token) {
// 클라이언트가 전달한 refresh token
String reqRefreshToken = token.getRefreshToken();
// redis의 refresh token
ActiveGardener activeGardener = redisRepository.findById(token.getGardenerId())
.orElseThrow(NoSuchElementException::new);
RefreshToken savedRefreshToken = activeGardener.getRefreshToken();
// refresh token 검증
if (!reqRefreshToken.equals(savedRefreshToken.getToken())) {
// redis에 사용자 정보 없음 -- B009
throw new BadCredentialsException(ExceptionCode.NO_TOKEN_IN_REDIS.getCode());
} else if (savedRefreshToken.getExpiredAt().isBefore(LocalDateTime.now())) {
// refresh token 만료 -- B002
throw new BadCredentialsException(ExceptionCode.REFRESH_TOKEN_EXPIRED.getCode());
}
// 새 access token 만들기
AccessToken accessToken = tokenProvider.createAccessToken(activeGardener.getGardenerId(), activeGardener.getName());
return new GardenerDto.Token(accessToken.getToken(), null);
}
이렇게 다시 전달된 새로운 access token을 사용하여 클라이언트가 직전 요청을 다시 요청한다.
만일 refresh token도 만료되었다면, 사용자는 로그아웃된다.