이 게시글은 강의를 듣고 사이드 프로젝트에 적용한 내용이 주를 이루고 있습니다
수강 강의 : (인프런) 스프링부트 시큐리티 & JWT 강의
🏠 Basic Exam : https://github.com/devwuu/spring-security-exam
🏠 사이드 프로젝트 : https://github.com/devwuu/todaktodak
보안을 위해 Access Token은 보통 짧은 유효시간을 갖는다. Access Token이 만료되면 사용자는 다시 인증(로그인)을 해야하는데 짧은 유효 시간 때문에 자주 인증해야하는 불편함이 생긴다. 이 경우, Access Token보다 유효시간이 긴 Refresh Token을 사용함으로써 재인증의 번거로움을 줄여줄 수 있다. 일반적으로 Access Token이 만료되면 Refresh Token으로 Access Token을 재발급 받고, 추후 Refresh Token까지 만료되고 나서야 재인증(로그인)하는 방식으로 이루어진다.
내가 직접 구현해본 적은 없지만 Refresh Token은 일반적으로 DB에 저장해 관리한다는 이야기를 들은 적이 있어서 조금 더 구글링을 해봤다. Refresh Token의 경우, 유효시간이 길고 Access Token을 재발급 받을 수 있는 수단이 되기 때문에 탈취당했을 때를 대비해 Token 소유자의 Username, 발급 당시 Ip 등 다양한 정보를 함께 DB에 저장한다는 이야기도 있었고 DB에 저장할 거면 결국 Session과 비교했을 때 큰 이득이 없다는 이야기도 있었다.
결국 내가 선택한 방법은 Redis에 Refresh Token과 User정보를 저장하는 방식이었다. Redis는 (기본적으로)싱글 스레드로 동작하는 인메모리 데이터 저장소로 데이터 처리 속도가 빨라 DB에 저장하는 것보다 유리하다고 한다. 또 저장할 때 유효 시간을 정할 수 있기 때문에 나중에 Batch를 따로 돌릴 필요가 없다는 점도 좋았다. 무엇보다 내가 실무에 있을 때 들었던 이야기와 어느정도 유사했고, 구글링 했을 때 Redis에 Token을 저장하는 방식이 꽤 나와 마음이 갔다.
redis는 docker를 사용하기로 했다. 프로젝트 DB 역시 docker container로 띄웠기 때문에 동일한 환경으로 구축하는 게 일관성 있을 것 같았다. container를 띄우는 기본 명령어는 $ docker run --name some-redis -d redis 인데 port 옵션이 없으면 interlliJ에서 연결이 안되어서 port 옵션만 추가해줬다.
docker pull redis
docker run -p 6379:6379 --name redis -d redis
spring boot에서 redis는 JPA 를 사용하는 것과 비슷했다 (데이터 저장소라서 그런가...) 처음엔 redis + jwt 키워드로 구글링을 해서 설정을 했는데 버전이 바뀌면서 설정 방법이 바뀐건지 내 프로젝트에선 제대로 연결되지 않았다. 다행히 공식 문서에 사용 방법이 아주 친절히 적혀있어서 어렵지 않게 적용할 수 있었다. 사랑해요!
2개 모두 추가해줘야한다!
//redis
// https://mvnrepository.com/artifact/org.springframework.data/spring-data-redis
implementation 'org.springframework.data:spring-data-redis:3.1.2'
// https://mvnrepository.com/artifact/io.lettuce/lettuce-core
implementation 'io.lettuce:lettuce-core:6.2.5.RELEASE'
환경별로 설정을 바꿔주기 위해 port와 host 설정은 application-local.yml 파일로 분리했다.
spring:
...
data:
redis:
host: localhost
port: 6379
@Configuration
@EnableRedisRepositories
public class RedisConfiguration {
@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory();
}
@Bean
public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
데이터를 저장하는 방식에는 (1)RedisTemplate를 사용하는 방법과 (2)Repository를 사용하는 방법이 있는데 Token을 관리하는 용도로 간단하게만 사용할 것이기 때문에 Repository를 사용하는 방법을 선택했다.
@RedisHash(value = "refresh")
@Getter
@Setter
@Accessors(chain = true, fluent = true)
@NoArgsConstructor
public class UserRefreshToken {
@Id
private String id;
private String refreshToken;
@TimeToLive(unit = TimeUnit.MINUTES)
private Long expiration;
public UserRefreshToken(UserDetails userDetails, String refreshToken, Long expiration){
this.id = userDetails.getUsername();
this.refreshToken = refreshToken;
this.expiration = expiration;
}
}
public interface UserRefreshTokenRepository extends CrudRepository<UserRefreshToken, String> {
}
@RedisHash의 value는 domain을 구분하는 이름으로 @Id 는 데이터의 식별자로 사용이 된다.


Redis Repository 방식은 Transaction을 제공하지 않는다. 즉, 로직 중간에 exception이 발생해도 처리된 스크립트는 롤백되지 않고 완료가 된다. 데이터 간 일관성이 중요한 데이터라면 Repository 방식이 아니라 RedisTemplate을 이용하는 방식으로 설정해야하며 RedisTemplate을 사용할 때도 Transaction과 관련된 설정은 추가적으로 해줘야한다. 공식에서 제공해주는 가이드는 다음과 같다. https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#tx.spring
본격적으로 Refresh Token을 구현하기 전에 Refresh Token과 관련된 비즈니스 로직을 정리해봤다.
가장 간단한 DTO 추가부터 시작하기로 했다.
@Getter @Setter
@Accessors(fluent = true, chain = true)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class AuthenticationRequest implements Serializable {
private static final long serialVersionUID = -1695490485907383846L;
private String id;
private String password;
private String refreshToken;
}
@Getter @Setter
@Accessors(chain = true, fluent = true)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class AuthenticationResponse implements Serializable {
private static final long serialVersionUID = 9181222863315822243L;
private String refreshToken;
private String accessToken;
}
JwtProvider에 Refresh Token을 검증하고 발급하는 메서드를 추가해줬다. 더불어 Token 발급 및 검증 외에 다른 유틸성 메서드도 추가되어 클래스 이름을 변경해주었다.
@Service
@RequiredArgsConstructor
public class JwtUtil {
private final JwtProperties properties;
private final UserRefreshTokenRepository tokenRepository;
...
public String generateRefreshToken(UserDetails userDetails){
String token = createToken(userDetails, properties.getRefreshTokenSubject(), properties.getRefreshTokenExpiredAt());
UserRefreshToken refreshToken = new UserRefreshToken(userDetails, token, (long)properties.getRefreshTokenExpiredTime());
tokenRepository.save(refreshToken);
return token;
}
public Optional<DecodedJWT> verifyRefreshToken(String token){
DecodedJWT decodedJWT = decodeToken(token);
Optional<UserRefreshToken> refreshToken = tokenRepository.findById(decodedJWT.getClaim("id").asString());
return refreshToken.filter(userRefreshToken -> userRefreshToken.refreshToken().equals(token)).map(userRefreshToken -> decodedJWT);
}
public Boolean isAccessToken(String header){
return decodeToken(header).getSubject().equals(properties.getAccessTokenSubject());
}
}
기존에 UserDTO로 받았던 걸 AuthenticationRequest DTO로 변경했다. userId와 password로 인증하는 경우엔 AuthenticationManager를 통해 사용자 인증을 하고 Refresh Token으로 인증하는 경우엔 userDetailService에서 바로 user를 인증하도록 했다 (AuthorizationFilter와 동일한 로직)
public class UserAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
AuthenticationRequest authenticationRequest = JsonUtil.readValue(request, AuthenticationRequest.class);
if(StringUtil.isEmpty(authenticationRequest.refreshToken())){
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(authenticationRequest.id(), authenticationRequest.password());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
return authenticate;
}else{
Optional<DecodedJWT> verifyToken = jwtUtil.verifyRefreshToken(authenticationRequest.refreshToken());
DecodedJWT decodedJWT = verifyToken.orElseThrow(() -> new InvalidTokenException("INVALID TOKEN"));
UserDetails userDetails = userDetailsService.loadUserByUsername(decodedJWT.getClaim("id").asString());
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(userDetails, null, userDetails.getAuthorities());
return authenticationToken;
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
UserDetails principal = (UserDetails) authResult.getPrincipal();
// access token과 refresh token 모두 재발급
String accessToken = jwtUtil.generateAccessToken(principal);
String refreshToken = jwtUtil.generateRefreshToken(principal);
AuthenticationResponse token = new AuthenticationResponse().accessToken(accessToken).refreshToken(refreshToken);
JsonUtil.writeValue(response.getOutputStream(), token);
}
}
상기 로직에서 Refresh Token으로 인증하는 경우에, Authentication 타입이 아닌 UsernamePasswordAuthenticationToken 타입으로 return 해도 Authentication이 정상적으로 완료되는 이유는 UsernamePasswordAuthenticationToken 타입이 Authentication의 구현체이기 때문이다.


UserAuthorizationFilter에 Access Token인지 검증하는 로직을 추가했다.
public class UserAuthorizationFilter extends OncePerRequestFilter {
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if(StringUtil.isEmpty(header) || !jwtUtil.isStartWithPrefix(header) ||!jwtUtil.isAccessToken(header)){
filterChain.doFilter(request, response);
return;
}
Optional<DecodedJWT> decodedJWT = jwtUtil.verifyAccessToken(header); // 로그아웃된 user인지 확인
if(decodedJWT.isEmpty()){
throw new InvalidTokenException("INVALID ACCESS TOKEN"); // 로그아웃된 user일 경우
}
String id = decodedJWT.get().getClaim("id").asString();
UserDetails details = userDetailsService.loadUserByUsername(id);
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.authenticated(details, null, details.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(token);
filterChain.doFilter(request, response);
}
}
Refresh Token을 다루는 방법에는 여러가지가 있겠지만, 가장 일반적으로, 또 간단하게 적용해볼 수 있는 방법이 어떤 것일지를 고민했다. 우선 작동하는 결과물을 얻었다는 데 의의가 있다고 생각한다. 아직 포스팅하진 않았지만 Redis를 이용해 logout 기능도 구현해볼 수 있었기 때문에 꽤 괜찮은 선택이지 않았나 하는 생각이 든다(아직까지는...)
https://taes-k.github.io/2020/07/23/redis-essential/
https://hub.docker.com/_/redis
https://docs.spring.io/spring-data/redis/docs/current/reference/html/#redis.repositories
https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#redis:connectors:lettuce