Refresh Token
로그인이 성공하면 기존에 단일 토큰만 발급했지만 Access/Refresh에 해당하는 다중 토큰을 발급하고자 한다.... 화이팅
따라서 로그인이 성공한 이후 실행되는 successfulAuthentication() 메서드에서 2개의 토큰을 발급한다.
이때 각각의 토큰은 생명주기와 사용처가 다르기 때문에 서로 다른 저장소에 발급한다.
- Access : 헤더에 발급 후 프론트에서 로컬 스토리지에 저장
- Refresh : 쿠키에 발급
로그인 성공 핸들러 - Accress/Refresh Token
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
//유저 정보
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
//토큰 생성
String access = jwtUtil.createJwt("access", username, role, 600000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
기존 코드 - Access Token만 발급했을 경우
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
Integer id = customUserDetails.getMember().getId();
String username = customUserDetails.getUsername(); //사실 email
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
String token = jwtUtil.createJwt(id, username, role, 60*60*10*1000L);
//Authorization: Bearer 인증토큰string
response.addHeader("Authorization", "Bearer " + token);
}
기존 코드에서는 사용자 정보는 SecurityContextHolder -> SecurityContext -> Authentication -> Principal 내부에 존재한다. 따라서 getPrincipal로 순차적으로 접근하는 방식이 기존 방식이지만, 편안하게 바로 값을 획득할 수 있는 getName 메소드도 있기 때문에 둘 중 아무거나 사용해도 무방하다.
필요한 데이터가 Name이 아닌 Email이기 때문에 getPrincipal()을 통해 가져오도록 코드를 변경하였다.
변경 후 - Access/Refresh Token
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
//유저 정보
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername(); //사실 email
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
//토큰 생성
String access = jwtUtil.createJwt("access", username, role, 600000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//응답 설정
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
토큰 카테고리(access, refresh)를 판단하기 위해 Jwt 생성시 category를 담도록 수정하고 getCategory() 메서드를 추가한다.
/* getCategory 메서드 추가 : 토큰 판단용 */
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
JWTFilter 수정
헤더에서 access키에 담긴 토큰을 꺼내고
- 토큰이 있는지 확인
- 토큰이 없다면 다음 필터로 넘긴다.
- 토큰이 있다면
- 토큰 만료 여부 확인
- 토큰이 만료되면 다음 필터로 넘기지 않고 응답 코드를 발생시켜서 토큰이 만료되었다고 응답한다.
- 토큰이 만료되지 않았다면
- 토큰이 access인지 확인
- access 토큰이 아니라면 accessToken이 아니라고 응답 메시지와 함께 상태코드를 응답한다. (다음 필터로 넘기지 않는다)
- 토큰 검증이 완료되면 username, role 값을 획득하여 유저 세션을 생성한다.
JSON으로 로그인하려면.....
LoginDTO loginDTO = new LoginDTO();
try {
ObjectMapper objectMapper = new ObjectMapper();
ServletInputStream inputStream = request.getInputStream();
String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
loginDTO = objectMapper.readValue(messageBody, LoginDTO.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
System.out.println(loginDTO.getUsername());
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
이때 필요한 LoginDTO는 아래와 같이 DTO 클래스를 추가하시면 됩니다.
@Getter
@Setter
public class LoginDTO {
private String username;
private String password;
}
일반적으로 access, refresh 모두 헤더로 보냄
Refresh로 Access 토큰 재발급 (reissue api)
서버측 JWTFilter에서 Access 토큰의 만료로 인한 특정한 상태 코드가 응답되면 프론트측의 Axios Interceptor와 같은 예외 핸들러에서 Access 토큰 재발급을 위한 Refresh를 서버측으로 전송한다.
이때 서버에서는 Refresh 토큰을 받아 새로운 Access 토큰을 응답하는 코드를 작성하면 된다.
만약에 Refresh Rotate 방법으로 토큰을 갱신하고자 하면 아래와 같이 작성할 수 있다.
@PostMapping("/api/account/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
//get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies(); // 모든 쿠키를 쿠키 배열에 담음
for (Cookie cookie : cookies) { // 순회
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
if (refresh == null) { // refresh가 null이라면
//response status code
return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
}
//refresh token이 있다면
//expired check
try {
jwtUtil.isExpired(refresh);
}
catch (ExpiredJwtException e) {
//refresh token 만료
//response status code
return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make new JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//response
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return new ResponseEntity<>(HttpStatus.OK);
}
Refresh Rotate란
reissue 엔드포인트에서 Refresh 토큰을 받아 Access 토큰 갱신 시 Refresh 토큰도 함께 갱신하는 방법이다. Refresh Rotate의 장점은
- Refresh 토큰 교체로 보안성 강화
- 로그인 지속시간 길어짐
라고 말할 수 있다. Refresh Rotate 방법을 사용하고자 할 땐 주의해야할 점이 있는데 발급했던 Refresh 토큰을 모두 기억한 뒤, Rotate 이전의 Refresh 토큰은 사용하지 못하도록 해야한다는 것이다. (이전의 Refresh 토큰 삭제)
기존의 Reissue 메서드에서 다음 코드를 추가하면
// 새로운 Refresh Token을 생성하고
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
// 쿠키에 담는다
response.addCookie(createCookie("refresh", newRefresh));
새로운 Refresh Token을 만들고 쿠키에 담아서 전달할 수 있다.