Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 전체 폐기
Expand Down Expand Up @@ -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`의 적용 개수 검증을 함께 갱신
- (세부 작성 규칙은 별도 스킬로 관리)
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
### Added
- ADR(아키텍처 결정 기록) 도입 (`adr/`)

### Changed
- 로그인 계정에 연결되지 않은 기존 API Key는 인증 단계에서 code `4034`로 거절(2026.09.30 공지 기한 경과). DB 데이터는 만료시키지 않으며 발급 이메일로 로그인하면 자동 연결되어 같은 키가 다시 동작

## [0.0.5] (2026.06.20)

### Changed
Expand Down
2 changes: 1 addition & 1 deletion module.secrets
3 changes: 3 additions & 0 deletions profanity-api/src/main/resources/openapi/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`로 거부합니다.
Expand All @@ -26,3 +28,4 @@ API Key는 SSO 로그인 후 개발자 포털에서 발급·재발행·만료할
| `4030` | 권한 부족, 차단 또는 폐기 상태 |
| `4031` | API Key 정보 없음 또는 만료 |
| `4032` | 유효하지 않은 API Key |
| `4034` | 로그인 계정에 연결되지 않은 API Key |
1 change: 1 addition & 0 deletions profanity-api/src/main/resources/openapi/error-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 서비스 점검 또는 일시 사용 불가 |
26 changes: 21 additions & 5 deletions profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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을 확인해 주세요."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@

public class FakeApiKeyMetadataReader implements ApiKeyMetadataReader {
public static final List<String> validKeys = new ArrayList<>();

/** 유효하지만 로그인 계정에 연결되지 않은 키. 실제 ApiKeyReader의 미연결 거절 규칙을 흉내낸다. */
public static final List<String> 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());
}
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
37 changes: 35 additions & 2 deletions profanity-test-support/src/main/resources/db/seed/0002.sql
Original file line number Diff line number Diff line change
@@ -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`,
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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
);
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
Loading