diff --git a/AGENTS.md b/AGENTS.md index 8fe0fa7..19deae0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ Gradle 모듈 밖에는 `ui/`(프런트엔드), `adr/`(아키텍처 결정 기 - `CustomAuthenticationFilter` → `RequestCredentialResolver` → 타입별 authenticator가 정확히 하나의 `Authentication`만 새 `SecurityContext`에 설정 - 기존 외부 API는 `X-API-KEY`와 `AUTH_API_KEY`; `/api/v1/auth/me`, `/api/v1/dashboard/**`는 RS256 로그인 JWT와 `AUTH_LOGIN_JWT`/`ROLE_USER` 사용 - `api_keys`가 API Key 인증의 유일한 원장이며 원문 대신 SHA-256 hash만 저장 +- `ApiKeyReader`는 만료되지 않았고 `user_id`가 연결된 키만 통과시킴. 미연결 레거시 키는 HTTP 200/code 4034(`API_KEY_NOT_LINKED`)로 거절하며 데이터는 건드리지 않음. 발급 이메일로 SSO 로그인하면 `claimUnownedByEmail`이 자동 연결해 같은 키가 다시 통과함(2026.09.30 공지 기한 경과 후 적용) - OAuth2 Client Credentials access token은 의도적으로 미구현. 외부 API Bearer는 `OAUTH2_ACCESS_TOKEN` 경계에서 HTTP 401/code 4017로 fail-closed - SSO 성공은 일회용 교환 코드 → `/api/v1/auth/exchange`; access token 15분, opaque refresh 14일/절대 세션 30일, MySQL hash 저장과 rotation 사용 - refresh replay는 5초 grace 안에서 loser 요청만 실패하고 family를 유지하며, grace 이후 재사용은 session family 전체 폐기 @@ -190,6 +191,7 @@ cd ui/app && npm run typecheck && npm run build - JUnit 5(Platform), 한글 `@DisplayName` + `@Nested` BDD 스타일. API 스펙은 Springdoc 기반 `/openapi.json` 응답으로 검증 - 도메인 계층은 Mock보다 **테스트 더블 우선** — `Inmemory*Repository`, `Fake*` 등 실제 구현 사용. Mockito는 외부 의존 격리가 필요한 일부에만 제한적 사용 - E2E와 마이그레이션 테스트는 Testcontainers MySQL을 사용(`AbstractApiTester`, `MySqlTestContainer`) +- E2E seed API Key(`SeedApiKeys`)는 `READ_CLIENT`·`WRITE_CLIENT`가 seed 사용자(`e2e-seed-owner@example.com`)에 연결돼 있고, `LEGACY_CLIENT`만 미연결 상태로 인증 거절과 최초 로그인 자동 연결 검증에 씀 - 도메인 Repository 포트에 메서드를 추가하면 포트를 직접 구현한 테스트 더블을 전부 고쳐야 컴파일됨. `profanity-domain/src/test/java/app/domain/InMemory*Repository` 외에 인증 테스트 안의 중첩 더블(`LoginJwtServiceTest`, `SsoAccountServiceTest` 등)도 포함 - 마이그레이션을 추가하면 `AdminPortalMigrationTest`의 적용 개수 검증을 함께 갱신 - (세부 작성 규칙은 별도 스킬로 관리) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88dc72e..d7c4f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ ### Added - ADR(아키텍처 결정 기록) 도입 (`adr/`) +### Changed +- 로그인 계정에 연결되지 않은 기존 API Key는 인증 단계에서 code `4034`로 거절(2026.09.30 공지 기한 경과). DB 데이터는 만료시키지 않으며 발급 이메일로 로그인하면 자동 연결되어 같은 키가 다시 동작 + ## [0.0.5] (2026.06.20) ### Changed diff --git a/module.platform b/module.platform index f575cc2..8c13762 160000 --- a/module.platform +++ b/module.platform @@ -1 +1 @@ -Subproject commit f575cc242561accc17e89dc920789fae6d50463b +Subproject commit 8c13762b30ae13de5fda48abaffd5a008f3ce67a diff --git a/module.secrets b/module.secrets index 2958f30..97866a7 160000 --- a/module.secrets +++ b/module.secrets @@ -1 +1 @@ -Subproject commit 2958f303c0a56cb042237862ded04704bde96d2b +Subproject commit 97866a7da1d998e5c44e162d34103f0d050b47ad diff --git a/profanity-api/src/main/resources/openapi/authentication.md b/profanity-api/src/main/resources/openapi/authentication.md index 830cfba..7ad2ba6 100644 --- a/profanity-api/src/main/resources/openapi/authentication.md +++ b/profanity-api/src/main/resources/openapi/authentication.md @@ -10,6 +10,8 @@ API Key는 SSO 로그인 후 개발자 포털에서 발급·재발행·만료할 보호된 API는 발급받은 API Key를 `x-api-key` 헤더에 포함해야 합니다. API Key가 없거나 유효하지 않으면 응답 본문의 `status.code`로 실패 원인을 확인합니다. +로그인 도입 전에 발급받은 API Key는 로그인 계정에 연결되어 있어야 사용할 수 있습니다. 연결되지 않은 키는 code `4034`로 거절되며, 발급 때 사용한 이메일과 같은 Google 또는 GitHub 계정으로 개발자 포털에 로그인하면 키 값 변경 없이 자동으로 연결됩니다. + ### Credential 정책 - API Key와 `Authorization`을 동시에 보내거나 같은 인증 헤더를 중복 제출하면 HTTP `400`/code `4004`로 거부합니다. @@ -26,3 +28,4 @@ API Key는 SSO 로그인 후 개발자 포털에서 발급·재발행·만료할 | `4030` | 권한 부족, 차단 또는 폐기 상태 | | `4031` | API Key 정보 없음 또는 만료 | | `4032` | 유효하지 않은 API Key | +| `4034` | 로그인 계정에 연결되지 않은 API Key | diff --git a/profanity-api/src/main/resources/openapi/error-model.md b/profanity-api/src/main/resources/openapi/error-model.md index 1a364fe..e83ac15 100644 --- a/profanity-api/src/main/resources/openapi/error-model.md +++ b/profanity-api/src/main/resources/openapi/error-model.md @@ -32,6 +32,7 @@ OpenAPI의 `responses`는 HTTP 상태 코드를 기준으로 표시합니다. | `4030` | `Forbidden` | 권한 부족 또는 차단된 클라이언트 | | `4031` | `Not_found_client` | 클라이언트 정보 없음 | | `4032` | `Invalid_api_key` | 유효하지 않은 API Key | +| `4034` | `Api_key_not_linked` | 로그인 계정에 연결되지 않은 API Key. 발급 이메일로 로그인하면 자동 연결 | | `4290` | `Too_many_requests` | 요청 제한 초과 | | `5000` | `Internal_server_error` | 서버 내부 오류 | | `5030` | `Service_unavailable` | 서비스 점검 또는 일시 사용 불가 | diff --git a/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java b/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java index 63f9d7c..54922c3 100644 --- a/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java @@ -9,6 +9,7 @@ import app.core.data.response.constant.StatusCode; import app.domain.user.OAuthLoginProfile; import app.domain.user.OAuthProvider; +import app.test.support.fixture.SeedApiKeys; import com.fasterxml.jackson.databind.JsonNode; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -31,19 +32,34 @@ class ApiKeysE2ETest extends AbstractApiTester { @Test @DisplayName("기존 API Key는 최초 SSO 로그인 시 검증된 이메일로 한 번만 연결된다") void login_matchingLegacyEmail_claimsMigratedKeyOnce() throws Exception { - String token = login("e2e-read@example.com", "legacy-owner"); + String email = SeedApiKeys.LEGACY_CLIENT.email(); + String token = login(email, "legacy-owner"); - awaitOwnership("e2e-read@example.com"); + awaitOwnership(email); JsonNode first = body(list(token)).at("/data"); - String secondToken = login("e2e-read@example.com", "legacy-owner"); - awaitOwnership("e2e-read@example.com"); + String secondToken = login(email, "legacy-owner"); + awaitOwnership(email); JsonNode second = body(list(secondToken)).at("/data"); assertThat(first).hasSize(1); - assertThat(first.get(0).at("/keyHint").asText()).isEqualTo("Hmikqf...-nDU"); + assertThat(first.get(0).at("/keyHint").asText()).isEqualTo("GFx8zn...FZzI"); assertThat(second).hasSize(1); } + @Test + @DisplayName("로그인 계정에 연결되지 않은 기존 API Key는 인증을 거부하고 연결된 뒤에는 다시 허용한다") + void filter_unlinkedLegacyKey_isRejectedUntilClaimed() throws Exception { + String apiKey = SeedApiKeys.LEGACY_CLIENT.apiKey(); + + assertThat(filter(apiKey).at("/status/code").asInt()) + .isEqualTo(StatusCode.API_KEY_NOT_LINKED.code()); + + login(SeedApiKeys.LEGACY_CLIENT.email(), "legacy-owner"); + awaitOwnership(SeedApiKeys.LEGACY_CLIENT.email()); + + assertThat(filter(apiKey).at("/status/code").asInt()).isEqualTo(StatusCode.OK.code()); + } + @Test @DisplayName("로그인 사용자는 여러 API Key를 발급하고 목록에서는 원문을 다시 볼 수 없다") void issue_multipleKeys_returnsPlaintextOnlyFromIssueResponse() throws Exception { diff --git a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java index edc3c0b..7b6194d 100644 --- a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java +++ b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java @@ -188,6 +188,26 @@ void test_4040() throws Exception { FakeApiKeyMetadataReader.validKeys.add(key); } + @Test + @DisplayName("로그인 계정에 연결되지 않은 API 키 요청시 4034 API_KEY_NOT_LINKED 응답을 반환한다") + void test_4034() throws Exception { + ApiRequest request = quickRequest("test text"); + String key = FakeApiKeyMetadataReader.validKeys.get(0); + FakeApiKeyMetadataReader.unlinkedKeys.add(key); + + mockMvc + .perform( + post(REQUEST_URL) + .header("X-API-KEY", key) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status.code").value(StatusCode.API_KEY_NOT_LINKED.code())) + .andExpect(jsonPath("$.status.message").value(StatusCode.API_KEY_NOT_LINKED.status())); + + FakeApiKeyMetadataReader.unlinkedKeys.remove(key); + } + private static ApiRequest quickRequest(String text) { return new ApiRequest(text, Mode.QUICK, null); } diff --git a/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java b/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java index c25f159..2527c30 100644 --- a/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java +++ b/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java @@ -31,6 +31,12 @@ public ApiKeyMetadata read(String plaintextApiKey) { .filter(ApiKey::isActive) .orElseThrow( () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); + // 2026.09.30 공지 기한이 지난 미연결 키는 데이터를 만료시키지 않고 인증에서만 막는다. + // 발급 이메일로 로그인하면 소유권이 자동 연결되어 같은 키가 다시 통과한다. + if (apiKey.getUserId() == null) { + log.warn("[AUTH] 로그인 계정에 연결되지 않은 API Key 거절 keyHint={}", apiKey.getKeyHint()); + throw new NoSuchElementException(StatusCode.API_KEY_NOT_LINKED.stringCode()); + } // 조회 커넥션을 반환한 뒤 기록한다. 바깥 트랜잭션을 유지하면 요청마다 연결 두 개가 필요하다. if (apiKey.isUsageRecordStale(apiKeyUsageRecorder.now(), ApiKeyUsageRecorder.RECORD_INTERVAL)) { try { diff --git a/profanity-domain/src/test/java/app/application/apikey/ApiKeyReaderTest.java b/profanity-domain/src/test/java/app/application/apikey/ApiKeyReaderTest.java index 06b55a3..a874c32 100644 --- a/profanity-domain/src/test/java/app/application/apikey/ApiKeyReaderTest.java +++ b/profanity-domain/src/test/java/app/application/apikey/ApiKeyReaderTest.java @@ -4,11 +4,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import app.application.client.APIKeyGenerator; +import app.core.data.response.constant.StatusCode; import app.domain.InMemoryApiKeyRepository; import app.domain.apikey.ApiKey; +import java.lang.reflect.Field; import java.time.Clock; import java.time.LocalDateTime; import java.util.List; +import java.util.NoSuchElementException; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -36,6 +39,40 @@ void read_unexpectedFailure_propagates() throws Exception { .isInstanceOf(IllegalStateException.class); } + @Test + @DisplayName("로그인 계정에 연결되지 않은 API Key는 4034 코드로 거절하고 사용 기록도 남기지 않는다") + void read_unlinkedKey_rejectsWithNotLinkedCode() throws Exception { + var repository = new InMemoryApiKeyRepository(); + var generator = new APIKeyGenerator("test-salt", "SHA-256"); + String key = generator.generateApiKey(); + ApiKey legacy = + ApiKey.issue( + UUID.randomUUID(), + "legacy", + "legacy@example.test", + generator.hashApiKey(key), + generator.keyHint(key), + "legacy", + null, + LocalDateTime.now()); + Field userId = ApiKey.class.getDeclaredField("userId"); + userId.setAccessible(true); + userId.set(legacy, null); + repository.save(legacy); + var recorder = + new ApiKeyUsageRecorder(repository, Clock.systemUTC()) { + @Override + public boolean recordUsage(UUID id) { + throw new AssertionError("거절된 키의 사용 기록은 남기지 않아야 한다"); + } + }; + var reader = new ApiKeyReader(repository, generator, recorder); + + assertThatThrownBy(() -> reader.read(key)) + .isInstanceOf(NoSuchElementException.class) + .hasMessage(StatusCode.API_KEY_NOT_LINKED.stringCode()); + } + private Fixture fixture(RuntimeException failure) throws Exception { var repository = new InMemoryApiKeyRepository(); var generator = new APIKeyGenerator("test-salt", "SHA-256"); diff --git a/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java b/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java index 5b3fd15..5718898 100644 --- a/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java +++ b/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java @@ -22,6 +22,7 @@ public enum StatusCode { NOT_FOUND_CLIENT(4031, "클라이언트 정보를 찾을 수 없습니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), INVALID_API_KEY(4032, "API 키가 유효하지 않습니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), USER_INACTIVE(4033, "비활성 사용자 계정입니다."), + API_KEY_NOT_LINKED(4034, "로그인 계정에 연결되지 않은 API Key입니다. 발급 이메일로 로그인하면 자동으로 연결됩니다."), TOO_MANY_REQUESTS(4290, "요청 횟수가 제한이 초과 되었습니다. 일정 시간이 지나면 다시 시도해 주세요."), INVALID_TRACKING_ID(4002, "유효하지 않은 Tracking ID 입니다. Tracking ID를 확인해 주세요."), INVALID_CALLBACK_URL(4001, "콜백 URL 형식이 올바르지 않습니다. 콜백 URL을 확인해 주세요."), diff --git a/profanity-test-support/src/main/java/app/test/support/fake/FakeApiKeyMetadataReader.java b/profanity-test-support/src/main/java/app/test/support/fake/FakeApiKeyMetadataReader.java index 7f5471a..53ab31a 100644 --- a/profanity-test-support/src/main/java/app/test/support/fake/FakeApiKeyMetadataReader.java +++ b/profanity-test-support/src/main/java/app/test/support/fake/FakeApiKeyMetadataReader.java @@ -12,11 +12,16 @@ public class FakeApiKeyMetadataReader implements ApiKeyMetadataReader { public static final List validKeys = new ArrayList<>(); + + /** 유효하지만 로그인 계정에 연결되지 않은 키. 실제 ApiKeyReader의 미연결 거절 규칙을 흉내낸다. */ + public static final List unlinkedKeys = new ArrayList<>(); + private final KeyGenerator apiKeyGenerator; public FakeApiKeyMetadataReader(KeyGenerator apiKeyGenerator) throws NoSuchAlgorithmException { this.apiKeyGenerator = apiKeyGenerator; validKeys.clear(); + unlinkedKeys.clear(); validKeys.add(apiKeyGenerator.generateApiKey()); validKeys.add(apiKeyGenerator.generateApiKey()); } @@ -29,6 +34,9 @@ public ApiKeyMetadata read(String apiKey) { if (!validKeys.contains(apiKey)) { throw new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode()); } + if (unlinkedKeys.contains(apiKey)) { + throw new NoSuchElementException(StatusCode.API_KEY_NOT_LINKED.stringCode()); + } return new ApiKeyMetadata( UUID.nameUUIDFromBytes(apiKey.getBytes()), "tester@example.com", diff --git a/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java b/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java index 466a3af..e2f5197 100644 --- a/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java +++ b/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java @@ -16,5 +16,13 @@ public final class SeedApiKeys { "e2e-write@example.com", "u6N_yQZAPfyrLheRXi7V0tZkvqe5Mno__vV0BlxpCjk"); + /** 로그인 계정에 연결되지 않은 기존 키. 인증 거절과 최초 로그인 시 자동 연결 검증에 쓴다. */ + public static final SeedApiKey LEGACY_CLIENT = + new SeedApiKey( + "00000000-0000-0000-0000-000000000003", + "E2E Legacy Client", + "e2e-legacy@example.com", + "GFx8znIDWePH3M_MOPd00aLwXp0C7LGPCKsQb13FZzI"); + private SeedApiKeys() {} } diff --git a/profanity-test-support/src/main/resources/db/seed/0002.sql b/profanity-test-support/src/main/resources/db/seed/0002.sql index 92d6c6e..5c80048 100644 --- a/profanity-test-support/src/main/resources/db/seed/0002.sql +++ b/profanity-test-support/src/main/resources/db/seed/0002.sql @@ -1,3 +1,23 @@ +-- 미연결 API Key는 인증에서 거절되므로 필터·단어 요청 E2E가 쓰는 키는 seed 사용자에 연결한다. +-- 로그인은 하지 않는 계정이라 테스트에서 쓰는 로그인 이메일과 겹치지 않아야 한다. +INSERT INTO `users` ( + `id`, + `display_name`, + `primary_email`, + `status`, + `role`, + `created_at`, + `updated_at` +) VALUES ( + UNHEX(REPLACE('00000000-0000-0000-0000-0000000000a1', '-', '')), + 'E2E Seed Owner', + 'e2e-seed-owner@example.com', + 'ACTIVE', + 'CLIENT', + CURRENT_TIMESTAMP(6), + CURRENT_TIMESTAMP(6) +); + INSERT INTO `api_keys` ( `id`, `user_id`, @@ -13,7 +33,7 @@ INSERT INTO `api_keys` ( ) VALUES ( UNHEX(REPLACE('00000000-0000-0000-0000-000000000001', '-', '')), - NULL, + UNHEX(REPLACE('00000000-0000-0000-0000-0000000000a1', '-', '')), 'E2E Read Client', 'e2e-read@example.com', SHA2('HmikqfE546l5lP4R5UbETsfROP8go0Kq-9cZqNw-nDU', 256), @@ -26,7 +46,7 @@ INSERT INTO `api_keys` ( ), ( UNHEX(REPLACE('00000000-0000-0000-0000-000000000002', '-', '')), - NULL, + UNHEX(REPLACE('00000000-0000-0000-0000-0000000000a1', '-', '')), 'E2E Write Client', 'e2e-write@example.com', SHA2('u6N_yQZAPfyrLheRXi7V0tZkvqe5Mno__vV0BlxpCjk', 256), @@ -36,4 +56,17 @@ INSERT INTO `api_keys` ( CURRENT_TIMESTAMP, 'READ/WRITE/DELETE', 0 + ), + ( + UNHEX(REPLACE('00000000-0000-0000-0000-000000000003', '-', '')), + NULL, + 'E2E Legacy Client', + 'e2e-legacy@example.com', + SHA2('GFx8znIDWePH3M_MOPd00aLwXp0C7LGPCKsQb13FZzI', 256), + 'GFx8zn...FZzI', + 'e2e-seed', + '로그인 계정에 연결되지 않은 기존 키 테스트 클라이언트', + CURRENT_TIMESTAMP, + 'READ', + 0 ); diff --git a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java index 6b77be0..e240b25 100644 --- a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java +++ b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java @@ -29,9 +29,9 @@ void mysqlContainer_whenMigrateAndSeed_succeeds() throws SQLException { assertThat(columnNullability(dataSource, "users", "primary_email")).isEqualTo("NO"); assertThat(columnCollation(dataSource, "users", "primary_email")).isEqualTo("utf8mb4_bin"); assertThat(uniqueIndexCount(dataSource, "users", "primary_email")).isEqualTo(1); - assertThat(count(dataSource, "api_keys")).isEqualTo(2); + assertThat(count(dataSource, "api_keys")).isEqualTo(3); assertThat(count(dataSource, "profanity_word")).isEqualTo(3); - assertThat(count(dataSource, "users")).isZero(); + assertThat(count(dataSource, "users")).isEqualTo(1); assertThat(count(dataSource, "login_exchange_codes")).isZero(); assertThat(count(dataSource, "login_refresh_sessions")).isZero(); assertThat(count(dataSource, "login_refresh_tokens")).isZero(); @@ -41,7 +41,7 @@ void mysqlContainer_whenMigrateAndSeed_succeeds() throws SQLException { """ insert into api_keys (id, user_id, name, email, key_hash, key_hint, issuer_info, permissions, issued_at) - values (UNHEX(REPLACE('00000000-0000-0000-0000-000000000003', '-', '')), + values (UNHEX(REPLACE('00000000-0000-0000-0000-000000000009', '-', '')), null, 'Temporary Client', 'temporary@example.com', @@ -127,8 +127,8 @@ insert into login_exchange_codes (id, user_id, code_hash, created_at, expires_at MySqlTestContainer.resetSeedData(MYSQL); - assertThat(count(dataSource, "api_keys")).isEqualTo(2); - assertThat(count(dataSource, "users")).isZero(); + assertThat(count(dataSource, "api_keys")).isEqualTo(3); + assertThat(count(dataSource, "users")).isEqualTo(1); assertThat(count(dataSource, "login_exchange_codes")).isZero(); assertThat(count(dataSource, "login_refresh_sessions")).isZero(); assertThat(count(dataSource, "login_refresh_tokens")).isZero();