-
Notifications
You must be signed in to change notification settings - Fork 0
[DM-48] API Key 검증 로직 구현 #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+204
−14
Merged
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
4377fa5
feat: [DM-48] API Key 검증 구현
godqhrenf ee4f0e0
refactor: [DM-48] API Key 검증 방식을 Prefix 조회에서 해시 직접 검색으로 전환하여 보안 및 성능 개선
godqhrenf fc19c14
fix: [DM-48] 테스트 코드의 API Key 포맷 통일
godqhrenf 1889a07
fix: [DM-48] HMAC Secret 주입 경로를 통일하여 설정 불일치 해결
godqhrenf 2b23a7e
fix: [DM-48] 불필요한 쿼리 실행 코드 수정
godqhrenf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 45 additions & 14 deletions
59
src/main/java/kr/java/documind/domain/member/service/ProjectApiKeyValidationService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * [임시] 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; | ||
| } | ||
godqhrenf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| String prefix = HmacApiKeyUtil.extractPrefix(rawApiKey); | ||
| Optional<ProjectApiKey> apiKeyOptional = projectApiKeyRepository.findByKeyPrefix(prefix); | ||
|
|
||
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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()); | ||
godqhrenf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return apiKey.getProject().getId(); | ||
| } else { | ||
| log.warn("API Key 해시 값이 일치하지 않습니다: {}", HmacApiKeyUtil.maskApiKey(rawApiKey)); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
150 changes: 150 additions & 0 deletions
150
src/test/java/kr/java/documind/domain/member/service/ProjectApiKeyValidationServiceTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
godqhrenf marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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(); | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.