[JWT를 활용한 토큰 재발급 API 구현] 과정을 기록해보려고 한다. 프로젝트 개발 환경은 아래와 같다.
Java: 17
JDK: 17.0.6
IDE: IntelliJ IDEA 2024.1
Framework: Spring Boot 3.3.1
Refresh Token 생성 및 Redis에 저장
아래 글에서 보안상의 이유로 JWT를 Access Token과 Refresh Token으로 나눠서 사용한다고 했다.
Access Token
- 보통 2시간 정도의 유효 시간을 가짐
- 사용자를 인증하는 용도로 사용
Refresh Token
- 보통 2주 정도의 유효 시간을 가짐
- 사용자를 인증하는 Access Token을 재발급하는 용도로 사용
JwtUtil
Access Token 생성 코드와 동일하게 구현하면 된다. 회원의 이메일을 JWT의 claim에 넣고 만료 시간을 설정한 뒤 secretKey로 서명을 진행한다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
// 리프레시 토큰 생성 - payload claim에 member의 ID와 이메일을 저장 (role은 null 값)
public static String createRefreshJwt(Long memberId, String email, String role) {
return Jwts.builder()
.header()
.add("typ", "JWT")
.and()
.claim("memberId", memberId)
.claim("email", email)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + refreshExpireMs))
.signWith(secretKey)
.compact();
}
}
MemberResponseDTO
로그인 후 Access Token과 Refresh Token까지 발급하도록 수정한다.
public class MemberResponseDTO {
...
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class LoginJwtTokenDTO {
String grantType;
String accessToken;
Long accessTokenExpiresAt;
String refreshToken;
Long refreshTokenExpiresAt;
}
}
MemberService
Access Token만 발급하던 기존 코드에 Refresh Token까지 발급받도록 수정한다.
- 발급받은 Refresh Token을 Redis에 저장한다. 이때 저장 유효 시간은 Refresh Token을 설정할 때 넣은 ms와 동일하게 지정해야 한다.
- 기존에 이메일 인증 방식 회원가입을 구현할 때 key를 email로, value를 인증코드로 저장하도록 구현한 적이 있다. 따라서 차이를 주기 위해 key에 넣는 값을 이메일 앞에 "RT"를 붙이도록 했다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
...
// 로그인 - 액세스 토큰 발급
public MemberResponseDTO.LoginJwtTokenDTO login(MemberRequestDTO.MemberLoginDTO loginDTO) {
...
// 리프레시 토큰 발급 코드 추가
String accessToken = jwtUtil.createAccessJwt(member.getId(), member.getEmail(), null);
String refreshToken = jwtUtil.createRefreshJwt(member.getId(), member.getEmail(), null);
Long accessExpiredAt = jwtUtil.getExpiration(accessToken);
Long refreshExpiredAt = jwtUtil.getExpiration(refreshToken);
// 리프레시 토큰을 Redis에 저장
// key:value => ex. RTemail@email.com:refreshToken
redisTemplate.opsForValue().set("RT" + email, refreshToken, refreshExpiredAt, TimeUnit.MILLISECONDS);
return MemberResponseDTO.NewLoginJwtTokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
.accessTokenExpiresAt(accessTokenExpiredAt)
.refreshToken(refreshToken)
.refreshTokenEpriresAt(refreshExpiredAt)
.build();
}
}
토큰 재발급 API 구현하기
MemberService
입력받은 Refresh Token을 검증하고, payload claim에서 이메일을 파싱한다. 이후 해당 Refresh Token이 Redis에 저장돼 있는지 확인하고, 이메일로 찾은 회원에게 새로운 Access Token과 Refresh Token을 발급한다. 발급하는 부분은 login()과 동일하다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final RedisTemplate redisTemplate;
private final JwtUtil jwtUtil;
...
// 토큰 재발급 - 액세스 & 리프레시
public MemberResponseDTO.LoginJwtTokenDTO reissue(String refreshToken) {
if (!jwtUtil.validateToken(refreshToken)) {
throw new JwtExceptionHandler(ErrorStatus.NOT_VALID_TOKEN.getMessage());
}
String email = jwtUtil.getEmail(refreshToken);
Object o = redisTemplate.opsForValue().get("RT" + email);
if (o == null) {
throw new JwtExceptionHandler(ErrorStatus.NO_MATCH_REFRESHTOKEN.getMessage());
}
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new MemberHandler(ErrorStatus.NO_MEMBER_EXIST));
String newAccessToken = JwtUtil.createAccessJwt(member.getId(), member.getEmail(), null);
String newRefreshToken = JwtUtil.createRefreshJwt(member.getId(), member.getEmail(), null);
Long accessExpiredAt = JwtUtil.getExpiration(newAccessToken);
Long refreshExpiredAt = JwtUtil.getExpiration(newRefreshToken);
redisTemplate.opsForValue().set("RT" + member.getEmail(), newRefreshToken, refreshExpiredAt, TimeUnit.MILLISECONDS);
return MemberResponseDTO.LoginJwtTokenDTO.builder()
.grantType("Bearer")
.accessToken(newAccessToken)
.accessTokenExpiresAt(accessExpiredAt)
.refreshToken(newRefreshToken)
.refreshTokenExpirationAt(refreshExpiredAt)
.build();
}
}
MemberApiController
Refresh Token을 입력받아 새로운 Access Token과 Refresh Token을 발급한 뒤 응답을 통일해 반환한다. 원래는 @RequestHeader("RefreshToken") 애노테이션을 사용해 헤더에서 토큰을 긁어와야 하지만, Swagger로만 테스트해 보느라 Refresh Token을 헤더에 넣고 테스트를 해보지 못해서 그냥 요청 파라미터로 받게 구현했다.
- 이 애노테이션은 다음 글에 작성할 로그아웃과 회원탈퇴 API에서 사용한다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
@Tag(name = "member", description = "회원 관련 API")
public class MemberApiController {
...
@PostMapping("/refresh")
@Operation(summary = "액세스 토큰과 리프레시 토큰 재발급 API", description = "리프레시 토큰으로 액세스 토큰과 리프레시 토큰을 재발급하는 API (액세스 토큰 필요 없음)")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4005", description = "존재하지 않는 회원입니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4028", description = "일치하는 리프레시 토큰이 존재하지 않습니다.",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
})
@Parameters({
@Parameter(name = "refreshToken", description = "리프레시 토큰")
})
public ApiResponse<MemberResponseDTO.LoginJwtTokenDTO> refreshToken(@RequestParam("refreshToken") String refreshToken) {
MemberResponseDTO.LoginJwtTokenDTO tokens = memberService.reissue(refreshToken);
return ApiResponse.onSuccess(tokens);
}
}
SecurityConfig
토큰 재발급 API는 Access Token이 만료됐을 때 호출한다. 따라서 인증 정보(토큰)가 없어도 API를 호출할 수 있도록 인증 필요 여부 경로를 추가해야 한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
...
.authorizeHttpRequests((request) -> request
.requestMatchers("/member/signup/**").permitAll()
.requestMatchers("/member/login").permitAll()
.requestMatchers("/member/refresh").permitAll() // 추가
.requestMatchers("/health").permitAll() // health check
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class);
return http.build();
}
}
Swagger로 테스트하기
테스트 방식은 로그인 API 구현과 동일하다. 테스트를 위해 두 토큰의 유효 시간을 1시간으로 만들어뒀다.
참고자료