Refresh Token은 만료된 Access Token을 재발급하기 위해 필요한 토큰이다.
Access Token은 보안상의 이유로 짧은 만료 시간을 가지고 있다. 따라서, 사용자는 빈번하게 재로그인을 해야한다는 번거로움을 겪어야 한다. 이러한 불편함을 해소하기 위해 Refresh Token이 사용된다.
Refresh Token은 Access Token에 비해 긴 유효 기간을 가진다. 다만, 서버 측에 토큰을 저장해야 하기 때문에, Access Token의 장점인 stateless하지 않다는 특징을 가진다.
Access Token과 Refresh Token은 보안성과 성능, 편의성 등을 적절하게 타협한 결과라고 할 수 있다.
Refresh Token의 플로우를 정리해보면, 다음과 같다.
1. 클라이언트에서 로그인을 하면, 서버는 사용자 정보와 함께 Access Token과 Refresh Token을 발급한다. 동시에 Refresh Token은 서버에 저장한다. 클라이언트는 두 토큰을 로컬에 저장한다.
2. 클라이언트가 Access Token을 헤더에 담아 요청한다.
3. 서버는 Access Token의 유효성을 체크한다. 이때, Access Token이 만료되었다면, 클라이언트에게 만료되었다는 응답을 준다.
4. 클라이언트는 Access Token이 만료되었다는 응답을 받으면, Refresh Token을 가지고 Access Token 재발급 요청을 한다.
5. 서버는 Refresh Token의 유효성을 체크한 후, 유효하다면 새로운 Access Token을 발급한다.
6. 클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓴다.
Refresh Token 역시 Access Token과 마찬가지로 탈취당할 위험이 있다.
따라서, 최초 로그인 시 로그인 요청 ip를 Refresh Token과 함께 저장하고, 재발급 요청이 왔을 때, 요청이 온 ip와 저장된 ip를 비교해서 다른 경우 토큰을 재발급하지 않거나 알림을 보내는 등의 조치를 취할 수 있다.
이전에 JWT를 적용했던 코드에 Refresh Token을 추가해보자.
먼저, 나는 사용자id와 email, refresh token을 기존의 db에 저장하는 방식을 선택했다.
@Builder
@Data
@AllArgsConstructor
public class Token{
private String accessToken;
private String refreshToken;
private String keyEmail;
}
@Entity
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String refreshToken;
private String keyEmail;
}
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private String secretKey = "testkeyyyy";
private final RefreshTokenRepository refreshTokenRepository;
public void saveRefreshToken(Token token) {
String loginMemberEmail = token.getKeyEmail();
RefreshToken refreshToken = RefreshToken.builder()
.keyEmail(loginMemberEmail)
.refreshToken(token.getRefreshToken()).build();
// if refresh token exists, delete refresh token
if (refreshTokenRepository.existsByKeyEmail(loginMemberEmail)) {
refreshTokenRepository.deleteByKeyEmail(loginMemberEmail);
}
// save refresh token
refreshTokenRepository.save(refreshToken);
}
public String verifyRefreshToken(String refreshToken) {
if (getRefreshToken(refreshToken).isPresent()) {
String savedRefreshToken = getRefreshToken(refreshToken).get().getRefreshToken();
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(savedRefreshToken);
// recreate access token
if (!claims.getBody().getExpiration().before(new Date())) {
return recreationAccessToken(claims.getBody().get("sub").toString(), claims.getBody().get("roles"));
}
} catch (Exception e) {
// if expired refresh token, need to login -> 예외 처리 필요!
//System.out.println(e.getMessage());
return null;
}
}
// 저장된 refresh token이 없는 경우 -> 예외 처리 필요!
return null;
}
public Optional<RefreshToken> getRefreshToken(String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken);
}
public String recreationAccessToken(String email, Object roles) {
long accessTokenPeriod = 1000L * 60L * 60L * 24L;
Claims claims = Jwts.claims().setSubject(email);
claims.put("roles", roles);
Date now = new Date();
String accessToken = Jwts.builder()
.setClaims(claims) // save info
.setIssuedAt(now) // token generated time info
.setExpiration(new Date(now.getTime() + accessTokenPeriod)) // set expire time
.signWith(SignatureAlgorithm.HS256, secretKey) // using encryption algorithm and set secret value
.compact();
return accessToken;
}
}
레포지토리에 접근해야하기 때문에 TokenService와 분리하여 RefreshTokenService를 생성했다.
주요 로직은 다음과 같다.
@Transactional
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long>
{
Optional<RefreshToken> findByRefreshToken(String refreshToken);
boolean existsByKeyEmail(String email);
void deleteByKeyEmail(String email);
}
String newAccessToken = refreshTokenService.verifyRefreshToken(bodyJson.get("refreshToken"));
Map<String, String> map = new HashMap<>();
// if uneffective refreshtoken
if(newAccessToken == null) {
map.put("errortype", "Forbidden");
map.put("status", "402");
map.put("message", "Refresh 토큰이 유효하지 않습니다. 로그인이 필요합니다.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(map);
}
// if effective refreshtoken
map.put("status", "200");
map.put("message", "Refresh 토큰을 통한 Access Token 생성이 완료되었습니다.");
map.put("accessToken", newAccessToken);
return ResponseEntity.status(HttpStatus.OK).body(map);
postmapping을 통해 refresh token을 클라이언트로부터 전달받아 검증한다. 만일 refresh token이 유효하다면, access token을 전달하고 유효하지않다면, 402에러와 함께 바디메시지를 전달한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.exceptionHandling()
.and()
.headers()
.frameOptions()
.sameOrigin()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/oauth/**", "/refresh").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
}
access token을 재발급하기 위한 요청도 access token 유효성 검사를 타지 않아야하기 때문에, antMatchers에 "/refresh"를 추가한다.
@GetMapping("/oauth/kakao")
public ResponseEntity kakaoLoin(@RequestParam("code") String code)
throws JsonProcessingException {
Member member = oauthMemberService.oauthLogin(code, "kakao");
// generate jwt
Token token = tokenService.generateToken(member.getEmail(), member.getRoles());
// save refresh token
refreshTokenService.saveRefreshToken(token);
MemberResponseDto memberResponseDto = new MemberResponseDto(member.getEmail(), member.getNickname(),
token.getAccessToken(), token.getRefreshToken());
return ResponseEntity.status(HttpStatus.OK).body(memberResponseDto);
}
참고
https://wildeveloperetrain.tistory.com/245
https://velog.io/@jkijki12/Jwt-Refresh-Token-%EC%A0%81%EC%9A%A9%EA%B8%B0