이전 포스트에서는 JWT 인증 필터 구현과 적용 방법에 대해 알아보았습니다. 이번 포스트에서는 Refresh Token을 활용한 인증 시스템 구현과 보안을 강화하는 방법에 대해 알아보겠습니다.
Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위한 토큰입니다. Access Token의 유효기간을 짧게 설정하고, Refresh Token의 유효기간을 길게 설정하여 보안성과 사용자 편의성을 모두 확보할 수 있습니다.
public class JwtTokenProvider {
@Value("${jwt.token-validity-in-milliseconds}")
private long tokenValidityInMilliseconds; // Access Token 유효기간
// Access Token 생성
public String createAccessToken(String id, Long memberNo, String name, MemberRole role) {
return createToken(id, memberNo, name, role, tokenValidityInMilliseconds);
}
// Refresh Token 생성 (유효기간 2배)
public String createRefreshToken(String id, Long memberNo, String name, MemberRole role) {
return createToken(id, memberNo, name, role, tokenValidityInMilliseconds * 2);
}
// 토큰 만료 시간 확인
public Long getExpiration(String token) {
Date expiration = parseClaims(token).getExpiration();
long now = new Date().getTime();
return (expiration.getTime() - now);
}
}
Refresh Token을 안전하게 관리하기 위한 저장소를 구현합니다.
@Repository
@RequiredArgsConstructor
public class TokenRepository {
private final JdbcTemplate jdbcTemplate;
public void saveRefreshToken(Long memberNo, String refreshToken) {
String sql = "INSERT INTO refresh_tokens (member_no, token, created_at) " +
"VALUES (?, ?, NOW()) " +
"ON DUPLICATE KEY UPDATE token = ?, created_at = NOW()";
jdbcTemplate.update(sql, memberNo, refreshToken, refreshToken);
}
public Optional<String> findRefreshTokenByMemberNo(Long memberNo) {
String sql = "SELECT token FROM refresh_tokens WHERE member_no = ?";
try {
String token = jdbcTemplate.queryForObject(sql, String.class, memberNo);
return Optional.ofNullable(token);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
public void deleteRefreshToken(Long memberNo) {
String sql = "DELETE FROM refresh_tokens WHERE member_no = ?";
jdbcTemplate.update(sql, memberNo);
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class TokenService {
private final JwtTokenProvider tokenProvider;
private final TokenRepository tokenRepository;
public TokenResponse refreshAccessToken(String refreshToken) {
// Refresh Token 검증
if (!tokenProvider.validateToken(refreshToken)) {
throw new InvalidTokenException("Invalid refresh token");
}
// Refresh Token에서 사용자 정보 추출
String userId = tokenProvider.getIdFromToken(refreshToken);
Long memberNo = tokenProvider.getMemberNoFromToken(refreshToken);
String name = tokenProvider.getNameFromToken(refreshToken);
MemberRole role = tokenProvider.getRoleFromToken(refreshToken);
// DB에 저장된 Refresh Token과 비교
Optional<String> savedToken = tokenRepository.findRefreshTokenByMemberNo(memberNo);
if (savedToken.isEmpty() || !savedToken.get().equals(refreshToken)) {
throw new InvalidTokenException("Refresh token not found or not matched");
}
// 새로운 토큰 발급
String newAccessToken = tokenProvider.createAccessToken(userId, memberNo, name, role);
// Refresh Token 만료 시간이 일정 기간 이하로 남은 경우 새로운 Refresh Token 발급
long remainingTime = tokenProvider.getExpiration(refreshToken);
String newRefreshToken = refreshToken;
if (remainingTime < tokenProvider.getRefreshTokenRenewalTime()) {
newRefreshToken = tokenProvider.createRefreshToken(userId, memberNo, name, role);
tokenRepository.saveRefreshToken(memberNo, newRefreshToken);
}
return new TokenResponse(newAccessToken, newRefreshToken);
}
}
@Component
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
public void blacklistToken(String token, long expirationTime) {
redisTemplate.opsForValue().set(
"blacklist:" + token,
"true",
expirationTime,
TimeUnit.MILLISECONDS
);
}
public boolean isTokenBlacklisted(String token) {
return Boolean.TRUE.toString().equals(
redisTemplate.opsForValue().get("blacklist:" + token)
);
}
}
@Component
public class SecurityHeaderFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 보안 헤더 설정
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("X-Frame-Options", "DENY");
response.setHeader("X-XSS-Protection", "1; mode=block");
response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
filterChain.doFilter(request, response);
}
}
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {
private final TokenService tokenService;
@PostMapping("/token/refresh")
public ResponseEntity<TokenResponse> refreshToken(
@RequestHeader("Refresh-Token") String refreshToken) {
try {
TokenResponse newTokens = tokenService.refreshAccessToken(refreshToken);
return ResponseEntity.ok(newTokens);
} catch (InvalidTokenException e) {
log.error("Token refresh failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
토큰 저장
토큰 갱신
보안 설정