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
21 changes: 15 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, Async 이벤트 리스너, EmailService, HttpClient
├─ web/response/ 응답 meta 커스터마이징 (ResponseBodyAdvice 기반)
├─ exception/ GlobalExceptionHandler
└─ config/ Aspect / LocalCache(Caffeine) / Mail
Expand All @@ -30,8 +30,9 @@ 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/auth/ SSO 계정 upsert, 교환 코드, refresh session rotation
├─ application/event/ FilterEvent / AsyncFilterEvent / TrackingRecorder
└─ domain/ 엔티티(Clients, ProfanityWord, Report, Records) + Repository 포트
└─ domain/ 엔티티(Clients, User/OAuthAccount, LoginSession, ProfanityWord 등) + Repository 포트

profanity-storage:rdb (Data Access - RDB)
└─ domain 의 Repository 포트를 Spring Data JPA(Jpa*Repository)로 구현
Expand Down Expand Up @@ -74,9 +75,13 @@ profanity-shared (Common)
- **[갭] 적용 조건이 `body instanceof ApiResponse` 인데, 메인 필터 엔드포인트는 `FilterApiResponse`(별도 record, `ApiResponse` 아님)를 반환하므로 `meta`가 붙지 않음.** 현재 `ApiResponse` 반환 경로(clients 등)에만 적용됨

### 인증 체계 (`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`, `LOGIN_JWT`, 미래 확장용 `OAUTH2_ACCESS_TOKEN`을 명시적으로 분리
- `CustomAuthenticationFilter` → `RequestCredentialResolver` → 타입별 authenticator가 정확히 하나의 `Authentication`만 새 `SecurityContext`에 설정
- 기존 외부 API는 `X-API-KEY`와 `AUTH_API_KEY`; `/api/v1/auth/me`, `/api/v1/dashboard/**`는 RS256 로그인 JWT와 `AUTH_LOGIN_JWT`/`ROLE_USER` 사용
- OAuth2 Client Credentials access token은 의도적으로 미구현. 외부 API Bearer는 `OAUTH2_ACCESS_TOKEN` 경계에서 HTTP 401/code 4017로 fail-closed
- SSO 성공은 일회용 교환 코드 → `/api/v1/auth/exchange`; access token 15분, opaque refresh 14일/절대 세션 30일, MySQL hash 저장과 rotation 사용
- refresh replay는 5초 grace 안에서 loser 요청만 실패하고 family를 유지하며, grace 이후 재사용은 session family 전체 폐기
- `ExcludePath` enum으로 public/자체 검증 경로를 관리하고 refresh는 HttpOnly cookie와 CSRF로 보호
- `@VerifiedClientOnly` + `ClientVerificationAspect`(`@Around @Order(1)`): BLOCK/DISCARD 권한 클라이언트를 403으로 차단
- 권한(`PermissionsType`): READ / WRITE / DELETE / BLOCK / DISCARD (기본 [READ])

Expand All @@ -101,6 +106,10 @@ profanity-shared (Common)
| POST | `/api/v1/word/accept/{requestId}` | 단어 요청 승인 (WRITE 권한) |
| GET | `/api/v1/sync?password=...` | 수동 동기화 (관리자) |
| GET | `/api/v1/health`, `/api/v1/ping` | 헬스 체크 |
| POST | `/api/v1/auth/exchange` | SSO 일회용 코드를 access/refresh token으로 교환 |
| GET | `/api/v1/auth/csrf` | refresh 요청용 CSRF token 조회 |
| POST | `/api/v1/auth/refresh` | refresh token rotation |
| GET | `/api/v1/auth/me` | LOGIN_JWT 사용자 조회 |

- 응답은 대부분 HTTP 200이며, 비즈니스 결과는 `status.code`로 전달. 전체 코드는 `StatusCode` enum과 `/overview.md`의 Error Model 기준

Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
> - https://api.kr-filter.com/api/v1/health

- 기존 `x-api-key` 신규 발급은 중단 예정이며, 이미 발급된 키는 호환성 유지를 위해 유지합니다.
- 신규 사용자는 Google/GitHub SSO 기반 대시보드에서 API 클라이언트를 발급하고, OAuth2 Client Credentials 기반 Bearer 인증으로 전환할 예정입니다.
- Google/GitHub SSO 로그인용 JWT access token과 rotating refresh token을 지원합니다.
- OAuth2 Client Credentials 기반 외부 API Bearer 인증은 아직 미구현이며, 향후 대시보드의 API 클라이언트 발급 기능과 함께 제공할 예정입니다.

## Overview

Expand Down Expand Up @@ -48,9 +49,9 @@

### 인증 전환 방향

현재 API 호출은 `x-api-key` 헤더를 사용합니다. 신규 인증 모델은 SSO 로그인으로 API 클라이언트를 발급한 뒤 `client_id`와 `client_secret`으로 `/oauth2/token`에서 access token을 발급받아 `Authorization: Bearer {access_token}`으로 호출하는 방식입니다.
현재 외부 API 호출은 기존 `x-api-key` 헤더를 사용합니다. 사람의 대시보드 로그인은 Google/GitHub SSO 완료 후 발급되는 `LOGIN_JWT`와 rotating refresh token을 사용하며, 외부 API 인증과 분리되어 있습니다.

전환 기간에는 기존 `x-api-key`와 신규 Bearer token을 모두 지원합니다. 대시보드 접근은 Google/GitHub SSO 세션만 허용하고, 외부 API 호출 인증과 분리합니다.
OAuth2 Client Credentials의 `/oauth2/token`, `client_id/client_secret`, 외부 API용 Bearer access token은 다음 단계의 범위입니다. 현재 외부 API에 제출된 Bearer token은 지원되지 않는 `OAUTH2_ACCESS_TOKEN` 경계에서 fail-closed 처리하며, 기존 API Key 동작은 유지합니다. 상세 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)을 참고하세요.

- [ADR 0005. SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md)
- [ADR 0006. OAuth2 Client Credentials 기반 API 인증 전환](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md)
Expand Down Expand Up @@ -87,10 +88,19 @@
| 4001 | Invalid Callback URL | 콜백 URL 형식이 올바르지 않은 경우 발생합니다. |
| 4002 | Invalid Tracking ID | Tracking ID가 유효하지 않은 경우 발생합니다. |
| 4003 | Not Fount Tracking ID | Tracking ID를 찾을 수 없는 경우 발생합니다. |
| 4004 | Ambiguous Credentials | 다중 또는 중복 인증 정보를 제출한 경우 발생합니다. |
| 4010 | Unauthorized | 요청을 인증할 API 키 값이 없는 경우 발생하는 오류 입니다. |
| 4011 | OAuth2 Login Failed | Google/GitHub SSO 로그인에 실패한 경우 발생합니다. |
| 4012 | Login Code Invalid | 로그인 교환 코드가 잘못됐거나 만료 또는 재사용된 경우 발생합니다. |
| 4013 | Login Token Invalid | 로그인 access token 검증에 실패한 경우 발생합니다. |
| 4014 | Login Token Expired | 로그인 access token이 만료된 경우 발생합니다. |
| 4015 | Refresh Token Invalid | refresh token 또는 session이 잘못됐거나 만료·폐기된 경우 발생합니다. |
| 4016 | Refresh Token Reused | 이미 소비된 refresh token이 다시 제출된 경우 발생합니다. |
| 4017 | OAuth2 Token Unsupported | 외부 API용 OAuth2 access token이 아직 지원되지 않는 경우 발생합니다. |
| 4030 | Forbidden | 서버에서 요청에 API 키값을 인식하였으나 해당 키가 적절한 권한을 가지지 않았다고 판정한 경우 발생합니다. |
| 4031 | Not Found Client | API Key에 해당하는 클라이언트 정보를 찾을 수 없는 경우 발생합니다. |
| 4032 | Invalid API Key | API Key가 유효하지 않은 경우 발생합니다. |
| 4033 | User Inactive | 로그인 사용자가 비활성 상태인 경우 발생합니다. |
| 4290 | Too Many Requests | 특정 클라이언트가 너무 많은 요청을 단위 시간 안에 보낸 경우에 이 응답이 리턴됩니다. |
| 5000 | Internal Server Error | 서버 측의 문제로 요청에 대한 처리가 불가능한 경우 오류가 발생하였음을 알리기 위해 본 코드를 사용합니다. |
| 5030 | Service Unavailable | 서비스 점검 또는 일시 사용 불가 상태를 의미합니다. |
Expand Down
27 changes: 21 additions & 6 deletions docs/adr/0005 SSO 기반 사용자 계정 모델 도입.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 5. SSO 기반 사용자 계정 모델 도입

## Status
제안 (2026.06.30)
채택 (2026.07.11)

## Context
현재 신규 클라이언트 등록은 `POST /api/v1/clients/register` 요청만으로 API Key를 즉시 발급한다. 입력값은 이름, 이메일, 발급자 정보, 메모 수준이며, 이메일 소유 증명이나 사용자 로그인 주체가 없다.
Expand All @@ -18,22 +18,37 @@

- 신규 사용자는 Google 또는 GitHub OAuth2 로그인으로만 가입한다.
- 내부 사용자 모델은 `users`로 표현하고, 외부 로그인 연결은 `oauth_accounts`로 분리한다.
- `users`는 서비스 내부의 사람 계정이며, 대시보드 로그인과 사용자 소유 리소스의 소유 주체가 된다.
- `users`는 서비스 내부의 사람 계정이며, 대시보드 로그인과 사용자 소유 리소스의 소유 주체가 된다. `primary_email`은 필수이며 lowercase 정규화와 binary 비교로 사용자 간 중복을 허용하지 않는다.
- `oauth_accounts`는 provider, provider user id, provider email, email verified 상태처럼 외부 로그인 식별 정보를 저장한다.
- 한 사용자가 Google과 GitHub를 모두 연결할 수 있도록 `users`와 `oauth_accounts`는 1:N 관계로 둔다.
- 새로운 provider 로그인의 검증된 이메일이 기존 `users.primary_email`과 같고 provider가 현재 소유권을 신뢰할 수 있으면 새 사용자를 만들지 않고 해당 사용자에 provider 계정을 연결한다.
- GitHub는 `/user/emails` 응답에서 `primary=true`이면서 `verified=true`인 이메일만 대표 이메일로 사용한다. 이 조건을 만족하는 이메일이 없으면 로그인을 완료하지 않는다.
- Google은 `email_verified=true`이면서 `@gmail.com` 또는 서명된 `hd` claim이 있는 이메일만 사용자 계정 생성과 로그인에 사용한다.
- 기존 `clients.email`은 로그인 계정으로 승격하지 않고, 기존 키 claim 및 연락용 legacy email로 취급한다.
- 외부 공개 API용 OAuth2 Client Credentials 토큰 발급과 신규 API Key 정책은 이 ADR에서 결정하지 않고 별도 ADR에서 다룬다.

## Implementation Scope
이번 0005 적용은 Google과 GitHub SSO 앱 등록, Spring OAuth2 client registration, authorization 진입점, callback URL, success/failure handler로 이어지는 콜백 파이프라인을 파악하고 검증하는 데 집중한다.
현재 구현은 다음 범위까지 포함한다.

대시보드용 서버 로그인 토큰 발급, claim 할당, 기존 API Key 연결, 사용자 생성 및 `oauth_accounts` upsert 처리 로직은 다음 구현 단계에서 다룬다.
- `(provider, provider_user_id)`와 검증된 대표 이메일 기준의 race-safe `users` 생성 및 `oauth_accounts` 연결
- Google의 검증된 이메일과 GitHub `/user/emails`의 primary·verified 이메일만 필수 대표 이메일로 사용하고, 현재 소유권을 신뢰할 수 있는 같은 이메일의 provider 계정만 한 사용자에 연결하는 정책
- provider email만으로 legacy client를 자동 연결하지 않는 정책
- OAuth2 callback 성공 후 60초 수명의 일회용 교환 코드를 URL fragment로 전달하고 `POST /api/v1/auth/exchange`에서 한 번만 소비하는 흐름
- `users.id`를 subject로 사용하는 15분 RS256 대시보드 access token
- MySQL에 원문 대신 SHA-256 hash를 저장하는 14일 rotating refresh token과 30일 절대 세션
- 동시 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의 후속 구현 범위로 남긴다.

## Consequences
- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드용 stateless 인증 토큰과 대시보드 API 호출 인증은 SSO 이후 단계에서 별도로 구현한다.
- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드는 stateless `LOGIN_JWT`를 사용하고 기존 외부 API는 legacy API Key 계약을 유지한다.
- 로컬 비밀번호 저장과 비밀번호 재설정 기능을 만들지 않아도 된다.
- 대시보드에서 “내 계정”, “외부 로그인 연결”, “기존 키 연결” 같은 사용자 중심 기능을 제공할 수 있다.
- GitHub는 이메일 비공개 또는 미검증 케이스가 있으므로 provider email만으로 기존 키 소유권을 자동 확정하면 안 된다.
- GitHub primary·verified 이메일과 Google의 Gmail·Workspace 검증 이메일이 같으면 하나의 내부 사용자에 두 provider를 연결할 수 있다.
- 비-Gmail이고 `hd`가 없는 Google 외부 이메일은 현재 소유권을 Google이 보장하지 않으므로 사용자 계정 생성과 로그인을 완료하지 않는다.
- GitHub에서 primary·verified 이메일을 제공하지 않으면 내부 사용자를 생성하거나 로그인을 완료할 수 없다.
- provider email이 검증됐더라도 그 값만으로 기존 키 소유권을 자동 확정하면 안 된다.
- 기존 사용자는 SSO 로그인 후 기존 API Key와 이메일 인증을 통해 기존 키를 claim하는 별도 마이그레이션 흐름이 필요하다.
- 운영자는 기존 사용자에게 전환 안내 메일을 보내고, 미전환 사용자를 추적할 수 있어야 한다.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@
- 외부 API 엔드포인트는 전환 기간 동안 Bearer token과 legacy `x-api-key`를 모두 허용한다.
- 대시보드 엔드포인트는 SSO 세션만 허용하고, legacy `x-api-key`로 접근할 수 없게 한다.

## Implementation Status

이 ADR은 아직 제안 상태이며 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이다.

## Consequences
- 신규 API 인증은 OAuth2 표준 형식을 따르므로 OpenAPI 문서, SDK, 외부 개발자 경험을 개선할 수 있다.
- `client_id/client_secret`은 토큰 발급에만 사용하고, 실제 API 호출에는 짧은 수명의 access token을 사용한다.
Expand Down
2 changes: 1 addition & 1 deletion module.secrets
1 change: 1 addition & 0 deletions profanity-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.security:spring-security-oauth2-jose'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-aop'

Expand Down
Loading