diff --git a/AGENTS.md b/AGENTS.md index e1452b5..5a5e316 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Aho-Corasick 알고리즘 기반의 한국어 비속어 필터링 REST API 서 profanity-api (Presentation, Boot JAR) ── domain + storage:rdb + storage:redis 의존 ├─ presentation/ REST Controllers ├─ security/ API Key / 로그인 JWT 인증, SSO handler, refresh cookie·CSRF - ├─ application/ 로그인 orchestration, Async 이벤트 리스너, EmailService, HttpClient + ├─ application/ 로그인 orchestration, API Key 소유권 연결, Async 이벤트 리스너, HttpClient ├─ web/response/ 응답 meta 커스터마이징 (ResponseBodyAdvice 기반) ├─ exception/ GlobalExceptionHandler └─ config/ Aspect / LocalCache(Caffeine) / Mail @@ -29,10 +29,11 @@ profanity-api (Presentation, Boot JAR) ── domain + storage:rdb + storage:r profanity-domain (Business Logic, 라이브러리) ── shared 를 api() 로 재노출 ├─ application/filter/ NormalProfanityFilter(Aho-Corasick), DefaultProfanityHandler ├─ application/manage/ SyncScheduler, DailyReportScheduler, Word/Report/Sync 서비스 - ├─ application/client/ ClientsCommandService, MetadataReader, APIKeyGenerator + ├─ application/apikey/ API Key 발급·재발행·만료·소유권 연결 + ├─ application/client/ APIKeyGenerator ├─ application/auth/ SSO 계정 upsert, 교환 코드, refresh session rotation ├─ application/event/ FilterEvent / AsyncFilterEvent / TrackingRecorder - └─ domain/ 엔티티(Clients, User/OAuthAccount, LoginSession, ProfanityWord 등) + Repository 포트 + └─ domain/ 엔티티(ApiKey, User/OAuthAccount, LoginSession, ProfanityWord 등) + Repository 포트 profanity-storage:rdb (Data Access - RDB) └─ domain 의 Repository 포트를 Spring Data JPA(Jpa*Repository)로 구현 @@ -65,14 +66,14 @@ profanity-shared (Common) - `AsyncFilterEventListener`(api 모듈, `@Async @EventListener`)가 `RestClient`로 콜백 URL에 POST (재시도 없음) ### 요청 기록 (`FilterEvent` / `TrackingRecorder`) -- 동기 필터링 후 `FilterEvent` 발행 → `records` 저장 (trackingId, mode, apiKey, 요청문, 검출 단어, referrer, ip) +- 동기 필터링 후 `FilterEvent` 발행 → `records` 저장 (trackingId, mode, apiKeyHash, 요청문, 검출 단어, referrer, ip) - 클라이언트 IP는 `HttpClient.getClientIP`가 추출: `CF-Connecting-IP` → `X-Forwarded-For`(첫 IP) → 폴백 헤더 → `getRemoteAddr` 순. Cloudflare proxied 경로(`api.kr-filter.com`)에서만 실제 IP가 기록되고, DNS-only 경로(레거시 도메인)에서는 klipper-lb L4 SNAT로 인해 k3s 내부망 IP(`10.42.x`)가 기록됨 ### 응답 커스터마이징 (`profanity-api/.../web/response`) - `ResponseCustomizingAdvice`(`@RestControllerAdvice` + `ResponseBodyAdvice`)가 직렬화 직전 응답 `meta`(Map)에 컨텍스트 정보 주입 - `ResponseCustomizer` 인터페이스 + `HostResponseCustomizer`(요청 호스트가 `app.response.proxied-host` 설정값과 일치하면 `servedVia` 추가) - `meta`는 `@JsonInclude(NON_EMPTY)`라 비어 있으면 직렬화에서 제외(기존 응답 불변 = 하위호환) -- **[갭] 적용 조건이 `body instanceof ApiResponse` 인데, 메인 필터 엔드포인트는 `FilterApiResponse`(별도 record, `ApiResponse` 아님)를 반환하므로 `meta`가 붙지 않음.** 현재 `ApiResponse` 반환 경로(clients 등)에만 적용됨 +- **[갭] 적용 조건이 `body instanceof ApiResponse` 인데, 메인 필터 엔드포인트는 `FilterApiResponse`(별도 record, `ApiResponse` 아님)를 반환하므로 `meta`가 붙지 않음.** 현재 `ApiResponse` 반환 경로에만 적용됨 ### 인증 체계 (`profanity-api/.../security`) - Stateless Spring Security에서 `API_KEY`, `LOGIN_JWT`, 미래 확장용 `OAUTH2_ACCESS_TOKEN`을 명시적으로 분리 @@ -87,7 +88,7 @@ profanity-shared (Common) ### 스케줄러 (`profanity-domain/.../application/manage`) - `SyncScheduler`: `@Scheduled(fixedDelay = 60000)` — 1분마다 DB 단어 수 비교, 변경 시에만 Trie 재동기화. (`@SchedulerLock`은 주석 처리되어 미적용) -- `DailyReportScheduler`: `@Scheduled(cron = "0 0 1 * * ?")` — 매일 01:00. ShedLock 5.10.0 `@SchedulerLock` 적용됨 +- `DailyReportScheduler`: 매일 01:00에 `api_keys.request_count`와 `client_reports`를 집계한다. 두 경로는 수집 중단 검토 대상으로 `@Deprecated(forRemoval = true)`이며 서로 다른 ShedLock을 사용한다. ## REST 엔드포인트 (`profanity-api/.../presentation`) @@ -96,12 +97,10 @@ profanity-shared (Common) | POST | `/api/v1/filter` (JSON) | 동기/비동기 필터링, `@Cacheable` | | POST | `/api/v1/filter` (form-urlencoded) | 동기 필터링 | | POST | `/api/v1/filter/advanced` | 단일 word 마스킹 | -| GET | `/api/v1/clients` | 클라이언트 정보 조회 | -| DELETE | `/api/v1/clients` | 클라이언트 폐기 | -| POST | `/api/v1/clients/register` | 신규 등록 (인증 불필요) | -| POST | `/api/v1/clients/update` | 정보 수정 | -| POST | `/api/v1/clients/reissue` | API Key 재발급 | -| GET\|PUT | `/api/v1/clients/send-email` | 이메일 인증 코드 발송 / 검증 | +| GET | `/api/v1/dashboard/keys` | 로그인 사용자의 API Key 목록 조회 | +| POST | `/api/v1/dashboard/keys` | API Key 발급 | +| POST | `/api/v1/dashboard/keys/{apiKeyId}/reissue` | API Key 재발급 | +| DELETE | `/api/v1/dashboard/keys/{apiKeyId}` | API Key 만료 | | POST | `/api/v1/word/request` | 단어 추가/제거/수정 요청 | | POST | `/api/v1/word/accept/{requestId}` | 단어 요청 승인 (WRITE 권한) | | GET | `/api/v1/sync?password=...` | 수동 동기화 (관리자) | diff --git a/CLAUDE.md b/CLAUDE.md index 52827de..e814327 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,8 @@ Aho-Corasick 알고리즘 기반의 한국어 비속어 필터링 REST API 서 ``` profanity-api (Presentation, Boot JAR) ── domain + storage:rdb + storage:redis 의존 ├─ presentation/ REST Controllers - ├─ security/ API Key 인증 (filter / authentication / aspect / annotation) - ├─ application/ Async 이벤트 리스너, EmailService, HttpClient(클라이언트 IP/referrer 추출) + ├─ security/ API Key / 로그인 JWT 인증, SSO handler, refresh cookie·CSRF + ├─ application/ 로그인 orchestration, API Key 소유권 연결, Async 이벤트 리스너, HttpClient ├─ web/response/ 응답 meta 커스터마이징 (ResponseBodyAdvice 기반) ├─ exception/ GlobalExceptionHandler └─ config/ Aspect / LocalCache(Caffeine) / Mail @@ -29,9 +29,11 @@ profanity-api (Presentation, Boot JAR) ── domain + storage:rdb + storage:r profanity-domain (Business Logic, 라이브러리) ── shared 를 api() 로 재노출 ├─ application/filter/ NormalProfanityFilter(Aho-Corasick), DefaultProfanityHandler ├─ application/manage/ SyncScheduler, DailyReportScheduler, Word/Report/Sync 서비스 - ├─ application/client/ ClientsCommandService, MetadataReader, APIKeyGenerator + ├─ application/apikey/ API Key 발급·재발행·만료·소유권 연결 + ├─ application/client/ APIKeyGenerator + ├─ application/auth/ SSO 계정 upsert, 교환 코드, refresh session rotation ├─ application/event/ FilterEvent / AsyncFilterEvent / TrackingRecorder - └─ domain/ 엔티티(Clients, ProfanityWord, Report, Records) + Repository 포트 + └─ domain/ 엔티티(ApiKey, User/OAuthAccount, LoginSession, ProfanityWord 등) + Repository 포트 profanity-storage:rdb (Data Access - RDB) └─ domain 의 Repository 포트를 Spring Data JPA(Jpa*Repository)로 구현 @@ -64,25 +66,25 @@ profanity-shared (Common) - `AsyncFilterEventListener`(api 모듈, `@Async @EventListener`)가 `RestClient`로 콜백 URL에 POST (재시도 없음) ### 요청 기록 (`FilterEvent` / `TrackingRecorder`) -- 동기 필터링 후 `FilterEvent` 발행 → `records` 저장 (trackingId, mode, apiKey, 요청문, 검출 단어, referrer, ip) +- 동기 필터링 후 `FilterEvent` 발행 → `records` 저장 (trackingId, mode, apiKeyHash, 요청문, 검출 단어, referrer, ip) - 클라이언트 IP는 `HttpClient.getClientIP`가 추출: `CF-Connecting-IP` → `X-Forwarded-For`(첫 IP) → 폴백 헤더 → `getRemoteAddr` 순. Cloudflare proxied 경로(`api.kr-filter.com`)에서만 실제 IP가 기록되고, DNS-only 경로(레거시 도메인)에서는 klipper-lb L4 SNAT로 인해 k3s 내부망 IP(`10.42.x`)가 기록됨 ### 응답 커스터마이징 (`profanity-api/.../web/response`) - `ResponseCustomizingAdvice`(`@RestControllerAdvice` + `ResponseBodyAdvice`)가 직렬화 직전 응답 `meta`(Map)에 컨텍스트 정보 주입 - `ResponseCustomizer` 인터페이스 + `HostResponseCustomizer`(요청 호스트가 `app.response.proxied-host` 설정값과 일치하면 `servedVia` 추가) - `meta`는 `@JsonInclude(NON_EMPTY)`라 비어 있으면 직렬화에서 제외(기존 응답 불변 = 하위호환) -- **[갭] 적용 조건이 `body instanceof ApiResponse` 인데, 메인 필터 엔드포인트는 `FilterApiResponse`(별도 record, `ApiResponse` 아님)를 반환하므로 `meta`가 붙지 않음.** 현재 `ApiResponse` 반환 경로(clients 등)에만 적용됨 +- **[갭] 적용 조건이 `body instanceof ApiResponse` 인데, 메인 필터 엔드포인트는 `FilterApiResponse`(별도 record, `ApiResponse` 아님)를 반환하므로 `meta`가 붙지 않음.** 현재 `ApiResponse` 반환 경로에만 적용됨 ### 인증 체계 (`profanity-api/.../security`) -- `X-API-KEY` 헤더 기반 Stateless Spring Security -- `CustomAuthenticationFilter`(OncePerRequestFilter) → `AuthenticationService`가 메타데이터 조회 후 권한을 `ROLE_` 접두사로 변환 → `SecurityContext` -- `ExcludePath` enum으로 필터 제외 경로 관리 (clients/register, send-email, health, ping, resource). actuator/관측 스택은 제거됨 +- Stateless Spring Security에서 API Key와 로그인 JWT를 분리한다. +- 외부 API는 `X-API-KEY`, `/api/v1/dashboard/**`는 로그인 JWT를 사용한다. +- `api_keys`가 API Key 인증의 유일한 원장이며 원문 대신 SHA-256 hash만 저장한다. - `@VerifiedClientOnly` + `ClientVerificationAspect`(`@Around @Order(1)`): BLOCK/DISCARD 권한 클라이언트를 403으로 차단 - 권한(`PermissionsType`): READ / WRITE / DELETE / BLOCK / DISCARD (기본 [READ]) ### 스케줄러 (`profanity-domain/.../application/manage`) - `SyncScheduler`: `@Scheduled(fixedDelay = 60000)` — 1분마다 DB 단어 수 비교, 변경 시에만 Trie 재동기화. (`@SchedulerLock`은 주석 처리되어 미적용) -- `DailyReportScheduler`: `@Scheduled(cron = "0 0 1 * * ?")` — 매일 01:00. ShedLock 5.10.0 `@SchedulerLock` 적용됨 +- `DailyReportScheduler`: 매일 01:00에 `api_keys.request_count`와 `client_reports`를 집계한다. 두 경로는 수집 중단 검토 대상으로 `@Deprecated(forRemoval = true)`이며 서로 다른 ShedLock을 사용한다. ## REST 엔드포인트 (`profanity-api/.../presentation`) @@ -91,12 +93,10 @@ profanity-shared (Common) | POST | `/api/v1/filter` (JSON) | 동기/비동기 필터링, `@Cacheable` | | POST | `/api/v1/filter` (form-urlencoded) | 동기 필터링 | | POST | `/api/v1/filter/advanced` | 단일 word 마스킹 | -| GET | `/api/v1/clients` | 클라이언트 정보 조회 | -| DELETE | `/api/v1/clients` | 클라이언트 폐기 | -| POST | `/api/v1/clients/register` | 신규 등록 (인증 불필요) | -| POST | `/api/v1/clients/update` | 정보 수정 | -| POST | `/api/v1/clients/reissue` | API Key 재발급 | -| GET\|PUT | `/api/v1/clients/send-email` | 이메일 인증 코드 발송 / 검증 | +| GET | `/api/v1/dashboard/keys` | 로그인 사용자의 API Key 목록 조회 | +| POST | `/api/v1/dashboard/keys` | API Key 발급 | +| POST | `/api/v1/dashboard/keys/{apiKeyId}/reissue` | API Key 재발급 | +| DELETE | `/api/v1/dashboard/keys/{apiKeyId}` | API Key 만료 | | POST | `/api/v1/word/request` | 단어 추가/제거/수정 요청 | | POST | `/api/v1/word/accept/{requestId}` | 단어 요청 승인 (WRITE 권한) | | GET | `/api/v1/sync?password=...` | 수동 동기화 (관리자) | diff --git a/README.md b/README.md index d21863f..31b0561 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Aho-Corasick 알고리즘을 기반으로 한국어 비속어를 검출하고 외부 API는 현재 `x-api-key: {API_KEY}`로 인증합니다. OAuth2 Client Credentials Bearer token은 준비 중이며 아직 사용할 수 없습니다. +API Key는 SSO 로그인 후 개발자 포털에서 발급·관리합니다. 기존 키는 값 변경 없이 계속 사용할 수 있으며, 같은 검증 이메일로 로그인하면 자동으로 계정에 연결됩니다. + 외부 API에 아직 지원하지 않는 Bearer token을 보내면 HTTP `401`, business code `4017`로 거부됩니다. 로그인 JWT를 외부 API Key 대신 사용할 수도 없습니다. ## 빠른 시작 @@ -66,18 +68,6 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \ | `POST` | `/api/v1/filter` | 비속어 필터링 요청 | API Key | | `POST` | `/api/v1/filter/advanced` | 단일 단어 기반 고급 마스킹 | API Key | -### 클라이언트 및 API Key - -| Method | Path | 설명 | 현재 인증 | -|---|---|---|---| -| `GET` | `/api/v1/clients` | 클라이언트 정보 조회 | API Key | -| `DELETE` | `/api/v1/clients` | 클라이언트 폐기 | API Key | -| `POST` | `/api/v1/clients/update` | 클라이언트 정보 변경 | API Key | -| `POST` | `/api/v1/clients/reissue` | API Key 재발급 | API Key | -| `POST` | `/api/v1/clients/register` | 레거시 신규 클라이언트 등록 | 공개, 로그인 기반 발급으로 전환 예정 | -| `GET` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 발송 | 공개 | -| `PUT` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 검증 | 공개 | - ### 단어 변경 요청 | Method | Path | 설명 | 인증 | diff --git "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" index 747c461..d701fa9 100644 --- "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" +++ "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" @@ -3,6 +3,8 @@ ## Status 채택 (2026.07.11) +API Key 소유권 연결 정책은 [ADR 0007](./0007%20SSO%20사용자%20소유%20API%20Key%20원장%20전환.md)에서 변경됐다. + ## Context 현재 신규 클라이언트 등록은 `POST /api/v1/clients/register` 요청만으로 API Key를 즉시 발급한다. 입력값은 이름, 이메일, 발급자 정보, 메모 수준이며, 이메일 소유 증명이나 사용자 로그인 주체가 없다. @@ -39,7 +41,7 @@ - 동시 refresh 중 하나만 성공시키고, 5초 grace 이후 소비된 token 재사용 시 session family를 폐기하는 replay 정책 - `GET /api/v1/auth/me`와 `/api/v1/dashboard/**`를 `LOGIN_JWT` 전용 경계로 분리 -기존 API Key claim과 기존 사용자의 자동 마이그레이션은 포함하지 않는다. 외부 API용 OAuth2 Client Credentials도 ADR 0006의 후속 구현 범위로 남긴다. +기존 API Key claim과 기존 사용자의 자동 마이그레이션은 이 ADR의 구현 범위에 포함하지 않았으며, 이후 ADR 0007에서 SSO 로그인 완료 시 비동기 이메일 연결 방식으로 구현했다. 외부 API용 OAuth2 Client Credentials는 ADR 0006의 후속 구현 범위로 남긴다. ## Consequences - 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드는 stateless `LOGIN_JWT`를 사용하고 기존 외부 API는 legacy API Key 계약을 유지한다. diff --git "a/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" "b/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" index 40cb902..4298408 100644 --- "a/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" +++ "b/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" @@ -33,12 +33,14 @@ API Key와 OAuth2 Client Credentials를 모두 제공하되, 두 자격 증명 ## Implementation Status -이 ADR은 아직 제안 상태이며 OAuth2 Client Credentials는 구현하지 않았다. +API Key의 로그인 기반 관리 부분은 ADR 0007로 구현했으며 OAuth2 Client Credentials는 아직 구현하지 않았다. - `/oauth2/token`, `client_id/client_secret` 발급·저장, Authorization Server는 존재하지 않는다. - 인증 타입에는 미래 확장 경계인 `OAUTH2_ACCESS_TOKEN`만 정의한다. - 외부 API에 제출된 Bearer token은 현재 HTTP `401`과 business code `4017`로 fail-closed 처리하며 API Key나 로그인 JWT로 fallback하지 않는다. - 구현된 Bearer 인증은 사람의 대시보드 접근을 위한 `LOGIN_JWT`이며, 본 ADR의 외부 API access token과 다른 credential이다. +- `/api/v1/dashboard/keys`에서 API Key 목록·발급·재발행·만료를 제공하며 모두 `LOGIN_JWT`만 허용한다. +- 기존 `/api/v1/clients/**`는 제거했고 API Key 원문은 발급·재발행 성공 응답에서만 한 번 반환한다. ## Consequences - 사용자는 연동 복잡도와 운영 요구에 따라 API Key와 Client Credentials 중 하나를 선택할 수 있다. diff --git "a/docs/adr/0007 SSO \354\202\254\354\232\251\354\236\220 \354\206\214\354\234\240 API Key \354\233\220\354\236\245 \354\240\204\355\231\230.md" "b/docs/adr/0007 SSO \354\202\254\354\232\251\354\236\220 \354\206\214\354\234\240 API Key \354\233\220\354\236\245 \354\240\204\355\231\230.md" new file mode 100644 index 0000000..24cfde8 --- /dev/null +++ "b/docs/adr/0007 SSO \354\202\254\354\232\251\354\236\220 \354\206\214\354\234\240 API Key \354\233\220\354\236\245 \354\240\204\355\231\230.md" @@ -0,0 +1,40 @@ +# 7. SSO 사용자 소유 API Key 원장 전환 + +## Status +채택 (2026.07.17) + +## Context +기존 `clients`는 사용자 정보, API Key 원문, 권한과 사용량을 한 행에 저장한다. API Key 관리 API도 제출된 API Key 자체로 인증하거나 이메일 인증 코드로 원문을 복구해, SSO 사용자가 자신의 여러 키를 안전하게 관리하는 구조로 확장하기 어렵다. + +## Decision +`api_keys`를 API Key의 유일한 원장으로 사용하고 `clients`와 `/api/v1/clients/**`를 제거한다. + +- V4 migration에서 모든 `clients` 행을 같은 ID의 `api_keys`로 복제한다. +- `legacy_client_id` 같은 중간 연결 컬럼은 두지 않는다. +- API Key 원문은 저장하지 않고 SHA-256 hash와 화면 표시용 `key_hint`만 저장한다. +- 기존 `records.api_key`도 hash로 전환해 요청 기록에 원문을 남기지 않는다. +- 기존 키는 migration 시 소유자를 추측하지 않고 `user_id`를 비워 둔다. +- SSO 로그인 완료 후 검증된 primary email과 같은 미이관 키를 비동기 작업으로 현재 사용자에게 연결한다. +- 연결 쿼리는 `user_id IS NULL`인 행만 갱신하므로 재로그인과 동시 실행에서 멱등성을 유지한다. +- 신규 발급 이메일은 요청값을 받지 않고 Login JWT 사용자의 primary email로 고정한다. +- 한 사용자가 여러 활성 API Key를 용도별로 가질 수 있다. +- 키 원문은 발급·재발행 성공 응답에서만 한 번 반환한다. +- 재발행은 기존 키를 만료하고 새 행을 생성하며, 만료는 기존 행의 만료 시각을 유지하는 멱등 작업이다. +- 관리 API는 `/api/v1/dashboard/keys` 아래에 두고 Login JWT만 허용한다. + +기존 `client_reports`와 `request_count` 집계는 `api_keys` 기준으로 동작을 유지한다. 다만 해당 모델과 scheduler에는 `@Deprecated(forRemoval = true)`를 선언하고 신규 기능이 의존하지 않게 한다. 두 scheduler의 ShedLock 이름은 분리하며 향후 수집 중단 여부를 별도 결정한다. + +## Consequences +- 기존 API Key는 값 변경 없이 계속 외부 API 인증에 사용할 수 있다. +- API Key와 요청 기록에서 원문 저장이 제거된다. +- 로그인 사용자는 기존 이메일의 키를 별도 복구 코드 없이 목록에서 확인할 수 있다. +- provider가 검증하고 서비스가 신뢰하는 primary email을 소유권 근거로 사용하므로 ADR 0005의 수동 claim 결정을 변경한다. +- 비동기 연결 직후 짧은 eventual consistency 구간이 생길 수 있지만 이후 로그인에서는 갱신할 행이 없다. +- 기존 공개 발급·이메일 복구·API Key 자체 인증 관리 API는 호환되지 않는다. +- 사용량 집계 중단 시 deprecated reporting 계층과 `client_reports`, `request_count`를 함께 제거해야 한다. + +## Alternatives +- `legacy_client_id` 보존: 동일 ID 복제로 충분하며 중복 식별자를 만들 이유가 없어 제외한다. +- 로그인 요청에서 동기 연결: 목록 가시성은 즉시 보장하지만 OAuth 완료 응답을 DB 이관 작업에 결합하므로 제외한다. +- `clients` fallback 조회: migration 이후 원장이 두 개가 되고 만료된 키가 fallback으로 다시 인증될 수 있어 제외한다. +- 기존 이메일 인증 claim 유지: 추가 사용자 입력과 API Key 원문 복구 계약을 유지해야 하므로 제외한다. diff --git "a/http/\354\213\240\352\267\234 \355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \353\223\261\353\241\235.http" "b/http/\354\213\240\352\267\234 \355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \353\223\261\353\241\235.http" deleted file mode 100644 index 10425bb..0000000 --- "a/http/\354\213\240\352\267\234 \355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \353\223\261\353\241\235.http" +++ /dev/null @@ -1,54 +0,0 @@ -### (성공 케이스) 유효한 클라이언트 등록 요청 -POST {{host}}/api/v1/clients/register -Content-Type: application/json - -{ - "name": "ABCD Corporation", - "email": "contact@abccorp.com", - "issuerInfo": "발급자 정보", - "note": "메모 내용" -} - -### (실패 케이스) 이름이 비어있는 경우 -POST {{host}}/api/v1/clients/register -Content-Type: application/json - -{ - "name": "", - "email": "contact@abccorp.com", - "issuerInfo": "발급자 정보", - "note": "메모 내용" -} - -### (실패 케이스) 이메일 형식이 잘못된 경우 -POST {{host}}/api/v1/clients/register -Content-Type: application/json - -{ - "name": "ABC Corporation", - "email": "contactabccorp.com", - "issuerInfo": "발급자 정보", - "note": "메모 내용" -} - -### (실패 케이스) 발급자 정보가 비어있는 경우 -POST {{host}}/api/v1/clients/register -Content-Type: application/json - -{ - "name": "ABC Corporation", - "email": "contact@abccorp.com", - "issuerInfo": "", - "note": "메모 내용" -} - -### (실패 케이스) 이름이 2자 미만인 경우 -POST {{host}}/api/v1/clients/register -Content-Type: application/json - -{ - "name": "A", - "email": "contact@abccorp.com", - "issuerInfo": "발급자 정보", - "note": "메모 내용" -} diff --git "a/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235.http" "b/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235.http" deleted file mode 100644 index e7eb76c..0000000 --- "a/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\235\264\353\251\224\354\235\274 \354\235\270\354\246\235.http" +++ /dev/null @@ -1,12 +0,0 @@ -### 이메일 인증 번호 요청 -@email = rlagusrl01234@naver.com -GET {{host}}/api/v1/clients/send-email?email={{email}} - -### 이메일 인증 후 API 키 조회 -PUT {{host}}/api/v1/clients/send-email -Content-Type: application/json - -{ - "email": "{{email}}", - "code": "601994" -} diff --git "a/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\240\225\353\263\264 \354\210\230\354\240\225.http" "b/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\240\225\353\263\264 \354\210\230\354\240\225.http" deleted file mode 100644 index 79bfa8a..0000000 --- "a/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\240\225\353\263\264 \354\210\230\354\240\225.http" +++ /dev/null @@ -1,13 +0,0 @@ -### 클라이언트 정보 수정 -POST {{host}}/api/v1/clients/update -Content-Type: application/json -X-API-KEY: {{api_key}} - -{ - "issuerInfo": "issuerInfo update", - "note": "note update" -} - -### 클라이언트 정보 폐기 -DELETE {{host}}/api/v1/clients -X-API-KEY: {{api_key}} diff --git "a/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\240\225\353\263\264 \354\241\260\355\232\214.http" "b/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\240\225\353\263\264 \354\241\260\355\232\214.http" deleted file mode 100644 index 4ca0192..0000000 --- "a/http/\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270 \354\240\225\353\263\264 \354\241\260\355\232\214.http" +++ /dev/null @@ -1,4 +0,0 @@ -### (성공 케이스) 유효한 클라이언트 등록 요청 -GET {{host}}/api/v1/clients -Content-Type: application/json -X-API-KEY: {{api_key}} diff --git a/profanity-api/build.gradle b/profanity-api/build.gradle index 2baff8c..3b1e93c 100644 --- a/profanity-api/build.gradle +++ b/profanity-api/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation libs.springdoc.openapi.starter.webmvc.api testImplementation project(':profanity-test-support') testImplementation libs.archunit.junit5 + testImplementation 'org.flywaydb:flyway-core' //Mail implementation 'org.springframework.boot:spring-boot-starter-mail' diff --git a/profanity-api/src/main/java/app/application/EmailService.java b/profanity-api/src/main/java/app/application/EmailService.java deleted file mode 100644 index 79baf06..0000000 --- a/profanity-api/src/main/java/app/application/EmailService.java +++ /dev/null @@ -1,78 +0,0 @@ -package app.application; - -import jakarta.mail.internet.MimeMessage; -import java.security.SecureRandom; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Random; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.scheduling.annotation.Async; -import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.stereotype.Service; -import org.thymeleaf.context.Context; -import org.thymeleaf.spring6.SpringTemplateEngine; - -@Slf4j -@Service -@EnableAsync -@RequiredArgsConstructor -public class EmailService { - private static final String EMAIL_KEY_PREFIX = "email:"; // 상수로 분리 - private static final String EMAIL_TEMPLATE = "email-template"; // 상수로 분리 - - private final JavaMailSender javaMailSender; - private final SpringTemplateEngine templateEngine; - private final RedisTemplate redisTemplate; - private final Random random = new SecureRandom(); - - @Async - public void sendEmailNotice(String email) { - MimeMessage mimeMessage = javaMailSender.createMimeMessage(); - try { - String emailVerificationCode = createEmailVerificationCode(email); - MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); - mimeMessageHelper.setTo(email); - mimeMessageHelper.setSubject("인증 번호 안내"); // 메일 제목 - mimeMessageHelper.setText(setContext(emailVerificationCode), true); - mimeMessageHelper.setFrom("noreply@korea-profanity-filter.com", "한국어 비속어 검증 API [말조심하세욧]"); - javaMailSender.send(mimeMessage); - - log.info("Succeeded to send Email {}", email); - } catch (Exception e) { - log.info("Failed to send Email : {}", e.getMessage()); - } - } - - public String setContext(String code) { - // 현재 시간으로부터 5분 후의 시간 계산 - LocalDateTime expirationTime = LocalDateTime.now(ZoneId.of("Asia/Seoul")).plusMinutes(5); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH시 mm분 ss초"); - String formattedTime = expirationTime.format(formatter); - - Context context = new Context(); - context.setVariable("code", code); - context.setVariable("expirationTime", formattedTime); - return templateEngine.process(EMAIL_TEMPLATE, context); - } - - public String createEmailVerificationCode(String email) { - String code = String.format("%06d", random.nextInt(1000000)); - redisTemplate.opsForValue().set(EMAIL_KEY_PREFIX + email, code, Duration.ofMinutes(5)); - return code; - } - - public boolean verifyEmailCode(String email, String code) { - String storedCode = redisTemplate.opsForValue().get(EMAIL_KEY_PREFIX + email); - if (storedCode != null && storedCode.equals(code)) { - redisTemplate.delete(EMAIL_KEY_PREFIX + email); - return true; - } - return false; - } -} diff --git a/profanity-api/src/main/java/app/application/auth/ApiKeyOwnershipClaimListener.java b/profanity-api/src/main/java/app/application/auth/ApiKeyOwnershipClaimListener.java new file mode 100644 index 0000000..2508676 --- /dev/null +++ b/profanity-api/src/main/java/app/application/auth/ApiKeyOwnershipClaimListener.java @@ -0,0 +1,24 @@ +package app.application.auth; + +import app.application.apikey.ApiKeyOwnershipService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ApiKeyOwnershipClaimListener { + private final ApiKeyOwnershipService apiKeyOwnershipService; + + @Async + @EventListener + public void claim(ApiKeyOwnershipClaimRequested event) { + int claimed = apiKeyOwnershipService.claimUnownedKeys(event.userId(), event.verifiedEmail()); + if (claimed > 0) { + log.info("기존 API Key 소유권 연결 완료 userId={} count={}", event.userId(), claimed); + } + } +} diff --git a/profanity-api/src/main/java/app/application/auth/ApiKeyOwnershipClaimRequested.java b/profanity-api/src/main/java/app/application/auth/ApiKeyOwnershipClaimRequested.java new file mode 100644 index 0000000..4411c20 --- /dev/null +++ b/profanity-api/src/main/java/app/application/auth/ApiKeyOwnershipClaimRequested.java @@ -0,0 +1,5 @@ +package app.application.auth; + +import java.util.UUID; + +public record ApiKeyOwnershipClaimRequested(UUID userId, String verifiedEmail) {} diff --git a/profanity-api/src/main/java/app/application/auth/LoginAuthService.java b/profanity-api/src/main/java/app/application/auth/LoginAuthService.java index b5073c9..22c02f2 100644 --- a/profanity-api/src/main/java/app/application/auth/LoginAuthService.java +++ b/profanity-api/src/main/java/app/application/auth/LoginAuthService.java @@ -15,6 +15,7 @@ import java.time.Instant; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,11 +31,14 @@ public class LoginAuthService implements SsoLoginCompletionService { private final LoginJwtService loginJwtService; private final LoginSessionProperties properties; private final Clock loginAuthClock; + private final ApplicationEventPublisher eventPublisher; @Override public String issueExchangeCode(OAuthLoginProfile profile) { Instant now = loginAuthClock.instant(); UserAccount userAccount = ssoAccountService.upsert(profile, now); + eventPublisher.publishEvent( + new ApiKeyOwnershipClaimRequested(userAccount.getId(), userAccount.getPrimaryEmail())); OpaqueToken exchangeCode = opaqueTokenService.generate(); exchangeCodeService.issue( userAccount.getId(), exchangeCode.hash(), now, properties.exchangeCodeTtl()); diff --git a/profanity-api/src/main/java/app/config/OpenApiConfig.java b/profanity-api/src/main/java/app/config/OpenApiConfig.java index d658f37..66d0dbc 100644 --- a/profanity-api/src/main/java/app/config/OpenApiConfig.java +++ b/profanity-api/src/main/java/app/config/OpenApiConfig.java @@ -14,7 +14,7 @@ type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.HEADER, paramName = "x-api-key", - description = "클라이언트 등록 후 발급받은 API Key") + description = "SSO 로그인 후 개발자 포털에서 발급받은 API Key") public class OpenApiConfig { @Bean diff --git a/profanity-api/src/main/java/app/dto/request/ClientRegistRequest.java b/profanity-api/src/main/java/app/dto/request/ClientRegistRequest.java deleted file mode 100644 index 6c25552..0000000 --- a/profanity-api/src/main/java/app/dto/request/ClientRegistRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package app.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.Builder; - -@Builder -@Schema(description = "신규 클라이언트 등록 요청") -public record ClientRegistRequest( - @Schema(description = "이름 또는 조직명. 실제 정보 입력을 권장합니다.", example = "대학과제 프로젝트 X") - @NotBlank(message = "이름(조직명)은 필수 입력값입니다") - @Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하로 입력해주세요") - String name, - @Schema(description = "API Key 발급에 사용할 이메일", example = "user@example.com") - @NotBlank(message = "이메일은 필수 입력값입니다") - @Email(message = "올바른 이메일 형식이 아닙니다") - String email, - @Schema(description = "발급자 정보", example = "개인 프로젝트 비속어 필터링") - @NotBlank(message = "발급자 정보는 필수 입력값입니다") - @Size(max = 200, message = "발급자 정보는 200자 이하로 입력해주세요") - String issuerInfo, - @Schema(description = "메모", example = "테스트 환경에서 사용") - @Size(max = 500, message = "메모는 500자 이하로 입력해주세요") - String note) { - public ClientRegistCommand toCommand() { - return ClientRegistCommand.from(name(), email(), issuerInfo(), note()); - } -} diff --git a/profanity-api/src/main/java/app/dto/request/ClientUpdateRequest.java b/profanity-api/src/main/java/app/dto/request/ClientUpdateRequest.java deleted file mode 100644 index ccc57a6..0000000 --- a/profanity-api/src/main/java/app/dto/request/ClientUpdateRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package app.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -@Schema(description = "클라이언트 정보 수정 요청") -public record ClientUpdateRequest( - @Schema(description = "수정할 발급자 정보", example = "운영 서비스 비속어 필터링") - @NotBlank(message = "발급자 정보는 필수 입력값입니다") - @Size(max = 200, message = "발급자 정보는 200자 이하로 입력해주세요") - String issuerInfo, - @Schema(description = "수정할 메모", example = "운영 환경에서 사용") - @Size(max = 500, message = "메모는 500자 이하로 입력해주세요") - String note) {} diff --git a/profanity-api/src/main/java/app/dto/request/CreateApiKeyRequest.java b/profanity-api/src/main/java/app/dto/request/CreateApiKeyRequest.java new file mode 100644 index 0000000..2c21a14 --- /dev/null +++ b/profanity-api/src/main/java/app/dto/request/CreateApiKeyRequest.java @@ -0,0 +1,14 @@ +package app.dto.request; + +import app.application.apikey.ApiKeyManagementService.CreateApiKeyCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateApiKeyRequest( + @NotBlank @Size(max = 100) String name, + @NotBlank @Size(max = 255) String issuerInfo, + @Size(max = 255) String note) { + public CreateApiKeyCommand toCommand() { + return new CreateApiKeyCommand(name, issuerInfo, note); + } +} diff --git a/profanity-api/src/main/java/app/dto/request/MailPayloadRequest.java b/profanity-api/src/main/java/app/dto/request/MailPayloadRequest.java deleted file mode 100644 index ee97566..0000000 --- a/profanity-api/src/main/java/app/dto/request/MailPayloadRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package app.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; - -@Schema(description = "이메일 인증 코드 검증 요청") -public record MailPayloadRequest( - @Schema(description = "인증할 이메일", example = "user@example.com") - @Email(message = "유효한 이메일 형식이 아닙니다") - @NotBlank(message = "이메일은 필수 입력값입니다") - String email, - @Schema(description = "이메일로 발송된 인증 코드", example = "123456") - @NotBlank(message = "인증 코드는 필수 입력값입니다") - String code) {} diff --git a/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java b/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java index f22cb2f..3d23f3d 100644 --- a/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java +++ b/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java @@ -27,8 +27,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException ex) { log.warn("비즈니스 예외 발생: {}", ex.getMessage()); - StatusCode resolve = StatusCode.resolve(ex.getMessage()); - return ApiResponse.error(Status.of(resolve)); + return ApiResponse.error(ex.getStatus()); } @ExceptionHandler(BadCredentialsException.class) diff --git a/profanity-api/src/main/java/app/openapi/ClientsOpenApi.java b/profanity-api/src/main/java/app/openapi/ClientsOpenApi.java deleted file mode 100644 index 0ba9fd5..0000000 --- a/profanity-api/src/main/java/app/openapi/ClientsOpenApi.java +++ /dev/null @@ -1,76 +0,0 @@ -package app.openapi; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -public final class ClientsOpenApi { - private ClientsOpenApi() {} - - @Target(ElementType.TYPE) - @Retention(RetentionPolicy.RUNTIME) - @Tag(name = "Clients", description = "API Key 발급 및 클라이언트 정보 관리 API") - public @interface ApiTag {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "클라이언트 정보 확인", - description = "발급된 API Key를 사용하여 가입 시 작성한 클라이언트 정보를 확인합니다.", - security = @SecurityRequirement(name = "ApiKeyAuth")) - public @interface GetClient {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "클라이언트 폐기", - description = "발급된 API Key를 사용하여 클라이언트 정보를 폐기합니다.", - security = @SecurityRequirement(name = "ApiKeyAuth")) - public @interface DiscardClient {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "신규 클라이언트 등록", - description = - """ - 사용자 정보를 등록하고 API Key를 발급합니다. - 생성 시 입력 정보는 최대한 실제 정보를 입력해야 하며, 비정상적인 발급 요청은 무통보 제거될 수 있습니다. - 발급된 API Key는 반드시 안전하게 보관해야 합니다. - """) - public @interface RegisterClient {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "클라이언트 정보 업데이트", - description = "발급된 API Key를 사용하여 클라이언트 발급자 정보와 메모를 업데이트합니다.", - security = @SecurityRequirement(name = "ApiKeyAuth")) - public @interface UpdateClient {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "API Key 재발급", - description = "발급된 API Key를 사용하여 새 API Key를 재발급합니다. 추후 이메일 인증 등 보안 강화 처리가 추가될 수 있습니다.", - security = @SecurityRequirement(name = "ApiKeyAuth")) - public @interface RegenerateApiKey {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "이메일 인증 코드 발송", - description = "발급한 이메일을 통해 인증 코드를 전송합니다.", - parameters = @Parameter(name = "email", description = "인증 코드를 받을 이메일", required = true)) - public @interface SendEmail {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation(summary = "이메일 인증 코드 검증", description = "이메일과 인증 코드를 확인하고 인증된 API Key를 반환합니다.") - public @interface VerifyEmail {} -} diff --git a/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java b/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java index 7b6b603..8426b75 100644 --- a/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java +++ b/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java @@ -32,7 +32,7 @@ private ProfanityOpenApi() {} summary = "비속어 필터링 요청", description = """ - 클라이언트 등록 후 발급받은 API Key로 비속어 검사를 요청합니다. + 개발자 포털에서 발급받은 API Key로 비속어 검사를 요청합니다. QUICK은 원색적인 표현을 간략히 검증하고, NORMAL은 데이터베이스의 모든 비속어를 검증하며, FILTER는 검출된 단어를 마스킹해 반환합니다. diff --git a/profanity-api/src/main/java/app/presentation/ApiKeysController.java b/profanity-api/src/main/java/app/presentation/ApiKeysController.java new file mode 100644 index 0000000..777ac6c --- /dev/null +++ b/profanity-api/src/main/java/app/presentation/ApiKeysController.java @@ -0,0 +1,67 @@ +package app.presentation; + +import app.application.apikey.ApiKeyManagementService; +import app.application.apikey.ApiKeyManagementService.ApiKeyView; +import app.application.apikey.ApiKeyManagementService.IssuedApiKey; +import app.core.data.response.ApiResponse; +import app.dto.request.CreateApiKeyRequest; +import app.security.SecurityContextUtil; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Hidden +@RequestMapping(value = "/api/v1/dashboard/keys", produces = MediaType.APPLICATION_JSON_VALUE) +public class ApiKeysController { + private final ApiKeyManagementService apiKeyManagementService; + + @GetMapping + public ResponseEntity>> list() { + return noStore(apiKeyManagementService.findAll(SecurityContextUtil.getCurrentLoginUserId())); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> issue( + @Valid @RequestBody CreateApiKeyRequest request) { + return noStore( + apiKeyManagementService.issue( + SecurityContextUtil.getCurrentLoginUserId(), + SecurityContextUtil.getCurrentUserEmail(), + request.toCommand())); + } + + @PostMapping("/{apiKeyId}/reissue") + public ResponseEntity> reissue(@PathVariable UUID apiKeyId) { + return noStore( + apiKeyManagementService.reissue(SecurityContextUtil.getCurrentLoginUserId(), apiKeyId)); + } + + @DeleteMapping("/{apiKeyId}") + public ResponseEntity> expire(@PathVariable UUID apiKeyId) { + return noStore( + apiKeyManagementService.expire(SecurityContextUtil.getCurrentLoginUserId(), apiKeyId)); + } + + private ResponseEntity> noStore(T data) { + ResponseEntity> response = ApiResponse.ok(data); + return ResponseEntity.status(response.getStatusCode()) + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .body(response.getBody()); + } +} diff --git a/profanity-api/src/main/java/app/presentation/ClientsController.java b/profanity-api/src/main/java/app/presentation/ClientsController.java deleted file mode 100644 index aef6d15..0000000 --- a/profanity-api/src/main/java/app/presentation/ClientsController.java +++ /dev/null @@ -1,114 +0,0 @@ -package app.presentation; - -import app.application.EmailService; -import app.application.client.ClientMetadataReader; -import app.application.client.ClientsCommandService; -import app.core.data.response.ApiResponse; -import app.core.data.response.Status; -import app.core.data.response.constant.StatusCode; -import app.domain.client.ClientMetadata; -import app.dto.request.ClientRegistCommand; -import app.dto.request.ClientRegistRequest; -import app.dto.request.ClientUpdateRequest; -import app.dto.request.MailPayloadRequest; -import app.dto.response.ApiKeyReissueResponse; -import app.dto.response.ClientsRegistResponse; -import app.dto.response.EmailVerificationResponse; -import app.openapi.ClientsOpenApi; -import app.security.SecurityContextUtil; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping(value = "/api/v1/clients", produces = MediaType.APPLICATION_JSON_VALUE) -@ClientsOpenApi.ApiTag -public class ClientsController { - private final ClientsCommandService clientsCommandService; - private final ClientMetadataReader clientReader; - private final EmailService emailService; - - @ClientsOpenApi.GetClient - @GetMapping - public ResponseEntity> get() { - final String apikey = SecurityContextUtil.getCurrentApikey(); - if (apikey == null || apikey.isBlank()) { - return ApiResponse.error(Status.of(StatusCode.UNAUTHORIZED)); - } - ClientMetadata read = clientReader.read(apikey); - return ApiResponse.ok(read); - } - - @ClientsOpenApi.DiscardClient - @DeleteMapping - public ResponseEntity> discardClient() { - final String apikey = SecurityContextUtil.getCurrentApikey(); - clientsCommandService.discardClient(apikey); - return ApiResponse.ok(Boolean.TRUE); - } - - @ClientsOpenApi.RegisterClient - @PostMapping("/register") - public ResponseEntity> registerClient( - @RequestBody @Valid ClientRegistRequest request) { - final ClientRegistCommand command = request.toCommand(); - ClientsRegistResponse response = clientsCommandService.registerNewClient(command); - return ApiResponse.ok(response); - } - - @ClientsOpenApi.UpdateClient - @PostMapping("/update") - public ResponseEntity> updateClient( - @RequestBody @Valid ClientUpdateRequest request) { - final String apikey = SecurityContextUtil.getCurrentApikey(); - final String issuerInfo = request.issuerInfo(); - final String note = request.note(); - - ClientMetadata response = clientsCommandService.updateClientInfo(apikey, issuerInfo, note); - return ApiResponse.ok(response); - } - - @ClientsOpenApi.RegenerateApiKey - @PostMapping("/reissue") - public ResponseEntity> regenerateApiKey() { - String currentApiKey = SecurityContextUtil.getCurrentApikey(); - String newApiKey = clientsCommandService.regenerateApiKey(currentApiKey); - return ApiResponse.ok(new ApiKeyReissueResponse(newApiKey)); - } - - @ClientsOpenApi.SendEmail - @GetMapping("/send-email") - public ResponseEntity> sendEmail(@RequestParam("email") String email) { - boolean verified = clientReader.verifyClientByEmail(email); - if (Boolean.FALSE.equals(verified)) { - return ApiResponse.error(Status.of(StatusCode.BAD_REQUEST, "해당 이메일로 가입된 사용자가 없습니다.")); - } - emailService.sendEmailNotice(email); - return ApiResponse.ok("send email"); - } - - @ClientsOpenApi.VerifyEmail - @PutMapping("/send-email") - public ResponseEntity> verifyEmail( - @Valid @RequestBody MailPayloadRequest request) { - boolean verified = emailService.verifyEmailCode(request.email(), request.code()); - if (Boolean.TRUE.equals(verified)) { - String apikey = clientReader.getApiKeyByEmail(request.email()); - return ApiResponse.ok(new EmailVerificationResponse(apikey)); - } else { - return ApiResponse.error(Status.of(StatusCode.BAD_REQUEST, "이메일 인증 코드가 올바르지 않습니다.")); - } - } -} diff --git a/profanity-api/src/main/java/app/presentation/ProfanityController.java b/profanity-api/src/main/java/app/presentation/ProfanityController.java index 2a2515f..f6e707f 100644 --- a/profanity-api/src/main/java/app/presentation/ProfanityController.java +++ b/profanity-api/src/main/java/app/presentation/ProfanityController.java @@ -9,6 +9,7 @@ import app.dto.request.ApiRequest; import app.dto.request.FilterRequest; import app.openapi.ProfanityOpenApi; +import app.security.SecurityContextUtil; import app.security.annotation.VerifiedClientOnly; import io.swagger.v3.oas.annotations.Hidden; import jakarta.servlet.http.HttpServletRequest; @@ -57,7 +58,12 @@ public ResponseEntity basicProfanity( request.isAsync()); final FilterRequest filterRequest = - FilterRequest.create(request.text(), request.mode(), apiKey, clientIp, referrer); + FilterRequest.create( + request.text(), + request.mode(), + SecurityContextUtil.getCurrentApiKeyHash(), + clientIp, + referrer); if (request.isAsync()) { FilterApiResponse response = @@ -88,7 +94,12 @@ public ResponseEntity basicProfanityByUrlencodedValue( request.text() == null ? 0 : request.text().length()); final FilterRequest filterRequest = - FilterRequest.create(request.text(), request.mode(), apiKey, clientIp, referrer); + FilterRequest.create( + request.text(), + request.mode(), + SecurityContextUtil.getCurrentApiKeyHash(), + clientIp, + referrer); FilterApiResponse response = profanityHandler.requestFacadeFilter(filterRequest, null); return ResponseEntity.ok(response); } diff --git a/profanity-api/src/main/java/app/security/SecurityConfig.java b/profanity-api/src/main/java/app/security/SecurityConfig.java index 744261f..89432c8 100644 --- a/profanity-api/src/main/java/app/security/SecurityConfig.java +++ b/profanity-api/src/main/java/app/security/SecurityConfig.java @@ -121,25 +121,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( HttpMethod.GET, "/openapi.json", "/overview.md", "/llms.txt", "/llm.txt") .permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/clients/register") - .permitAll() - .requestMatchers( - HttpMethod.GET, "/api/v1/clients/send-email", "/api/v1/auth/csrf") - .permitAll() - .requestMatchers(HttpMethod.PUT, "/api/v1/clients/send-email") + .requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf") .permitAll() .requestMatchers( HttpMethod.POST, "/api/v1/auth/exchange", "/api/v1/auth/refresh") .permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/auth/me", "/api/v1/dashboard/**") + .requestMatchers(HttpMethod.GET, "/api/v1/auth/me") .hasAuthority(AUTH_LOGIN_JWT) - .requestMatchers( - "/api/v1/filter/**", - "/api/v1/clients", - "/api/v1/clients/update", - "/api/v1/clients/reissue", - "/api/v1/word/**", - "/api/v1/sync") + .requestMatchers("/api/v1/dashboard/**") + .hasAuthority(AUTH_LOGIN_JWT) + .requestMatchers("/api/v1/filter/**", "/api/v1/word/**", "/api/v1/sync") .hasAuthority(AUTH_API_KEY) .anyRequest() .denyAll()) diff --git a/profanity-api/src/main/java/app/security/SecurityContextUtil.java b/profanity-api/src/main/java/app/security/SecurityContextUtil.java index 083f033..c408b45 100644 --- a/profanity-api/src/main/java/app/security/SecurityContextUtil.java +++ b/profanity-api/src/main/java/app/security/SecurityContextUtil.java @@ -121,6 +121,11 @@ public static String getCurrentApiKey() { return apiKey; } + /** 현재 API Key의 저장용 SHA-256 해시를 반환합니다. */ + public static String getCurrentApiKeyHash() { + return getApiKeyPrincipalWithCheck().keyHash(); + } + /** * 현재 인증된 사용자의 이메일을 반환합니다. * diff --git a/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java b/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java index 6fbc8eb..e787753 100644 --- a/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java +++ b/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java @@ -1,7 +1,7 @@ package app.security.authentication; -import app.application.client.MetadataReader; -import app.domain.client.ClientMetadata; +import app.application.apikey.ApiKeyMetadataReader; +import app.domain.apikey.ApiKeyMetadata; import app.security.filter.RequestCredential; import java.util.ArrayList; import java.util.List; @@ -17,7 +17,7 @@ public class ApiKeyAuthenticator implements RequestAuthenticator { private static final String ROLE_PREFIX = "ROLE_"; - private final MetadataReader clientMetadataReader; + private final ApiKeyMetadataReader apiKeyReader; @Override public AuthenticationType supports() { @@ -27,9 +27,9 @@ public AuthenticationType supports() { @Override public Authentication authenticate(RequestCredential credential) { String apiKey = credential.value(); - ClientMetadata metadata; + ApiKeyMetadata metadata; try { - metadata = clientMetadataReader.read(apiKey); + metadata = apiKeyReader.read(apiKey); } catch (IllegalArgumentException | NoSuchElementException exception) { throw new BadCredentialsException(exception.getMessage(), exception); } @@ -40,7 +40,8 @@ public Authentication authenticate(RequestCredential credential) { metadata.email(), metadata.issuerInfo(), metadata.permissions(), - metadata.issuedAt()); + metadata.issuedAt(), + metadata.keyHash()); List authorities = new ArrayList<>(); authorities.add(AuthenticationType.API_KEY.authority()); authorities.addAll( diff --git a/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java b/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java index 319116c..8038fcd 100644 --- a/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java +++ b/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java @@ -3,9 +3,14 @@ import java.util.List; import java.util.UUID; -/** 기존 외부 API 클라이언트의 인증 주체입니다. API Key 원문은 포함하지 않습니다. */ +/** 외부 API Key 인증 주체입니다. API Key 원문은 포함하지 않습니다. */ public record ApiKeyPrincipal( - UUID id, String email, String issuerInfo, List permissions, String issuedAt) + UUID id, + String email, + String issuerInfo, + List permissions, + String issuedAt, + String keyHash) implements ServicePrincipal { public ApiKeyPrincipal { diff --git a/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java b/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java deleted file mode 100644 index be7bedf..0000000 --- a/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java +++ /dev/null @@ -1,43 +0,0 @@ -package app.security.authentication; - -import java.io.Serializable; -import java.util.List; -import java.util.UUID; -import lombok.Builder; - -@Builder -public record CustomPrincipal( - String apiKey, - UUID id, - String email, - String issuerInfo, - List permissions, - String issuedAt) - implements Serializable { - - @Override - public String toString() { - return "CustomPrincipal[id=" + id + ", email=" + email + ", apiKey=redacted]"; - } - - public static CustomPrincipal of(Object principal) { - if (principal == null) { - return null; - } - if (principal instanceof CustomPrincipal customPrincipal) { - return customPrincipal; - } - return null; - } - - public static CustomPrincipal anonymous() { - return CustomPrincipal.builder() - .apiKey("anonymous") - .id(UUID.fromString("00000000-0000-0000-0000-000000000000")) - .email("anonymous") - .issuerInfo("anonymous") - .permissions(List.of("anonymous")) - .issuedAt("anonymous") - .build(); - } -} diff --git a/profanity-api/src/main/java/app/security/filter/ExcludePath.java b/profanity-api/src/main/java/app/security/filter/ExcludePath.java index c64764b..c51d4b5 100644 --- a/profanity-api/src/main/java/app/security/filter/ExcludePath.java +++ b/profanity-api/src/main/java/app/security/filter/ExcludePath.java @@ -6,9 +6,7 @@ @Getter public enum ExcludePath { - CLIENTS("clients/register", "클라이언트 관련 API", List.of(HttpMethod.POST)), RESOURCE("resource", "리소스 관련 API", List.of(HttpMethod.GET)), - EMAIL("send-email", "이메일 관련 API", List.of(HttpMethod.GET, HttpMethod.PUT)), HEALTH("health", "헬스 체크 API", List.of(HttpMethod.GET)), PING("ping", "헬스 체크 API", List.of(HttpMethod.GET)), OPENAPI("openapi.json", "OpenAPI JSON 스펙", List.of(HttpMethod.GET)), diff --git a/profanity-api/src/main/resources/openapi/authentication.md b/profanity-api/src/main/resources/openapi/authentication.md index ae596b3..830cfba 100644 --- a/profanity-api/src/main/resources/openapi/authentication.md +++ b/profanity-api/src/main/resources/openapi/authentication.md @@ -6,6 +6,8 @@ x-api-key: YOUR_SECRET_TOKEN ``` +API Key는 SSO 로그인 후 개발자 포털에서 발급·재발행·만료할 수 있습니다. 원문은 발급 또는 재발행 직후 한 번만 제공되므로 안전한 저장소에 보관해야 합니다. + 보호된 API는 발급받은 API Key를 `x-api-key` 헤더에 포함해야 합니다. API Key가 없거나 유효하지 않으면 응답 본문의 `status.code`로 실패 원인을 확인합니다. ### Credential 정책 @@ -21,6 +23,6 @@ x-api-key: YOUR_SECRET_TOKEN | `4004` | 다중 또는 중복 credential 제출 | | `4010` | API Key 누락 | | `4017` | 외부 API용 OAuth2 access token 미지원 | -| `4030` | 권한 부족 또는 차단된 클라이언트 | -| `4031` | 클라이언트 정보 없음 | +| `4030` | 권한 부족, 차단 또는 폐기 상태 | +| `4031` | API Key 정보 없음 또는 만료 | | `4032` | 유효하지 않은 API Key | diff --git a/profanity-api/src/main/resources/openapi/overview.md b/profanity-api/src/main/resources/openapi/overview.md index 1eb4060..9e989b3 100644 --- a/profanity-api/src/main/resources/openapi/overview.md +++ b/profanity-api/src/main/resources/openapi/overview.md @@ -4,10 +4,12 @@ ### 시작하기 -1. `POST /api/v1/clients/register`로 API Key를 발급합니다. +1. 개발자 포털에서 Google 또는 GitHub SSO로 로그인하고 API Key를 발급합니다. 2. 보호된 API는 `x-api-key` 헤더를 포함해 호출합니다. 3. `POST /api/v1/filter`에 `text`와 `mode`를 전달합니다. +API Key 원문은 발급·재발행 직후 한 번만 표시되며 분실한 키는 복구하지 않고 재발행합니다. + ### 처리 모드 | Mode | 설명 | diff --git a/profanity-api/src/test/java/app/application/EmailServiceTest.java b/profanity-api/src/test/java/app/application/EmailServiceTest.java deleted file mode 100644 index 85820b4..0000000 --- a/profanity-api/src/test/java/app/application/EmailServiceTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package app.application; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -@ExtendWith(MockitoExtension.class) -class EmailServiceTest { - - @Mock private RedisTemplate redisTemplate; - - @Mock private ValueOperations valueOperations; - - @InjectMocks private EmailService emailService; - - @Nested - @DisplayName("이메일 인증 코드를 검증할 때") - class VerifyEmailCode { - - @Test - @DisplayName("저장된 코드와 입력된 코드가 일치하면 true를 반환한다") - void returnsTrueWhenCodesMatch() { - // given - String email = "test@example.com"; - String code = "123456"; - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("email:" + email)).thenReturn(code); - - // when - boolean result = emailService.verifyEmailCode(email, code); - - // then - assertTrue(result); - verify(redisTemplate).delete("email:" + email); - } - - @Test - @DisplayName("저장된 코드와 입력된 코드가 일치하지 않으면 false를 반환한다") - void returnsFalseWhenCodesDontMatch() { - // given - String email = "test@example.com"; - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("email:" + email)).thenReturn("654321"); - - // when - boolean result = emailService.verifyEmailCode(email, "123456"); - - // then - assertFalse(result); - verify(redisTemplate, never()).delete(anyString()); - } - - @Test - @DisplayName("저장된 코드가 없으면 false를 반환한다") - void returnsFalseWhenNoStoredCode() { - // given - String email = "test@example.com"; - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("email:" + email)).thenReturn(null); - - // when - boolean result = emailService.verifyEmailCode(email, "123456"); - - // then - assertFalse(result); - verify(redisTemplate, never()).delete(anyString()); - } - } - - @Nested - @DisplayName("이메일 인증 코드를 생성할 때") - class CreateEmailVerificationCode { - - @Test - @DisplayName("6자리 숫자 코드를 생성하고 Redis에 저장한다") - void createsAndStoresSixDigitCode() { - // given - String email = "test@example.com"; - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // when - String code = emailService.createEmailVerificationCode(email); - - // then - assertNotNull(code); - assertEquals(6, code.length()); - assertTrue(code.matches("\\d{6}")); - verify(valueOperations).set(eq("email:" + email), anyString(), any(Duration.class)); - } - } -} diff --git a/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java b/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java new file mode 100644 index 0000000..63f9d7c --- /dev/null +++ b/profanity-api/src/test/java/app/e2e/ApiKeysE2ETest.java @@ -0,0 +1,209 @@ +package app.e2e; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import app.application.auth.LoginAuthService; +import app.core.data.response.constant.StatusCode; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; +import com.fasterxml.jackson.databind.JsonNode; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import javax.sql.DataSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; + +class ApiKeysE2ETest extends AbstractApiTester { + @Autowired private MockMvc mockMvc; + @Autowired private LoginAuthService loginAuthService; + @Autowired private DataSource dataSource; + + @Test + @DisplayName("기존 API Key는 최초 SSO 로그인 시 검증된 이메일로 한 번만 연결된다") + void login_matchingLegacyEmail_claimsMigratedKeyOnce() throws Exception { + String token = login("e2e-read@example.com", "legacy-owner"); + + awaitOwnership("e2e-read@example.com"); + JsonNode first = body(list(token)).at("/data"); + String secondToken = login("e2e-read@example.com", "legacy-owner"); + awaitOwnership("e2e-read@example.com"); + JsonNode second = body(list(secondToken)).at("/data"); + + assertThat(first).hasSize(1); + assertThat(first.get(0).at("/keyHint").asText()).isEqualTo("Hmikqf...-nDU"); + assertThat(second).hasSize(1); + } + + @Test + @DisplayName("로그인 사용자는 여러 API Key를 발급하고 목록에서는 원문을 다시 볼 수 없다") + void issue_multipleKeys_returnsPlaintextOnlyFromIssueResponse() throws Exception { + String token = login("keys-owner@gmail.com", "keys-owner"); + + JsonNode first = body(issue(token, "개발", "dev-server")).at("/data"); + JsonNode second = body(issue(token, "운영", "prod-server")).at("/data"); + JsonNode keys = body(list(token)).at("/data"); + + assertThat(first.at("/apiKey").asText()).isNotBlank(); + assertThat(second.at("/apiKey").asText()).isNotEqualTo(first.at("/apiKey").asText()); + assertThat(keys).hasSize(2); + assertThat(keys.toString()).doesNotContain(first.at("/apiKey").asText()); + assertThat(keys.toString()).doesNotContain(second.at("/apiKey").asText()); + assertThat(keys.get(0).at("/email").asText()).isEqualTo("keys-owner@gmail.com"); + } + + @Test + @DisplayName("재발행하면 이전 API Key 인증은 실패하고 새 API Key는 성공한다") + void reissue_activeKey_rotatesAuthenticationCredential() throws Exception { + String token = login("rotation-owner@gmail.com", "rotation-owner"); + JsonNode issued = body(issue(token, "운영", "prod-server")).at("/data"); + String oldKey = issued.at("/apiKey").asText(); + String keyId = issued.at("/key/id").asText(); + + MockHttpServletResponse reissued = + mockMvc + .perform( + post("/api/v1/dashboard/keys/{keyId}/reissue", keyId) + .header(HttpHeaders.AUTHORIZATION, bearer(token))) + .andReturn() + .getResponse(); + String newKey = body(reissued).at("/data/apiKey").asText(); + + assertThat(filter(oldKey).at("/status/code").asInt()) + .isEqualTo(StatusCode.NOT_FOUND_CLIENT.code()); + assertThat(filter(newKey).at("/status/code").asInt()).isEqualTo(StatusCode.OK.code()); + } + + @Test + @DisplayName("만료는 멱등 처리되고 만료된 API Key 인증을 거부한다") + void expire_twice_isIdempotentAndRejectsCredential() throws Exception { + String token = login("expire-owner@gmail.com", "expire-owner"); + JsonNode issued = body(issue(token, "일회성", "batch-server")).at("/data"); + String apiKey = issued.at("/apiKey").asText(); + String keyId = issued.at("/key/id").asText(); + + JsonNode first = body(expire(token, keyId)).at("/data"); + JsonNode second = body(expire(token, keyId)).at("/data"); + + assertThat(second.at("/expiredAt").asText()).isEqualTo(first.at("/expiredAt").asText()); + assertThat(filter(apiKey).at("/status/code").asInt()) + .isEqualTo(StatusCode.NOT_FOUND_CLIENT.code()); + } + + @Test + @DisplayName("다른 사용자의 API Key는 조회하거나 변경할 수 없다") + void ownership_otherUser_isIsolated() throws Exception { + String ownerToken = login("owner@gmail.com", "owner"); + String otherToken = login("other@gmail.com", "other"); + String keyId = body(issue(ownerToken, "운영", "owner-server")).at("/data/key/id").asText(); + + assertThat(body(list(otherToken)).at("/data")).isEmpty(); + MockHttpServletResponse response = + mockMvc + .perform( + post("/api/v1/dashboard/keys/{keyId}/reissue", keyId) + .header(HttpHeaders.AUTHORIZATION, bearer(otherToken))) + .andReturn() + .getResponse(); + + assertThat(body(response).at("/status/code").asInt()) + .isEqualTo(StatusCode.API_KEY_NOT_FOUND.code()); + } + + private MockHttpServletResponse issue(String token, String name, String issuerInfo) + throws Exception { + return mockMvc + .perform( + post("/api/v1/dashboard/keys") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of("name", name, "issuerInfo", issuerInfo, "note", "test")))) + .andReturn() + .getResponse(); + } + + private MockHttpServletResponse list(String token) throws Exception { + return mockMvc + .perform(get("/api/v1/dashboard/keys").header(HttpHeaders.AUTHORIZATION, bearer(token))) + .andReturn() + .getResponse(); + } + + private MockHttpServletResponse expire(String token, String keyId) throws Exception { + return mockMvc + .perform( + delete("/api/v1/dashboard/keys/{keyId}", keyId) + .header(HttpHeaders.AUTHORIZATION, bearer(token))) + .andReturn() + .getResponse(); + } + + private JsonNode filter(String apiKey) throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + post("/api/v1/filter") + .header("X-API-KEY", apiKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"text\":\"hello\",\"mode\":\"QUICK\"}")) + .andReturn() + .getResponse(); + return body(response); + } + + private String login(String email, String providerUserId) { + String code = + loginAuthService.issueExchangeCode( + new OAuthLoginProfile( + OAuthProvider.GOOGLE, + providerUserId, + email, + true, + true, + email, + providerUserId, + null)); + return loginAuthService.exchange(code).accessToken(); + } + + private void awaitOwnership(String email) throws Exception { + Instant deadline = Instant.now().plus(Duration.ofSeconds(3)); + while (Instant.now().isBefore(deadline)) { + if (ownedKeyCount(email) == 1) return; + Thread.sleep(25); + } + assertThat(ownedKeyCount(email)).isEqualTo(1); + } + + private int ownedKeyCount(String email) throws Exception { + try (var connection = dataSource.getConnection(); + var statement = + connection.prepareStatement( + "SELECT COUNT(*) FROM api_keys WHERE email = ? AND user_id IS NOT NULL")) { + statement.setString(1, email); + try (var result = statement.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private JsonNode body(MockHttpServletResponse response) throws Exception { + return objectMapper.readTree(response.getContentAsString(StandardCharsets.UTF_8)); + } + + private String bearer(String token) { + return "Bearer " + token; + } +} diff --git a/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java b/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java index 21d7269..eadd853 100644 --- a/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java @@ -43,14 +43,15 @@ void openapiJson_whenRequested_returnsOpenApiSpec() throws Exception { .isEqualTo("x-api-key"); assertThat(body.at("/components/securitySchemes/ApiKeyAuth/description").asText()) .as("ApiKeyAuth 보안 스키마는 x-api-key 헤더 설명을 제공해야 한다") - .isEqualTo("클라이언트 등록 후 발급받은 API Key"); + .isEqualTo("SSO 로그인 후 개발자 포털에서 발급받은 API Key"); assertThat(body.at("/components/securitySchemes/LoginJwtAuth").isMissingNode()).isTrue(); - assertThat(countOperations(body.path("paths"))).isEqualTo(12); + assertThat(countOperations(body.path("paths"))).isEqualTo(5); assertThat(body.at("/paths/~1api~1v1~1filter/post").isMissingNode()).isFalse(); - assertThat(body.at("/paths/~1api~1v1~1clients~1register/post").isMissingNode()).isFalse(); + assertThat(body.at("/paths/~1api~1v1~1clients~1register/post").isMissingNode()).isTrue(); + assertThat(body.at("/paths/~1api~1v1~1dashboard~1keys").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1api~1v1~1health/get").isMissingNode()).isFalse(); assertThat(body.at("/paths/~1api~1v1~1ping/get").isMissingNode()).isFalse(); - assertThat(body.at("/paths/~1api~1v1~1auth").isMissingNode()).isTrue(); + assertThat(body.at("/paths/~1api~1v1~1auth~1exchange").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1api~1v1~1sync").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1api~1v1~1word~1accept~1{requestId}").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1overview.md/get").isMissingNode()).isTrue(); @@ -120,13 +121,6 @@ void openapiJson_whenPublicApiHasSuccessResponse_returnsConcreteSuccessResponseS JsonNode body = objectMapper.readTree(response.getResponse().getContentAsString()); for (OperationPath operationPath : new OperationPath[] { - new OperationPath("/paths/~1api~1v1~1clients/get/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1clients/delete/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1clients~1register/post/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1clients~1update/post/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1clients~1reissue/post/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1clients~1send-email/get/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1clients~1send-email/put/responses/200/content"), new OperationPath("/paths/~1api~1v1~1filter/post/responses/200/content"), new OperationPath("/paths/~1api~1v1~1filter~1advanced/post/responses/200/content"), new OperationPath("/paths/~1api~1v1~1word~1request/post/responses/200/content") @@ -230,15 +224,8 @@ void openapiJson_whenResponseSchemasRendered_returnsDescribedResponseProperties( assertThat( body.at("/components/schemas/Status/properties/DetailDescription/description").asText()) .isNotBlank(); - assertThat( - body.at("/components/schemas/ApiResponseClientMetadata/properties/status/description") - .asText()) - .isNotBlank(); - assertThat(body.at("/components/schemas/ClientMetadata/properties/email/example").asText()) - .isEqualTo("user@example.com"); - assertThat( - body.at("/components/schemas/ClientsRegistResponse/properties/apiKey/example").asText()) - .isEqualTo("pf_sample_issued_api_key"); + assertThat(body.at("/components/schemas/ApiKeyView").isMissingNode()).isTrue(); + assertThat(body.at("/components/schemas/IssuedApiKey").isMissingNode()).isTrue(); } private record OperationPath(String pointer) {} diff --git a/profanity-api/src/test/java/app/e2e/ProfanityFilterE2ETest.java b/profanity-api/src/test/java/app/e2e/ProfanityFilterE2ETest.java index ccef5fd..b872e93 100644 --- a/profanity-api/src/test/java/app/e2e/ProfanityFilterE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/ProfanityFilterE2ETest.java @@ -1,6 +1,6 @@ package app.e2e; -import static app.test.support.fixture.SeedClients.READ_CLIENT; +import static app.test.support.fixture.SeedApiKeys.READ_CLIENT; import static app.test.support.fixture.SeedWords.ACTIVE_PROFANITY_SAMPLE; import static app.test.support.fixture.SeedWords.ACTIVE_SLANG_SAMPLE; import static org.assertj.core.api.Assertions.assertThat; diff --git a/profanity-api/src/test/java/app/e2e/SyncE2ETest.java b/profanity-api/src/test/java/app/e2e/SyncE2ETest.java index 15e3378..1e6415e 100644 --- a/profanity-api/src/test/java/app/e2e/SyncE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/SyncE2ETest.java @@ -1,6 +1,6 @@ package app.e2e; -import static app.test.support.fixture.SeedClients.READ_CLIENT; +import static app.test.support.fixture.SeedApiKeys.READ_CLIENT; import static app.test.support.fixture.SeedManageAccounts.ADMIN; import static org.assertj.core.api.Assertions.assertThat; diff --git a/profanity-api/src/test/java/app/e2e/WordManagementE2ETest.java b/profanity-api/src/test/java/app/e2e/WordManagementE2ETest.java index 583d9b1..570c3dd 100644 --- a/profanity-api/src/test/java/app/e2e/WordManagementE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/WordManagementE2ETest.java @@ -1,7 +1,7 @@ package app.e2e; -import static app.test.support.fixture.SeedClients.READ_CLIENT; -import static app.test.support.fixture.SeedClients.WRITE_CLIENT; +import static app.test.support.fixture.SeedApiKeys.READ_CLIENT; +import static app.test.support.fixture.SeedApiKeys.WRITE_CLIENT; import static org.assertj.core.api.Assertions.assertThat; import app.core.data.response.ApiResponse; @@ -144,14 +144,14 @@ void acceptWord_whenWriteClientRequestsMultipleIds_returnsCurrentServiceResult() } @Test - @DisplayName("READ 권한 클라이언트가 단어 요청을 승인하면 서버 에러 응답으로 말린다") - void acceptWord_whenReadClientRequests_returnsInternalServerError() throws Exception { + @DisplayName("READ 권한 클라이언트가 단어 요청을 승인하면 권한 오류를 반환한다") + void acceptWord_whenReadClientRequests_returnsBadRequest() throws Exception { // when ApiCallResponse> response = wordManagementApi.acceptWord(READ_CLIENT, 1L); // then assertThat(response.result()).hasStatusOk(); - assertThat(response.body().status().code()).isEqualTo(StatusCode.INTERNAL_SERVER_ERROR.code()); + assertThat(response.body().status().code()).isEqualTo(StatusCode.BAD_REQUEST.code()); } @Test diff --git a/profanity-api/src/test/java/app/e2e/client/ProfanityApiClient.java b/profanity-api/src/test/java/app/e2e/client/ProfanityApiClient.java index 202d5ea..5001c42 100644 --- a/profanity-api/src/test/java/app/e2e/client/ProfanityApiClient.java +++ b/profanity-api/src/test/java/app/e2e/client/ProfanityApiClient.java @@ -4,7 +4,7 @@ import app.core.data.response.FilterApiResponse; import app.dto.request.ApiRequest; import app.presentation.ProfanityController; -import app.test.support.fixture.SeedClient; +import app.test.support.fixture.SeedApiKey; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.assertj.MockMvcTester; @@ -31,7 +31,7 @@ private ProfanityApiClient(MockMvcTester mockMvcTester, ObjectMapper objectMappe this.objectMapper = objectMapper; } - public ApiCallResponse basicProfanity(SeedClient client, ApiRequest request) + public ApiCallResponse basicProfanity(SeedApiKey client, ApiRequest request) throws Exception { var result = mockMvcTester @@ -46,7 +46,7 @@ public ApiCallResponse basicProfanity(SeedClient client, ApiR } public ApiCallResponse basicProfanityByUrlencodedValue( - SeedClient client, String text, Mode mode) { + SeedApiKey client, String text, Mode mode) { var result = mockMvcTester .post() @@ -61,11 +61,11 @@ public ApiCallResponse basicProfanityByUrlencodedValue( } public ApiCallResponse basicProfanity( - SeedClient client, String text, Mode mode) throws Exception { + SeedApiKey client, String text, Mode mode) throws Exception { return basicProfanity(client, ApiRequest.builder().text(text).mode(mode).build()); } - public ApiCallResponse advancedProfanity(SeedClient client, String word) { + public ApiCallResponse advancedProfanity(SeedApiKey client, String word) { var result = mockMvcTester .post() diff --git a/profanity-api/src/test/java/app/e2e/client/SyncApiClient.java b/profanity-api/src/test/java/app/e2e/client/SyncApiClient.java index 264729e..a7316ab 100644 --- a/profanity-api/src/test/java/app/e2e/client/SyncApiClient.java +++ b/profanity-api/src/test/java/app/e2e/client/SyncApiClient.java @@ -2,7 +2,7 @@ import app.core.data.manage.response.ResultMessage; import app.presentation.SyncController; -import app.test.support.fixture.SeedClient; +import app.test.support.fixture.SeedApiKey; import app.test.support.fixture.SeedManageAccount; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.test.web.servlet.assertj.MockMvcTester; @@ -29,11 +29,11 @@ private SyncApiClient(MockMvcTester mockMvcTester, ObjectMapper objectMapper) { this.objectMapper = objectMapper; } - public ApiCallResponse doSync(SeedClient client, SeedManageAccount account) { + public ApiCallResponse doSync(SeedApiKey client, SeedManageAccount account) { return doSync(client, account.password()); } - public ApiCallResponse doSync(SeedClient client, String password) { + public ApiCallResponse doSync(SeedApiKey client, String password) { return ApiCallResponse.of( mockMvcTester .get() diff --git a/profanity-api/src/test/java/app/e2e/client/WordManagementApiClient.java b/profanity-api/src/test/java/app/e2e/client/WordManagementApiClient.java index f475c5e..8f0ecac 100644 --- a/profanity-api/src/test/java/app/e2e/client/WordManagementApiClient.java +++ b/profanity-api/src/test/java/app/e2e/client/WordManagementApiClient.java @@ -3,7 +3,7 @@ import app.core.data.response.ApiResponse; import app.dto.request.WordRequest; import app.presentation.WordManagementController; -import app.test.support.fixture.SeedClient; +import app.test.support.fixture.SeedApiKey; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; @@ -36,7 +36,7 @@ private WordManagementApiClient(MockMvcTester mockMvcTester, ObjectMapper object } public ApiCallResponse> requestNewWord( - SeedClient client, WordRequest request) throws Exception { + SeedApiKey client, WordRequest request) throws Exception { var result = mockMvcTester .post() @@ -50,12 +50,12 @@ public ApiCallResponse> requestNewWord( result, objectMapper, new TypeReference>() {}); } - public ApiCallResponse> acceptWord(SeedClient client, Long requestId) { + public ApiCallResponse> acceptWord(SeedApiKey client, Long requestId) { return acceptWord(client, List.of(requestId)); } public ApiCallResponse> acceptWord( - SeedClient client, List requestIds) { + SeedApiKey client, List requestIds) { String requestPath = requestIds.stream() .map(String::valueOf) diff --git a/profanity-api/src/test/java/app/migration/ApiKeyMigrationTest.java b/profanity-api/src/test/java/app/migration/ApiKeyMigrationTest.java new file mode 100644 index 0000000..11f9be3 --- /dev/null +++ b/profanity-api/src/test/java/app/migration/ApiKeyMigrationTest.java @@ -0,0 +1,120 @@ +package app.migration; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.test.support.container.MySqlTestContainer; +import java.sql.Connection; +import java.sql.DriverManager; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.mysql.MySQLContainer; + +class ApiKeyMigrationTest { + private static final String CLIENT_ID = "10000000-0000-0000-0000-000000000001"; + private static final String API_KEY = "legacy-plaintext-api-key"; + + @Test + @DisplayName("V4는 clients와 API Key 원문을 api_keys 해시 모델로 손실 없이 전환한다") + void migrate_v3ToV4_backfillsKeysAndRemovesLegacyStorage() throws Exception { + try (MySQLContainer container = MySqlTestContainer.create()) { + container.start(); + Flyway.configure() + .dataSource(container.getJdbcUrl(), container.getUsername(), container.getPassword()) + .locations("classpath:db/migration") + .target("3") + .load() + .migrate(); + + try (Connection connection = connection(container)) { + insertLegacyData(connection); + } + + Flyway.configure() + .dataSource(container.getJdbcUrl(), container.getUsername(), container.getPassword()) + .locations("classpath:db/migration") + .load() + .migrate(); + + try (Connection connection = connection(container)) { + assertThat(singleString(connection, "SELECT LOWER(HEX(id)) FROM api_keys")) + .isEqualTo(CLIENT_ID.replace("-", "")); + assertThat(singleString(connection, "SELECT key_hash FROM api_keys")) + .isEqualTo(singleString(connection, "SELECT SHA2('" + API_KEY + "', 256)")); + assertThat(singleString(connection, "SELECT api_key_hash FROM records")) + .isEqualTo(singleString(connection, "SELECT key_hash FROM api_keys")); + assertThat(singleLong(connection, tableCountSql("clients"))).isZero(); + assertThat(singleLong(connection, columnCountSql("records", "api_key"))).isZero(); + assertThat(singleLong(connection, columnCountSql("client_reports", "api_key"))).isZero(); + assertThat(singleLong(connection, "SELECT COUNT(*) FROM client_reports")).isEqualTo(1); + } + } + } + + private void insertLegacyData(Connection connection) throws Exception { + try (var statement = connection.createStatement()) { + statement.executeUpdate( + """ + INSERT INTO clients + (id, name, email, api_key, issuer_info, note, issued_at, permissions, request_count) + VALUES + (UNHEX(REPLACE('%s', '-', '')), 'Legacy', 'legacy@example.com', '%s', + 'legacy-system', 'migration-test', CURRENT_TIMESTAMP, 'READ', 1) + """ + .formatted(CLIENT_ID, API_KEY)); + statement.executeUpdate( + """ + INSERT INTO records + (tracking_id, api_key, request_text, mode, words, ip, created_at) + VALUES + (UNHEX(REPLACE('20000000-0000-0000-0000-000000000002', '-', '')), + '%s', 'text', 'QUICK', '', '127.0.0.1', CURRENT_TIMESTAMP(6)) + """ + .formatted(API_KEY)); + statement.executeUpdate( + """ + INSERT INTO client_reports + (client_id, api_key, report_year, report_month, report_day, request_count, + profanity_detection_count, created_at) + VALUES + (UNHEX(REPLACE('%s', '-', '')), '%s', 2026, 7, 17, 1, 0, CURRENT_TIMESTAMP) + """ + .formatted(CLIENT_ID, API_KEY)); + } + } + + private Connection connection(MySQLContainer container) throws Exception { + return DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + } + + private String singleString(Connection connection, String sql) throws Exception { + try (var statement = connection.createStatement(); + var result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } + + private long singleLong(Connection connection, String sql) throws Exception { + try (var statement = connection.createStatement(); + var result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getLong(1); + } + } + + private String tableCountSql(String table) { + return "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '" + + table + + "'"; + } + + private String columnCountSql(String table, String column) { + return "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '" + + table + + "' AND column_name = '" + + column + + "'"; + } +} diff --git a/profanity-api/src/test/java/app/presentation/ClientsControllerTest.java b/profanity-api/src/test/java/app/presentation/ClientsControllerTest.java deleted file mode 100644 index aee9d5f..0000000 --- a/profanity-api/src/test/java/app/presentation/ClientsControllerTest.java +++ /dev/null @@ -1,9 +0,0 @@ -package app.presentation; - -import app.TestConfig; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; - -@Import(TestConfig.class) -@WebMvcTest(ClientsController.class) -class ClientsControllerTest {} diff --git a/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java b/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java index ce4beff..ce28ae5 100644 --- a/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java +++ b/profanity-api/src/test/java/app/presentation/ProfanityControllerTest.java @@ -12,7 +12,12 @@ import app.core.data.constant.Mode; import app.core.data.response.FilterApiResponse; import app.dto.request.ApiRequest; +import app.security.authentication.ApiKeyPrincipal; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -22,6 +27,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; @WebMvcTest( @@ -36,6 +43,26 @@ class ProfanityControllerTest { @Autowired private ObjectMapper mapper; @Autowired private MockMvc mockMvc; + @BeforeEach + void setApiKeyAuthentication() { + ApiKeyPrincipal principal = + new ApiKeyPrincipal( + UUID.randomUUID(), + "test@example.com", + "test", + List.of("READ"), + "2026-07-17T00:00:00", + "a".repeat(64)); + SecurityContextHolder.getContext() + .setAuthentication( + UsernamePasswordAuthenticationToken.authenticated(principal, "test", List.of())); + } + + @AfterEach + void clearAuthentication() { + SecurityContextHolder.clearContext(); + } + @Nested @DisplayName("Quick 타입의 요청을 할 수 있다.") class QuickRequestTest { diff --git a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java index ff68784..51f24d0 100644 --- a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java +++ b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java @@ -22,7 +22,7 @@ import app.security.filter.RequestCredentialResolver; import app.security.login.LoginSessionProperties; import app.test.support.config.SecurityFakeStubConfig; -import app.test.support.fake.FakeClientMetadataReader; +import app.test.support.fake.FakeApiKeyMetadataReader; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Duration; import java.util.List; @@ -62,7 +62,7 @@ class SecurityAuthenticationTest { @Test @DisplayName("유효한 API 키 요청시 200 OK와 함께 성공 응답을 반환한다") void test_200() throws Exception { - validApiKey = FakeClientMetadataReader.validKeys.get(0); + validApiKey = FakeApiKeyMetadataReader.validKeys.get(0); ApiRequest request = quickRequest("test text"); mockMvc .perform( @@ -148,8 +148,8 @@ void test_4031() throws Exception { @DisplayName("존재하지 않는 API 키 요청시 4040 NOT_FOUND_CLIENT 응답을 반환한다") void test_4040() throws Exception { ApiRequest request = quickRequest("test text"); - String key = FakeClientMetadataReader.validKeys.get(0); - FakeClientMetadataReader.validKeys.remove(key); + String key = FakeApiKeyMetadataReader.validKeys.get(0); + FakeApiKeyMetadataReader.validKeys.remove(key); mockMvc .perform( @@ -161,7 +161,7 @@ void test_4040() throws Exception { .andExpect(jsonPath("$.status.code").value(StatusCode.NOT_FOUND_CLIENT.code())) .andExpect(jsonPath("$.status.message").value(StatusCode.NOT_FOUND_CLIENT.status())); - FakeClientMetadataReader.validKeys.add(key); + FakeApiKeyMetadataReader.validKeys.add(key); } private static ApiRequest quickRequest(String text) { diff --git a/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java b/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java index e55f9b1..0d5aaf1 100644 --- a/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java +++ b/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java @@ -28,7 +28,8 @@ void tearDown() { void apiKeyAuthentication_whenRead_returnsApiClientValues() { UUID clientId = UUID.randomUUID(); ApiKeyPrincipal principal = - new ApiKeyPrincipal(clientId, "client@example.com", "test", List.of("READ"), "2026-07-11"); + new ApiKeyPrincipal( + clientId, "client@example.com", "test", List.of("READ"), "2026-07-11", "key-hash"); setAuthentication( new CustomAuthentication( AuthenticationType.API_KEY, @@ -43,6 +44,7 @@ void apiKeyAuthentication_whenRead_returnsApiClientValues() { assertThat(SecurityContextUtil.getCurrentApiClientId()).isEqualTo(clientId); assertThat(SecurityContextUtil.getCurrentUserId()).isEqualTo(clientId); assertThat(SecurityContextUtil.getCurrentApiKey()).isEqualTo("secret-api-key"); + assertThat(SecurityContextUtil.getCurrentApiKeyHash()).isEqualTo("key-hash"); assertThat(SecurityContextUtil.getCurrentUserPermissions()).containsExactly("READ"); assertThat(SecurityContextUtil.isVerifiedClient()).isTrue(); } @@ -80,7 +82,8 @@ void apiKeyAuthentication_withBlockedPermission_isBlocked() { "blocked@example.com", "test", List.of(PermissionsType.BLOCK.getValue()), - "2026-07-11"); + "2026-07-11", + "key-hash"); setAuthentication( new CustomAuthentication(AuthenticationType.API_KEY, "redacted", List.of(), principal)); @@ -96,7 +99,8 @@ void authentication_toString_redactsCredential() { AuthenticationType.API_KEY, "must-not-leak", List.of(), - new ApiKeyPrincipal(UUID.randomUUID(), "client@example.com", "test", List.of(), "now")); + new ApiKeyPrincipal( + UUID.randomUUID(), "client@example.com", "test", List.of(), "now", "key-hash")); assertThat(authentication.toString()).doesNotContain("must-not-leak"); } diff --git a/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java b/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java index 0f5c5d6..4d63747 100644 --- a/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java +++ b/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; -import app.application.client.MetadataReader; -import app.domain.client.ClientMetadata; +import app.application.apikey.ApiKeyMetadataReader; +import app.domain.apikey.ApiKeyMetadata; import app.security.filter.RequestCredential; import java.util.List; import java.util.UUID; @@ -17,27 +17,17 @@ class ApiKeyAuthenticatorTest { @DisplayName("API Key 검증 결과에 인증 타입 authority와 기존 권한을 함께 설정한다") void authenticate_validApiKey_returnsTypedAuthenticationWithLegacyAuthorities() { UUID clientId = UUID.randomUUID(); - MetadataReader metadataReader = - new MetadataReader() { + ApiKeyMetadataReader metadataReader = + new ApiKeyMetadataReader() { @Override - public ClientMetadata read(String apiKey) { - return new ClientMetadata( + public ApiKeyMetadata read(String apiKey) { + return new ApiKeyMetadata( clientId, "client@example.com", "test client", - null, List.of("READ", "WRITE"), - "2026-07-11T00:00:00Z"); - } - - @Override - public String getApiKeyByEmail(String email) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean verifyClientByEmail(String email) { - throw new UnsupportedOperationException(); + "2026-07-11T00:00:00Z", + "key-hash"); } }; ApiKeyAuthenticator authenticator = new ApiKeyAuthenticator(metadataReader); diff --git a/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java b/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java index aa142df..8b1a12e 100644 --- a/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java +++ b/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java @@ -108,7 +108,12 @@ public Authentication authenticate(RequestCredential credential) { type == AuthenticationType.LOGIN_JWT ? new LoginUserPrincipal(UUID.randomUUID(), "user@example.com") : new ApiKeyPrincipal( - UUID.randomUUID(), "client@example.com", "test", List.of("READ"), "now")); + UUID.randomUUID(), + "client@example.com", + "test", + List.of("READ"), + "now", + "key-hash")); } } } diff --git a/profanity-domain/src/main/java/app/application/apikey/ApiKeyManagementService.java b/profanity-domain/src/main/java/app/application/apikey/ApiKeyManagementService.java new file mode 100644 index 0000000..31ed8c5 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/apikey/ApiKeyManagementService.java @@ -0,0 +1,130 @@ +package app.application.apikey; + +import app.application.client.KeyGenerator; +import app.core.data.response.constant.StatusCode; +import app.core.exception.BusinessException; +import app.domain.apikey.ApiKey; +import app.domain.apikey.ApiKeyRepository; +import io.swagger.v3.oas.annotations.media.Schema; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApiKeyManagementService { + private static final ZoneId SERVICE_ZONE = ZoneId.of("Asia/Seoul"); + + private final ApiKeyRepository apiKeyRepository; + private final KeyGenerator keyGenerator; + private final Clock loginAuthClock; + + @Transactional(readOnly = true) + public List findAll(UUID userId) { + return apiKeyRepository.findAllByUserIdOrderByIssuedAtDesc(userId).stream() + .map(ApiKeyView::from) + .toList(); + } + + @Transactional + public IssuedApiKey issue(UUID userId, String email, CreateApiKeyCommand command) { + GeneratedKey generated = generateUniqueKey(); + ApiKey apiKey = + ApiKey.issue( + userId, + command.name(), + email, + generated.hash(), + generated.hint(), + command.issuerInfo(), + command.note(), + now()); + return new IssuedApiKey(ApiKeyView.from(apiKeyRepository.save(apiKey)), generated.plaintext()); + } + + @Transactional + public IssuedApiKey reissue(UUID userId, UUID apiKeyId) { + ApiKey current = requireOwned(apiKeyId, userId); + if (!current.isActive()) { + throw new BusinessException(StatusCode.API_KEY_ALREADY_EXPIRED); + } + GeneratedKey generated = generateUniqueKey(); + ApiKey replacement = current.reissue(generated.hash(), generated.hint(), now()); + apiKeyRepository.save(current); + return new IssuedApiKey( + ApiKeyView.from(apiKeyRepository.save(replacement)), generated.plaintext()); + } + + @Transactional + public ApiKeyView expire(UUID userId, UUID apiKeyId) { + ApiKey apiKey = requireOwned(apiKeyId, userId); + apiKey.expire(now()); + return ApiKeyView.from(apiKeyRepository.save(apiKey)); + } + + private ApiKey requireOwned(UUID apiKeyId, UUID userId) { + return apiKeyRepository + .findByIdAndUserId(apiKeyId, userId) + .orElseThrow(() -> new BusinessException(StatusCode.API_KEY_NOT_FOUND)); + } + + private GeneratedKey generateUniqueKey() { + try { + String plaintext = keyGenerator.generateApiKey(); + String hash = keyGenerator.hashApiKey(plaintext); + if (apiKeyRepository.existsByKeyHash(hash)) { + throw new BusinessException(StatusCode.INTERNAL_SERVER_ERROR); + } + return new GeneratedKey(plaintext, hash, keyGenerator.keyHint(plaintext)); + } catch (NoSuchAlgorithmException exception) { + throw new BusinessException(StatusCode.INTERNAL_SERVER_ERROR); + } + } + + private LocalDateTime now() { + return LocalDateTime.ofInstant(loginAuthClock.instant(), SERVICE_ZONE); + } + + private record GeneratedKey(String plaintext, String hash, String hint) {} + + public record CreateApiKeyCommand(String name, String issuerInfo, String note) {} + + public record IssuedApiKey( + @Schema(description = "발급된 API Key 메타데이터") ApiKeyView key, + @Schema(description = "이 응답에서만 한 번 제공되는 API Key 원문", example = "pf_sample_issued_api_key") + String apiKey) {} + + public record ApiKeyView( + @Schema(description = "API Key 식별자") UUID id, + @Schema(description = "사용자가 지정한 API Key 이름", example = "운영 서버") String name, + @Schema(description = "SSO 대표 이메일", example = "user@example.com") String email, + @Schema(description = "목록 표시용 마스킹 값", example = "AbCdEf...1234") String keyHint, + @Schema(description = "발급자 정보") String issuerInfo, + @Schema(description = "선택 메모") String note, + @Schema(description = "API Key 권한") List permissions, + @Schema(description = "ACTIVE 또는 EXPIRED") String status, + @Schema(description = "발급 시각") LocalDateTime issuedAt, + @Schema(description = "만료 시각") LocalDateTime expiredAt, + @Schema(description = "기존 일일 집계 요청 수") long requestCount) { + private static ApiKeyView from(ApiKey apiKey) { + return new ApiKeyView( + apiKey.getId(), + apiKey.getName(), + apiKey.getEmail(), + apiKey.getKeyHint(), + apiKey.getIssuerInfo(), + apiKey.getNote(), + apiKey.plainPermissions(), + apiKey.isActive() ? "ACTIVE" : "EXPIRED", + apiKey.getIssuedAt(), + apiKey.getExpiredAt(), + apiKey.getRequestCount()); + } + } +} diff --git a/profanity-domain/src/main/java/app/application/apikey/ApiKeyMetadataReader.java b/profanity-domain/src/main/java/app/application/apikey/ApiKeyMetadataReader.java new file mode 100644 index 0000000..afa5c3a --- /dev/null +++ b/profanity-domain/src/main/java/app/application/apikey/ApiKeyMetadataReader.java @@ -0,0 +1,7 @@ +package app.application.apikey; + +import app.domain.apikey.ApiKeyMetadata; + +public interface ApiKeyMetadataReader { + ApiKeyMetadata read(String plaintextApiKey); +} diff --git a/profanity-domain/src/main/java/app/application/apikey/ApiKeyOwnershipService.java b/profanity-domain/src/main/java/app/application/apikey/ApiKeyOwnershipService.java new file mode 100644 index 0000000..9238d4a --- /dev/null +++ b/profanity-domain/src/main/java/app/application/apikey/ApiKeyOwnershipService.java @@ -0,0 +1,24 @@ +package app.application.apikey; + +import app.domain.apikey.ApiKeyRepository; +import java.util.Locale; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApiKeyOwnershipService { + private final ApiKeyRepository apiKeyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int claimUnownedKeys(UUID userId, String verifiedEmail) { + if (userId == null || verifiedEmail == null || verifiedEmail.isBlank()) { + throw new IllegalArgumentException("userId and verifiedEmail are required"); + } + return apiKeyRepository.claimUnownedByEmail( + userId, verifiedEmail.trim().toLowerCase(Locale.ROOT)); + } +} diff --git a/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java b/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java new file mode 100644 index 0000000..20e9d8c --- /dev/null +++ b/profanity-domain/src/main/java/app/application/apikey/ApiKeyReader.java @@ -0,0 +1,39 @@ +package app.application.apikey; + +import app.application.client.KeyGenerator; +import app.core.data.response.constant.StatusCode; +import app.domain.apikey.ApiKey; +import app.domain.apikey.ApiKeyMetadata; +import app.domain.apikey.ApiKeyRepository; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ApiKeyReader implements ApiKeyMetadataReader { + private final ApiKeyRepository apiKeyRepository; + private final KeyGenerator keyGenerator; + + @Override + @Transactional(readOnly = true) + public ApiKeyMetadata read(String plaintextApiKey) { + if (!keyGenerator.validateApiKey(plaintextApiKey)) { + throw new IllegalArgumentException(StatusCode.INVALID_API_KEY.stringCode()); + } + ApiKey apiKey = + apiKeyRepository + .findByKeyHash(keyGenerator.hashApiKey(plaintextApiKey)) + .filter(ApiKey::isActive) + .orElseThrow( + () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); + return new ApiKeyMetadata( + apiKey.getId(), + apiKey.getEmail(), + apiKey.getIssuerInfo(), + apiKey.plainPermissions(), + apiKey.getIssuedAt().toString(), + apiKey.getKeyHash()); + } +} diff --git a/profanity-domain/src/main/java/app/application/client/APIKeyGenerator.java b/profanity-domain/src/main/java/app/application/client/APIKeyGenerator.java index ea05ad3..e81eea3 100644 --- a/profanity-domain/src/main/java/app/application/client/APIKeyGenerator.java +++ b/profanity-domain/src/main/java/app/application/client/APIKeyGenerator.java @@ -1,9 +1,11 @@ package app.application.client; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; +import java.util.HexFormat; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -68,4 +70,25 @@ public boolean validateApiKey(String apiKey) { return false; } } + + @Override + public String hashApiKey(String apiKey) { + if (!validateApiKey(apiKey)) { + throw new IllegalArgumentException("API key must be valid before hashing"); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(apiKey.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + @Override + public String keyHint(String apiKey) { + if (!validateApiKey(apiKey)) { + throw new IllegalArgumentException("API key must be valid before creating a hint"); + } + return apiKey.substring(0, 6) + "..." + apiKey.substring(apiKey.length() - 4); + } } diff --git a/profanity-domain/src/main/java/app/application/client/ClientMetadataReader.java b/profanity-domain/src/main/java/app/application/client/ClientMetadataReader.java deleted file mode 100644 index 6c393bc..0000000 --- a/profanity-domain/src/main/java/app/application/client/ClientMetadataReader.java +++ /dev/null @@ -1,65 +0,0 @@ -package app.application.client; - -import app.core.data.response.constant.StatusCode; -import app.domain.client.ClientMetadata; -import app.domain.client.Clients; -import app.domain.client.ClientsRepository; -import app.domain.client.PermissionsType; -import java.util.List; -import java.util.NoSuchElementException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class ClientMetadataReader implements MetadataReader { - private final ClientsRepository clientsRepository; - private final APIKeyGenerator keyGenerator; - - @Override - @Transactional(readOnly = true) - public ClientMetadata read(String apiKey) { - - if (!keyGenerator.validateApiKey(apiKey)) { - throw new IllegalArgumentException(StatusCode.INVALID_API_KEY.stringCode()); - } - - Clients clients = - clientsRepository - .findByApiKey(apiKey) - .orElseThrow( - () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); - - final List permissions = - clients.getPermissions().stream().map(PermissionsType::getValue).toList(); - - return ClientMetadata.builder() - .id(clients.getId()) - .email(clients.getEmail()) - .issuerInfo(clients.getIssuerInfo()) - .note(clients.getNote()) - .permissions(permissions) - .issuedAt(clients.getIssuedAt().toString()) - .build(); - } - - @Override - @Transactional(readOnly = true) - public boolean verifyClientByEmail(String email) { - return clientsRepository.findByEmail(email).isPresent(); - } - - @Override - @Transactional(readOnly = true) - public String getApiKeyByEmail(String email) { - Clients clients = - clientsRepository - .findByEmail(email) - .orElseThrow( - () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); - return clients.getApiKey(); - } -} diff --git a/profanity-domain/src/main/java/app/application/client/ClientsCommandService.java b/profanity-domain/src/main/java/app/application/client/ClientsCommandService.java deleted file mode 100644 index 1133a8b..0000000 --- a/profanity-domain/src/main/java/app/application/client/ClientsCommandService.java +++ /dev/null @@ -1,107 +0,0 @@ -package app.application.client; - -import app.core.data.response.constant.StatusCode; -import app.core.exception.BusinessException; -import app.domain.client.ClientMetadata; -import app.domain.client.Clients; -import app.domain.client.ClientsRepository; -import app.dto.request.ClientRegistCommand; -import app.dto.response.ClientsRegistResponse; -import java.util.NoSuchElementException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class ClientsCommandService { - private final ClientsRepository clientRepository; - private final KeyGenerator apiKeyGenerator; - - @Transactional - public ClientsRegistResponse registerNewClient(ClientRegistCommand request) { - validateEmail(request.email()); - String apiKey = generateApiKey(); - validateApiKey(apiKey); - - Clients client = - Clients.builder() - .name(request.name()) - .email(request.email()) - .apiKey(apiKey) - .issuerInfo(request.issuerInfo()) - .note(request.note()) - .build(); - - Clients savedClient = clientRepository.save(client); - - return ClientsRegistResponse.builder() - .name(savedClient.getName()) - .email(savedClient.getEmail()) - .apiKey(savedClient.getApiKey()) - .note(savedClient.getNote()) - .build(); - } - - @Transactional - public ClientMetadata updateClientInfo( - final String apiKey, final String issuerInfo, final String note) { - Clients clients = - clientRepository - .findByApiKey(apiKey) - .orElseThrow( - () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); - - clients.updateInfo(issuerInfo, note); - - return ClientMetadata.builder() - .id(clients.getId()) - .email(clients.getEmail()) - .issuerInfo(clients.getIssuerInfo()) - .note(clients.getNote()) - .permissions(clients.getPlainPermissions()) - .issuedAt(clients.getIssuedAt().toString()) - .build(); - } - - @Transactional - public void discardClient(String apikey) { - Clients clients = - clientRepository - .findByApiKey(apikey) - .orElseThrow( - () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); - clients.discarded(); - } - - @Transactional - public String regenerateApiKey(String currentApiKey) { - Clients clients = - clientRepository - .findByApiKey(currentApiKey) - .orElseThrow( - () -> new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode())); - String newApiKey = generateApiKey(); - return clients.updateApiKey(newApiKey); - } - - private void validateEmail(String email) { - if (clientRepository.existsByEmail(email)) { - throw new BusinessException(StatusCode.BAD_REQUEST, "이미 등록된 이메일입니다."); - } - } - - private void validateApiKey(String apiKey) { - if (clientRepository.existsByApiKey(apiKey)) { - throw new BusinessException(StatusCode.INTERNAL_SERVER_ERROR, "API 키 생성 중 중복이 발생했습니다."); - } - } - - private String generateApiKey() { - try { - return apiKeyGenerator.generateApiKey(); - } catch (Exception e) { - throw new BusinessException(StatusCode.INTERNAL_SERVER_ERROR, "API 키 생성 중 오류가 발생했습니다."); - } - } -} diff --git a/profanity-domain/src/main/java/app/application/client/KeyGenerator.java b/profanity-domain/src/main/java/app/application/client/KeyGenerator.java index 3485680..aa0125c 100644 --- a/profanity-domain/src/main/java/app/application/client/KeyGenerator.java +++ b/profanity-domain/src/main/java/app/application/client/KeyGenerator.java @@ -6,4 +6,8 @@ public interface KeyGenerator { String generateApiKey() throws NoSuchAlgorithmException; boolean validateApiKey(String apiKey); + + String hashApiKey(String apiKey); + + String keyHint(String apiKey); } diff --git a/profanity-domain/src/main/java/app/application/client/MetadataReader.java b/profanity-domain/src/main/java/app/application/client/MetadataReader.java deleted file mode 100644 index 70882ea..0000000 --- a/profanity-domain/src/main/java/app/application/client/MetadataReader.java +++ /dev/null @@ -1,11 +0,0 @@ -package app.application.client; - -import app.domain.client.ClientMetadata; - -public interface MetadataReader { - ClientMetadata read(String apiKey); - - String getApiKeyByEmail(String email); - - boolean verifyClientByEmail(String email); -} diff --git a/profanity-domain/src/main/java/app/application/event/FilterEvent.java b/profanity-domain/src/main/java/app/application/event/FilterEvent.java index e462044..3ada585 100644 --- a/profanity-domain/src/main/java/app/application/event/FilterEvent.java +++ b/profanity-domain/src/main/java/app/application/event/FilterEvent.java @@ -11,7 +11,7 @@ public record FilterEvent( UUID trackingId, Mode mode, - String apiKey, + String apiKeyHash, String requestText, Set words, String referrer, @@ -21,7 +21,7 @@ public static FilterEvent create( return new FilterEvent( filterApiResponse.trackingId(), filterRequest.mode(), - filterRequest.apiKey(), + filterRequest.apiKeyHash(), filterRequest.text(), filterApiResponse.detected().stream() .map(Detected::filteredWord) diff --git a/profanity-domain/src/main/java/app/application/event/TrackingRecorder.java b/profanity-domain/src/main/java/app/application/event/TrackingRecorder.java index 9e58c5c..a0047d7 100644 --- a/profanity-domain/src/main/java/app/application/event/TrackingRecorder.java +++ b/profanity-domain/src/main/java/app/application/event/TrackingRecorder.java @@ -1,6 +1,5 @@ package app.application.event; -import app.core.util.ApiKeys; import app.domain.record.RecordRepository; import app.domain.record.Records; import java.util.stream.Collectors; @@ -26,7 +25,7 @@ public void recordTracking(FilterEvent event) { new Records.Builder() .trackingId(event.trackingId()) .mode(event.mode()) - .apiKey(event.apiKey()) + .apiKeyHash(event.apiKeyHash()) .requestText(event.requestText()) .words(words) .referrer(event.referrer()) @@ -35,12 +34,12 @@ public void recordTracking(FilterEvent event) { recordRepository.save(records); log.info( - "[FILTER] 처리 결과 trackingId={} mode={} detected={} words=[{}] ip={} apiKey={}", + "[FILTER] 처리 결과 trackingId={} mode={} detected={} words=[{}] ip={} apiKeyIdHash={}", event.trackingId(), event.mode(), event.words().size(), words, event.ip(), - ApiKeys.mask(event.apiKey())); + event.apiKeyHash() == null ? "none" : event.apiKeyHash().substring(0, 8)); } } diff --git a/profanity-domain/src/main/java/app/application/manage/DailyReportScheduler.java b/profanity-domain/src/main/java/app/application/manage/DailyReportScheduler.java index 98e7036..52ca636 100644 --- a/profanity-domain/src/main/java/app/application/manage/DailyReportScheduler.java +++ b/profanity-domain/src/main/java/app/application/manage/DailyReportScheduler.java @@ -1,6 +1,6 @@ package app.application.manage; -import app.domain.client.ClientsRepository; +import app.domain.apikey.ApiKeyRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; @@ -8,27 +8,35 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -/** 일일 보고서 스케줄러 */ +/** + * 기존 request_count 및 client_reports 집계 스케줄러입니다. + * + * @deprecated 사용량 수집 중단을 검토 중입니다. 제거 전까지 기존 집계만 유지합니다. + */ +@Deprecated(forRemoval = true) @Slf4j @Component @RequiredArgsConstructor public class DailyReportScheduler { - private final ClientsRepository clientsRepository; + private final ApiKeyRepository apiKeyRepository; private final ReportManagementService reportManagementService; - /** 클라이언트 요청 횟수 업데이트 스케줄러 */ + /** API Key별 누적 요청 횟수를 갱신하는 기존 집계 스케줄러입니다. */ @Transactional @Scheduled(cron = "0 0 1 * * ?") - @SchedulerLock(name = "daily_report_scheduler", lockAtMostFor = "PT5H") - public void updateReportSchedule() { + @SchedulerLock(name = "api_key_request_count_scheduler", lockAtMostFor = "PT5H") + @Deprecated(forRemoval = true) + public void updateRequestCount() { long start = System.currentTimeMillis(); - clientsRepository.updateClientRequestCount(); + apiKeyRepository.updateRequestCount(); long end = System.currentTimeMillis(); - log.info("클라이언트 요청 횟수 업데이트 완료 [소요시간: {}ms]", (end - start)); + log.info("API Key 요청 횟수 업데이트 완료 [소요시간: {}ms]", (end - start)); } + /** API Key별 전일 사용량을 client_reports에 저장하는 기존 집계 스케줄러입니다. */ @Scheduled(cron = "0 0 1 * * ?") - @SchedulerLock(name = "daily_report_scheduler", lockAtMostFor = "PT11H") + @SchedulerLock(name = "api_key_daily_report_scheduler", lockAtMostFor = "PT11H") + @Deprecated(forRemoval = true) public void createDailyReport() { long start = System.currentTimeMillis(); int dailyReport = reportManagementService.createDailyReport(); diff --git a/profanity-domain/src/main/java/app/application/manage/DefaultReportManagementService.java b/profanity-domain/src/main/java/app/application/manage/DefaultReportManagementService.java index af875b0..772df8c 100644 --- a/profanity-domain/src/main/java/app/application/manage/DefaultReportManagementService.java +++ b/profanity-domain/src/main/java/app/application/manage/DefaultReportManagementService.java @@ -2,7 +2,7 @@ import app.core.data.Const; import app.core.data.Pair; -import app.domain.client.ClientsRepository; +import app.domain.apikey.ApiKeyRepository; import app.domain.client.Report; import app.domain.client.ReportRepository; import app.domain.record.RecordRepository; @@ -17,10 +17,11 @@ @Slf4j @Service @RequiredArgsConstructor +@Deprecated(forRemoval = true) public class DefaultReportManagementService implements ReportManagementService { private final ReportRepository reportRepository; - private final ClientsRepository clientsRepository; + private final ApiKeyRepository apiKeyRepository; private final RecordRepository recordRepository; @Override @@ -29,13 +30,13 @@ public int createDailyReport() { List reports = new ArrayList<>(); LocalDateTime yesterday = Const.getCurrentDateTime().minusDays(1); - clientsRepository + apiKeyRepository .findAll() .forEach( - client -> { - Report todayReport = Report.createTodayReport(client); + apiKey -> { + Report todayReport = Report.createTodayReport(apiKey); Pair report = - recordRepository.getClientDailyUsageStatistics(client.getApiKey(), yesterday); + recordRepository.getApiKeyDailyUsageStatistics(apiKey.getKeyHash(), yesterday); todayReport.updateCounts(report.getFirst(), report.getSecond()); reports.add(todayReport); }); diff --git a/profanity-domain/src/main/java/app/application/manage/ReportManagementService.java b/profanity-domain/src/main/java/app/application/manage/ReportManagementService.java index bce9ecf..53e569a 100644 --- a/profanity-domain/src/main/java/app/application/manage/ReportManagementService.java +++ b/profanity-domain/src/main/java/app/application/manage/ReportManagementService.java @@ -1,5 +1,9 @@ package app.application.manage; +/** + * @deprecated 기존 일일 리포트 수집은 중단을 검토 중이며 신규 기능에서 사용하지 않습니다. + */ +@Deprecated(forRemoval = true) public interface ReportManagementService { /** 일일 리포트 생성 */ int createDailyReport(); diff --git a/profanity-domain/src/main/java/app/domain/apikey/ApiKey.java b/profanity-domain/src/main/java/app/domain/apikey/ApiKey.java new file mode 100644 index 0000000..d42df3b --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/apikey/ApiKey.java @@ -0,0 +1,133 @@ +package app.domain.apikey; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import app.domain.client.PermissionsType; +import app.domain.client.PermissionsTypeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder(access = PRIVATE) +@ToString(of = {"id", "userId", "name", "email", "keyHint"}) +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Entity(name = "api_keys") +@Table(name = "api_keys") +public class ApiKey { + + @Id + @Builder.Default + @Column(columnDefinition = "BINARY(16)") + private UUID id = UUID.randomUUID(); + + @Column(name = "user_id", columnDefinition = "BINARY(16)") + private UUID userId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String email; + + @Column( + name = "key_hash", + nullable = false, + unique = true, + length = 64, + columnDefinition = "CHAR(64)") + private String keyHash; + + @Column(name = "key_hint", nullable = false, length = 32) + private String keyHint; + + @Column(name = "issuer_info", nullable = false) + private String issuerInfo; + + @Column private String note; + + @Builder.Default + @Convert(converter = PermissionsTypeConverter.class) + @Column(nullable = false, columnDefinition = "TEXT") + private List permissions = PermissionsType.defaultPermissions(); + + @Column(name = "issued_at", nullable = false) + private LocalDateTime issuedAt; + + @Column(name = "expired_at") + private LocalDateTime expiredAt; + + @Builder.Default + @Column(name = "request_count", nullable = false) + private Long requestCount = 0L; + + public static ApiKey issue( + UUID userId, + String name, + String email, + String keyHash, + String keyHint, + String issuerInfo, + String note, + LocalDateTime issuedAt) { + return ApiKey.builder() + .userId(Objects.requireNonNull(userId, "userId must not be null")) + .name(requireText(name, "name")) + .email(requireText(email, "email").toLowerCase(Locale.ROOT)) + .keyHash(requireText(keyHash, "keyHash")) + .keyHint(requireText(keyHint, "keyHint")) + .issuerInfo(requireText(issuerInfo, "issuerInfo")) + .note(blankToNull(note)) + .issuedAt(Objects.requireNonNull(issuedAt, "issuedAt must not be null")) + .build(); + } + + public boolean isActive() { + return expiredAt == null; + } + + public void expire(LocalDateTime now) { + if (expiredAt == null) { + expiredAt = Objects.requireNonNull(now, "now must not be null"); + } + } + + public ApiKey reissue(String replacementHash, String replacementHint, LocalDateTime now) { + if (!isActive()) { + throw new IllegalStateException("Expired API key cannot be reissued"); + } + expire(now); + return issue(userId, name, email, replacementHash, replacementHint, issuerInfo, note, now); + } + + public List plainPermissions() { + return permissions.stream().map(PermissionsType::getValue).toList(); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/profanity-domain/src/main/java/app/domain/apikey/ApiKeyMetadata.java b/profanity-domain/src/main/java/app/domain/apikey/ApiKeyMetadata.java new file mode 100644 index 0000000..bd7040e --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/apikey/ApiKeyMetadata.java @@ -0,0 +1,12 @@ +package app.domain.apikey; + +import java.util.List; +import java.util.UUID; + +public record ApiKeyMetadata( + UUID id, + String email, + String issuerInfo, + List permissions, + String issuedAt, + String keyHash) {} diff --git a/profanity-domain/src/main/java/app/domain/apikey/ApiKeyRepository.java b/profanity-domain/src/main/java/app/domain/apikey/ApiKeyRepository.java new file mode 100644 index 0000000..9dc2a6e --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/apikey/ApiKeyRepository.java @@ -0,0 +1,29 @@ +package app.domain.apikey; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApiKeyRepository { + ApiKey save(ApiKey apiKey); + + List findAll(); + + List findAllByUserIdOrderByIssuedAtDesc(UUID userId); + + Optional findByIdAndUserId(UUID id, UUID userId); + + Optional findByKeyHash(String keyHash); + + boolean existsByKeyHash(String keyHash); + + int claimUnownedByEmail(UUID userId, String email); + + /** + * 기존 누적 요청 횟수 집계를 갱신합니다. + * + * @deprecated 사용량 수집 중단을 검토 중이며 신규 기능에서 사용하지 않습니다. + */ + @Deprecated(forRemoval = true) + void updateRequestCount(); +} diff --git a/profanity-domain/src/main/java/app/domain/client/ClientMetadata.java b/profanity-domain/src/main/java/app/domain/client/ClientMetadata.java deleted file mode 100644 index 3944260..0000000 --- a/profanity-domain/src/main/java/app/domain/client/ClientMetadata.java +++ /dev/null @@ -1,16 +0,0 @@ -package app.domain.client; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import java.util.UUID; -import lombok.Builder; - -@Builder -@Schema(description = "클라이언트 메타데이터") -public record ClientMetadata( - @Schema(description = "클라이언트 식별자", example = "018f4fd8-9f6f-7d1a-9b80-3f1f8dd7c001") UUID id, - @Schema(description = "API Key 발급에 사용한 이메일", example = "user@example.com") String email, - @Schema(description = "API Key 발급자 정보", example = "비속어 필터링 연동") String issuerInfo, - @Schema(description = "클라이언트 메모", example = "운영 환경에서 사용") String note, - @Schema(description = "클라이언트 권한 목록", example = "[\"READ\"]") List permissions, - @Schema(description = "API Key 발급 시각", example = "2026-06-23T09:00:00") String issuedAt) {} diff --git a/profanity-domain/src/main/java/app/domain/client/Clients.java b/profanity-domain/src/main/java/app/domain/client/Clients.java deleted file mode 100644 index 6ce9e16..0000000 --- a/profanity-domain/src/main/java/app/domain/client/Clients.java +++ /dev/null @@ -1,100 +0,0 @@ -package app.domain.client; - -import static lombok.AccessLevel.PRIVATE; -import static lombok.AccessLevel.PROTECTED; - -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; -import org.hibernate.annotations.Comment; - -@Getter -@Builder -@ToString(of = {"id", "name", "email"}) -@EqualsAndHashCode(of = "id") -@AllArgsConstructor(access = PRIVATE) -@NoArgsConstructor(access = PROTECTED) -@Entity(name = "clients") -@Table(name = "clients") -public class Clients { - - @Id - @Builder.Default - @Comment("클라이언트 고유 식별자") - @Column(columnDefinition = "BINARY(16)") - private UUID id = UUID.randomUUID(); - - @Comment("클라이언트명") - @Column(nullable = false) - private String name; - - @Comment("이메일") - @Column(nullable = false, unique = true) - private String email; - - @Comment("API 키") - @Column(name = "api_key", nullable = false, unique = true) - private String apiKey; - - @Comment("발급자 정보") - @Column(name = "issuer_info", nullable = false) - private String issuerInfo; - - @Comment("비고") - @Column(name = "note") - private String note; - - @Builder.Default - @Comment("권한") - @Column(name = "permissions", nullable = false, columnDefinition = "TEXT") - @Convert(converter = PermissionsTypeConverter.class) - private List permissions = PermissionsType.defaultPermissions(); - - @Builder.Default - @Comment("발급일시") - @Column(name = "issued_at") - private LocalDateTime issuedAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - - @Comment("만료일시") - @Column(name = "expired_at") - private LocalDateTime expiredAt; - - @Builder.Default - @Comment("요청 횟수") - @Column(name = "request_count") - private Long requestCount = 0L; - - public void updateInfo(String issuerInfo, String note) { - this.issuerInfo = Objects.requireNonNullElseGet(issuerInfo, () -> this.issuerInfo); - this.note = Objects.requireNonNullElseGet(note, () -> this.note); - } - - /** 권한 목록을 문자열 형태로 반환합니다. */ - public List getPlainPermissions() { - return permissions.stream().map(PermissionsType::getValue).toList(); - } - - /** 클라이언트 정보를 폐기합니다. */ - public void discarded() { - this.permissions = List.of(PermissionsType.DISCARD); - this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - } - - public String updateApiKey(String newApiKey) { - this.apiKey = newApiKey; - return newApiKey; - } -} diff --git a/profanity-domain/src/main/java/app/domain/client/ClientsRepository.java b/profanity-domain/src/main/java/app/domain/client/ClientsRepository.java deleted file mode 100644 index 44b3e7d..0000000 --- a/profanity-domain/src/main/java/app/domain/client/ClientsRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package app.domain.client; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface ClientsRepository { - Optional findById(UUID id); - - Clients save(Clients clients); - - List findAll(); - - void deleteById(UUID id); - - boolean existsById(UUID id); - - boolean existsByEmail(String email); - - boolean existsByApiKey(String apiKey); - - Optional findByEmail(String email); - - Optional findByApiKey(String apiKey); - - Optional findByEmailAndApiKey(String email, String apiKey); - - Optional findByIssuerInfo(String issuerInfo); - - void updateClientRequestCount(); -} diff --git a/profanity-domain/src/main/java/app/domain/client/Report.java b/profanity-domain/src/main/java/app/domain/client/Report.java index 66988c8..7f64121 100644 --- a/profanity-domain/src/main/java/app/domain/client/Report.java +++ b/profanity-domain/src/main/java/app/domain/client/Report.java @@ -4,6 +4,7 @@ import static lombok.AccessLevel.PROTECTED; import app.core.data.Const; +import app.domain.apikey.ApiKey; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -25,7 +26,12 @@ import org.hibernate.annotations.Comment; import org.hibernate.annotations.CreationTimestamp; -/** 클라이언트 일일 사용 리포트 */ +/** + * 기존 일일 API Key 사용 리포트입니다. + * + * @deprecated 수집 중단을 검토 중입니다. 신규 기능은 이 모델에 의존하지 않아야 합니다. + */ +@Deprecated(forRemoval = true) @Getter @Builder @ToString( @@ -53,16 +59,12 @@ public class Report { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "client_id", nullable = false) @Comment("클라이언트") - private Clients client; + private ApiKey apiKey; @Column(name = "client_id", insertable = false, updatable = false) @Comment("클라이언트 ID") private UUID clientId; - @Column(name = "api_key", nullable = false) - @Comment("API 키") - private String apiKey; - @Builder.Default @Column(name = "report_year", nullable = false) @Comment("리포트 년도") @@ -97,14 +99,13 @@ public class Report { /** * 특정 클라이언트의 오늘 리포트 생성 * - * @param client 클라이언트 + * @param apiKey API Key * @return 새 리포트 */ - public static Report createTodayReport(Clients client) { + public static Report createTodayReport(ApiKey apiKey) { return Report.builder() - .client(client) - .clientId(client.getId()) - .apiKey(client.getApiKey()) + .apiKey(apiKey) + .clientId(apiKey.getId()) .reportYear(Const.getCurrentYear()) .reportMonth(Const.getCurrentMonth()) .reportDay(Const.getCurrentDay()) diff --git a/profanity-domain/src/main/java/app/domain/client/ReportRepository.java b/profanity-domain/src/main/java/app/domain/client/ReportRepository.java index 15fac42..777b3b1 100644 --- a/profanity-domain/src/main/java/app/domain/client/ReportRepository.java +++ b/profanity-domain/src/main/java/app/domain/client/ReportRepository.java @@ -4,6 +4,10 @@ import java.util.Optional; import java.util.UUID; +/** + * @deprecated 기존 일일 집계 저장소이며 수집 중단을 검토 중입니다. + */ +@Deprecated(forRemoval = true) public interface ReportRepository { Optional findById(UUID id); diff --git a/profanity-domain/src/main/java/app/domain/record/RecordRepository.java b/profanity-domain/src/main/java/app/domain/record/RecordRepository.java index d2c1c38..6f51d47 100644 --- a/profanity-domain/src/main/java/app/domain/record/RecordRepository.java +++ b/profanity-domain/src/main/java/app/domain/record/RecordRepository.java @@ -13,7 +13,7 @@ public interface RecordRepository { void delete(Records records); - Pair getClientDailyUsageStatistics(String apiKey, LocalDateTime yesterday); + Pair getApiKeyDailyUsageStatistics(String apiKeyHash, LocalDateTime yesterday); Optional findByTrackingId(UUID trackingId); } diff --git a/profanity-domain/src/main/java/app/domain/record/Records.java b/profanity-domain/src/main/java/app/domain/record/Records.java index 62ab503..25abc91 100644 --- a/profanity-domain/src/main/java/app/domain/record/Records.java +++ b/profanity-domain/src/main/java/app/domain/record/Records.java @@ -37,9 +37,9 @@ public class Records { @Column(nullable = false, name = "mode") private Mode mode; - @Comment("API Key") - @Column - private String apiKey; + @Comment("API Key SHA-256 해시") + @Column(name = "api_key_hash", length = 64, columnDefinition = "CHAR(64)") + private String apiKeyHash; @Comment("요청된 텍스트") @Column(nullable = false) @@ -71,7 +71,7 @@ protected Records() {} private Records(Builder builder) { this.trackingId = builder.trackingId; this.mode = builder.mode; - this.apiKey = builder.apiKey; + this.apiKeyHash = builder.apiKeyHash; this.requestText = builder.requestText; this.words = builder.words; this.referrer = builder.referrer; @@ -81,7 +81,7 @@ private Records(Builder builder) { /** The type Builder. */ public static class Builder { private UUID trackingId; - private String apiKey; + private String apiKeyHash; private Mode mode; private String requestText; private String words; @@ -93,8 +93,8 @@ public Builder trackingId(UUID trackingId) { return this; } - public Builder apiKey(String apiKey) { - this.apiKey = apiKey; + public Builder apiKeyHash(String apiKeyHash) { + this.apiKeyHash = apiKeyHash; return this; } @@ -134,8 +134,8 @@ public String toString() { + trackingId + "mode=" + mode - + ", apiKey='" - + apiKey + + ", apiKeyHash='" + + apiKeyHash + '\'' + ", requestText='" + requestText diff --git a/profanity-domain/src/main/java/app/dto/request/ClientRegistCommand.java b/profanity-domain/src/main/java/app/dto/request/ClientRegistCommand.java deleted file mode 100644 index fb1655b..0000000 --- a/profanity-domain/src/main/java/app/dto/request/ClientRegistCommand.java +++ /dev/null @@ -1,8 +0,0 @@ -package app.dto.request; - -public record ClientRegistCommand(String name, String email, String issuerInfo, String note) { - public static ClientRegistCommand from( - String name, String email, String issuerInfo, String note) { - return new ClientRegistCommand(name, email, issuerInfo, note); - } -} diff --git a/profanity-domain/src/main/java/app/dto/request/FilterRequest.java b/profanity-domain/src/main/java/app/dto/request/FilterRequest.java index 47ca648..b09f0fe 100644 --- a/profanity-domain/src/main/java/app/dto/request/FilterRequest.java +++ b/profanity-domain/src/main/java/app/dto/request/FilterRequest.java @@ -3,10 +3,10 @@ import app.core.data.constant.Mode; public record FilterRequest( - String text, Mode mode, String apiKey, String clientIp, String referrer) { + String text, Mode mode, String apiKeyHash, String clientIp, String referrer) { public static FilterRequest create( - String text, Mode mode, String apiKey, String clientIp, String referrer) { - return new FilterRequest(text, mode, apiKey, clientIp, referrer); + String text, Mode mode, String apiKeyHash, String clientIp, String referrer) { + return new FilterRequest(text, mode, apiKeyHash, clientIp, referrer); } @Override @@ -17,8 +17,8 @@ public String toString() { + '\'' + ", mode=" + mode - + ", apiKey='" - + apiKey + + ", apiKeyHash='" + + apiKeyHash + '\'' + ", clientIp='" + clientIp diff --git a/profanity-domain/src/main/java/app/dto/response/ApiKeyReissueResponse.java b/profanity-domain/src/main/java/app/dto/response/ApiKeyReissueResponse.java deleted file mode 100644 index 165a426..0000000 --- a/profanity-domain/src/main/java/app/dto/response/ApiKeyReissueResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package app.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "API Key 재발급 결과") -public record ApiKeyReissueResponse( - @Schema(description = "새로 발급된 API Key", example = "pf_sample_reissued_api_key") - String newApiKey) {} diff --git a/profanity-domain/src/main/java/app/dto/response/ClientsRegistResponse.java b/profanity-domain/src/main/java/app/dto/response/ClientsRegistResponse.java deleted file mode 100644 index 581afac..0000000 --- a/profanity-domain/src/main/java/app/dto/response/ClientsRegistResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package app.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; - -@Builder -@Schema(description = "신규 클라이언트 등록 결과") -public record ClientsRegistResponse( - @Schema(description = "등록된 이름 또는 조직명", example = "샘플 프로젝트") String name, - @Schema(description = "API Key 발급에 사용한 이메일", example = "user@example.com") String email, - @Schema(description = "발급된 API Key", example = "pf_sample_issued_api_key") String apiKey, - @Schema(description = "등록 메모", example = "검증 환경에서 사용") String note) {} diff --git a/profanity-domain/src/main/java/app/dto/response/EmailVerificationResponse.java b/profanity-domain/src/main/java/app/dto/response/EmailVerificationResponse.java deleted file mode 100644 index 1c07d1c..0000000 --- a/profanity-domain/src/main/java/app/dto/response/EmailVerificationResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package app.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "이메일 인증 코드 검증 결과") -public record EmailVerificationResponse( - @Schema(description = "인증된 이메일에 연결된 API Key", example = "pf_sample_verified_api_key") - String apikey) {} diff --git a/profanity-domain/src/test/java/app/application/apikey/ApiKeyManagementServiceTest.java b/profanity-domain/src/test/java/app/application/apikey/ApiKeyManagementServiceTest.java new file mode 100644 index 0000000..3f73fc9 --- /dev/null +++ b/profanity-domain/src/test/java/app/application/apikey/ApiKeyManagementServiceTest.java @@ -0,0 +1,101 @@ +package app.application.apikey; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.application.apikey.ApiKeyManagementService.CreateApiKeyCommand; +import app.application.client.APIKeyGenerator; +import app.core.data.response.constant.StatusCode; +import app.core.exception.BusinessException; +import app.domain.InMemoryApiKeyRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ApiKeyManagementServiceTest { + private static final Instant NOW = Instant.parse("2026-07-17T00:00:00Z"); + private static final UUID USER_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID OTHER_USER_ID = UUID.fromString("20000000-0000-0000-0000-000000000002"); + + private InMemoryApiKeyRepository repository; + private APIKeyGenerator keyGenerator; + private ApiKeyManagementService service; + + @BeforeEach + void setUp() { + repository = new InMemoryApiKeyRepository(); + keyGenerator = new APIKeyGenerator("test-salt", "SHA-256"); + service = + new ApiKeyManagementService(repository, keyGenerator, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + @DisplayName("발급 이메일은 로그인 사용자의 이메일로 고정하고 원문은 저장하지 않는다") + void issue_loginEmail_returnsPlaintextOnceWithoutStoringIt() { + var issued = + service.issue( + USER_ID, + "User@Example.com", + new CreateApiKeyCommand("운영", "production server", "primary")); + + var stored = repository.findAll().get(0); + assertThat(issued.key().email()).isEqualTo("user@example.com"); + assertThat(issued.apiKey()).isNotBlank(); + assertThat(stored.getKeyHash()).isEqualTo(keyGenerator.hashApiKey(issued.apiKey())); + assertThat(stored.getKeyHash()).isNotEqualTo(issued.apiKey()); + } + + @Test + @DisplayName("한 사용자는 여러 활성 API Key를 발급할 수 있다") + void issue_twice_keepsMultipleActiveKeys() { + issue("개발"); + issue("운영"); + + assertThat(service.findAll(USER_ID)).hasSize(2).allMatch(key -> key.status().equals("ACTIVE")); + } + + @Test + @DisplayName("재발행은 기존 키를 만료하고 새 원문을 한 번 반환한다") + void reissue_activeKey_expiresPreviousAndCreatesReplacement() { + var original = issue("운영"); + + var replacement = service.reissue(USER_ID, original.key().id()); + + assertThat(replacement.apiKey()).isNotEqualTo(original.apiKey()); + assertThat(service.findAll(USER_ID)) + .extracting(ApiKeyManagementService.ApiKeyView::status) + .containsExactlyInAnyOrder("ACTIVE", "EXPIRED"); + } + + @Test + @DisplayName("만료 요청은 반복해도 만료 시각을 변경하지 않는다") + void expire_twice_isIdempotent() { + var issued = issue("운영"); + + var first = service.expire(USER_ID, issued.key().id()); + var second = service.expire(USER_ID, issued.key().id()); + + assertThat(second.expiredAt()).isEqualTo(first.expiredAt()); + assertThat(second.status()).isEqualTo("EXPIRED"); + } + + @Test + @DisplayName("다른 사용자의 API Key는 존재하지 않는 것처럼 처리한다") + void expire_otherUsersKey_returnsNotFound() { + var issued = issue("운영"); + + assertThatThrownBy(() -> service.expire(OTHER_USER_ID, issued.key().id())) + .isInstanceOf(BusinessException.class) + .extracting("status.code") + .isEqualTo(StatusCode.API_KEY_NOT_FOUND.code()); + } + + private ApiKeyManagementService.IssuedApiKey issue(String name) { + return service.issue( + USER_ID, "user@example.com", new CreateApiKeyCommand(name, name + " server", null)); + } +} diff --git a/profanity-domain/src/test/java/app/application/apikey/ApiKeyOwnershipServiceTest.java b/profanity-domain/src/test/java/app/application/apikey/ApiKeyOwnershipServiceTest.java new file mode 100644 index 0000000..e7f417a --- /dev/null +++ b/profanity-domain/src/test/java/app/application/apikey/ApiKeyOwnershipServiceTest.java @@ -0,0 +1,102 @@ +package app.application.apikey; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.application.client.APIKeyGenerator; +import app.domain.InMemoryApiKeyRepository; +import app.domain.apikey.ApiKey; +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ApiKeyOwnershipServiceTest { + private InMemoryApiKeyRepository repository; + private APIKeyGenerator generator; + private ApiKeyOwnershipService service; + + @BeforeEach + void setUp() { + repository = new InMemoryApiKeyRepository(); + generator = new APIKeyGenerator("test-salt", "SHA-256"); + service = new ApiKeyOwnershipService(repository); + } + + @Test + @DisplayName("검증된 이메일과 같은 미이관 키를 모두 한 사용자에게 연결한다") + void claim_matchingUnownedKeys_claimsAllOnce() throws Exception { + UUID userId = UUID.randomUUID(); + repository.save(unowned("User@Example.com", "first")); + repository.save(unowned("user@example.com", "second")); + repository.save(unowned("other@example.com", "other")); + + int first = service.claimUnownedKeys(userId, " USER@example.com "); + int second = service.claimUnownedKeys(userId, "user@example.com"); + + assertThat(first).isEqualTo(2); + assertThat(second).isZero(); + assertThat(repository.findAllByUserIdOrderByIssuedAtDesc(userId)).hasSize(2); + } + + @Test + @DisplayName("이미 다른 사용자에게 연결된 키는 이메일이 같아도 변경하지 않는다") + void claim_alreadyOwnedKey_preservesOwner() throws Exception { + UUID originalOwner = UUID.randomUUID(); + ApiKey key = unowned("user@example.com", "owned"); + setUserId(key, originalOwner); + repository.save(key); + + int claimed = service.claimUnownedKeys(UUID.randomUUID(), "user@example.com"); + + assertThat(claimed).isZero(); + assertThat(key.getUserId()).isEqualTo(originalOwner); + } + + @Test + @DisplayName("검증된 이메일과 다른 미이관 키는 연결하지 않는다") + void claim_nonMatchingEmail_preservesUnownedKey() throws Exception { + ApiKey key = unowned("other@example.com", "other"); + repository.save(key); + + int claimed = service.claimUnownedKeys(UUID.randomUUID(), "user@example.com"); + + assertThat(claimed).isZero(); + assertThat(key.getUserId()).isNull(); + } + + @Test + @DisplayName("사용자 ID나 검증 이메일이 없으면 연결을 시작하지 않는다") + void claim_missingIdentity_rejectsRequest() { + UUID userId = UUID.randomUUID(); + + assertThatThrownBy(() -> service.claimUnownedKeys(null, "user@example.com")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.claimUnownedKeys(userId, " ")) + .isInstanceOf(IllegalArgumentException.class); + } + + private ApiKey unowned(String email, String name) throws Exception { + String plaintext = generator.generateApiKey(); + ApiKey key = + ApiKey.issue( + UUID.randomUUID(), + name, + email, + generator.hashApiKey(plaintext), + generator.keyHint(plaintext), + "legacy", + null, + LocalDateTime.of(2026, 7, 17, 9, 0)); + setUserId(key, null); + return key; + } + + private void setUserId(ApiKey apiKey, UUID userId) throws Exception { + Field field = ApiKey.class.getDeclaredField("userId"); + field.setAccessible(true); + field.set(apiKey, userId); + } +} diff --git a/profanity-domain/src/test/java/app/application/apikey/ClientsCommandServiceTest.java b/profanity-domain/src/test/java/app/application/apikey/ClientsCommandServiceTest.java deleted file mode 100644 index e5f3032..0000000 --- a/profanity-domain/src/test/java/app/application/apikey/ClientsCommandServiceTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package app.application.apikey; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import app.application.client.APIKeyGenerator; -import app.application.client.ClientsCommandService; -import app.application.client.KeyGenerator; -import app.core.exception.BusinessException; -import app.domain.InmemoryClientsRepository; -import app.dto.request.ClientRegistCommand; -import app.dto.response.ClientsRegistResponse; -import app.fixture.ClientTestFixture; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class ClientsCommandServiceTest { - - private ClientsCommandService clientsCommandService; - private InmemoryClientsRepository clientsRepository; - private KeyGenerator keyGenerator; - - @BeforeEach - void setUp() { - clientsRepository = new InmemoryClientsRepository(); - keyGenerator = new APIKeyGenerator("test-salt", "SHA-256"); - clientsCommandService = new ClientsCommandService(clientsRepository, keyGenerator); - } - - @Nested - @DisplayName("신규 클라이언트를 등록할 때") - class RegisterNewClient { - - @Test - @DisplayName("유효한 요청이면 성공한다") - void validRequest() { - // given - ClientRegistCommand request = - ClientTestFixture.createRequest("테스트 클라이언트", "test@example.com"); - - // when - ClientsRegistResponse response = clientsCommandService.registerNewClient(request); - - // then - assertNotNull(response); - assertEquals(request.name(), response.name()); - assertEquals(request.email(), response.email()); - assertNotNull(response.apiKey()); - assertEquals(request.note(), response.note()); - } - - @Test - @DisplayName("이메일이 중복되면 실패한다") - void duplicateEmail() { - // given - String duplicateEmail = "test@example.com"; - clientsRepository.save( - ClientTestFixture.createClient("기존 클라이언트", duplicateEmail, "existing-key")); - - ClientRegistCommand request = ClientTestFixture.createRequest("신규 클라이언트", duplicateEmail); - - // when & then - BusinessException exception = - assertThrows( - BusinessException.class, () -> clientsCommandService.registerNewClient(request)); - assertEquals("이미 등록된 이메일입니다.", exception.getStatus().DetailDescription()); - } - - @Test - @DisplayName("API 키가 중복되면 실패한다") - void duplicateApiKey() { - // given - String duplicateApiKey = "duplicate-api-key"; - clientsRepository.save( - ClientTestFixture.createClient("기존 클라이언트", "existing@example.com", duplicateApiKey)); - - ClientsCommandService service = - new ClientsCommandService( - clientsRepository, ClientTestFixture.createKeyGenerator(duplicateApiKey)); - - ClientRegistCommand request = ClientTestFixture.createRequest("신규 클라이언트", "new@example.com"); - - // when & then - BusinessException exception = - assertThrows(BusinessException.class, () -> service.registerNewClient(request)); - assertEquals("API 키 생성 중 중복이 발생했습니다.", exception.getStatus().DetailDescription()); - } - } -} diff --git a/profanity-domain/src/test/java/app/domain/InMemoryApiKeyRepository.java b/profanity-domain/src/test/java/app/domain/InMemoryApiKeyRepository.java new file mode 100644 index 0000000..3508c27 --- /dev/null +++ b/profanity-domain/src/test/java/app/domain/InMemoryApiKeyRepository.java @@ -0,0 +1,79 @@ +package app.domain; + +import app.domain.apikey.ApiKey; +import app.domain.apikey.ApiKeyRepository; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class InMemoryApiKeyRepository implements ApiKeyRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public ApiKey save(ApiKey apiKey) { + values.put(apiKey.getId(), apiKey); + return apiKey; + } + + @Override + public List findAll() { + return new ArrayList<>(values.values()); + } + + @Override + public List findAllByUserIdOrderByIssuedAtDesc(UUID userId) { + return values.values().stream() + .filter(apiKey -> userId.equals(apiKey.getUserId())) + .sorted(Comparator.comparing(ApiKey::getIssuedAt).reversed()) + .toList(); + } + + @Override + public Optional findByIdAndUserId(UUID id, UUID userId) { + return Optional.ofNullable(values.get(id)).filter(apiKey -> userId.equals(apiKey.getUserId())); + } + + @Override + public Optional findByKeyHash(String keyHash) { + return values.values().stream() + .filter(apiKey -> apiKey.getKeyHash().equals(keyHash)) + .findFirst(); + } + + @Override + public boolean existsByKeyHash(String keyHash) { + return findByKeyHash(keyHash).isPresent(); + } + + @Override + public int claimUnownedByEmail(UUID userId, String email) { + int claimed = 0; + String normalized = email.toLowerCase(Locale.ROOT); + for (ApiKey apiKey : values.values()) { + if (apiKey.getUserId() == null + && apiKey.getEmail().toLowerCase(Locale.ROOT).equals(normalized)) { + setUserId(apiKey, userId); + claimed++; + } + } + return claimed; + } + + private void setUserId(ApiKey apiKey, UUID userId) { + try { + var field = ApiKey.class.getDeclaredField("userId"); + field.setAccessible(true); + field.set(apiKey, userId); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public void updateRequestCount() {} +} diff --git a/profanity-domain/src/test/java/app/domain/InmemoryClientsRepository.java b/profanity-domain/src/test/java/app/domain/InmemoryClientsRepository.java deleted file mode 100644 index a2d73d6..0000000 --- a/profanity-domain/src/test/java/app/domain/InmemoryClientsRepository.java +++ /dev/null @@ -1,83 +0,0 @@ -package app.domain; - -import app.domain.client.Clients; -import app.domain.client.ClientsRepository; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class InmemoryClientsRepository implements ClientsRepository { - private final Map store = new HashMap<>(); - - @Override - public Clients save(Clients client) { - store.put(client.getId(), client); - return client; - } - - @Override - public List findAll() { - return store.values().stream().toList(); - } - - @Override - public void deleteById(UUID id) { - store.remove(id); - } - - @Override - public boolean existsById(UUID id) { - return store.containsKey(id); - } - - @Override - public Optional findById(UUID id) { - return Optional.ofNullable(store.get(id)); - } - - @Override - public boolean existsByEmail(String email) { - return store.values().stream().anyMatch(client -> client.getEmail().equals(email)); - } - - @Override - public boolean existsByApiKey(String apiKey) { - return store.values().stream().anyMatch(client -> client.getApiKey().equals(apiKey)); - } - - @Override - public Optional findByEmail(String email) { - return store.values().stream().filter(client -> client.getEmail().equals(email)).findFirst(); - } - - @Override - public Optional findByApiKey(String apiKey) { - return store.values().stream().filter(client -> client.getApiKey().equals(apiKey)).findFirst(); - } - - @Override - public Optional findByEmailAndApiKey(String email, String apiKey) { - return store.values().stream() - .filter(client -> client.getEmail().equals(email) && client.getApiKey().equals(apiKey)) - .findFirst(); - } - - @Override - public Optional findByIssuerInfo(String issuerInfo) { - return store.values().stream() - .filter(client -> client.getIssuerInfo().equals(issuerInfo)) - .findFirst(); - } - - @Override - // This method is intentionally left empty as this is a test-specific implementation - // of the ClientsRepository interface. It is not required for in-memory testing. - public void updateClientRequestCount() {} - - // 테스트를 위한 추가 메서드 - public void clear() { - store.clear(); - } -} diff --git a/profanity-domain/src/test/java/app/domain/InmemoryRecordRepository.java b/profanity-domain/src/test/java/app/domain/InmemoryRecordRepository.java index 97f7f1b..11b2165 100644 --- a/profanity-domain/src/test/java/app/domain/InmemoryRecordRepository.java +++ b/profanity-domain/src/test/java/app/domain/InmemoryRecordRepository.java @@ -27,7 +27,8 @@ public Records save(Records records) { public void delete(Records records) {} @Override - public Pair getClientDailyUsageStatistics(String apiKey, LocalDateTime yesterday) { + public Pair getApiKeyDailyUsageStatistics( + String apiKeyHash, LocalDateTime yesterday) { return null; } diff --git a/profanity-domain/src/test/java/app/fixture/ClientTestFixture.java b/profanity-domain/src/test/java/app/fixture/ClientTestFixture.java deleted file mode 100644 index 50c664e..0000000 --- a/profanity-domain/src/test/java/app/fixture/ClientTestFixture.java +++ /dev/null @@ -1,30 +0,0 @@ -package app.fixture; - -import app.application.client.KeyGenerator; -import app.domain.client.Clients; -import app.dto.request.ClientRegistCommand; - -public class ClientTestFixture { - - public static ClientRegistCommand createRequest(String name, String email) { - return new ClientRegistCommand(name, email, "테스트 발급자", "테스트 노트"); - } - - public static Clients createClient(String name, String email, String apiKey) { - return Clients.builder().name(name).email(email).apiKey(apiKey).issuerInfo("기존 발급자").build(); - } - - public static KeyGenerator createKeyGenerator(String fixedApiKey) { - return new KeyGenerator() { - @Override - public String generateApiKey() { - return fixedApiKey; - } - - @Override - public boolean validateApiKey(String apiKey) { - return true; - } - }; - } -} 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 84c352e..2a2cec0 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 @@ -26,6 +26,8 @@ public enum StatusCode { INVALID_TRACKING_ID(4002, "유효하지 않은 Tracking ID 입니다. Tracking ID를 확인해 주세요."), INVALID_CALLBACK_URL(4001, "콜백 URL 형식이 올바르지 않습니다. 콜백 URL을 확인해 주세요."), AMBIGUOUS_CREDENTIALS(4004, "하나의 요청에는 하나의 인증 정보만 전달할 수 있습니다."), + API_KEY_ALREADY_EXPIRED(4005, "이미 만료된 API Key입니다."), + API_KEY_NOT_FOUND(4040, "API Key를 찾을 수 없습니다."), INTERNAL_SERVER_ERROR(5000, "서버 내부 오류가 발생 했습니다., 재시도 시 문제가 지속 되는 경우 관리자에게 문의 바랍니다."), SERVICE_UNAVAILABLE(5030, "현재 서비스가 점검중이므로 서비스 응답할 수 없는 경우 발생합니다."), ; diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaApiKeyRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaApiKeyRepository.java new file mode 100644 index 0000000..bd606ef --- /dev/null +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaApiKeyRepository.java @@ -0,0 +1,34 @@ +package app.storage.rds; + +import app.domain.apikey.ApiKey; +import app.domain.apikey.ApiKeyRepository; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface JpaApiKeyRepository extends ApiKeyRepository, JpaRepository { + + @Override + @Modifying + @Query( + """ + update api_keys k + set k.userId = :userId + where k.userId is null and lower(k.email) = lower(:email) + """) + int claimUnownedByEmail(@Param("userId") UUID userId, @Param("email") String email); + + @Override + @Modifying + @Query( + """ + update api_keys k + set k.requestCount = (select count(r.id) + from records r + where r.apiKeyHash = k.keyHash) + where k.expiredAt is null + """) + void updateRequestCount(); +} diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaClientsRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaClientsRepository.java deleted file mode 100644 index 7c8913d..0000000 --- a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaClientsRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package app.storage.rds; - -import app.domain.client.Clients; -import app.domain.client.ClientsRepository; -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; - -public interface JpaClientsRepository extends ClientsRepository, JpaRepository { - @Override - @Modifying - @Query( - """ - update clients c - set c.requestCount = (select count(r.id) - from records r - where r.apiKey = c.apiKey) - where c.expiredAt is null - """) - void updateClientRequestCount(); -} diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaRecordRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaRecordRepository.java index 3e87723..feb816f 100644 --- a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaRecordRepository.java +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaRecordRepository.java @@ -16,10 +16,10 @@ public interface JpaRecordRepository extends RecordRepository, JpaRepository 0 then 1 else null end)) FROM records r - WHERE r.apiKey = :apiKey + WHERE r.apiKeyHash = :apiKeyHash AND r.createdAt >= :yesterday """) - Pair getClientDailyUsageStatistics(String apiKey, LocalDateTime yesterday); + Pair getApiKeyDailyUsageStatistics(String apiKeyHash, LocalDateTime yesterday); @Override @Query("SELECT r FROM records r WHERE r.trackingId = :trackingId") diff --git a/profanity-storage/rdb/src/main/resources/db/migration/V4__api_keys.sql b/profanity-storage/rdb/src/main/resources/db/migration/V4__api_keys.sql new file mode 100644 index 0000000..657be29 --- /dev/null +++ b/profanity-storage/rdb/src/main/resources/db/migration/V4__api_keys.sql @@ -0,0 +1,75 @@ +CREATE TABLE api_keys +( + id binary(16) NOT NULL COMMENT 'API Key 식별자', + user_id binary(16) DEFAULT NULL COMMENT '소유 사용자 ID', + name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'API Key 이름', + email varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT '발급 사용자 이메일', + key_hash char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'API Key SHA-256 해시', + key_hint varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '화면 표시용 API Key 힌트', + issuer_info varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '발급자 정보', + note varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '비고', + permissions text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '권한', + issued_at datetime(6) NOT NULL COMMENT '발급 시각', + expired_at datetime(6) DEFAULT NULL COMMENT '만료 시각', + request_count bigint NOT NULL DEFAULT 0 COMMENT '요청 횟수', + PRIMARY KEY (id), + UNIQUE KEY uk_api_keys_key_hash (key_hash), + KEY idx_api_keys_user_issued (user_id, issued_at), + KEY idx_api_keys_email_owner (email, user_id), + CONSTRAINT fk_api_keys_user_id + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSO 사용자 소유 API Key'; + +INSERT INTO api_keys +( + id, + user_id, + name, + email, + key_hash, + key_hint, + issuer_info, + note, + permissions, + issued_at, + expired_at, + request_count +) +SELECT id, + NULL, + name, + LOWER(email), + COALESCE(SHA2(api_key, 256), SHA2(CONCAT('retired:', HEX(id)), 256)), + CASE + WHEN api_key IS NULL THEN '사용 불가' + ELSE CONCAT(LEFT(api_key, 6), '...', RIGHT(api_key, 4)) + END, + issuer_info, + note, + COALESCE(permissions, 'READ'), + COALESCE(issued_at, CURRENT_TIMESTAMP(6)), + CASE + WHEN api_key IS NULL THEN COALESCE(expired_at, CURRENT_TIMESTAMP(6)) + ELSE expired_at + END, + COALESCE(request_count, 0) +FROM clients; + +ALTER TABLE records + ADD COLUMN api_key_hash char(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT 'API Key SHA-256 해시' AFTER mode; + +UPDATE records +SET api_key_hash = SHA2(api_key, 256) +WHERE api_key IS NOT NULL; + +ALTER TABLE records + ADD KEY idx_records_api_key_hash_created (api_key_hash, created_at), + DROP COLUMN api_key; + +ALTER TABLE client_reports + DROP INDEX idx_client_reports_api_key, + DROP COLUMN api_key, + ADD CONSTRAINT fk_client_reports_api_key_id + FOREIGN KEY (client_id) REFERENCES api_keys (id); + +DROP TABLE clients; diff --git a/profanity-test-support/src/main/java/app/test/support/config/SecurityFakeStubConfig.java b/profanity-test-support/src/main/java/app/test/support/config/SecurityFakeStubConfig.java index 033d9fe..983b84a 100644 --- a/profanity-test-support/src/main/java/app/test/support/config/SecurityFakeStubConfig.java +++ b/profanity-test-support/src/main/java/app/test/support/config/SecurityFakeStubConfig.java @@ -1,8 +1,8 @@ package app.test.support.config; +import app.application.apikey.ApiKeyMetadataReader; import app.application.client.KeyGenerator; -import app.application.client.MetadataReader; -import app.test.support.fake.FakeClientMetadataReader; +import app.test.support.fake.FakeApiKeyMetadataReader; import java.security.NoSuchAlgorithmException; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -13,8 +13,8 @@ public class SecurityFakeStubConfig { @Bean @Primary - public MetadataReader fakeMetadataReader(KeyGenerator apiKeyGenerator) + public ApiKeyMetadataReader fakeMetadataReader(KeyGenerator apiKeyGenerator) throws NoSuchAlgorithmException { - return new FakeClientMetadataReader(apiKeyGenerator); + return new FakeApiKeyMetadataReader(apiKeyGenerator); } } 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 new file mode 100644 index 0000000..7f5471a --- /dev/null +++ b/profanity-test-support/src/main/java/app/test/support/fake/FakeApiKeyMetadataReader.java @@ -0,0 +1,40 @@ +package app.test.support.fake; + +import app.application.apikey.ApiKeyMetadataReader; +import app.application.client.KeyGenerator; +import app.core.data.response.constant.StatusCode; +import app.domain.apikey.ApiKeyMetadata; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +public class FakeApiKeyMetadataReader implements ApiKeyMetadataReader { + public static final List validKeys = new ArrayList<>(); + private final KeyGenerator apiKeyGenerator; + + public FakeApiKeyMetadataReader(KeyGenerator apiKeyGenerator) throws NoSuchAlgorithmException { + this.apiKeyGenerator = apiKeyGenerator; + validKeys.clear(); + validKeys.add(apiKeyGenerator.generateApiKey()); + validKeys.add(apiKeyGenerator.generateApiKey()); + } + + @Override + public ApiKeyMetadata read(String apiKey) { + if (!apiKeyGenerator.validateApiKey(apiKey)) { + throw new IllegalArgumentException(StatusCode.INVALID_API_KEY.stringCode()); + } + if (!validKeys.contains(apiKey)) { + throw new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode()); + } + return new ApiKeyMetadata( + UUID.nameUUIDFromBytes(apiKey.getBytes()), + "tester@example.com", + "test", + List.of("READ"), + "2026-07-17T00:00:00", + apiKeyGenerator.hashApiKey(apiKey)); + } +} diff --git a/profanity-test-support/src/main/java/app/test/support/fake/FakeClientMetadataReader.java b/profanity-test-support/src/main/java/app/test/support/fake/FakeClientMetadataReader.java deleted file mode 100644 index d993665..0000000 --- a/profanity-test-support/src/main/java/app/test/support/fake/FakeClientMetadataReader.java +++ /dev/null @@ -1,53 +0,0 @@ -package app.test.support.fake; - -import app.application.client.KeyGenerator; -import app.application.client.MetadataReader; -import app.core.data.response.constant.StatusCode; -import app.domain.client.ClientMetadata; -import app.domain.client.PermissionsType; -import java.security.NoSuchAlgorithmException; -import java.util.NoSuchElementException; -import java.util.UUID; -import java.util.concurrent.CopyOnWriteArrayList; - -public class FakeClientMetadataReader implements MetadataReader { - public static final CopyOnWriteArrayList validKeys = new CopyOnWriteArrayList<>(); - private final KeyGenerator apiKeyGenerator; - - public FakeClientMetadataReader(KeyGenerator apiKeyGenerator) throws NoSuchAlgorithmException { - this.apiKeyGenerator = apiKeyGenerator; - validKeys.clear(); - validKeys.add(apiKeyGenerator.generateApiKey()); - validKeys.add(apiKeyGenerator.generateApiKey()); - } - - @Override - public ClientMetadata read(String apiKey) { - if (Boolean.FALSE.equals(apiKeyGenerator.validateApiKey(apiKey))) { - throw new IllegalArgumentException(StatusCode.INVALID_API_KEY.stringCode()); - } - - if (validKeys.contains(apiKey)) { - return ClientMetadata.builder() - .id(UUID.randomUUID()) - .email("fake@mail.com") - .issuerInfo("issuer info") - .note("note") - .permissions( - PermissionsType.allPermissions().stream().map(PermissionsType::getValue).toList()) - .issuedAt("2025-09-01T00:00:00Z") - .build(); - } - throw new NoSuchElementException(StatusCode.NOT_FOUND_CLIENT.stringCode()); - } - - @Override - public boolean verifyClientByEmail(String email) { - return validKeys.contains(email); - } - - @Override - public String getApiKeyByEmail(String email) { - return validKeys.stream().findFirst().orElseThrow(); - } -} diff --git a/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKey.java b/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKey.java new file mode 100644 index 0000000..6b12441 --- /dev/null +++ b/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKey.java @@ -0,0 +1,3 @@ +package app.test.support.fixture; + +public record SeedApiKey(String id, String name, String email, String apiKey) {} diff --git a/profanity-test-support/src/main/java/app/test/support/fixture/SeedClients.java b/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java similarity index 65% rename from profanity-test-support/src/main/java/app/test/support/fixture/SeedClients.java rename to profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java index 71a5e71..466a3af 100644 --- a/profanity-test-support/src/main/java/app/test/support/fixture/SeedClients.java +++ b/profanity-test-support/src/main/java/app/test/support/fixture/SeedApiKeys.java @@ -1,20 +1,20 @@ package app.test.support.fixture; -public final class SeedClients { +public final class SeedApiKeys { - public static final SeedClient READ_CLIENT = - new SeedClient( + public static final SeedApiKey READ_CLIENT = + new SeedApiKey( "00000000-0000-0000-0000-000000000001", "E2E Read Client", "e2e-read@example.com", "HmikqfE546l5lP4R5UbETsfROP8go0Kq-9cZqNw-nDU"); - public static final SeedClient WRITE_CLIENT = - new SeedClient( + public static final SeedApiKey WRITE_CLIENT = + new SeedApiKey( "00000000-0000-0000-0000-000000000002", "E2E Write Client", "e2e-write@example.com", "u6N_yQZAPfyrLheRXi7V0tZkvqe5Mno__vV0BlxpCjk"); - private SeedClients() {} + private SeedApiKeys() {} } diff --git a/profanity-test-support/src/main/java/app/test/support/fixture/SeedClient.java b/profanity-test-support/src/main/java/app/test/support/fixture/SeedClient.java deleted file mode 100644 index 747880b..0000000 --- a/profanity-test-support/src/main/java/app/test/support/fixture/SeedClient.java +++ /dev/null @@ -1,3 +0,0 @@ -package app.test.support.fixture; - -public record SeedClient(String id, String name, String email, String apiKey) {} diff --git a/profanity-test-support/src/main/java/app/test/support/probe/RecordProbe.java b/profanity-test-support/src/main/java/app/test/support/probe/RecordProbe.java index dd53a2c..4179709 100644 --- a/profanity-test-support/src/main/java/app/test/support/probe/RecordProbe.java +++ b/profanity-test-support/src/main/java/app/test/support/probe/RecordProbe.java @@ -1,7 +1,7 @@ package app.test.support.probe; import app.core.data.constant.Mode; -import app.test.support.fixture.SeedClient; +import app.test.support.fixture.SeedApiKey; import app.test.support.fixture.SeedWord; import java.sql.Connection; import java.sql.PreparedStatement; @@ -16,7 +16,7 @@ public RecordProbe(DataSource dataSource) { this.dataSource = dataSource; } - public void assertFilterRecord(SeedClient client, String requestText, SeedWord word) { + public void assertFilterRecord(SeedApiKey client, String requestText, SeedWord word) { int count = countFilterRecords(client, requestText, word); if (count != 1) { @@ -25,16 +25,16 @@ public void assertFilterRecord(SeedClient client, String requestText, SeedWord w } } - public int countFilterRecords(SeedClient client, String requestText, SeedWord word) { + public int countFilterRecords(SeedApiKey client, String requestText, SeedWord word) { return countRecords(client, requestText, Mode.FILTER, word.word()); } - public int countRecords(SeedClient client, String requestText, Mode mode, String words) { + public int countRecords(SeedApiKey client, String requestText, Mode mode, String words) { String sql = """ SELECT COUNT(*) FROM records - WHERE api_key = ? + WHERE api_key_hash = SHA2(?, 256) AND request_text = ? AND mode = ? AND words = ? @@ -56,12 +56,12 @@ SELECT COUNT(*) } } - public int countRecords(SeedClient client, String requestText, Mode mode) { + public int countRecords(SeedApiKey client, String requestText, Mode mode) { String sql = """ SELECT COUNT(*) FROM records - WHERE api_key = ? + WHERE api_key_hash = SHA2(?, 256) AND request_text = ? AND mode = ? """; diff --git a/profanity-test-support/src/main/java/app/test/support/reader/WordManagementRequestReader.java b/profanity-test-support/src/main/java/app/test/support/reader/WordManagementRequestReader.java index b9cd282..316e83b 100644 --- a/profanity-test-support/src/main/java/app/test/support/reader/WordManagementRequestReader.java +++ b/profanity-test-support/src/main/java/app/test/support/reader/WordManagementRequestReader.java @@ -1,6 +1,6 @@ package app.test.support.reader; -import app.test.support.fixture.SeedClient; +import app.test.support.fixture.SeedApiKey; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -14,7 +14,7 @@ public WordManagementRequestReader(DataSource dataSource) { this.dataSource = dataSource; } - public int countRequests(SeedClient client, String word, String requestType) { + public int countRequests(SeedApiKey client, String word, String requestType) { String sql = """ SELECT COUNT(*) diff --git a/profanity-test-support/src/main/resources/db/seed/0001.sql b/profanity-test-support/src/main/resources/db/seed/0001.sql index 4e3b1d5..9920f05 100644 --- a/profanity-test-support/src/main/resources/db/seed/0001.sql +++ b/profanity-test-support/src/main/resources/db/seed/0001.sql @@ -7,10 +7,10 @@ DELETE FROM `oauth_accounts`; DELETE FROM `users`; DELETE FROM `client_reports`; DELETE FROM `records`; +DELETE FROM `api_keys`; DELETE FROM `word_management`; DELETE FROM `profanity_word`; DELETE FROM `manage_account`; -DELETE FROM `clients`; ALTER TABLE `client_reports` AUTO_INCREMENT = 1; ALTER TABLE `records` AUTO_INCREMENT = 1; 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 4e54d09..92d6c6e 100644 --- a/profanity-test-support/src/main/resources/db/seed/0002.sql +++ b/profanity-test-support/src/main/resources/db/seed/0002.sql @@ -1,8 +1,10 @@ -INSERT INTO `clients` ( +INSERT INTO `api_keys` ( `id`, + `user_id`, `name`, `email`, - `api_key`, + `key_hash`, + `key_hint`, `issuer_info`, `note`, `issued_at`, @@ -11,9 +13,11 @@ INSERT INTO `clients` ( ) VALUES ( UNHEX(REPLACE('00000000-0000-0000-0000-000000000001', '-', '')), + NULL, 'E2E Read Client', 'e2e-read@example.com', - 'HmikqfE546l5lP4R5UbETsfROP8go0Kq-9cZqNw-nDU', + SHA2('HmikqfE546l5lP4R5UbETsfROP8go0Kq-9cZqNw-nDU', 256), + 'Hmikqf...-nDU', 'e2e-seed', '읽기 권한 테스트 클라이언트', CURRENT_TIMESTAMP, @@ -22,9 +26,11 @@ INSERT INTO `clients` ( ), ( UNHEX(REPLACE('00000000-0000-0000-0000-000000000002', '-', '')), + NULL, 'E2E Write Client', 'e2e-write@example.com', - 'u6N_yQZAPfyrLheRXi7V0tZkvqe5Mno__vV0BlxpCjk', + SHA2('u6N_yQZAPfyrLheRXi7V0tZkvqe5Mno__vV0BlxpCjk', 256), + 'u6N_yQ...pCjk', 'e2e-seed', '쓰기 권한 테스트 클라이언트', CURRENT_TIMESTAMP, 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 2a50d64..6b77be0 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,7 +29,7 @@ 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, "clients")).isEqualTo(2); + assertThat(count(dataSource, "api_keys")).isEqualTo(2); assertThat(count(dataSource, "profanity_word")).isEqualTo(3); assertThat(count(dataSource, "users")).isZero(); assertThat(count(dataSource, "login_exchange_codes")).isZero(); @@ -39,13 +39,17 @@ void mysqlContainer_whenMigrateAndSeed_succeeds() throws SQLException { execute( dataSource, """ - insert into clients (id, name, email, api_key, issuer_info, permissions) + 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', '-', '')), + null, 'Temporary Client', 'temporary@example.com', - 'temporary-api-key', + SHA2('temporary-api-key', 256), + 'tempor...-key', 'smoke-test', - 'READ') + 'READ', + CURRENT_TIMESTAMP(6)) """); execute( @@ -123,7 +127,7 @@ insert into login_exchange_codes (id, user_id, code_hash, created_at, expires_at MySqlTestContainer.resetSeedData(MYSQL); - assertThat(count(dataSource, "clients")).isEqualTo(2); + assertThat(count(dataSource, "api_keys")).isEqualTo(2); assertThat(count(dataSource, "users")).isZero(); assertThat(count(dataSource, "login_exchange_codes")).isZero(); assertThat(count(dataSource, "login_refresh_sessions")).isZero(); diff --git a/ui/API_AND_USAGE_FLOWS.md b/ui/API_AND_USAGE_FLOWS.md index 63e77a2..7122cb4 100644 --- a/ui/API_AND_USAGE_FLOWS.md +++ b/ui/API_AND_USAGE_FLOWS.md @@ -8,9 +8,9 @@ | 구분 | Credential | 사용 범위 | | --- | --- | --- | -| Public | 없음 | SSO 시작·교환·갱신, 클라이언트 등록·복구, 문서, 상태 확인 | -| Login JWT | `Authorization: Bearer {accessToken}` | 로그인 사용자 조회와 향후 `/api/v1/dashboard/**` | -| API Key | `x-api-key: {apiKey}` | 필터, 클라이언트 관리, 단어 관리, 동기화 | +| Public | 없음 | SSO 시작·교환·갱신, 문서, 상태 확인 | +| Login JWT | `Authorization: Bearer {accessToken}` | 로그인 사용자와 API Key 관리 | +| API Key | `x-api-key: {apiKey}` | 필터, 단어 관리, 동기화 | Login JWT를 API Key 대신 사용할 수 없으며, 현재 외부 API용 OAuth2 access token은 지원하지 않는다. @@ -31,21 +31,18 @@ Login JWT를 API Key 대신 사용할 수 없으며, 현재 외부 API용 OAuth2 | POST | `/api/v1/auth/refresh` | Refresh cookie + CSRF | access token 갱신과 refresh token rotation | | GET | `/api/v1/auth/me` | Login JWT | 현재 로그인 사용자 조회 | -현재 `/api/v1/dashboard/**`는 Login JWT 보호 경로만 예약되어 있고 실제 endpoint는 없다. 로그아웃 endpoint도 없다. +로그아웃 endpoint는 아직 없다. ### API Key 발급과 관리 | Method | Path | 인증 | 역할 | | --- | --- | --- | --- | -| POST | `/api/v1/clients/register` | Public | `name`, `email`, `issuerInfo`, `note`로 신규 API Key 발급 | -| GET | `/api/v1/clients/send-email` | Public | `email` query의 기존 등록 이메일로 인증 코드 발송 | -| PUT | `/api/v1/clients/send-email` | Public | 이메일과 인증 코드를 검증하고 기존 API Key 반환 | -| GET | `/api/v1/clients` | API Key | 현재 클라이언트 정보 조회 | -| POST | `/api/v1/clients/update` | API Key | 발급자 정보와 메모 수정 | -| POST | `/api/v1/clients/reissue` | API Key | API Key 재발급 | -| DELETE | `/api/v1/clients` | API Key | 클라이언트 폐기 | +| GET | `/api/v1/dashboard/keys` | Login JWT | 본인의 활성·만료 API Key 목록 조회 | +| POST | `/api/v1/dashboard/keys` | Login JWT | `name`, `issuerInfo`, `note`로 신규 API Key 발급 | +| POST | `/api/v1/dashboard/keys/{keyId}/reissue` | Login JWT | 기존 키 만료 후 대체 키 발급 | +| DELETE | `/api/v1/dashboard/keys/{keyId}` | Login JWT | API Key 만료 | -`send-email` 두 API는 신규 등록 전 이메일 인증이 아니라 이미 등록된 이메일의 API Key 복구 흐름이다. 현재 SSO 사용자와 API Key 클라이언트를 연결하는 API는 없다. +입력 이메일은 받지 않고 Login JWT 사용자의 primary email을 사용한다. API Key 원문은 발급·재발행 응답에서만 한 번 반환한다. 기존 `/api/v1/clients/**`는 삭제됐다. ### 비속어 필터 @@ -87,22 +84,22 @@ Login JWT를 API Key 대신 사용할 수 없으며, 현재 외부 API용 OAuth2 7. access token 갱신이 필요하면 `GET /api/v1/auth/csrf` 후 반환된 header 이름과 token으로 `POST /api/v1/auth/refresh`를 호출한다. 8. 갱신 응답의 새 access token으로 메모리 상태를 교체한다. -### API Key 신규 발급 +### 기존 API Key 자동 연결 -1. 사용자가 이름 또는 조직명, 이메일, 발급자 정보와 메모를 입력한다. -2. UI가 `POST /api/v1/clients/register`를 호출한다. -3. 반환된 API Key를 한 번 명확하게 노출하고 사용자가 안전하게 복사하도록 한다. -4. 이후 외부 API 호출에는 `x-api-key` 헤더를 사용한다. +1. 기존 `clients` 데이터는 V4 migration에서 같은 ID의 `api_keys`로 복제되고 원문은 SHA-256 hash로 전환된다. +2. 사용자가 SSO 로그인하면 검증된 primary email을 기준으로 소유자가 없는 기존 키를 비동기로 조회한다. +3. 동일 이메일의 미이관 키에만 현재 `users.id`를 연결한다. +4. 이미 소유자가 있거나 이메일이 다른 키는 변경하지 않으며 이후 로그인은 no-op이다. -신규 발급 전에 `send-email` 인증을 거치지 않는다. +### API Key 신규 발급 -### 기존 API Key 복구 +1. 로그인 사용자가 이름, 발급자 정보와 선택 메모를 입력한다. +2. UI가 Login JWT로 `POST /api/v1/dashboard/keys`를 호출한다. +3. 서버는 요청 이메일 대신 SSO primary email로 키를 발급한다. +4. 반환된 API Key 원문을 완료 화면에서 한 번 노출하고 안전하게 복사하도록 한다. +5. 이후 외부 API 호출에는 `x-api-key` 헤더를 사용한다. -1. 사용자가 기존 등록 이메일을 입력한다. -2. UI가 `GET /api/v1/clients/send-email?email=...`로 인증 코드를 요청한다. -3. 사용자가 이메일로 받은 코드를 입력한다. -4. UI가 이메일과 코드를 `PUT /api/v1/clients/send-email`로 검증한다. -5. 성공 응답의 기존 API Key를 노출하고 복사할 수 있게 한다. +API Key 원문 복구 기능은 없다. 분실한 키는 재발행한다. ### 필터 사용 @@ -115,9 +112,10 @@ Login JWT를 API Key 대신 사용할 수 없으며, 현재 외부 API용 OAuth2 ### API Key 관리 -1. API Key로 `GET /api/v1/clients`를 호출해 현재 정보를 확인한다. -2. 정보 수정은 `/update`, 키 교체는 `/reissue`, 폐기는 `DELETE /api/v1/clients`로 각각 분리한다. -3. 재발급과 폐기는 기존 credential에 영향을 주는 작업이므로 실행 전 확인 단계를 둔다. +1. Login JWT로 `/api/v1/dashboard/keys`를 호출해 본인의 활성·만료 키를 확인한다. +2. 재발행은 기존 키를 즉시 만료하고 새 키 원문을 한 번 반환한다. +3. 만료는 soft-expire로 반복 요청해도 같은 만료 시각을 유지한다. +4. 재발행과 만료는 기존 credential에 영향을 주므로 UI에서 실행 전 확인 단계를 둔다. ### 단어 변경 요청 diff --git a/ui/INFORMATION_ARCHITECTURE.md b/ui/INFORMATION_ARCHITECTURE.md index 6ea8c5f..fde0e54 100644 --- a/ui/INFORMATION_ARCHITECTURE.md +++ b/ui/INFORMATION_ARCHITECTURE.md @@ -17,8 +17,8 @@ - API Key를 먼저 제공하고 OAuth2 Client Credentials는 후속 업데이트로 제공한다. - 두 자격 증명은 Google 또는 GitHub SSO 로그인 후에만 발급한다. - 비로그인 공개 API Key 발급은 제공하지 않는다. -- API가 준비되기 전에는 자격 증명 발급을 동작하는 UI처럼 표현하지 않는다. -- 초기 UI가 호출하는 제품 API는 로그인 완료와 세션 유지에 필요한 인증 API로 제한한다. +- API Key 발급·관리는 실제 API를 연결하고 OAuth2 Client Credentials만 준비 중으로 표현한다. +- UI가 호출하는 제품 API는 로그인·문서·본인 API Key 관리 API로 제한한다. ## 메뉴 트리 @@ -30,6 +30,7 @@ Public Signed-in utility └── 현재 사용자 + ├── API Key 관리 └── 내 계정 ``` @@ -43,12 +44,13 @@ Signed-in utility | Public | API 문서 | `/docs` | 누구나 | 운영 OpenAPI와 사용 문서를 읽기 전용으로 제공 | | Public | 로그인 | `/login` | 비로그인 | Google·GitHub SSO 진입과 로그인 상태 표시 | | Signed-in | 내 계정 | `/app/account` | Login JWT | 현재 사용자 기본 정보 읽기 전용 표시 | +| Signed-in | API Key 관리 | `/app/keys` | Login JWT | 키 목록·발급·재발행·만료 관리 | -기존 `/app`, `/app/credentials` 경로는 `/`로 이동하는 호환 경로로만 유지한다. +기존 `/app`, `/app/credentials` 경로는 `/app/keys`로 이동하는 호환 경로로 유지한다. ### 접근 규칙 -- 비로그인 사용자가 `/app` 또는 `/app/account`에 접근하면 `/login`으로 이동한다. +- 비로그인 사용자가 `/app`, `/app/account`, `/app/keys`에 접근하면 `/login`으로 이동한다. - 로그인 사용자가 `/login`에 접근하면 `/app`으로 이동한다. - 알 수 없는 route는 별도 기능으로 추정하지 않고 Not Found 상태를 표시한다. - 권한이나 API가 없는 메뉴는 disabled 상태로 미리 노출하지 않는다. 단, 자격 증명 비교와 API 문서에서는 후속 OAuth2 방식을 `추후 제공` 상태로 명확히 구분해 미리 볼 수 있다. @@ -76,7 +78,7 @@ Signed-in utility - 전역 메뉴의 `로그인` 자리는 현재 사용자 진입점으로 바뀐다. - 별도의 Signed-in 메뉴 행은 표시하지 않는다. - `소개` 안에서 시작 안내와 자격 증명 비교를 연속된 섹션으로 제공한다. -- `내 계정`은 우측 현재 사용자 버튼으로만 진입한다. +- 우측 현재 사용자 메뉴에서 `API Key 관리`와 `내 계정`으로 진입한다. - `Playground`, `단어 관리`, `운영` 메뉴는 초기 범위에 포함하지 않는다. ## 화면별 정보 구조 @@ -117,7 +119,8 @@ Signed-in utility 1. API Key: 빠르고 단순한 연동 2. OAuth2 Client Credentials: 운영·서버 간 연동 권장, 현재는 `추후 제공` 상태 3. 각 방식의 발급 순서와 환경 변수 기반 요청 예시 -4. 자격 증명 목록에서는 API Key, Client Secret, access token 원문을 표시하지 않음 +4. API Key CTA는 `/app/keys` 관리 화면으로 연결 +5. 목록에서는 API Key, Client Secret, access token 원문을 표시하지 않음 소개 페이지의 하위 섹션에서 두 방식을 같은 높이와 같은 정보 순서로 비교한다. OAuth2 Client Credentials는 내용을 읽을 수 있게 유지하되 팻말 형태의 `추후 제공` 표시와 disabled 상태로 생성·복사 상호작용을 차단한다. Client Secret은 후속 구현에서도 별도의 발급 완료 단계에서 최초 1회만 제공한다. @@ -130,6 +133,16 @@ Signed-in utility 초기 버전은 `GET /api/v1/auth/me`가 반환하는 정보만 표시한다. OAuth provider 연결·해제, 이름 수정, 탈퇴, 로그아웃은 해당 API가 마련되기 전까지 제공하지 않는다. +### API Key 관리 + +1. 활성·만료 API Key를 한 목록에서 상태 텍스트와 함께 표시 +2. API Key 이름, 고정된 SSO 이메일, 발급자 정보와 선택 메모 입력 +3. 발급·재발행 응답의 키 원문을 완료 dialog에서 최초 1회만 표시 +4. 재발행은 현재 키가 즉시 만료됨을 확인한 뒤 실행 +5. 만료는 되돌릴 수 없는 작업임을 확인한 뒤 실행 +6. 목록에는 `keyHint`만 표시하며 원문 복구 기능은 제공하지 않음 +7. loading, empty, error, processing 상태를 색상 외 텍스트와 `aria-live`로 전달 + ## 핵심 사용 흐름 ### 로그인 @@ -163,7 +176,27 @@ Signed-in utility → Overview / Authentication / OpenAPI reference 탐색 ``` -## 초기 API 허용 범위 +### 기존 API Key 연결 + +```text +SSO 로그인 완료 +→ 검증된 primary email로 미이관 api_keys 조회 +→ user_id가 없는 동일 이메일 키만 비동기 연결 +→ 이후 로그인에서는 변경할 키가 없어 no-op +→ API Key 관리 목록에서 확인 +``` + +### API Key 관리 + +```text +현재 사용자 → API Key 관리 +→ 새 키 발급 또는 기존 키 선택 +→ 발급·재발행 시 원문 최초 1회 복사 +→ 목록에서는 keyHint와 상태만 확인 +→ 필요 시 재발행 또는 만료 +``` + +## UI API 허용 범위 현재 API 구현이 완료되기 전 UI 애플리케이션 코드는 다음 경로만 호출할 수 있다. @@ -175,31 +208,31 @@ Signed-in utility | GET | `/api/v1/auth/csrf` | refresh용 CSRF token 조회 | | POST | `/api/v1/auth/refresh` | 로그인 session 복구와 rotation | | GET | `/api/v1/auth/me` | 현재 사용자 확인 | +| GET | `/api/v1/dashboard/keys` | 내 API Key 목록 조회 | +| POST | `/api/v1/dashboard/keys` | 새 API Key 발급 | +| POST | `/api/v1/dashboard/keys/{keyId}/reissue` | API Key 재발행 | +| DELETE | `/api/v1/dashboard/keys/{keyId}` | API Key 만료 | | GET | `/openapi.json` | API 문서 조회 | | GET | `/overview.md` | API 개요 조회 | ## 금지 및 보류 범위 -### 비로그인 UI에서 차단 +### 삭제된 legacy API -- `POST /api/v1/clients/register` -- API Key 및 Client Credentials 발급 CTA, form, 자동 호출 +- `/api/v1/clients/**` 전체 +- 이메일 인증 코드 기반 API Key 복구 -서버 endpoint가 존재하더라도 비로그인 UI에서는 접근 경로나 호출 코드를 만들지 않는다. +API Key 외부 호출 인증은 유지하지만 발급·관리에는 Login JWT만 허용한다. ### 현재 API 구현 전 보류 -- 로그인 기반 API Key 발급·조회·재발급·폐기 - API client 생성과 `client_id`, `client_secret` 최초 1회 노출 - `POST /oauth2/token` -`자격 증명` 메뉴는 유지하되 실제 API가 마련되기 전에는 준비 중 상태만 표시한다. +OAuth2 Client Credentials만 준비 중 상태로 표시한다. ### API 구현 후 추가 -- 기존 공개 발급 API 폐쇄와 로그인 기반 API Key 발급 API -- 로그인 사용자와 기존 API Key의 소유 관계 정리 -- API Key 및 API client 목록·재발급·폐기 API - Client Credentials access token 기반 Playground - 로그아웃, 계정 변경, OAuth provider 연결 관리 diff --git a/ui/app/src/ApiKeysPage.tsx b/ui/app/src/ApiKeysPage.tsx new file mode 100644 index 0000000..588f4fd --- /dev/null +++ b/ui/app/src/ApiKeysPage.tsx @@ -0,0 +1,265 @@ +import { + ArrowClockwise, + Check, + Copy, + Key, + Plus, + ShieldCheck, + Trash, + X, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react"; + +import { + expireApiKey, + issueApiKey, + listApiKeys, + reissueApiKey, + type ApiKeyView, + type CreateApiKeyInput, + type IssuedApiKey, +} from "./apiKeys"; +import type { LoginUser } from "./auth"; + +type Action = { key: ApiKeyView; type: "expire" | "reissue" } | null; + +export default function ApiKeysPage({ + accessToken, + user, +}: { + accessToken: string; + user: LoginUser; +}) { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [creating, setCreating] = useState(false); + const [action, setAction] = useState(null); + const [issued, setIssued] = useState(null); + + async function refresh() { + setError(""); + try { + setKeys(await listApiKeys(accessToken)); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "API Key를 불러오지 못했습니다."); + } finally { + setLoading(false); + } + } + + useEffect(() => { void refresh(); }, [accessToken]); + + async function handleCreated(result: IssuedApiKey) { + setCreating(false); + setIssued(result); + await refresh(); + } + + async function handleAction() { + if (!action) return; + try { + if (action.type === "reissue") { + const result = await reissueApiKey(accessToken, action.key.id); + setAction(null); + setIssued(result); + } else { + await expireApiKey(accessToken, action.key.id); + setAction(null); + } + await refresh(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "요청을 처리하지 못했습니다."); + setAction(null); + } + } + + const activeCount = useMemo(() => keys.filter((key) => key.status === "ACTIVE").length, [keys]); + + return ( +
+
+
+

Credentials

+

API Key 관리

+

키 원문은 발급 직후 한 번만 확인할 수 있습니다.

+
+ +
+ +
+ {activeCount} 활성 + {keys.length - activeCount} 만료 + SSO 이메일 고정 +
+ + {error ?
{error}
: null} + {loading ?
API Key를 확인하고 있습니다.
: null} + {!loading && !error && keys.length === 0 ? ( +
+ +

아직 발급한 API Key가 없습니다.

+

용도별로 키를 나누면 재발행과 만료 처리가 쉬워집니다.

+ +
+ ) : null} + + {keys.length > 0 ? ( +
+ {keys.map((apiKey) => ( +
+
+

{apiKey.name}

+ {apiKey.keyHint} +

{apiKey.note || apiKey.issuerInfo}

+
+
+
이메일
{apiKey.email}
+
발급일
{formatDate(apiKey.issuedAt)}
+
요청 수
{apiKey.requestCount.toLocaleString("ko-KR")}
+
+
+ + +
+
+ ))} +
+ ) : null} + + {creating ? setCreating(false)} onCreated={handleCreated} token={accessToken} /> : null} + {action ? setAction(null)} onConfirm={handleAction} /> : null} + {issued ? setIssued(null)} /> : null} +
+ ); +} + +function Status({ status }: { status: ApiKeyView["status"] }) { + return {status === "ACTIVE" ? "활성" : "만료"}; +} + +function CreateKeyDialog({ email, onClose, onCreated, token }: { email: string; onClose: () => void; onCreated: (issued: IssuedApiKey) => void; token: string }) { + const [form, setForm] = useState({ issuerInfo: "", name: "", note: "" }); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(""); + try { + await onCreated(await issueApiKey(token, form)); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "API Key를 발급하지 못했습니다."); + setSubmitting(false); + } + } + + return ( + +
+

New credential

새 API Key

환경이나 용도를 알아볼 수 있는 이름을 사용하세요.

+ + + +