diff --git a/.codex/skills/api-development/SKILL.md b/.codex/skills/api-development/SKILL.md new file mode 100644 index 0000000..d9e6206 --- /dev/null +++ b/.codex/skills/api-development/SKILL.md @@ -0,0 +1,78 @@ +--- +name: api-development +description: Implement or modify HTTP API endpoints in this profanity-filter-api repository. Use this skill whenever a request adds a controller endpoint, changes an API contract, adds OpenAPI documentation, decides whether an endpoint should appear in /openapi.json, or introduces dashboard, administrator, system, health, or duplicate transport APIs. Classify visibility before coding and keep controllers, composed OpenAPI annotations, public docs, architecture tests, and E2E tests aligned. +--- + +# API Development + +## Core Policy + +Treat `/openapi.json` as the allowlist of APIs intended for external integrators, not as an inventory of every implemented endpoint. + +Classify the endpoint before implementation: + +- External integrator API: document it with one detailed composed annotation from `app.openapi`. +- Dashboard, administrator, or system API: put `@Hidden` directly on the controller class or endpoint method. +- Duplicate transport representation of an already documented operation: use `@Hidden` and keep one canonical public operation. +- Ambiguous audience: stop and ask whether external integrators must implement against it. + +Do not infer visibility from authentication alone. An authenticated API can still be public, while a technically public login callback can still be dashboard-internal. + +## Implementation Workflow + +1. Inspect the matching controller, security rule, DTOs, response types, existing `app.openapi` holder, README API list, and `OpenApiSpecE2ETest`. +2. State the audience classification and its code evidence before editing. +3. Preserve runtime mappings, security, and behavior unless the user explicitly asks to change them. +4. Apply exactly one documentation decision: + - Public: add one runtime-retained composed method annotation under the matching `FooOpenApi` holder. + - Hidden: add direct `@Hidden`; do not create or retain a composed operation annotation for that endpoint. +5. For a public operation, document the complete external contract: + - summary and integration-focused description + - request body, path/query/header parameters, media types, and validation constraints + - concrete success and error response schemas + - realistic but obviously fake examples + - `ApiKeyAuth` when the endpoint requires an external API Key +6. Remove unused holder annotations and imports when an existing endpoint becomes hidden. Delete an empty holder class. +7. Align public surfaces. Remove hidden dashboard, administrator, and system paths from README and the Markdown assembled into `/overview.md`. Keep internal ADRs unless the user asks to delete them. +8. Update architecture and E2E coverage in the same change. + +## Architecture Contract + +Every non-document controller endpoint must satisfy one exclusive state: + +- one `app.openapi` composed annotation and no effective `@Hidden`, or +- effective `@Hidden` on the method or owning controller and no `app.openapi` method annotation. + +Allow direct Swagger dependency in controllers only for `@Hidden`. Keep all other Swagger operation details in `app.openapi` holders. Apply holder-name matching and `ApiKeyAuth` checks only to documented endpoints. + +## Verification + +Use MockMvc-based checks first so another worktree using port 8080 cannot interfere. + +```bash +./gradlew --no-daemon staticCheck +./gradlew --no-daemon :profanity-api:unitTest --tests app.architecture.OpenApiArchitectureTest +./gradlew --no-daemon :profanity-api:e2eTest --tests app.e2e.OpenApiSpecE2ETest +``` + +The OpenAPI E2E test must assert both sides of the boundary: + +- required public operation and security/schema details exist +- every newly hidden path is absent +- the total public operation count changes only when intentionally approved +- `/overview.md` exactly matches its classpath source fragments + +If an actual server smoke test is necessary, avoid port 8080: + +```bash +SERVER_PORT=18081 ./gradlew :profanity-api:bootRun +``` + +Then check `/openapi.json`, `/overview.md`, `/api/v1/health`, and `/api/v1/ping` on port 18081. Stop the process after verification. + +## Current Visibility Examples + +- Public: JSON filter, advanced filter, client/API Key APIs, word change request, health, ping. +- Hidden: dashboard authentication/session APIs, manual sync, word request approval, duplicate form-urlencoded filter operation. + +These examples describe the current repository, but the audience rule is authoritative for future endpoints. diff --git a/.codex/skills/api-development/agents/openai.yaml b/.codex/skills/api-development/agents/openai.yaml new file mode 100644 index 0000000..06c8c0b --- /dev/null +++ b/.codex/skills/api-development/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "API Development" + short_description: "외부 공개 API와 내부 Hidden 경계를 지키며 endpoint를 개발합니다." + default_prompt: "신규 API의 외부 공개 여부를 먼저 분류하고 controller, 상세 OpenAPI 합성 어노테이션 또는 @Hidden, 공개 문서, ArchUnit과 E2E 테스트까지 일관되게 구현해줘." diff --git a/.codex/skills/api-development/evals/evals.json b/.codex/skills/api-development/evals/evals.json new file mode 100644 index 0000000..d9f5718 --- /dev/null +++ b/.codex/skills/api-development/evals/evals.json @@ -0,0 +1,23 @@ +{ + "skill_name": "api-development", + "evals": [ + { + "id": 1, + "prompt": "외부 고객이 API Key로 호출할 수 있는 GET /api/v1/filter/history endpoint를 추가해줘. 기간과 페이지 파라미터가 있고 필터링 이력 목록을 반환해야 해.", + "expected_output": "외부 API로 분류하고 matching OpenApi holder에 상세 합성 annotation, ApiKeyAuth, concrete schema와 예제를 추가하며 공개 문서와 양방향 E2E를 갱신한다.", + "files": [] + }, + { + "id": 2, + "prompt": "운영자가 장애 대응 때 캐시를 강제로 비우는 POST /api/v1/admin/cache/evict API를 추가해줘.", + "expected_output": "관리자 API로 분류해 controller 또는 method에 @Hidden만 사용하고 OpenApi holder를 만들지 않으며 스펙 비노출 E2E를 추가한다.", + "files": [] + }, + { + "id": 3, + "prompt": "대시보드에서 통계 조회할 API 하나 추가해줘. GET /api/v1/stats로 하면 될 것 같아.", + "expected_output": "외부 연동 대상인지 자사 대시보드 전용인지 불명확하므로 구현 전에 공개 여부를 사용자에게 확인한다.", + "files": [] + } + ] +} diff --git a/README.md b/README.md index e6b191c..d21863f 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,10 @@ Aho-Corasick 알고리즘을 기반으로 한국어 비속어를 검출하고 ## 인증 모델 -사람의 개발자 포털 로그인과 외부 API 호출 인증은 서로 다른 자격 증명을 사용합니다. - -| 용도 | 방식 | 상태 | -|---|---|---| -| 개발자 포털 로그인 | Google/GitHub SSO 및 `LOGIN_JWT` | 제공 중 | -| 외부 API 호출 | `x-api-key: {API_KEY}` | 제공 중 | -| 외부 API 호출 | OAuth2 Client Credentials Bearer token | 준비 중, 현재 비활성화 | - -API Key와 OAuth2 Client Credentials는 모두 SSO 로그인 후 발급하는 정책으로 전환합니다. 다만 현재 운영 API에는 기존 공개 발급 경로가 남아 있으므로, 백엔드 전환이 완료되기 전까지 아래 API 목록의 현재 상태를 기준으로 사용해야 합니다. +외부 API는 현재 `x-api-key: {API_KEY}`로 인증합니다. OAuth2 Client Credentials Bearer token은 준비 중이며 아직 사용할 수 없습니다. 외부 API에 아직 지원하지 않는 Bearer token을 보내면 HTTP `401`, business code `4017`로 거부됩니다. 로그인 JWT를 외부 API Key 대신 사용할 수도 없습니다. -자세한 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)과 다음 ADR을 참고하세요. - -- [ADR 0005: SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md) -- [ADR 0006: 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md) - ## 빠른 시작 ```bash @@ -56,7 +43,7 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \ | `NORMAL` | 매칭된 비속어를 모두 반환합니다. | | `FILTER` | 매칭된 비속어를 `*`로 마스킹합니다. | -`text`와 `mode`는 필수이며, 비동기 결과가 필요하면 `callbackUrl`을 추가할 수 있습니다. 동일한 `/api/v1/filter` 경로에서 JSON과 `application/x-www-form-urlencoded` 요청을 모두 지원합니다. +`text`와 `mode`는 필수이며, 비동기 결과가 필요하면 `callbackUrl`을 추가할 수 있습니다. ## API 목록 @@ -72,18 +59,6 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \ | `GET` | `/api/v1/health` | 애플리케이션 상태 확인 | | `GET` | `/api/v1/ping` | 연결 확인 | -### SSO 로그인 - -| Method | Path | 설명 | 인증 | -|---|---|---|---| -| `GET` | `/oauth2/authorization/{google\|github}` | 소셜 로그인 시작 | 공개 | -| `POST` | `/api/v1/auth/exchange` | 일회용 로그인 코드를 access token으로 교환 | 공개 코드 | -| `GET` | `/api/v1/auth/csrf` | refresh 요청용 CSRF token 조회 | refresh cookie | -| `POST` | `/api/v1/auth/refresh` | 로그인 access token 갱신 | refresh cookie와 CSRF | -| `GET` | `/api/v1/auth/me` | 로그인 사용자 조회 | `LOGIN_JWT` | - -로그인 access token은 15분, refresh token은 14일이며 refresh session의 절대 수명은 30일입니다. 브라우저 로그인 흐름은 개발자 포털이 처리하므로 일반 API 사용자가 직접 구현할 필요는 없습니다. - ### 비속어 필터 | Method | Path | 설명 | 인증 | @@ -103,13 +78,11 @@ curl --request POST 'https://api.kr-filter.com/api/v1/filter' \ | `GET` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 발송 | 공개 | | `PUT` | `/api/v1/clients/send-email` | 레거시 이메일 인증 코드 검증 | 공개 | -### 관리 API +### 단어 변경 요청 | Method | Path | 설명 | 인증 | |---|---|---|---| | `POST` | `/api/v1/word/request` | 비속어 단어 변경 요청 | API Key | -| `POST` | `/api/v1/word/accept/{requestId}` | 단어 변경 요청 승인 | WRITE 권한 API Key | -| `GET` | `/api/v1/sync` | 비속어 데이터 수동 동기화 | 관리자 API Key 및 password | ## 응답과 오류 diff --git a/profanity-api/src/main/java/app/config/OpenApiConfig.java b/profanity-api/src/main/java/app/config/OpenApiConfig.java index e7e375e..d658f37 100644 --- a/profanity-api/src/main/java/app/config/OpenApiConfig.java +++ b/profanity-api/src/main/java/app/config/OpenApiConfig.java @@ -3,27 +3,18 @@ import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.security.SecurityScheme; -import io.swagger.v3.oas.annotations.security.SecuritySchemes; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -@SecuritySchemes({ - @SecurityScheme( - name = "ApiKeyAuth", - type = SecuritySchemeType.APIKEY, - in = SecuritySchemeIn.HEADER, - paramName = "x-api-key", - description = "클라이언트 등록 후 발급받은 API Key"), - @SecurityScheme( - name = "LoginJwtAuth", - type = SecuritySchemeType.HTTP, - scheme = "bearer", - bearerFormat = "JWT", - description = "Google/GitHub SSO 로그인 후 발급된 대시보드 access token") -}) +@SecurityScheme( + name = "ApiKeyAuth", + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + paramName = "x-api-key", + description = "클라이언트 등록 후 발급받은 API Key") public class OpenApiConfig { @Bean diff --git a/profanity-api/src/main/java/app/openapi/AuthOpenApi.java b/profanity-api/src/main/java/app/openapi/AuthOpenApi.java deleted file mode 100644 index be59ec8..0000000 --- a/profanity-api/src/main/java/app/openapi/AuthOpenApi.java +++ /dev/null @@ -1,393 +0,0 @@ -package app.openapi; - -import app.core.data.response.Status; -import app.dto.request.AuthCodeExchangeRequest; -import app.dto.response.CsrfTokenResponse; -import app.dto.response.LoginTokenResponse; -import app.dto.response.LoginUserResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.headers.Header; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.parameters.RequestBody; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -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; -import java.util.Map; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; - -public final class AuthOpenApi { - private static final String TOKEN_RESPONSE_EXAMPLE = - """ - { - "status": { - "code": 2000, - "message": "Ok", - "description": "정상적으로 처리 되었습니다.", - "DetailDescription": "" - }, - "data": { - "accessToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImxvZ2luLWtleS0xIn0.example.signature", - "tokenType": "Bearer", - "expiresIn": 900, - "user": { - "id": "018f4fd8-9f6f-7d1a-9b80-3f1f8dd7c001", - "displayName": "홍길동", - "email": "user@example.com", - "avatarUrl": "https://example.com/avatar.png" - } - } - } - """; - - private static final String USER_RESPONSE_EXAMPLE = - """ - { - "status": { - "code": 2000, - "message": "Ok", - "description": "정상적으로 처리 되었습니다.", - "DetailDescription": "" - }, - "data": { - "id": "018f4fd8-9f6f-7d1a-9b80-3f1f8dd7c001", - "displayName": "홍길동", - "email": "user@example.com", - "avatarUrl": "https://example.com/avatar.png" - } - } - """; - - private static final String INVALID_LOGIN_CODE_EXAMPLE = - """ - { - "status": { - "code": 4012, - "message": "Login_code_invalid", - "description": "로그인 교환 코드가 유효하지 않습니다.", - "DetailDescription": "" - }, - "data": null - } - """; - - private static final String INVALID_EXCHANGE_REQUEST_EXAMPLE = - """ - { - "status": { - "code": 4000, - "message": "Bad_request", - "description": "처리에 실패하였습니다. 요청이 잘못 되었거나 필수 파라미터가 누락된 경우 발생 합니다. Description에서 보다 상세한 오류 메세지를 확인할 수 있습니다.", - "DetailDescription": "code: must not be blank / " - }, - "data": null - } - """; - - private static final String INVALID_REFRESH_TOKEN_EXAMPLE = - """ - { - "status": { - "code": 4015, - "message": "Refresh_token_invalid", - "description": "로그인 refresh token이 유효하지 않습니다.", - "DetailDescription": "" - }, - "data": null - } - """; - - private static final String REUSED_REFRESH_TOKEN_EXAMPLE = - """ - { - "status": { - "code": 4016, - "message": "Refresh_token_reused", - "description": "이미 사용한 refresh token이 다시 제출되었습니다.", - "DetailDescription": "" - }, - "data": null - } - """; - - private static final String INVALID_LOGIN_TOKEN_EXAMPLE = - """ - { - "status": { - "code": 4013, - "message": "Login_token_invalid", - "description": "로그인 access token이 유효하지 않습니다.", - "DetailDescription": "" - }, - "data": null - } - """; - - private static final String FORBIDDEN_EXAMPLE = - """ - { - "status": { - "code": 4030, - "message": "Forbidden", - "description": "인증 권한이 부적절합니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다.", - "DetailDescription": "" - }, - "data": null - } - """; - - private static final String INACTIVE_USER_EXAMPLE = - """ - { - "status": { - "code": 4033, - "message": "User_inactive", - "description": "비활성 사용자 계정입니다.", - "DetailDescription": "" - }, - "data": null - } - """; - - @Target(ElementType.TYPE) - @Retention(RetentionPolicy.RUNTIME) - @Tag(name = "Authentication", description = "SSO 로그인 JWT와 refresh token 관리 API") - public @interface ApiTag {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "로그인 코드 교환", - description = - """ - OAuth2 로그인 성공 후 frontend redirect URI로 전달된 일회용 code를 로그인 token으로 교환합니다. - code는 한 번만 사용할 수 있으며, 성공하면 access token은 응답 body로, refresh token은 HttpOnly cookie로 발급합니다. - """, - requestBody = - @RequestBody( - required = true, - description = "OAuth2 로그인 완료 후 발급받은 일회용 교환 코드", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = AuthCodeExchangeRequest.class), - examples = - @ExampleObject( - name = "exchangeCode", - summary = "일회용 로그인 코드 교환", - value = "{\"code\":\"sso_exchange_code_example\"}"))), - responses = { - @ApiResponse( - responseCode = "200", - description = - "access token 발급 및 refresh cookie 설정. 요청 검증 실패는 status.code 4000으로 반환됩니다.", - headers = - @Header( - name = HttpHeaders.SET_COOKIE, - description = "HttpOnly refresh token cookie", - schema = @Schema(type = "string")), - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = LoginTokenApiResponse.class), - examples = { - @ExampleObject(name = "success", value = TOKEN_RESPONSE_EXAMPLE), - @ExampleObject( - name = "invalidRequest", - summary = "code 누락 또는 공백", - value = INVALID_EXCHANGE_REQUEST_EXAMPLE) - })), - @ApiResponse( - responseCode = "401", - description = "교환 코드가 유효하지 않거나 만료 또는 이미 소비됨", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ErrorApiResponse.class), - examples = - @ExampleObject(name = "invalidCode", value = INVALID_LOGIN_CODE_EXAMPLE))), - @ApiResponse( - responseCode = "403", - description = "연결된 사용자 계정이 비활성 상태임", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ErrorApiResponse.class), - examples = - @ExampleObject(name = "inactiveUser", value = INACTIVE_USER_EXAMPLE))) - }) - public @interface Exchange {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "CSRF token 조회", - description = - """ - refresh 요청에 사용할 CSRF token을 발급합니다. - 응답의 headerName과 token을 refresh 요청 header에 넣고, 함께 발급된 XSRF-TOKEN cookie도 전송해야 합니다. - """, - responses = - @ApiResponse( - responseCode = "200", - description = "CSRF header 이름과 token 반환 및 XSRF-TOKEN cookie 설정", - headers = - @Header( - name = HttpHeaders.SET_COOKIE, - description = "CSRF 검증용 XSRF-TOKEN cookie", - schema = @Schema(type = "string")), - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = CsrfTokenApiResponse.class), - examples = - @ExampleObject( - name = "csrfToken", - value = - """ - { - "status": { - "code": 2000, - "message": "Ok", - "description": "정상적으로 처리 되었습니다.", - "DetailDescription": "" - }, - "data": { - "headerName": "X-XSRF-TOKEN", - "token": "csrf_token_example" - } - } - """)))) - public @interface Csrf {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "로그인 token 갱신", - description = - """ - refresh token을 rotate하고 새 access token과 refresh token을 발급합니다. - 먼저 GET /api/v1/auth/csrf를 호출한 뒤 PF_LOGIN_REFRESH 및 XSRF-TOKEN cookie와 X-XSRF-TOKEN header를 함께 전송해야 합니다. - 이미 사용한 refresh token을 재사용하면 보안 정책에 따라 요청 또는 token family가 거부됩니다. - """, - parameters = { - @Parameter( - name = "PF_LOGIN_REFRESH", - in = ParameterIn.COOKIE, - required = true, - description = "HttpOnly refresh token cookie. 브라우저가 자동으로 전송합니다.", - schema = @Schema(type = "string")), - @Parameter( - name = "XSRF-TOKEN", - in = ParameterIn.COOKIE, - required = true, - description = "GET /api/v1/auth/csrf에서 발급된 CSRF cookie", - schema = @Schema(type = "string")), - @Parameter( - name = "X-XSRF-TOKEN", - in = ParameterIn.HEADER, - required = true, - description = "GET /api/v1/auth/csrf 응답의 data.token 값", - schema = @Schema(type = "string")) - }, - responses = { - @ApiResponse( - responseCode = "200", - description = "새 access token 발급 및 refresh cookie 교체", - headers = - @Header( - name = HttpHeaders.SET_COOKIE, - description = "rotate된 HttpOnly refresh token cookie", - schema = @Schema(type = "string")), - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = LoginTokenApiResponse.class), - examples = @ExampleObject(name = "success", value = TOKEN_RESPONSE_EXAMPLE))), - @ApiResponse( - responseCode = "401", - description = "refresh token이 무효·만료되었거나 이미 사용됨", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ErrorApiResponse.class), - examples = { - @ExampleObject(name = "invalidToken", value = INVALID_REFRESH_TOKEN_EXAMPLE), - @ExampleObject(name = "reusedToken", value = REUSED_REFRESH_TOKEN_EXAMPLE) - })), - @ApiResponse( - responseCode = "403", - description = "CSRF 검증 실패 또는 사용자 계정 비활성", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ErrorApiResponse.class), - examples = { - @ExampleObject(name = "csrfRejected", value = FORBIDDEN_EXAMPLE), - @ExampleObject(name = "inactiveUser", value = INACTIVE_USER_EXAMPLE) - })) - }) - public @interface Refresh {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "로그인 사용자 조회", - description = "Authorization: Bearer 으로 인증된 현재 로그인 사용자를 반환합니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "현재 로그인 사용자 정보", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = LoginUserApiResponse.class), - examples = - @ExampleObject(name = "currentUser", value = USER_RESPONSE_EXAMPLE))), - @ApiResponse( - responseCode = "401", - description = "access token이 누락·무효·만료됨", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ErrorApiResponse.class), - examples = - @ExampleObject( - name = "invalidToken", - value = INVALID_LOGIN_TOKEN_EXAMPLE))), - @ApiResponse( - responseCode = "403", - description = "사용자 계정이 비활성 상태임", - content = - @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ErrorApiResponse.class), - examples = - @ExampleObject(name = "inactiveUser", value = INACTIVE_USER_EXAMPLE))) - }, - security = @SecurityRequirement(name = "LoginJwtAuth")) - public @interface Me {} - - @Schema(name = "LoginTokenApiResponse", description = "로그인 token 발급 응답") - public record LoginTokenApiResponse( - Status status, LoginTokenResponse data, Map meta) {} - - @Schema(name = "CsrfTokenApiResponse", description = "CSRF token 발급 응답") - public record CsrfTokenApiResponse( - Status status, CsrfTokenResponse data, Map meta) {} - - @Schema(name = "LoginUserApiResponse", description = "로그인 사용자 조회 응답") - public record LoginUserApiResponse( - Status status, LoginUserResponse data, Map meta) {} - - @Schema(name = "AuthErrorApiResponse", description = "로그인 인증 오류 응답") - public record ErrorApiResponse(Status status, Object data, Map meta) {} -} diff --git a/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java b/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java index 0f228d1..7b6b603 100644 --- a/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java +++ b/profanity-api/src/main/java/app/openapi/ProfanityOpenApi.java @@ -2,7 +2,6 @@ import app.core.data.response.FilterApiResponse; import app.dto.request.ApiRequest; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -174,15 +173,6 @@ private ProfanityOpenApi() {} security = @SecurityRequirement(name = "ApiKeyAuth")) public @interface BasicProfanity {} - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Hidden - @Operation( - summary = "비속어 필터링 요청 form", - description = "application/x-www-form-urlencoded 형식으로 비속어 검사를 요청합니다.", - security = @SecurityRequirement(name = "ApiKeyAuth")) - public @interface BasicProfanityForm {} - @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Operation( diff --git a/profanity-api/src/main/java/app/openapi/SyncOpenApi.java b/profanity-api/src/main/java/app/openapi/SyncOpenApi.java deleted file mode 100644 index 63c12d5..0000000 --- a/profanity-api/src/main/java/app/openapi/SyncOpenApi.java +++ /dev/null @@ -1,26 +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.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 SyncOpenApi { - private SyncOpenApi() {} - - @Target(ElementType.TYPE) - @Retention(RetentionPolicy.RUNTIME) - @Tag(name = "Sync", description = "비속어 데이터 동기화 API") - public @interface ApiTag {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "비속어 데이터 동기화", - description = "관리 비밀번호로 비속어 데이터를 동기화합니다.", - parameters = @Parameter(name = "password", description = "동기화 관리 비밀번호", required = true)) - public @interface DoSync {} -} diff --git a/profanity-api/src/main/java/app/openapi/WordManagementOpenApi.java b/profanity-api/src/main/java/app/openapi/WordManagementOpenApi.java index 3d3e316..f077845 100644 --- a/profanity-api/src/main/java/app/openapi/WordManagementOpenApi.java +++ b/profanity-api/src/main/java/app/openapi/WordManagementOpenApi.java @@ -1,7 +1,6 @@ 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; @@ -28,13 +27,4 @@ private WordManagementOpenApi() {} """, security = @SecurityRequirement(name = "ApiKeyAuth")) public @interface RequestNewWord {} - - @Target(ElementType.METHOD) - @Retention(RetentionPolicy.RUNTIME) - @Operation( - summary = "단어 변경 요청 승인", - description = "WRITE 권한을 가진 클라이언트가 단어 변경 요청을 승인합니다.", - parameters = @Parameter(name = "requestId", description = "승인할 요청 ID 목록", required = true), - security = @SecurityRequirement(name = "ApiKeyAuth")) - public @interface AcceptWord {} } diff --git a/profanity-api/src/main/java/app/presentation/AuthController.java b/profanity-api/src/main/java/app/presentation/AuthController.java index 5d0e7cb..9b2d48b 100644 --- a/profanity-api/src/main/java/app/presentation/AuthController.java +++ b/profanity-api/src/main/java/app/presentation/AuthController.java @@ -7,10 +7,10 @@ import app.dto.response.CsrfTokenResponse; import app.dto.response.LoginTokenResponse; import app.dto.response.LoginUserResponse; -import app.openapi.AuthOpenApi; import app.security.SecurityContextUtil; import app.security.login.LoginFlowException; import app.security.login.LoginRefreshCookieWriter; +import io.swagger.v3.oas.annotations.Hidden; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -31,13 +31,12 @@ @RestController @RequiredArgsConstructor -@AuthOpenApi.ApiTag +@Hidden @RequestMapping(value = "/api/v1/auth", produces = MediaType.APPLICATION_JSON_VALUE) public class AuthController { private final LoginAuthService loginAuthService; private final LoginRefreshCookieWriter refreshCookieWriter; - @AuthOpenApi.Exchange @PostMapping(value = "/exchange", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> exchange( @Valid @RequestBody AuthCodeExchangeRequest request, HttpServletResponse response) { @@ -46,7 +45,6 @@ public ResponseEntity> exchange( return noStore(tokenResponse(bundle)); } - @AuthOpenApi.Csrf @GetMapping("/csrf") public ResponseEntity> csrf(HttpServletRequest request) { CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); @@ -59,7 +57,6 @@ public ResponseEntity> csrf(HttpServletRequest re return noStore(new CsrfTokenResponse(csrfToken.getHeaderName(), csrfToken.getToken())); } - @AuthOpenApi.Refresh @PostMapping("/refresh") public ResponseEntity> refresh( HttpServletRequest request, HttpServletResponse response) { @@ -76,7 +73,6 @@ public ResponseEntity> refresh( } } - @AuthOpenApi.Me @GetMapping("/me") public ResponseEntity> me() { UUID userId = SecurityContextUtil.getCurrentLoginUserId(); diff --git a/profanity-api/src/main/java/app/presentation/ProfanityController.java b/profanity-api/src/main/java/app/presentation/ProfanityController.java index 10d2280..2a2515f 100644 --- a/profanity-api/src/main/java/app/presentation/ProfanityController.java +++ b/profanity-api/src/main/java/app/presentation/ProfanityController.java @@ -10,6 +10,7 @@ import app.dto.request.FilterRequest; import app.openapi.ProfanityOpenApi; import app.security.annotation.VerifiedClientOnly; +import io.swagger.v3.oas.annotations.Hidden; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.Objects; @@ -69,7 +70,7 @@ public ResponseEntity basicProfanity( @VerifiedClientOnly @Cacheable(value = "request_filter", key = "#request.text + '_' + #request.mode") - @ProfanityOpenApi.BasicProfanityForm + @Hidden @PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity basicProfanityByUrlencodedValue( HttpServletRequest httpRequest, diff --git a/profanity-api/src/main/java/app/presentation/SyncController.java b/profanity-api/src/main/java/app/presentation/SyncController.java index 000e069..4ab7830 100644 --- a/profanity-api/src/main/java/app/presentation/SyncController.java +++ b/profanity-api/src/main/java/app/presentation/SyncController.java @@ -5,7 +5,7 @@ import app.application.manage.SyncHandler; import app.core.data.manage.response.ResultMessage; -import app.openapi.SyncOpenApi; +import io.swagger.v3.oas.annotations.Hidden; import jakarta.servlet.http.HttpServletRequest; import java.util.Objects; import lombok.AllArgsConstructor; @@ -21,12 +21,11 @@ @AllArgsConstructor @RequestMapping("/api/v1/sync") @RestController -@SyncOpenApi.ApiTag +@Hidden public class SyncController { private final SyncHandler syncHandler; - @SyncOpenApi.DoSync @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity doSync( HttpServletRequest httpRequest, @RequestParam("password") String password) { diff --git a/profanity-api/src/main/java/app/presentation/WordManagementController.java b/profanity-api/src/main/java/app/presentation/WordManagementController.java index 4e421cd..b7c3a25 100644 --- a/profanity-api/src/main/java/app/presentation/WordManagementController.java +++ b/profanity-api/src/main/java/app/presentation/WordManagementController.java @@ -9,6 +9,7 @@ import app.dto.response.MessageResponse; import app.openapi.WordManagementOpenApi; import app.security.SecurityContextUtil; +import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import java.util.List; import java.util.UUID; @@ -49,7 +50,7 @@ public ResponseEntity> requestNewWord( return ApiResponse.ok(response); } - @WordManagementOpenApi.AcceptWord + @Hidden @PostMapping(value = "/accept/{requestId}", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> acceptWord(@PathVariable List requestId) { final String write = PermissionsType.WRITE.getValue(); diff --git a/profanity-api/src/main/resources/openapi/authentication.md b/profanity-api/src/main/resources/openapi/authentication.md index e5db14d..ae596b3 100644 --- a/profanity-api/src/main/resources/openapi/authentication.md +++ b/profanity-api/src/main/resources/openapi/authentication.md @@ -1,90 +1,26 @@ # Authentication -사람의 대시보드 로그인과 외부 API 호출 인증은 서로 다른 경계로 처리합니다. - -| 인증 타입 | Credential | 허용 범위 | -|---|---|---| -| `API_KEY` | `x-api-key` | 기존 필터, 클라이언트, 단어 관리, 동기화 API | -| `LOGIN_JWT` | `Authorization: Bearer {access_token}` | `GET /api/v1/auth/me`, `/api/v1/dashboard/**` | -| `OAUTH2_ACCESS_TOKEN` | 미래의 Bearer access token | 아직 미지원, 외부 API에서 HTTP `401`/code `4017`로 종료 | - -API Key와 `Authorization`을 동시에 보내거나 같은 인증 헤더를 중복 제출하면 HTTP `400`/code `4004`로 거부합니다. 로그인 JWT를 외부 API 인증으로 재사용할 수 없으며, 외부 API Bearer를 로그인 JWT로 추정하거나 fallback하지 않습니다. - -### API Key - -기존 외부 API는 다음 헤더를 사용합니다. +외부 API 호출은 API Key로 인증합니다. ```http x-api-key: YOUR_SECRET_TOKEN ``` -기존 API Key의 성공, 권한, 오류 응답 계약은 유지됩니다. - -### Google/GitHub SSO 로그인 - -1. 브라우저를 `GET /oauth2/authorization/google` 또는 `GET /oauth2/authorization/github`로 이동합니다. -2. OAuth2 callback 성공 시 서버는 frontend 로그인 경로의 URL fragment에 60초 수명의 일회용 `code`만 전달합니다. -3. frontend는 `POST /api/v1/auth/exchange`의 JSON body로 코드를 한 번 교환합니다. -4. 응답 body의 RS256 access token은 메모리에서만 사용하고, opaque refresh token은 `HttpOnly` cookie로만 전달됩니다. - -```json -{ - "code": "ONE_TIME_EXCHANGE_CODE" -} -``` +보호된 API는 발급받은 API Key를 `x-api-key` 헤더에 포함해야 합니다. API Key가 없거나 유효하지 않으면 응답 본문의 `status.code`로 실패 원인을 확인합니다. -access token 기본 계약은 다음과 같습니다. +### Credential 정책 -- 수명: 15분, clock skew: 30초 -- 필수 검증: `alg=RS256`, `iss`, `aud`, `sub=users.id`, `iat`, `nbf`, `exp`, `jti` -- 용도 claim: `token_use=access`, `auth_type=LOGIN_JWT` -- authority: `AUTH_LOGIN_JWT`, `ROLE_USER` -- 매 요청마다 내부 사용자의 `ACTIVE` 상태를 다시 확인 - -### Refresh token rotation - -- `GET /api/v1/auth/csrf`에서 CSRF token을 받은 뒤 `POST /api/v1/auth/refresh`를 호출합니다. -- refresh 요청은 `PF_LOGIN_REFRESH` HttpOnly cookie와 CSRF header/cookie가 모두 필요합니다. -- 정상 rotation은 기존 refresh token을 즉시 소비하고 새 access token과 새 refresh cookie를 발급합니다. -- refresh token 원문은 저장하지 않고 SHA-256 hash만 MySQL에 저장합니다. -- refresh token 수명은 14일이며, 세션의 절대 수명은 최초 로그인부터 30일입니다. -- 소비된 token이 rotation 후 5초 grace 안에 중복 제출되면 그 요청만 실패하고 winner token family는 유지합니다. -- grace 이후 소비된 token이 재사용되면 replay로 판단해 해당 refresh session family 전체를 폐기합니다. -- 사용자 비활성화, token/session 만료, 폐기, 잘못된 token은 refresh cookie를 만료시킵니다. grace 안의 중복 요청은 winner cookie를 보호하기 위해 cookie를 삭제하지 않습니다. - -기본 refresh cookie 속성은 `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`입니다. 운영 프로필에서는 `Secure=true`가 아니면 애플리케이션이 시작되지 않습니다. 로그인 CORS는 명시적으로 허용된 frontend origin에만 credential을 허용합니다. - -### 인증 제외 경로 - -- `GET /`, `GET /index.html`, 정적 리소스 -- `GET /sso/**` -- `GET /oauth2/authorization/**`, `GET /login/oauth2/code/**` -- `POST /api/v1/auth/exchange` -- `GET /api/v1/auth/csrf` -- `POST /api/v1/auth/refresh` (refresh cookie와 CSRF로 자체 검증) -- `POST /api/v1/clients/register` -- `GET|PUT /api/v1/clients/send-email` -- `GET /api/v1/health`, `GET /api/v1/ping` -- `GET /openapi.json`, `GET /overview.md`, `GET /llms.txt`, `GET /llm.txt` +- API Key와 `Authorization`을 동시에 보내거나 같은 인증 헤더를 중복 제출하면 HTTP `400`/code `4004`로 거부합니다. +- 로그인 JWT를 외부 API 인증으로 사용할 수 없습니다. +- 외부 API용 OAuth2 access token은 아직 지원하지 않으며 HTTP `401`/code `4017`로 종료합니다. ### 인증 실패 | Code | 조건 | |---|---| | `4004` | 다중 또는 중복 credential 제출 | -| `4010` | 기존 외부 API의 API Key 누락 | -| `4011` | SSO 로그인 실패 | -| `4012` | 로그인 교환 코드가 잘못됐거나 만료·소비됨 | -| `4013` | 로그인 access token이 잘못됨 | -| `4014` | 로그인 access token 만료 | -| `4015` | refresh token/session이 잘못됐거나 만료·폐기됨 | -| `4016` | 소비된 refresh token 재사용 | +| `4010` | API Key 누락 | | `4017` | 외부 API용 OAuth2 access token 미지원 | -| `4030` | 권한 부족, 차단, 폐기 상태 | -| `4031` | API Key에 해당하는 클라이언트 정보 없음 | -| `4032` | API Key 유효하지 않음 | -| `4033` | 로그인 사용자 비활성 상태 | - -### 의도적으로 미구현된 범위 - -OAuth2 Client Credentials의 `/oauth2/token`, `client_id/client_secret`, 외부 API access token 발급·검증은 아직 제공하지 않습니다. 현재 외부 API의 Bearer credential은 `OAUTH2_ACCESS_TOKEN` 확장 경계에서 fail-closed 처리합니다. +| `4030` | 권한 부족 또는 차단된 클라이언트 | +| `4031` | 클라이언트 정보 없음 | +| `4032` | 유효하지 않은 API Key | diff --git a/profanity-api/src/main/resources/openapi/error-model.md b/profanity-api/src/main/resources/openapi/error-model.md index 58105e2..1a364fe 100644 --- a/profanity-api/src/main/resources/openapi/error-model.md +++ b/profanity-api/src/main/resources/openapi/error-model.md @@ -4,7 +4,7 @@ OpenAPI의 `responses`는 HTTP 상태 코드를 기준으로 표시합니다. 이 API는 대부분의 실패를 HTTP `200`과 본문 `status.code`로 반환하므로, 각 operation에 HTTP `400`이나 `404`가 따로 표시되지 않을 수 있습니다. -기존 API Key 실패 계약은 HTTP `200`과 본문 code를 유지합니다. 새 로그인 인증 API와 credential 충돌은 실제 HTTP `400`/`401`/`403`과 해당 본문 code를 함께 반환합니다. +기존 API Key 실패 계약은 HTTP `200`과 본문 code를 유지합니다. credential 충돌과 지원하지 않는 Bearer credential은 실제 HTTP 오류와 해당 본문 code를 함께 반환합니다. ### 오류 응답 형식 @@ -28,17 +28,10 @@ OpenAPI의 `responses`는 HTTP 상태 코드를 기준으로 표시합니다. | `4003` | `Not_fount_tracking_id` | tracking ID를 찾을 수 없음 | | `4004` | `Ambiguous_credentials` | 다중 또는 중복 인증 정보 제출 | | `4010` | `Unauthorized` | API Key 누락 | -| `4011` | `Oauth2_login_failed` | OAuth2 로그인 실패 | -| `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_access_token_unsupported` | 외부 API OAuth2 access token 미지원 | | `4030` | `Forbidden` | 권한 부족 또는 차단된 클라이언트 | | `4031` | `Not_found_client` | 클라이언트 정보 없음 | | `4032` | `Invalid_api_key` | 유효하지 않은 API Key | -| `4033` | `User_inactive` | 로그인 사용자 비활성 상태 | | `4290` | `Too_many_requests` | 요청 제한 초과 | | `5000` | `Internal_server_error` | 서버 내부 오류 | | `5030` | `Service_unavailable` | 서비스 점검 또는 일시 사용 불가 | diff --git a/profanity-api/src/test/java/app/architecture/OpenApiArchitectureTest.java b/profanity-api/src/test/java/app/architecture/OpenApiArchitectureTest.java index 9fea85e..116e737 100644 --- a/profanity-api/src/test/java/app/architecture/OpenApiArchitectureTest.java +++ b/profanity-api/src/test/java/app/architecture/OpenApiArchitectureTest.java @@ -1,6 +1,5 @@ package app.architecture; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; @@ -70,6 +69,14 @@ public boolean test(JavaMethod method) { } }; + private static final DescribedPredicate DOCUMENTED_CONTROLLER_ENDPOINT = + new DescribedPredicate<>("공개 OpenAPI 컨트롤러 엔드포인트") { + @Override + public boolean test(JavaMethod method) { + return isControllerEndpoint(method) && !isHidden(method); + } + }; + private static final Set ENDPOINT_MAPPING_ANNOTATIONS = Set.of( DeleteMapping.class.getName(), @@ -91,34 +98,33 @@ public boolean test(JavaMethod method) { Content.class.getName(), ExampleObject.class.getName(), Schema.class.getName(), - Tag.class.getName(), - Hidden.class.getName()); + Tag.class.getName()); @ArchTest - @DisplayName("컨트롤러는 Swagger 문서 어노테이션을 직접 사용하지 않는다") + @DisplayName("컨트롤러는 Hidden 외 Swagger 문서 어노테이션을 직접 사용하지 않는다") static void controller_should_not_depend_on_swagger_operation_annotations(JavaClasses classes) { - log.info("컨트롤러 계층의 직접 Swagger 문서 어노테이션 의존을 검사합니다."); + log.info("컨트롤러 계층이 @Hidden 외 Swagger 문서 어노테이션에 직접 의존하지 않는지 검사합니다."); noClasses() .that() .resideInAPackage("app.presentation..") .should() .dependOnClassesThat(DISALLOWED_SWAGGER_ANNOTATION) - .as("컨트롤러는 Swagger 문서 어노테이션을 직접 사용하지 않는다") - .because("API 문서 계약은 app.openapi의 합성 어노테이션으로만 관리해야 컨트롤러가 비대해지지 않습니다.") + .as("컨트롤러는 @Hidden 외 Swagger 문서 어노테이션을 직접 사용하지 않는다") + .because("공개 계약은 app.openapi 합성 어노테이션으로 관리하고 비공개 여부만 @Hidden으로 표시합니다.") .check(classes); } @ArchTest - @DisplayName("컨트롤러 엔드포인트는 OpenAPI 합성 어노테이션을 정확히 하나 가진다") - static void controller_endpoint_should_have_exactly_one_openapi_annotation(JavaClasses classes) { - log.info("컨트롤러 엔드포인트와 OpenAPI 합성 어노테이션의 1:1 매핑을 검사합니다."); + @DisplayName("컨트롤러 엔드포인트는 공개 문서 또는 Hidden 중 하나로만 분류한다") + static void controller_endpoint_should_have_exactly_one_visibility_decision(JavaClasses classes) { + log.info("컨트롤러 엔드포인트의 공개 문서와 @Hidden 배타 분류를 검사합니다."); methods() .that(CONTROLLER_ENDPOINT) - .should(haveExactlyOneOpenApiAnnotation()) - .as("컨트롤러 엔드포인트 메서드는 app.openapi 합성 어노테이션을 정확히 하나 가져야 한다") - .because("문서 누락과 중복 문서 정의를 동시에 막기 위한 규칙입니다.") + .should(haveExactlyOneVisibilityDecision()) + .as("엔드포인트는 app.openapi 합성 어노테이션 하나 또는 @Hidden 중 하나만 가져야 한다") + .because("공개 여부 미결정과 공개 문서 및 숨김의 중복 선언을 동시에 막기 위한 규칙입니다.") .check(classes); } @@ -128,7 +134,7 @@ static void controller_endpoint_should_use_matching_openapi_holder(JavaClasses c log.info("컨트롤러와 OpenAPI holder의 이름 기반 매칭을 검사합니다."); methods() - .that(CONTROLLER_ENDPOINT) + .that(DOCUMENTED_CONTROLLER_ENDPOINT) .should(useMatchingOpenApiHolder()) .as("예: ProfanityController는 ProfanityOpenApi의 합성 어노테이션만 사용해야 한다") .because("컨트롤러와 문서 어노테이션의 소유 경계를 1:1로 유지하기 위한 규칙입니다.") @@ -172,7 +178,13 @@ private static boolean isControllerEndpoint(JavaMethod method) { } private static boolean isVerifiedClientEndpoint(JavaMethod method) { - return isControllerEndpoint(method) && method.isAnnotatedWith(VerifiedClientOnly.class); + return isControllerEndpoint(method) + && !isHidden(method) + && method.isAnnotatedWith(VerifiedClientOnly.class); + } + + private static boolean isHidden(JavaMethod method) { + return method.isAnnotatedWith(Hidden.class) || method.getOwner().isAnnotatedWith(Hidden.class); } private static boolean hasAnyAnnotation(JavaMethod method, Set annotationNames) { @@ -182,18 +194,24 @@ private static boolean hasAnyAnnotation(JavaMethod method, Set annotatio .anyMatch(annotationNames::contains); } - private static ArchCondition haveExactlyOneOpenApiAnnotation() { - return new ArchCondition<>("have exactly one app.openapi annotation") { + private static ArchCondition haveExactlyOneVisibilityDecision() { + return new ArchCondition<>("have exactly one OpenAPI visibility decision") { @Override public void check(JavaMethod method, ConditionEvents events) { List> annotations = openApiAnnotations(method); - if (annotations.size() != 1) { + boolean hidden = isHidden(method); + boolean valid = hidden ? annotations.isEmpty() : annotations.size() == 1; + if (!valid) { events.add( SimpleConditionEvent.violated( method, method.getFullName() - + " must have exactly one app.openapi annotation but has " - + annotations.size())); + + " must be either @Hidden or have exactly one app.openapi annotation" + + " (hidden=" + + hidden + + ", annotations=" + + annotations.size() + + ")")); } } }; diff --git a/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java b/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java index e4eb127..21d7269 100644 --- a/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.node.MissingNode; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; @@ -43,19 +44,15 @@ void openapiJson_whenRequested_returnsOpenApiSpec() throws Exception { assertThat(body.at("/components/securitySchemes/ApiKeyAuth/description").asText()) .as("ApiKeyAuth 보안 스키마는 x-api-key 헤더 설명을 제공해야 한다") .isEqualTo("클라이언트 등록 후 발급받은 API Key"); - assertThat(body.at("/components/securitySchemes/LoginJwtAuth/type").asText()).isEqualTo("http"); - assertThat(body.at("/components/securitySchemes/LoginJwtAuth/scheme").asText()) - .isEqualTo("bearer"); - assertThat(body.at("/components/securitySchemes/LoginJwtAuth/bearerFormat").asText()) - .isEqualTo("JWT"); + assertThat(body.at("/components/securitySchemes/LoginJwtAuth").isMissingNode()).isTrue(); + assertThat(countOperations(body.path("paths"))).isEqualTo(12); 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~1auth~1exchange/post").isMissingNode()).isFalse(); - assertThat(body.at("/paths/~1api~1v1~1auth~1csrf/get").isMissingNode()).isFalse(); - assertThat(body.at("/paths/~1api~1v1~1auth~1refresh/post").isMissingNode()).isFalse(); - assertThat(body.at("/paths/~1api~1v1~1auth~1me/get/security/0/LoginJwtAuth").isArray()) - .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~1sync").isMissingNode()).isTrue(); + assertThat(body.at("/paths/~1api~1v1~1word~1accept~1{requestId}").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1overview.md/get").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1llms.txt/get").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1swagger-ui~1index.html/get").isMissingNode()).isTrue(); @@ -132,13 +129,7 @@ void openapiJson_whenPublicApiHasSuccessResponse_returnsConcreteSuccessResponseS 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~1auth~1exchange/post/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1auth~1csrf/get/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1auth~1refresh/post/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1auth~1me/get/responses/200/content"), - new OperationPath("/paths/~1api~1v1~1sync/get/responses/200/content"), - new OperationPath( - "/paths/~1api~1v1~1word~1accept~1{requestId}/post/responses/200/content") + new OperationPath("/paths/~1api~1v1~1word~1request/post/responses/200/content") }) { JsonNode responseContent = body.at(operationPath.pointer()); assertThat(responseContent.has("application/json")) @@ -219,87 +210,6 @@ void openapiJson_whenFilterOpenApiAnnotationProvided_returnsRequestAndResponseEx .isEqualTo("advanced *****"); } - @Test - @DisplayName("로그인 API는 token, cookie, CSRF와 오류 응답 계약을 문서화한다") - void openapiJson_whenAuthOpenApiAnnotationProvided_returnsAuthenticationContract() - throws Exception { - // when - var response = mockMvcTester.get().uri("/openapi.json").exchange(); - - // then - assertThat(response).hasStatusOk(); - - JsonNode body = objectMapper.readTree(response.getResponse().getContentAsString()); - JsonNode exchange = body.at("/paths/~1api~1v1~1auth~1exchange/post"); - assertThat(exchange.at("/requestBody/content/application~1json/schema/$ref").asText()) - .isEqualTo("#/components/schemas/AuthCodeExchangeRequest"); - assertThat( - exchange - .at("/requestBody/content/application~1json/examples/exchangeCode/value/code") - .asText()) - .isEqualTo("sso_exchange_code_example"); - assertThat(exchange.at("/responses/200/headers/Set-Cookie").isMissingNode()).isFalse(); - assertThat( - exchange - .at( - "/responses/200/content/application~1json/examples/success/value/data/tokenType") - .asText()) - .isEqualTo("Bearer"); - assertThat( - exchange - .at( - "/responses/200/content/application~1json/examples/invalidRequest/value/status/code") - .asInt()) - .isEqualTo(4000); - assertThat( - exchange - .at( - "/responses/401/content/application~1json/examples/invalidCode/value/status/code") - .asInt()) - .isEqualTo(4012); - - JsonNode csrf = body.at("/paths/~1api~1v1~1auth~1csrf/get"); - assertThat(csrf.at("/responses/200/headers/Set-Cookie").isMissingNode()).isFalse(); - assertThat( - csrf.at( - "/responses/200/content/application~1json/examples/csrfToken/value/data/headerName") - .asText()) - .isEqualTo("X-XSRF-TOKEN"); - - JsonNode refresh = body.at("/paths/~1api~1v1~1auth~1refresh/post"); - assertThat( - findParameter(refresh.path("parameters"), "PF_LOGIN_REFRESH", "cookie").isMissingNode()) - .isFalse(); - assertThat(findParameter(refresh.path("parameters"), "XSRF-TOKEN", "cookie").isMissingNode()) - .isFalse(); - assertThat(findParameter(refresh.path("parameters"), "X-XSRF-TOKEN", "header").isMissingNode()) - .isFalse(); - assertThat( - refresh - .at( - "/responses/401/content/application~1json/examples/reusedToken/value/status/code") - .asInt()) - .isEqualTo(4016); - assertThat( - refresh - .at( - "/responses/403/content/application~1json/examples/csrfRejected/value/status/code") - .asInt()) - .isEqualTo(4030); - - JsonNode me = body.at("/paths/~1api~1v1~1auth~1me/get"); - assertThat(me.at("/security/0/LoginJwtAuth").isArray()).isTrue(); - assertThat( - me.at("/responses/200/content/application~1json/examples/currentUser/value/data/email") - .asText()) - .isEqualTo("user@example.com"); - assertThat( - me.at( - "/responses/401/content/application~1json/examples/invalidToken/value/status/code") - .asInt()) - .isEqualTo(4013); - } - @Test @DisplayName("응답 모델은 Scalar 모델 섹션에 표시할 설명을 가진다") void openapiJson_whenResponseSchemasRendered_returnsDescribedResponseProperties() @@ -333,6 +243,21 @@ void openapiJson_whenResponseSchemasRendered_returnsDescribedResponseProperties( private record OperationPath(String pointer) {} + private static int countOperations(JsonNode paths) { + Set methods = + Set.of("get", "post", "put", "patch", "delete", "head", "options", "trace"); + int count = 0; + for (JsonNode pathItem : paths) { + var fields = pathItem.fieldNames(); + while (fields.hasNext()) { + if (methods.contains(fields.next())) { + count++; + } + } + } + return count; + } + private static JsonNode findParameter(JsonNode parameters, String name, String in) { if (!parameters.isArray()) { return MissingNode.getInstance();