Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@ 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

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)로 구현
Expand Down Expand Up @@ -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`을 명시적으로 분리
Expand All @@ -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`)

Expand All @@ -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=...` | 수동 동기화 (관리자) |
Expand Down
32 changes: 16 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ 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

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)로 구현
Expand Down Expand Up @@ -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`)

Expand All @@ -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=...` | 수동 동기화 (관리자) |
Expand Down
14 changes: 2 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 대신 사용할 수도 없습니다.

## 빠른 시작
Expand Down Expand Up @@ -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 | 설명 | 인증 |
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0005 SSO 기반 사용자 계정 모델 도입.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 즉시 발급한다. 입력값은 이름, 이메일, 발급자 정보, 메모 수준이며, 이메일 소유 증명이나 사용자 로그인 주체가 없다.

Expand Down Expand Up @@ -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 계약을 유지한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 중 하나를 선택할 수 있다.
Expand Down
40 changes: 40 additions & 0 deletions docs/adr/0007 SSO 사용자 소유 API Key 원장 전환.md
Original file line number Diff line number Diff line change
@@ -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 원문 복구 계약을 유지해야 하므로 제외한다.
Loading