API 서명 기반 인증 구현 방식

API 호출 시 서버와 클라이언트가 동일한 해싱 로직으로 서명값을 생성하고 비교함으로써 요청의 무결성과 유효성을 검증하는 방식이다. 타임스탬프를 포함하여 재생 공격을 방지하며, 대칭키 기반의 HMAC 알고리즘을 활용한다.

서명 생성 및 검증 유틸리티

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class ApiSignatureGenerator {

    private static final String HMAC_ALGORITHM = "HmacSHA256";
    private static final int VALIDITY_WINDOW_SECONDS = 3600;

    private final Base64.Encoder base64Encoder = Base64.getEncoder();

    public String generateSignature(String appId, String secret, long epochMillis) {
        try {
            String payload = appId + "|" + epochMillis;
            SecretKeySpec keySpec = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), 
                HMAC_ALGORITHM
            );
            Mac mac = Mac.getInstance(HMAC_ALGORITHM);
            mac.init(keySpec);
            byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
            return base64Encoder.encodeToString(signatureBytes);
        } catch (Exception ex) {
            throw new SignatureCreationException("서명 생성 중 오류 발생", ex);
        }
    }

    public boolean validateSignature(
            String receivedSignature,
            String appId, 
            String secret, 
            long clientTimestamp,
            long serverTimestamp) {
        
        long timeGap = Math.abs(serverTimestamp - clientTimestamp);
        if (timeGap > VALIDITY_WINDOW_SECONDS * 1000L) {
            return false;
        }

        String expectedSignature = generateSignature(appId, secret, clientTimestamp);
        return constantTimeEquals(receivedSignature, expectedSignature);
    }

    private boolean constantTimeEquals(String a, String b) {
        byte[] aBytes = a.getBytes(StandardCharsets.UTF_8);
        byte[] bBytes = b.getBytes(StandardCharsets.UTF_8);
        
        if (aBytes.length != bBytes.length) {
            return false;
        }
        
        int result = 0;
        for (int i = 0; i < aBytes.length; i++) {
            result |= aBytes[i] ^ bBytes[i];
        }
        return result == 0;
    }

    public static class SignatureCreationException extends RuntimeException {
        public SignatureCreationException(String message, Throwable cause) {
            super(message, cause);
        }
    }
}

요청 검증 인터셉터

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class SignatureValidationFilter implements HandlerInterceptor {

    private final ApiSignatureGenerator signatureGenerator;
    private final CredentialRepository credentialRepository;

    public SignatureValidationFilter(
            ApiSignatureGenerator signatureGenerator,
            CredentialRepository credentialRepository) {
        this.signatureGenerator = signatureGenerator;
        this.credentialRepository = credentialRepository;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                           Object handler) {
        
        String signature = extractHeader(request, "X-Request-Signature");
        String appId = extractHeader(request, "X-App-Identifier");
        String timestampHeader = extractHeader(request, "X-Request-Time");
        
        long clientTime = Long.parseLong(timestampHeader);
        long currentTime = System.currentTimeMillis();
        
        AppCredential credentials = credentialRepository.findByAppId(appId);
        
        boolean isValid = signatureGenerator.validateSignature(
            signature,
            appId,
            credentials.getApiSecret(),
            clientTime,
            currentTime
        );
        
        if (!isValid) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        
        return true;
    }

    private String extractHeader(HttpServletRequest request, String headerName) {
        String value = request.getHeader(headerName);
        if (value == null || value.trim().isEmpty()) {
            throw new MissingHeaderException("필수 헤더 누락: " + headerName);
        }
        return value;
    }

    public static class MissingHeaderException extends RuntimeException {
        public MissingHeaderException(String message) {
            super(message);
        }
    }
}

핵심 설계 고려사항

  • 알고리즘 강화: HmacSHA1 대신 HmacSHA256을 사용하여 충돌 저항성 향상
  • 타이밍 공격 방지: constantTimeEquals 메서드로 서명 비교 시간 일정화
  • 유효 시간 축소: 2시간에서 1시간으로 윈도우 축소로 리플레이 공격 위험 감소
  • 헤더 명세화: 커스텀 접두사를 가진 명확한 헤더 이름 사용

태그: HMAC API 보안 Spring Interceptor 요청 서명 인증 필터

8월 2일 05:44에 게시됨