Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ Optional<ProjectApiKey> findFirstByProjectAndApiKeyStatusNotOrderByCreatedAtDesc
Project project, ApiKeyStatus status);

List<ProjectApiKey> findAllByProjectAndApiKeyStatusNot(Project project, ApiKeyStatus status);

Optional<ProjectApiKey> findByKeyPrefix(String prefix);
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,64 @@
package kr.java.documind.domain.member.service;

import java.util.Optional;
import java.util.UUID;
import kr.java.documind.domain.auth.model.entity.ProjectApiKey;
import kr.java.documind.domain.auth.model.repository.ProjectApiKeyRepository;
import kr.java.documind.global.util.HmacApiKeyUtil;
import kr.java.documind.domain.member.model.enums.ApiKeyStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class ProjectApiKeyValidationService {

// 테스트를 위한 약속된 더미 데이터 세팅
public static final String VALID_TEST_API_KEY = "test-api-key-1234";
public static final UUID DUMMY_PROJECT_ID =
UUID.fromString("550e8400-e29b-41d4-a716-446655440000");
private final ProjectApiKeyRepository projectApiKeyRepository;

@Value("${api-key.hmac-secret}")
private String hmacSecret;

/**
* [임시] API Key 검증 로직 (해싱 및 DB 연동 구현 전까지 사용)
* TODO: [godqhrenf] Api-Key 검증 후 projectId 반환 로직 구현
* API Key를 검증하고, 유효한 경우 연결된 ProjectId를 반환합니다.
*
* @param rawApiKey 사용자가 제공한 API Key (plain text)
* @return 유효한 경우 ProjectId, 그렇지 않으면 null
*/
@Transactional(readOnly = true)
public UUID getProjectIdByApiKey(String rawApiKey) {
log.info("요청된 API Key (테스트 모드): {}", rawApiKey);
if (rawApiKey == null) {
log.warn("API Key가 제공되지 않았습니다.");
return null;
}

String prefix = HmacApiKeyUtil.extractPrefix(rawApiKey);
Optional<ProjectApiKey> apiKeyOptional = projectApiKeyRepository.findByKeyPrefix(prefix);

if (apiKeyOptional.isEmpty()) {
log.warn("제공된 API Key prefix와 일치하는 키가 없습니다: {}", HmacApiKeyUtil.maskApiKey(rawApiKey));
return null;
}

ProjectApiKey apiKey = apiKeyOptional.get();

// 1. 약속된 테스트용 API Key가 들어온 경우 -> 고정된 더미 ProjectId 반환
if (VALID_TEST_API_KEY.equals(rawApiKey)) {
log.info("API Key 검증 성공! 테스트 ProjectId 반환: {}", DUMMY_PROJECT_ID);
return DUMMY_PROJECT_ID;
if (apiKey.getApiKeyStatus() != ApiKeyStatus.ACTIVE) {
log.warn("비활성 API Key입니다 (상태: {}): {}", apiKey.getApiKeyStatus(), HmacApiKeyUtil.maskApiKey(rawApiKey));
return null;
}

// 2. 그 외의 키가 들어온 경우 -> null 반환 (Filter에서 401 에러로 튕겨냄)
log.warn("유효하지 않은 API Key 접근 시도: {}", rawApiKey);
return null;
String requestHashedKey = HmacApiKeyUtil.computeHmac(rawApiKey, hmacSecret);

// DB에 저장된 해시값과 비교할 때 타이밍 공격에 안전한 방식으로 비교
if (HmacApiKeyUtil.constantTimeEquals(apiKey.getApiKeyHash(), requestHashedKey)) {
log.info("API Key 검증 성공! ProjectId: {}", apiKey.getProject().getPublicId());
return apiKey.getProject().getId();
} else {
log.warn("API Key 해시 값이 일치하지 않습니다: {}", HmacApiKeyUtil.maskApiKey(rawApiKey));
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package kr.java.documind.domain.member.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;

import java.util.Optional;
import java.util.UUID;
import kr.java.documind.domain.auth.model.entity.Project;
import kr.java.documind.domain.auth.model.entity.ProjectApiKey;
import kr.java.documind.domain.auth.model.repository.ProjectApiKeyRepository;
import kr.java.documind.global.util.HmacApiKeyUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

@ExtendWith(MockitoExtension.class)
@DisplayName("ProjectApiKeyValidationService 단위 테스트")
class ProjectApiKeyValidationServiceTest {

@Mock private ProjectApiKeyRepository projectApiKeyRepository;
@Mock private Project testProject;

@InjectMocks private ProjectApiKeyValidationService validationService;

private final String hmacSecret = "test-secret";
private final UUID projectId = UUID.randomUUID();

@BeforeEach
void setUp() {
ReflectionTestUtils.setField(validationService, "hmacSecret", hmacSecret);
lenient().when(testProject.getId()).thenReturn(projectId);
}

@Test
@DisplayName("API Key 발급부터 검증까지 전체 흐름: 유효한 키 → ProjectId 반환")
void getProjectIdByApiKey_ValidKey_ReturnsProjectId() {
// Given: HmacApiKeyUtil을 사용하여 API Key 생성 및 저장 준비
String generatedRawApiKey = HmacApiKeyUtil.generatePlainKey();
String extractedPrefix = HmacApiKeyUtil.extractPrefix(generatedRawApiKey);
String last4 = HmacApiKeyUtil.extractLast4(generatedRawApiKey);
String hashedApiKey = HmacApiKeyUtil.computeHmac(generatedRawApiKey, hmacSecret);

ProjectApiKey apiKey = ProjectApiKey.create(testProject, hashedApiKey, extractedPrefix, last4);

when(projectApiKeyRepository.findByKeyPrefix(extractedPrefix)).thenReturn(Optional.of(apiKey));

// When: 생성된 API Key로 검증 시도
UUID resultProjectId = validationService.getProjectIdByApiKey(generatedRawApiKey);

// Then: 검증 성공 및 ProjectId 반환
assertThat(resultProjectId).isNotNull();
assertThat(resultProjectId).isEqualTo(projectId);
}

@Test
@DisplayName("API Key 검증 실패: API Key가 null인 경우 → null 반환")
void getProjectIdByApiKey_NullApiKey_ReturnsNull() {
// Given
String nullApiKey = null;

// When
UUID resultProjectId = validationService.getProjectIdByApiKey(nullApiKey);

// Then
assertThat(resultProjectId).isNull();
}

@Test
@DisplayName("API Key 검증 실패: 존재하지 않는 Prefix → null 반환")
void getProjectIdByApiKey_NonExistentPrefix_ReturnsNull() {
// Given
String rawApiKey = "docu_nonexistentkey";
when(projectApiKeyRepository.findByKeyPrefix(anyString())).thenReturn(Optional.empty());

// When
UUID resultProjectId = validationService.getProjectIdByApiKey(rawApiKey);

// Then
assertThat(resultProjectId).isNull();
}

@Test
@DisplayName("API Key 검증 실패: 해시 값이 일치하지 않는 경우 → null 반환")
void getProjectIdByApiKey_HashMismatch_ReturnsNull() {
// Given
String rawApiKey = "docu_1234567890abcdef";
String prefix = HmacApiKeyUtil.extractPrefix(rawApiKey);
String last4 = HmacApiKeyUtil.extractLast4(rawApiKey);
// DB에는 다른 키의 해시가 저장되어 있다고 가정
String wrongHashedApiKey = HmacApiKeyUtil.computeHmac("wrong-api-key", hmacSecret);
ProjectApiKey apiKey = ProjectApiKey.create(testProject, wrongHashedApiKey, prefix, last4);

when(projectApiKeyRepository.findByKeyPrefix(prefix)).thenReturn(Optional.of(apiKey));

// When
UUID resultProjectId = validationService.getProjectIdByApiKey(rawApiKey);

// Then
assertThat(resultProjectId).isNull();
}

@Test
@DisplayName("API Key 검증 실패: 정지된(SUSPENDED) 키 → null 반환")
void getProjectIdByApiKey_SuspendedKey_ReturnsNull() {
// Given
String generatedRawApiKey = HmacApiKeyUtil.generatePlainKey();
String extractedPrefix = HmacApiKeyUtil.extractPrefix(generatedRawApiKey);
String last4 = HmacApiKeyUtil.extractLast4(generatedRawApiKey);
String hashedApiKey = HmacApiKeyUtil.computeHmac(generatedRawApiKey, hmacSecret);

ProjectApiKey apiKey = ProjectApiKey.create(testProject, hashedApiKey, extractedPrefix, last4);
apiKey.suspend();

when(projectApiKeyRepository.findByKeyPrefix(extractedPrefix)).thenReturn(Optional.of(apiKey));

// When
UUID resultProjectId = validationService.getProjectIdByApiKey(generatedRawApiKey);

// Then
assertThat(resultProjectId).isNull();
}

@Test
@DisplayName("API Key 검증 실패: 폐기된(REVOKED) 키 → null 반환")
void getProjectIdByApiKey_RevokedKey_ReturnsNull() {
// Given
String generatedRawApiKey = HmacApiKeyUtil.generatePlainKey();
String extractedPrefix = HmacApiKeyUtil.extractPrefix(generatedRawApiKey);
String last4 = HmacApiKeyUtil.extractLast4(generatedRawApiKey);
String hashedApiKey = HmacApiKeyUtil.computeHmac(generatedRawApiKey, hmacSecret);

ProjectApiKey apiKey = ProjectApiKey.create(testProject, hashedApiKey, extractedPrefix, last4);
apiKey.revoke();

when(projectApiKeyRepository.findByKeyPrefix(extractedPrefix)).thenReturn(Optional.of(apiKey));

// When
UUID resultProjectId = validationService.getProjectIdByApiKey(generatedRawApiKey);

// Then
assertThat(resultProjectId).isNull();
}
}