우당탕탕 개발일지
[Spring] Redis를 활용한 토큰 관리 최적화 본문
Redis는 데이터를 주로 메모리에 저장하기에 매우 빠른 데이터 액세스 속도를 제공한다. RefreshToken의 경우, 자주 액세스 되는 데이터이기 때문에 데이터를 캐시로 사용하는 데 매우 효과적이었다. 가장 큰 장점은 Time to Live (TTL) 설정을 통하여 만료 시간을 설정하는 기능을 가지고 있다. Redis 사용 순서는 다음과 같았다.
1. 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
2. application.yml 작성
spring:
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD} # 선택사항
3. RefreshToken 엔티티 생성 (문제 발생)
@Getter
@RedisHash(value = "refreshToken", timeToLive = 86400) // 24시간 (60 * 60 * 24)
public class RefreshToken {
@Id
private String userName;
@Indexed
private String refreshToken;
private LocalDateTime createdAt;
public RefreshToken(String userName, String refreshToken) {
this.userName = userName;
this.refreshToken = refreshToken;
this.createdAt = LocalDateTime.now();
}
}
설정된 24시간이 지나면 해당 키와 연관된 데이터는 자동으로 삭제되어야 했다. 문제점은 @Indexed 어노테이션이었다.
@Index
Spring Data Redis 모듈의 주요 어노테이션 중 하나로, @Id가 붙여진 객체 외에도 @Indexed가 붙여진 객체로 값을 조회할 수 있다.
새로운 프로젝트를 시작하게 되면서 Redis를 확인했는데 만료되면 사라져야 할 RefreshToken 객체들이 한달이 지나서도 남아있었다. @Indexed로 생성된 보조 인덱스는 TTL이 만료되더라도 해당 데이터가 자동으로 삭제가 되지 않는다고 한다. 원인은 @Indexed 기능이 Redis의 자체적 기능이 아닌, Spring Data Redis에서 추가적으로 제공하는 기능이기 때문이다. Redis의 TTL이 만료된 데이터를 자동으로 삭제하는 기능이 Spring Data Redis에는 포함되어 있지 않다.
해결방안
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
@EnableRedisRepositories 어노테이션을 추가한 RedisConfig를 작성하였다. Redis의 Key Space Notifications 기능을 활용하면, TTL이 만료되는 시점에 이벤트를 감지하고 보조 인덱스를 삭제할 수 있다. 다음은 RefreshToken 저장 과정이다.
4. RedisConfig 작성
@Configuration
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfig {
}
5. RefreshTokenRepository 작성
@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
RefreshToken findByRefreshToken(String refreshToken);
}
Redis 사용 방식에는RedisTemplate와 RedisRepository가 있다.
RedisTemplate
Redis를 세밀하게 제어할 수 있고, 다양한 데이터 구조(해시, 리스트, 세트 등)를 다룰 수 있다.
RedisRepository
간단한 CRUD 작업을 쉽게 수행할 수 있고, JPA 사용 방식과 비슷하다.
RedisRepository 방식은 Transaction을 제공하지 않는다. 데이터 간 일관성이 중요한 데이터라면 RedisTemplate 방식을 이용해야 한다. RefreshToken 은 중요성이 낮다고 판단되어 RedisRepository를 사용하였다.
6. RefreshToken 저장
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationManager authenticationManager;
@Transactional(readOnly = true)
public AuthResponse getAuthToken(String userName, String password){
log.info("Attempting authentication for user: {}", userName);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userName, password);
Authentication authentication = authenticationManager.authenticate(authenticationToken);
String accessToken = jwtTokenProvider.createAccessToken(authentication);
String refreshToken = jwtTokenProvider.createRefreshToken(authentication);
// RefreshToken 저장
RefreshToken redis = new RefreshToken(userName, refreshToken);
refreshTokenRepository.save(redis);
log.info("RefreshToken saved ID: {}", userName);
return new AuthResponse(accessToken, refreshToken);
}
}
두 번째의 문제점은 로그아웃 기능이었다. 단순히 Redis에서 해당 데이터만 제거하면 될 것이라 생각했었지만, 로그아웃 이후 토큰의 유효기간이 남아있다면 해당 토큰으로 정보를 탈취하는게 가능하다. 이를 방지하기 위해 accessToken의 남은 유효 기간만큼 redis에 유효기간을 설정해 토큰을 블랙리스트로 등록해야 한다. 하지만, AccessToken이 블랙리스트에 존재하는지 매번 DB를 조회하게 되면 JWT 장점을 살리지 못한다. 토큰 기반 인증 방식을 사용하는 이유는 state-less 라는 특징을 가지고 있기 때문이다.
State-less
서버-클라이언트 구조에서 서버가 클라이언트의 상태를 가지고 있지 않는것을 말한다.
- 장점
- 서버의 확장성이 높아 대량의 트래픽이 발생해도 대처할 수 있음
- DB에 의존하지 않아도 인증할 수 있음
- 단점
- 세션 방식보다 많은 양의 데이터가 반복적으로 전송되기 때문에 네트워크 성능 저하
- 데이터 노출로 인한 보안적인 문제
따라서 로그아웃 시 블랙리스트에 RefreshToken을 저장하는 방식을 사용하였다.
1. BlackList 엔티티 생성
@Getter
@RedisHash(value = "blackList")
public class BlackList {
@Id
private String id; //accessToken
private String refreshToken;
@TimeToLive(unit = TimeUnit.MINUTES)
private Long expiration;
public BlackList(String accessToken, String refreshToken, Long expiration) {
this.id = accessToken;
this.refreshToken = refreshToken;
this.expiration = expiration;
}
}
처음에 ID를 userName으로 설정했었다가 accessToken으로 변경하였다. Redis에서 @Id로 지정된 필드가 중복될 경우, 해당 키에 저장된 기존 데이터가 덮어쓰여진다.
2. BlackListRepository 작성
public interface BlackListRepository extends CrudRepository<BlackList, String> {}
3. 로그아웃 기능
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
. . .
@Transactional(readOnly = true)
public void deleteAuthToken(String accessToken, String refreshToken){
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
refreshTokenRepository.deleteById(authentication.getName());
// 블랙리스트 저장
Long expiraion = jwtTokenProvider.getExpiration(refreshToken);
BlackList blackList = new BlackList(accessToken, refreshToken, expiraion);
blackListRepository.save(blackList);
}
}
가장 어려웠던 부분은 JwtTokenFilter 필터에서 토큰을 걸러내는 부분이었다. 헤더에서 Bearer이 포함된 경우, AccessToken 값을 추출하여 유효성 검사를 진행하는 로직 까지는 무난히 진행되었지만, RefreshToken 도 검사해야 한다 생각하여 해맸었다. 결론은 JwtTokenFilter 필터는 AccessToken을 검증하는 역할만 수행한다. RefreshToken을 사용하는 API에서 RefreshToken 유효성 검사를 진행한다.
4. JwtTokenFilter
@Slf4j
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
public JwtTokenFilter(JwtTokenProvider jwtTokenProvider, AuthService authService) {
this.jwtTokenProvider = jwtTokenProvider;
this.authService = authService;
}
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request); // JWT 토큰
String requestURI = request.getRequestURI(); // 요청 URI
String path = request.getRequestURI();
if (path.equals("/api/signup") || path.equals("/api/signin")) {
filterChain.doFilter(request, response); // 로그인, 회원가입 요청은 건너뛰기
return;
}
if(StringUtils.hasText(token) && jwtTokenProvider.validateCredential(token) ){
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug(String.format("Security Context에 %s 인증 정보를 저장했습니다. URI : %s", authentication.getName(), requestURI));
} else {
throw new GeneralException(ErrorCode.EXPIRED_JWT_TOKEN);
}
//다음 필터로 넘기기
filterChain.doFilter(request, response);
}
}
5. AccessToken 갱신 기능
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
. . .
@Transactional(readOnly = true)
public String getRefresh(String accessToken, String refreshToken){
// redis 엔티티 조회
RefreshToken redis = refreshTokenRepository.findByRefreshToken(refreshToken);
if (redis == null) {
throw new GeneralException(ErrorCode.INVALID_REFRESH_TOKEN);
}
// refreshToken 유효성 검사
if (!jwtTokenProvider.validateCredential(refreshToken)) {
throw new GeneralException(ErrorCode.INVALID_AUTH_TOKEN);
}
// 블랙리스트 확인
BlackList blackList = blackListRepository.findById(refreshToken).orElse(null);
if (blackList != null) {
throw new GeneralException(ErrorCode.BLACKLISTED_TOKEN); // 블랙리스트에 있는 토큰이면 예외 처리
}
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
String newAccessToken = jwtTokenProvider.createAccessToken(authentication);
return newAccessToken;
}
}
Rdis가 잘 작동하는지 확인해보도록 하겠다. JWT 설계 과정은 생략하였으며, 이전 글을 참고하면 된다.
JWT 로그인 / 로그아웃 과정은 다음과 같다.
1. 아이디, 비밀번호로 로그인
2. ACT, RCT 발급 및 Redis에 RCT 저장
3. 클라이언트는 ACT를 사용해 요청
4. ACT가 만료되었을 경우, ACT, RCT를 전송하여 ACT 재발급
5. 클라이언트가 로그아웃 요청할 경우, RCT를 Redis에서 삭제 후 블랙리스트에 RCT 등록
6. 로그아웃 이후, 블랙리스트에 등록된 RCT로 ACT 갱신 요청 시 401에러 반환
Controller
@Slf4j
@RestController
@RequestMapping("/api")
@Tag(name = "Auth-Controller", description = "Auth API")
public class AuthController {
private final UserService userService;
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
@Autowired
public AuthController(UserService userService, JwtTokenProvider jwtTokenProvider, AuthService authService) {
this.userService = userService;
this.jwtTokenProvider = jwtTokenProvider;
this.authService = authService;
}
@Operation(
summary = "회원가입 API",
)
@PostMapping("/signup")
public CommonResponse signup(@RequestBody @Valid RegisterRequest request){
log.info("[회원가입 요청] ID: {}", request.getUserName());
userService.register(request);
return CommonResponse.res(true, SuccessCode.SUCCESS_SIGNUP);
}
@Operation(
summary = "로그인 API"
)
@PostMapping("/signin")
public ApiSuccessResponse<AuthResponse> signin(@RequestBody @Valid AuthRequest request){
log.info("[로그인 요청] ID: {}", request.getUserName());
return ApiSuccessResponse.res(SuccessCode.SUCCESS_SIGNIN, userService.login(request));
}
@Operation(
summary = "AccessToken 갱신 API"
)
@PostMapping("/refresh")
public ApiSuccessResponse<RefreshResponse> refresh(HttpServletRequest request,
@RequestBody RefreshRequest refreshRequest){
log.info("[AccessToken 갱신 요청]: ");
String accessToken = jwtTokenProvider.resolveToken(request);
return ApiSuccessResponse.res(SuccessCode.SUCCESS_TOKEN_REFRESH, authService.getRefresh(accessToken, refreshRequest.getRefreshToken()));
}
@Operation(
summary = "로그아웃 API"
)
@GetMapping("/logout")
public CommonResponse logout(HttpServletRequest request,
@RequestBody RefreshRequest refreshRequest){
log.info("[로그아웃 요청]: ");
String accessToken = jwtTokenProvider.resolveToken(request);
userService.logout(accessToken, refreshRequest.getRefreshToken());
return CommonResponse.res(true, SuccessCode.SUCCESS_LOGOUT);
}
}
Redis 설치 파일은 Program Files 폴더 내에 있다. redis-cli.exe를 실행시키면 접속이 가능하다.
기존에 등록해두었던 유저 정보로 로그인을 진행하였다.
redis-cli에서 KEYS * 명령어로 RefreshToken이 잘 저장되었는지 확인이 가능하다. 임시로 TTL을 1분 설정해두었더니, 사라지는것을 금방 확인할 수 있었다. 참고로 FLUSHDB 명령어는 모든 데이터를 제거하는 명령어이다.
로그아웃 진행 과정에서 403 에러가 발생하였다. 접속 log가 아예 없는 것으로 보아 JwtTokenFilter에서 문제가 발생한 듯 하였다.
확인을 위해 application.properties에 시큐리티 로그를 활성화 해주었다.
logging.level.org.springframework.security=DEBUG
2024-09-20T20:58:40.344+09:00 DEBUG 6068 --- [budget] [nio-9091-exec-4] o.s.security.web.FilterChainProxy : Securing POST /api/logout
2024-09-20T20:58:40.345+09:00 DEBUG 6068 --- [budget] [nio-9091-exec-4] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2024-09-20T20:58:40.347+09:00 DEBUG 6068 --- [budget] [nio-9091-exec-4] o.s.s.w.a.Http403ForbiddenEntryPoint : Pre-authenticated entry point called. Rejecting access
2024-09-20T20:58:40.349+09:00 DEBUG 6068 --- [budget] [nio-9091-exec-4] o.s.security.web.FilterChainProxy : Securing POST /error
2024-09-20T20:58:40.349+09:00 DEBUG 6068 --- [budget] [nio-9091-exec-4] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2024-09-20T20:58:40.350+09:00 DEBUG 6068 --- [budget] [nio-9091-exec-4] o.s.s.w.a.Http403ForbiddenEntryPoint : Pre-authenticated entry point called. Rejecting access
확인한 결과, 인증 정보가 제대로 설정되지 않아서 SecurityContext에 익명 사용자가 설정되고 있었다. JWT 토큰을 생성 시 권한 설정이 따로 필요없다 판단해서 값을 지정안했더니 GrantedAuthority가 빈 값으로 처리된 것이다.
JwtTokenProvider
@Slf4j
@Component
public class JwtTokenProvider {
. . .
private final String roles = "USER";
@Transactional(readOnly = true)
public String createAccessToken(Authentication authentication){
Date now = new Date();
Date expireDate = new Date(now.getTime() + accessExpirationTime);
String roles = this.roles; // 기본 권한 사용
return Jwts.builder()
.setSubject(authentication.getName())
.claim("roles", roles)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(secretKey, SignatureAlgorithm.HS512) // HMAC + SHA512
.compact();
}
@Transactional(readOnly = true)
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getPayload();
String username = claims.getSubject();
String roles = claims.get("roles", String.class);
if (roles == null || roles.isEmpty()) {
roles = this.roles; // 기본 권한 설정
}
// 권한 정보를 GrantedAuthority 리스트로 변환
List<GrantedAuthority> authorities = Arrays.stream(roles.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 사용자 이름과 권한을 포함한 인증 객체 생성
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
}
권한을 부여했음에도 403 에러가 발생하였다. JWT 필터가 Spring Security의 기본 인증 필터보다 먼저 실행이 되지 않아 발생한 에러였다. UsernamePasswordAuthenticationFilter 전에 JwtTokenFilter를 적용해야 한다.
SecurityConfig
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtTokenProvider jwtTokenProvider;
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(cors -> cors.disable()) // CORS 설정
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exception -> {
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint);
exception.accessDeniedHandler(jwtAccessDeniedHandler);
})
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/signup", "/api/signin").permitAll()
.requestMatchers("/api-docs/**", "/swagger-ui/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(new JwtTokenFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
정상적으로 로그아웃이 처리되었고, 블랙리스트에도 등록이 되었다.
AccessToken 갱신도 성공적으로 처리되었다.
'Spring' 카테고리의 다른 글
[Spring] 스케줄링+ Discord webhook (1) | 2024.10.06 |
---|---|
[Spring] JUnit + Mockito 테스트 코드 작성 (3) | 2024.09.27 |
[Spring] API 공통 응답 (0) | 2024.09.20 |
[Spring] Swagger - API 명세서 작성 (1) | 2024.09.15 |
[Spring] JWT 서비스 (0) | 2024.07.11 |