JWT Stateless + 권한 변경 대처
클라이언트 ←--JWT 토큰--→ 서버
(서버는 상태 저장 안함)
JWT 토큰 안에 사용자 정보와 권한이 모두 들어있어서, 서버는 토큰만 검증하면 됨 (별도 세션 저장소 불필요)
// JWT 토큰 내용 예시
{
"userId": 123,
"username": "john",
"roles": ["USER", "ADMIN"], // 권한 정보
"exp": 1640995200 // 만료시간
}
문제 상황: 권한 변경이 즉시 반영 안됨
시나리오:
- 사용자가 JWT 토큰을 받음 (권한: ADMIN)
- 관리자가 해당 사용자의 권한을 USER로 변경
- 하지만 JWT 토큰은 여전히 ADMIN 권한을 가지고 있음!
- 토큰이 만료될 때까지 계속 ADMIN으로 동작
// 문제 상황
@RestController
public class AdminController {
@GetMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')") // JWT에서 권한 확인
public List<User> getUsers(HttpServletRequest request) {
// JWT 토큰에 ADMIN이 있으면 통과
// 실제 DB에서는 권한이 변경되었지만 반영 안됨!
return userService.getAllUsers();
}
}
해결 방법
1. 토큰 만료시간을 짧게 설정 + 자주 갱신
// JWT 생성 시 짧은 만료시간 설정
public String generateToken(User user) {
return Jwts.builder()
.setSubject(user.getUsername())
.claim("roles", user.getRoles())
.setExpiration(new Date(System.currentTimeMillis() + 300000)) // 5분
.signWith(secretKey)
.compact();
}
// 리프레시 토큰으로 자주 갱신
@PostMapping("/refresh")
public ResponseEntity<String> refreshToken(@RequestBody RefreshRequest request) {
// 최신 권한 정보로 새 토큰 발급
User user = userService.findById(request.getUserId());
String newToken = jwtService.generateToken(user); // 최신 권한 반영
return ResponseEntity.ok(newToken);
}
2. 블랙리스트 방식 (Redis 활용)
@Service
public class TokenBlacklistService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 권한 변경 시 기존 토큰을 블랙리스트에 추가
public void blacklistToken(String token) {
String jti = extractJti(token); // 토큰 고유 ID
long expiration = extractExpiration(token);
// Redis에 블랙리스트 저장 (만료시간까지)
redisTemplate.opsForValue().set(
"blacklist:" + jti,
"true",
Duration.ofMillis(expiration - System.currentTimeMillis())
);
}
// 토큰 검증 시 블랙리스트 확인
public boolean isBlacklisted(String token) {
String jti = extractJti(token);
return redisTemplate.hasKey("blacklist:" + jti);
}
}
// JWT 필터에서 블랙리스트 확인
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
String token = extractToken(request);
if (token != null && jwtService.isValidToken(token)) {
// 블랙리스트 확인 추가!
if (tokenBlacklistService.isBlacklisted(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
// 정상 처리
Authentication auth = getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
3. 중요한 작업 시 실시간 권한 확인
@RestController
public class CriticalController {
@DeleteMapping("/admin/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> deleteUser(@PathVariable Long id,
HttpServletRequest request) {
String token = extractToken(request);
Long userId = extractUserId(token);
// 중요한 작업이므로 DB에서 실시간 권한 재확인!
User currentUser = userService.findById(userId);
if (!currentUser.hasRole("ADMIN")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("권한이 변경되었습니다. 다시 로그인해주세요.");
}
// 권한 확인 후 실행
userService.deleteUser(id);
return ResponseEntity.ok("삭제 완료");
}
}
4. 이벤트 기반 토큰 무효화
// 권한 변경 시 이벤트 발행
@Service
public class UserService {
@EventListener
public void updateUserRole(Long userId, String newRole) {
User user = userRepository.findById(userId);
user.setRole(newRole);
userRepository.save(user);
// 이벤트 발행
eventPublisher.publishEvent(new UserRoleChangedEvent(userId));
}
}
// 이벤트 처리
@EventListener
public void onUserRoleChanged(UserRoleChangedEvent event) {
// 해당 사용자의 모든 활성 토큰 무효화
List<String> userTokens = findActiveTokensByUserId(event.getUserId());
userTokens.forEach(token -> tokenBlacklistService.blacklistToken(token));
// 클라이언트에게 재로그인 알림 (WebSocket 등)
notificationService.notifyRelogin(event.getUserId());
}