generated from gc/template-java-maven
feat(gateway): API 인증 쿼리파라미터 변경 및 일일 요청량 제한
- API Key 인증: X-API-KEY 헤더 → authKey 쿼리 파라미터 변경 - 일일 요청량 제한 기능 (daily_request_limit, HTTP 429) - 인증/권한 거부 로그 상태 DENIED 분리 (기존 FAIL에서 분리) - 에러 응답에 code 필드 추가 (ApiResponse, GatewayController) - API Key 생성/검토 시 dailyRequestLimit 설정 지원 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
dd1ac022d2
커밋
5ce1ca233d
@ -9,6 +9,7 @@ public record ApiKeyRequestReviewDto(
|
||||
String reviewComment,
|
||||
List<Long> adjustedApiIds,
|
||||
String adjustedFromDate,
|
||||
String adjustedToDate
|
||||
String adjustedToDate,
|
||||
Long adjustedDailyRequestLimit
|
||||
) {
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.gcsc.connection.apikey.dto;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CreateApiKeyRequest(
|
||||
@NotBlank String keyName
|
||||
@NotBlank String keyName,
|
||||
Long dailyRequestLimit
|
||||
) {
|
||||
}
|
||||
|
||||
@ -60,10 +60,13 @@ public class SnpApiKey extends BaseEntity {
|
||||
@Column(name = "last_used_at")
|
||||
private LocalDateTime lastUsedAt;
|
||||
|
||||
@Column(name = "daily_request_limit")
|
||||
private Long dailyRequestLimit;
|
||||
|
||||
@Builder
|
||||
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
|
||||
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
|
||||
LocalDateTime expiresAt) {
|
||||
LocalDateTime expiresAt, Long dailyRequestLimit) {
|
||||
this.user = user;
|
||||
this.apiKey = apiKey;
|
||||
this.apiKeyPrefix = apiKeyPrefix;
|
||||
@ -72,6 +75,7 @@ public class SnpApiKey extends BaseEntity {
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedAt = approvedAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.dailyRequestLimit = dailyRequestLimit;
|
||||
}
|
||||
|
||||
public void revoke() {
|
||||
|
||||
@ -150,6 +150,11 @@ public class ApiKeyRequestService {
|
||||
String prefix = rawKey.substring(0, PREFIX_LENGTH);
|
||||
String encryptedKey = aesEncryptor.encrypt(rawKey);
|
||||
|
||||
// 일일 요청 제한: 검토자가 조정한 값 > 신청자 입력값 > null(무제한)
|
||||
Long dailyLimit = dto.adjustedDailyRequestLimit() != null
|
||||
? dto.adjustedDailyRequestLimit()
|
||||
: request.getDailyRequestEstimate();
|
||||
|
||||
SnpApiKey apiKey = SnpApiKey.builder()
|
||||
.user(request.getUser())
|
||||
.apiKey(encryptedKey)
|
||||
@ -159,6 +164,7 @@ public class ApiKeyRequestService {
|
||||
.approvedBy(reviewer)
|
||||
.approvedAt(LocalDateTime.now())
|
||||
.expiresAt(request.getUsageToDate())
|
||||
.dailyRequestLimit(dailyLimit)
|
||||
.build();
|
||||
|
||||
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);
|
||||
|
||||
@ -75,6 +75,7 @@ public class ApiKeyService {
|
||||
.apiKeyPrefix(prefix)
|
||||
.keyName(request.keyName())
|
||||
.status(ApiKeyStatus.ACTIVE)
|
||||
.dailyRequestLimit(request.dailyRequestLimit())
|
||||
.build();
|
||||
|
||||
SnpApiKey saved = snpApiKeyRepository.save(apiKey);
|
||||
|
||||
@ -10,6 +10,7 @@ import lombok.Getter;
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private final boolean success;
|
||||
private final String code;
|
||||
private final String message;
|
||||
private final T data;
|
||||
|
||||
@ -34,4 +35,12 @@ public class ApiResponse<T> {
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String code, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.success(false)
|
||||
.code(code)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ public enum ErrorCode {
|
||||
GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"),
|
||||
GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
|
||||
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
|
||||
GATEWAY_DAILY_LIMIT_EXCEEDED(429, "GW007", "일일 최대 호출 건수 제한으로 사용할 수 없습니다"),
|
||||
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
|
||||
|
||||
private final int status;
|
||||
|
||||
@ -26,7 +26,7 @@ public class GlobalExceptionHandler {
|
||||
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
|
||||
return ResponseEntity
|
||||
.status(errorCode.getStatus())
|
||||
.body(ApiResponse.error(errorCode.getMessage()));
|
||||
.body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -37,7 +37,7 @@ public class GatewayController {
|
||||
String remainingPath = extractRemainingPath(serviceCode, request);
|
||||
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
|
||||
} catch (BusinessException e) {
|
||||
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage());
|
||||
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getCode(), e.getErrorCode().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,8 +60,8 @@ public class GatewayController {
|
||||
/**
|
||||
* Gateway 소비자용 JSON 에러 응답 생성
|
||||
*/
|
||||
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) {
|
||||
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}";
|
||||
private ResponseEntity<byte[]> buildErrorResponse(int status, String code, String message) {
|
||||
String json = "{\"success\":false,\"code\":\"" + escapeJson(code) + "\",\"message\":\"" + escapeJson(message) + "\"}";
|
||||
return ResponseEntity.status(status)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(json.getBytes());
|
||||
|
||||
@ -8,6 +8,7 @@ import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.common.util.AesEncryptor;
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository;
|
||||
import com.gcsc.connection.monitoring.service.RequestLogService;
|
||||
import com.gcsc.connection.service.entity.SnpService;
|
||||
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||
@ -23,6 +24,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
@ -34,8 +36,9 @@ import java.util.Set;
|
||||
public class GatewayService {
|
||||
|
||||
private static final int API_KEY_PREFIX_LENGTH = 8;
|
||||
private static final String AUTH_KEY_PARAM = "authKey";
|
||||
private static final Set<String> EXCLUDED_HEADERS = Set.of(
|
||||
"host", "x-api-key", "connection", "content-length"
|
||||
"host", "connection", "content-length"
|
||||
);
|
||||
|
||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||
@ -45,6 +48,7 @@ public class GatewayService {
|
||||
private final AesEncryptor aesEncryptor;
|
||||
private final WebClient webClient;
|
||||
private final RequestLogService requestLogService;
|
||||
private final SnpApiRequestLogRepository snpApiRequestLogRepository;
|
||||
|
||||
/**
|
||||
* API Gateway 프록시 요청 처리
|
||||
@ -70,8 +74,8 @@ public class GatewayService {
|
||||
// 2. 대상 URL 조합 (실패 로그에도 사용)
|
||||
targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request);
|
||||
|
||||
// 3. API Key 추출
|
||||
String rawKey = request.getHeader("X-API-KEY");
|
||||
// 3. API Key 추출 (쿼리 파라미터 authKey)
|
||||
String rawKey = request.getParameter(AUTH_KEY_PARAM);
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING);
|
||||
}
|
||||
@ -82,7 +86,10 @@ public class GatewayService {
|
||||
// 5. Key 상태/만료 검증
|
||||
validateApiKey(apiKey);
|
||||
|
||||
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
|
||||
// 6. 일일 요청량 제한 검증
|
||||
validateDailyLimit(apiKey);
|
||||
|
||||
// 7. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
|
||||
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
|
||||
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
|
||||
|
||||
@ -109,7 +116,8 @@ public class GatewayService {
|
||||
|
||||
} catch (BusinessException e) {
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL",
|
||||
String logStatus = isDeniedError(e.getErrorCode()) ? "DENIED" : "FAIL";
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, logStatus,
|
||||
e.getErrorCode().getStatus(), responseTime, 0L,
|
||||
e.getErrorCode().getMessage(), requestedAt);
|
||||
throw e;
|
||||
@ -192,6 +200,36 @@ public class GatewayService {
|
||||
}
|
||||
}
|
||||
|
||||
private static final Set<ErrorCode> DENIED_ERROR_CODES = Set.of(
|
||||
ErrorCode.GATEWAY_API_KEY_MISSING,
|
||||
ErrorCode.GATEWAY_API_KEY_INVALID,
|
||||
ErrorCode.GATEWAY_API_KEY_EXPIRED,
|
||||
ErrorCode.GATEWAY_PERMISSION_DENIED,
|
||||
ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED
|
||||
);
|
||||
|
||||
private boolean isDeniedError(ErrorCode errorCode) {
|
||||
return DENIED_ERROR_CODES.contains(errorCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 요청량 제한 검증
|
||||
*/
|
||||
private void validateDailyLimit(SnpApiKey apiKey) {
|
||||
Long limit = apiKey.getDailyRequestLimit();
|
||||
if (limit == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||
long todayCount = snpApiRequestLogRepository
|
||||
.countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(apiKey.getApiKeyId(), startOfDay);
|
||||
|
||||
if (todayCount >= limit) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_DAILY_LIMIT_EXCEEDED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 URL 구성
|
||||
*/
|
||||
@ -206,7 +244,13 @@ public class GatewayService {
|
||||
|
||||
String queryString = request.getQueryString();
|
||||
if (queryString != null && !queryString.isEmpty()) {
|
||||
url.append("?").append(queryString);
|
||||
// authKey 파라미터는 프록시 대상에 전달하지 않음
|
||||
String filtered = java.util.Arrays.stream(queryString.split("&"))
|
||||
.filter(p -> !p.startsWith(AUTH_KEY_PARAM + "="))
|
||||
.collect(java.util.stream.Collectors.joining("&"));
|
||||
if (!filtered.isEmpty()) {
|
||||
url.append("?").append(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
@ -327,9 +371,7 @@ public class GatewayService {
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String name = headerNames.nextElement();
|
||||
if (!"x-api-key".equalsIgnoreCase(name)) {
|
||||
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
|
||||
}
|
||||
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@ import java.util.List;
|
||||
public interface SnpApiRequestLogRepository extends JpaRepository<SnpApiRequestLog, Long>,
|
||||
JpaSpecificationExecutor<SnpApiRequestLog> {
|
||||
|
||||
/** API Key별 일일 요청 건수 */
|
||||
long countByApiKeyApiKeyIdAndRequestedAtGreaterThanEqual(Long apiKeyId, LocalDateTime startOfDay);
|
||||
|
||||
/** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */
|
||||
@Query(value = "SELECT COUNT(*) as total, " +
|
||||
"COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " +
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user