[스프링/Spring] JWT 인증 구현하기 (3) - Refresh Token 구현과 보안 강화

dongbrown·2024년 11월 3일

Spring

목록 보기
13/23

이전 포스트에서는 JWT 인증 필터 구현과 적용 방법에 대해 알아보았습니다. 이번 포스트에서는 Refresh Token을 활용한 인증 시스템 구현과 보안을 강화하는 방법에 대해 알아보겠습니다.

1. Refresh Token이란?

Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위한 토큰입니다. Access Token의 유효기간을 짧게 설정하고, Refresh Token의 유효기간을 길게 설정하여 보안성과 사용자 편의성을 모두 확보할 수 있습니다.

2. 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);
    }
}

3. Token 저장소 구현

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);
    }
}

4. Token 갱신 로직 구현

@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);
    }
}

5. 보안 강화를 위한 추가 구현

5.1 토큰 블랙리스트 관리

@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)
        );
    }
}

5.2 보안 헤더 설정

@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);
    }
}

6. 토큰 갱신 API 구현

@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();
        }
    }
}

7. 보안 체크리스트

  1. 토큰 저장

    • Access Token은 메모리에 저장 (예: JavaScript 변수)
    • Refresh Token은 HttpOnly 쿠키로 저장
    • 민감한 정보는 토큰 payload에 포함하지 않음
  2. 토큰 갱신

    • Access Token 만료 전에 자동으로 갱신
    • Refresh Token 사용 시 이전 토큰 무효화
    • 정기적인 Refresh Token 순환
  3. 보안 설정

    • HTTPS 필수 사용
    • CORS 설정 적절히 구성
    • XSS, CSRF 방어
    • Rate Limiting 구현

0개의 댓글