들어가기 전에
쉽게 이해하는 Spring Security + JWT 로그인 구현기에 이어 이번엔 로컬 캐시 라이브러리인 Caffeine을 활용해서 Refresh Token을 구현한 경험을 남겨보고자 한다. 이 게시물은 이미 Refresh Token을 적용해야 하는 이유를 알고 계신 분들이 볼 것 같아서 Refresh Token이 필요한 이유나 프로세스는 생략하며 아래 첨부된 코드들에는 로그아웃이나 다른 기능을 위한 로직은 존재하지 않는다.
01. DB가 아닌 캐시를 선택한 이유
Refresh Token 구현에 DB를 사용할 것인가, 캐시를 사용할 것인가를 고민했다. 우선 고려해야 할 것은 Access token의 유효 시간이 굉장히 짧기 때문에 계속해서 Refresh token을 통해 재발급 받아야 한다는 것이었다. DB를 사용한다면 I/O access가 계속해서 일어나게 되어 성능 문제가 발생할 수 있는 문제였다. 그리고 캐시를 사용할 때는 Time-based 기능을 사용할 수 있지만 DB에는 이러한 기능이 따로 없기 때문에 따로 스케줄링을 통해 만료 기한이 지난 토큰을 제거해주는 추가 작업이 필요했다. 그래서 결과적으로 캐시를 활용하는 게 더 적합하다는 판단이 들었고, 개인적으로는 직접 프로젝트에서 활용해 본 적이 없어서 경험하고 싶었다!
02. 로컬 캐시를 선택한 이유
우리는 프로젝트 후반부에 다중 서버를 구축하고 레디스를 도입할 계획이 있었다. 미리 레디스를 도입해서 사용했어도 됐지만 나는 최대한 상황에 맞는 기술을 도입하고 사용해 보고 싶었다. 그래서 글로벌 캐시를 사용하기 전에 싱글 서버에서 로컬 캐시를 활용해 보고자 했다.
03. Caffeine cache를 선택한 이유
나는 Encache와 Caffeine을 두고 고민했었는데 여러 면에서 Caffeine이 더 적합하다고 생각했다. 우선 Caffeine cache는 총 세 가지 전략을 제공하는데 그중 하나가 내가 원하는 Time-based였다. 그리고 Encache는 분산 캐싱, 캐시 리스너와 같이 다양한 기능을 제공했지만 지금 내가 구현하고자 하는 기능에는 필요하지 않았기에 최종적으로 캐시 메모리를 활용할 수 있으면서 고성능 퍼포먼스를 제공하는 Caffeine을 선택했다.
프로젝트 스펙
- Java 17
- Spring Boot 3.0.4
- Gradle
구현하기
1. build.gradle
Caffeine 라이브러리 의존성을 추가한다.
implementation 'com.github.ben-manes.caffeine:caffeine:3.0.0'
2. RefreshToken
캐시에 Refresh Token을 저장하기 위한 VO 클래스를 생성한다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RefreshToken {
private String email;
private String refreshToken;
private LocalDateTime expiredTime;
public static RefreshToken of(String email, String refreshToken, LocalDateTime expiredTime) {
RefreshToken token = new RefreshToken();
token.email = email;
token.refreshToken = refreshToken;
token.expiredTime = expiredTime;
return token;
}
}
3. TokenCache
로컬 캐시 생성 및 삭제 등의 Repository 역할을 하는 TokenCache 클래스를 만들었다.
@Component
public class TokenCache {
private final Cache<String, RefreshToken> cache;
private static final int expirationDate = 14;
public TokenCache() {
this.cache = Caffeine.newBuilder()
.expireAfterWrite(expirationDate, TimeUnit.DAYS)
.build();
}
public void saveToken(String email, String refreshToken) {
cache.put(email, RefreshToken.of(email, refreshToken, LocalDateTime.now().plusDays(expirationDate)));
}
public void deleteToken(String email) {
cache.invalidate(email);
}
public Optional<RefreshToken> hasToken(String email) {
return Optional.ofNullable(cache.getIfPresent(email));
}
}
Caffeine.newBuilder().expireAfterWrite()로 지정한 만료 기한이 지나면 리프레시 토큰이 삭제될 수 있도록 했다. TimeUnit에는 DAYS 뿐만 아니라 SECONDS, MINUTES, HOURS 등이 있으니 원하는 유형을 선택하면 된다.
4. JwtTokenProvider
Refresh Token을 생성하는 메소드를 생성했다. 이때 JWT로 생성할지 UUID와 같은 난수 값으로 생성할지 고민했는데 Self-Contained된 유저 정보가 필요한 토큰은 아니라는 생각이 들어서 UUID로 생성하기로 했다.
추가 코드
public String generateRefreshToken() {
return UUID.randomUUID().toString();
}
최종 코드
@Slf4j
@Component
public class JwtTokenProvider {
private String secretKey;
private long tokenValidityInMilliseconds;
private final PrincipalDetailsService principalDetailsService;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORIZATION_TYPE = "Bearer";
private static final String ROLE = "role";
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds,
PrincipalDetailsService principalDetailsService) {
this.secretKey = secretKey;
this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
this.principalDetailsService = principalDetailsService;
}
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public String generateAccessToken(String email, Role role) {
Claims claims = Jwts.claims().setSubject(email);
claims.put(ROLE, role);
Date now = new Date();
Date validity = new Date(now.getTime() + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public String generateRefreshToken() {
return UUID.randomUUID().toString();
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = principalDetailsService.loadUserByUsername(getMemberEmail(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getMemberEmail(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody().getSubject();
}
public String getRole(String token) {
return (String)Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody().get(ROLE);
}
public Optional<String> resolveToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER))
.filter(StringUtils::hasText)
.filter(header -> header.startsWith(AUTHORIZATION_TYPE))
.map(header -> header.substring(7));
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch (RuntimeException exception) {
log.error("Invalid token : {} ", token, exception);
}
return false;
}
}
5. AuthenticationController
Refresh Token을 활용해서 새롭게 Access Token을 발급받을 수 있는 API를 생성했다. ReIssueRequest DTO 객체에는 Access Token과 Refresh Token이 포함되어있다.
@RestController
@RequestMapping("/api/v1/auth")
public class AuthenticationController {
private final AuthenticationService authenticationService;
public AuthenticationController(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody @Valid LoginRequest dto) {
return ResponseEntity.ok(authenticationService.login(dto));
}
@PostMapping("/reissue")
public ResponseEntity<TokenResponse> reissueToken(@RequestBody @Valid ReIssueRequest dto) {
return ResponseEntity.ok(authenticationService.reissueToken(dto));
}
}
6. AuthenticationService
기존에는 로그인 시 Access Token만 발급했지만 이번에는 Refresh Token도 같이 발급할 수 있도록 추가하고 캐시에도 토큰을 저장했다. 그리고 Access Token을 재발급하기 위한 메소드를 생성해서 Access Token에 포함된 유저 정보로 Refresh Token 만료 여부를 검증하고 토큰을 재발급하는 로직을 추가했다.
추가 코드
@Service
@RequiredArgsConstructor
public class AuthenticationService {
...
public TokenResponse login(LoginRequest loginRequest) {
...
String accessToken = jwtTokenProvider.generateAccessToken(member.getEmail(), member.getRole());
String refreshToken = jwtTokenProvider.generateRefreshToken();
tokenCache.saveToken(member.getEmail(), refreshToken);
return new TokenResponse(accessToken, refreshToken);
}
...
public TokenResponse reissueToken(ReIssueRequest request) {
String accessToken = request.accessToken();
String refreshToken = request.refreshToken();
String memberEmail = jwtTokenProvider.getMemberEmail(accessToken);
String role = jwtTokenProvider.getRole(accessToken);
validateRefreshToken(memberEmail);
accessToken = jwtTokenProvider.generateAccessToken(memberEmail, Role.valueOf(role));
return new TokenResponse(accessToken, refreshToken);
}
public void validateRefreshToken(String email) {
tokenCache.hasToken(email).orElseThrow(() -> new NotExistedTokenException(ErrorMessage.NOT_EXISTED_TOKEN));
}
}
최종 코드
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final MemberMapper memberMapper;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final TokenCache tokenCache;
private final MemberService memberService;
public TokenResponse login(LoginRequest loginRequest) {
Member member = memberMapper.findByEmail(loginRequest.email())
.filter(m -> isMatchedPassword(loginRequest.password(), m.getPassword()))
.orElseThrow(() -> new NotMatchedInformationException(ErrorMessage.NOT_MATCHED_LOGIN_INFORMATION));
if (!memberService.isActiveMember(member)) {
throw new NotActivatedMemberException(ErrorMessage.NOT_ACTIVATED_MEMBER);
}
String accessToken = jwtTokenProvider.generateAccessToken(member.getEmail(), member.getRole());
String refreshToken = jwtTokenProvider.generateRefreshToken();
tokenCache.saveToken(member.getEmail(), refreshToken);
return new TokenResponse(accessToken, refreshToken);
}
public boolean isMatchedPassword(String password, String existedPassword) {
return passwordEncoder.matches(password, existedPassword);
}
public TokenResponse reissueToken(ReIssueRequest request) {
String accessToken = request.accessToken();
String refreshToken = request.refreshToken();
String memberEmail = jwtTokenProvider.getMemberEmail(accessToken);
String role = jwtTokenProvider.getRole(accessToken);
validateRefreshToken(memberEmail);
accessToken = jwtTokenProvider.generateAccessToken(memberEmail, Role.valueOf(role));
return new TokenResponse(accessToken, refreshToken);
}
public void validateRefreshToken(String email) {
tokenCache.hasToken(email).orElseThrow(() -> new NotExistedTokenException(ErrorMessage.NOT_EXISTED_TOKEN));
}
}
참고
'Spring' 카테고리의 다른 글
[Spring] Custom Filter로 로그 파밍하기 (CloudWatch X Logback) (4) | 2024.10.09 |
---|---|
[Spring] Server-Sent Events로 알람 서비스 개선하기 (0) | 2023.08.28 |
[Spring Security] POST 테스트 : 403 Forbidden 에러 해결 (0) | 2023.05.17 |
[Spring] 쉽게 이해하는 Spring Security + JWT 로그인 구현기 (0) | 2023.05.12 |
[Spring] REST Docs 설정 (0) | 2023.04.19 |