이전에 로그인 성공 시 accessToken과 refreshToken을 발급해 주는 것까지 구현했다.
이번에는 accessToken이 만료가 되면 refreshToken을 통해 재발급 받는 것을 구현해 보려고 한다.
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
private final JwtTokenResolver jwtTokenResolver;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = jwtTokenResolver.resolveToken((HttpServletRequest) request);
if (token == null) {
//회원가입 로그인 시
} else {
// 2. validateToken 으로 토큰 유효성 검사
switch (jwtTokenProvider.validateToken(token)) {
case VALID -> {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
case EXPIRED -> {
throw new ExpiredJWTTokenExcep("만료된 토큰입니다.");
}
case INVALID -> {
throw new InvalidJWTTokenExcep("유효하지 않은 토큰입니다.");
}
case UNSUPPORTED -> {
throw new UnsupportedJWTTokenExcep("지원되지 않는 토큰입니다");
}
case EMPTY -> {
throw new EmptyJWTTokenExcep("claims 내용이 빈 토큰입니다.");
}
}
}
chain.doFilter(request, response);
}
}
Request Header에 토큰이 있다면 유효성 검사를 진행하고 실패한다면 각각의 이유에 맞는 예외를 발생시킨다.
예외는 모두 RuntimeException을 상속해서 만들어주었다.
@Component
public class JwtTokenResolver {
// Request Header 에서 토큰 정보 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
@Slf4j
@Component
public class JwtTokenProvider {
@Getter
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public TokenInfo generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + 30 * 60 * 1000);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 14 * 24 * 60 * 60 * 1000))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증하는 메서드
public ValidationTokenSign validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return ValidationTokenSign.VALID;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.error("Invalid JWT Token");
return ValidationTokenSign.INVALID;
} catch (ExpiredJwtException e) {
log.error("Expired JWT Token");
return ValidationTokenSign.EXPIRED;
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT Token");
return ValidationTokenSign.UNSUPPORTED;
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty.");
return ValidationTokenSign.EMPTY;
}
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
public enum ValidationTokenSign {
VALID, INVALID, EXPIRED, UNSUPPORTED, EMPTY
}
getAuthentication 메서드는 토큰에서 PAYLOAD 부분을 추출하고 권한을 확인해서 Authentication 객체를 리턴한다.
validateToken 메서드는 토큰의 유효성을 검사해서 실패한다면 알맞은 예외를 발생시키기 위해 ValidationTokenSign 값을 리턴한다.(이 값에 따라 JwtAuthenticationFilter에서 해당 예외 발생시킴)
@RequiredArgsConstructor
public class ExceptionHandlerFilter extends OncePerRequestFilter {
private final ObjectMapper mapper;
private final JWTService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try{
filterChain.doFilter(request, response);
}catch (InvalidJWTTokenExcep | UnsupportedJWTTokenExcep
| EmptyJWTTokenExcep e){
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.UNAUTHORIZED.value(), e.getMessage());
mapper.writeValue(response.getWriter(), exceptionResponse);
}catch (ExpiredJWTTokenExcep e){
String refreshToken = request.getHeader("RefreshToken");
if (StringUtils.hasText(refreshToken) && refreshToken.startsWith("Bearer")) {
String token = refreshToken.substring(7);
ResponseResult result = jwtService.refreshToken(token.substring(0, token.length()-1));
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
mapper.writeValue(response.getWriter(), result);
} else{
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.UNAUTHORIZED.value(), e.getMessage());
mapper.writeValue(response.getWriter(), exceptionResponse);
}
}
}
}
ExceptionHandlerFilter는 JwtAuthenticationFilter에서 발생시킨 예외를 처리해 준다. 유효시간 만료 예외가 발생하면 Request Header에 refreshToken이 있는지 확인하고 있다면 jwtService.refreshToken을 통해 토큰 재발급을 시도한다.
유효시간 만료 예외가 발생했는데 Request Header에 refreshToken이 존재하지 않거나 다른 예외가 발생했다면 해당 예외 메시지를 response로 전달한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class JWTService {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public ResponseResult refreshToken(String inputToken) {
Base64.Decoder decoder = Base64.getDecoder();
String payload = new String(decoder.decode(inputToken.split("\\.")[1]));
long time = new Date().getTime();
long expTime = Long.parseLong(payload.substring(7, payload.length() - 1));
if ((time / 1000) < expTime) {
Optional<Member> byRefreshToken = memberRepository.findByRefreshToken(inputToken);
if (byRefreshToken.isPresent()) {
Member findMember = byRefreshToken.get();
String token = findMember.getRefreshToken();
if (token.equals(inputToken)) {
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + 30 * 60 * 1000);
String accessToken = Jwts.builder()
.setSubject(findMember.getUsername())
.claim("auth", "ROLE_" + findMember.getRole())
.setExpiration(accessTokenExpiresIn)
.signWith(jwtTokenProvider.getKey(), SignatureAlgorithm.HS512)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 14 * 24 * 60 * 60 * 1000))
.signWith(jwtTokenProvider.getKey(), SignatureAlgorithm.HS512)
.compact();
TokenInfo tokenInfo = TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
findMember.setRefreshToken(tokenInfo.getRefreshToken());
log.info("토큰이 재발급 되었습니다.");
return new ResponseResult(HttpStatus.OK.value(), tokenInfo);
} else {
return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "잘못된 토큰입니다. 토큰 재발급이 불가능하니 " +
"다시 로그인 부탁드립니다.");
}
} else {
return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "존재하지 않는 토큰입니다. 토큰 재발급이 불가능하니 " +
"다시 로그인 부탁드립니다.");
}
} else {
return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "토큰이 만료 되었습니다. 다시 로그인 해주세요.");
}
}
}
JWTService는 넘어온 refreshToken이 만료된 토큰인지 확인하고 유효하다면 DB에 있는 refreshToken과 일치하는지 확인한다. 일치한다면 새로운 accessToken과 refreshToken을 발급해 준다.
refreshToken이 만료되었거나 db에 있는 값과 일치하지 않는다면 적절한 오류메시지를 ResponseResult에 넣어 리턴한다.
https://codingdog.tistory.com/entry/spring-security-filter-exception-을-custom-하게-처리해-봅시다
https://stackoverflow.com/questions/57194249/how-to-return-response-as-json-from-spring-filter