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시간으로 윈도우 축소로 리플레이 공격 위험 감소
- 헤더 명세화: 커스텀 접두사를 가진 명확한 헤더 이름 사용