diff --git a/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenController.java b/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenController.java index 5bdbb9b..50749d9 100644 --- a/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenController.java +++ b/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenController.java @@ -22,12 +22,13 @@ */ @RestController @RequiredArgsConstructor -public class AuthTokenController { +public class AuthTokenController implements AuthTokenControllerDocs { private final RefreshTokenService refreshTokenService; private final JwtTokenService jwtTokenService; private final TrustedOriginValidator trustedOriginValidator; + @Override @PostMapping("/api/v1/auth/token/refresh") public ResponseEntity> refresh( @CookieValue(name = RefreshTokenService.COOKIE_NAME, required = false) String refreshToken, diff --git a/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenControllerDocs.java new file mode 100644 index 0000000..ac1dc37 --- /dev/null +++ b/src/main/java/com/shinhan/klljs/domain/auth/controller/AuthTokenControllerDocs.java @@ -0,0 +1,75 @@ +package com.shinhan.klljs.domain.auth.controller; + +import com.shinhan.klljs.domain.auth.dto.TokenResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; + +/** + * {@link AuthTokenController}의 Swagger(OpenAPI) 문서 전용 인터페이스. + */ +@Tag( + name = "인증", + description = "카카오 로그인 이후의 Access/Refresh Token 발급·회전·로그아웃을 다룬다. 카카오 " + + "로그인 자체(`GET /oauth2/authorization/kakao` → 카카오 인가 → " + + "`GET /login/oauth2/code/kakao` 콜백)는 Spring Security의 OAuth2 로그인 필터 체인이 " + + "처리하는 리다이렉트 흐름이라 일반 @RestController 엔드포인트가 아니고, 그래서 이 " + + "목록에 나타나지 않는다 — 브라우저로 직접 로그인 시작 URL에 접속해야 하며, Swagger의 " + + "\"Try it out\"으로는 테스트할 수 없다." +) +public interface AuthTokenControllerDocs { + + @Operation( + summary = "Access Token 재발급", + description = """ + 카카오 로그인 성공 리다이렉트 직후, 또는 Access Token이 만료됐을 때 프론트가 호출한다. + `refresh_token` HttpOnly 쿠키(경로 `/api/v1/auth`)를 읽어 검증하고, 새 Access + Token과 회전된 새 Refresh Token 쿠키를 함께 내려준다. 브라우저 리다이렉트 URL에는 + 토큰을 절대 싣지 않고 이 별도의 fetch 호출로만 전달한다. + + ### 토큰 회전(Rotation)과 재사용 탐지 + Refresh Token은 한 번 쓰면 즉시 폐기되고 새 토큰으로 교체되는 1회용 토큰이다. 이미 + 폐기된(= 한 번 쓰인) 토큰이 다시 제시되면 탈취로 간주해 같은 `token_family_id`의 + 모든 토큰을 한꺼번에 폐기한다 — 공격자와 정상 사용자 중 누가 진짜인지 서버가 구분할 + 수 없으므로, 양쪽 다 다시 로그인해야 한다. + + ### CSRF 방어 + 쿠키는 브라우저가 요청마다 자동으로 첨부하므로, `Origin`/`Referer` 헤더가 허용된 + 프론트 오리진(`app.allowed-origins`) 또는 Swagger 자신의 오리진(`app.swagger-origin`) + 중 하나와 일치하는지 먼저 확인한다 — 어느 쪽도 아니면 403. + + ### 응답 형태가 다른 엔드포인트다 + 다른 API처럼 `ApiResponse.onSuccess(...)`를 직접 반환하지 않고 + `ResponseEntity>`로 감싼다 — 회전된 새 Refresh Token을 + `Set-Cookie` 헤더에 실어야 하기 때문이다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "재발급 성공. 응답 헤더의 `Set-Cookie`로 새 refresh_token 쿠키가 함께 내려간다", + content = @Content(mediaType = "application/json", examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "COMMON_200_001", + "message": "성공적으로 요청을 처리했습니다.", + "result": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJrbGxqcyIsInN1YiI6IjEiLCJ0eXAiOiJhY2Nlc3MiLCJleHAiOjE3ODQ2MTAwMTcsImlhdCI6MTc4NDYwOTExN30.l6sIK9E69bv6PnbXzsCE2XpobrvavpPqdCO8UMQqaDY" + } + } + """)) + ), + @ApiResponse(responseCode = "401", description = "`AUTH_401_001`: refresh_token 쿠키가 없거나, 존재하지 않거나, 만료·폐기됐거나(재사용 탐지 포함) 유효하지 않음"), + @ApiResponse(responseCode = "403", description = "신뢰할 수 없는 오리진에서의 요청 (`Origin`/`Referer`가 허용 목록에 없음)") + }) + ResponseEntity> refresh( + @Parameter(hidden = true) String refreshToken, + @Parameter(hidden = true) HttpServletRequest request + ); +} diff --git a/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutController.java b/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutController.java index 2067502..1118733 100644 --- a/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutController.java +++ b/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutController.java @@ -16,11 +16,12 @@ */ @RestController @RequiredArgsConstructor -public class LogoutController { +public class LogoutController implements LogoutControllerDocs { private final RefreshTokenService refreshTokenService; private final TrustedOriginValidator trustedOriginValidator; + @Override @PostMapping("/api/v1/auth/logout") public ResponseEntity logout( @CookieValue(name = RefreshTokenService.COOKIE_NAME, required = false) String refreshToken, diff --git a/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutControllerDocs.java new file mode 100644 index 0000000..0443c56 --- /dev/null +++ b/src/main/java/com/shinhan/klljs/domain/auth/controller/LogoutControllerDocs.java @@ -0,0 +1,54 @@ +package com.shinhan.klljs.domain.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; + +/** + * {@link LogoutController}의 Swagger(OpenAPI) 문서 전용 인터페이스. + */ +@Tag( + name = "인증", + description = "카카오 로그인 이후의 Access/Refresh Token 발급·회전·로그아웃을 다룬다. 카카오 " + + "로그인 자체(`GET /oauth2/authorization/kakao` → 카카오 인가 → " + + "`GET /login/oauth2/code/kakao` 콜백)는 Spring Security의 OAuth2 로그인 필터 체인이 " + + "처리하는 리다이렉트 흐름이라 일반 @RestController 엔드포인트가 아니고, 그래서 이 " + + "목록에 나타나지 않는다 — 브라우저로 직접 로그인 시작 URL에 접속해야 하며, Swagger의 " + + "\"Try it out\"으로는 테스트할 수 없다." +) +public interface LogoutControllerDocs { + + @Operation( + summary = "로그아웃", + description = """ + 현재 브라우저 세션의 Refresh Token 하나만 폐기하고 쿠키를 지운다. **카카오 계정 + 연결 자체는 끊지 않는다** — `user_social_accounts`는 그대로 남고, 다음에 다시 + 카카오로 로그인하면 즉시 재로그인된다. 카카오 쪽 Access/Refresh Token은 로그인 + 직후 이미 제거했으므로 여기서 별도로 만료시킬 대상이 없다. + + ### 멱등(idempotent)이다 + 쿠키가 없거나 이미 폐기된 토큰이 와도 에러 없이 그대로 204를 반환한다 — 이미 + 로그아웃된 상태에서 다시 호출해도 안전하다. + + ### CSRF 방어 + 재발급 API와 동일하게 `Origin`/`Referer`가 허용된 오리진인지 먼저 확인한다. + + ### 응답 형태가 다른 엔드포인트다 + 다른 API처럼 `ApiResponse` 봉투로 감싸지 않는다 — 성공 시 본문 없이 + `204 No Content`를 반환하고, `Set-Cookie` 헤더로 즉시 만료되는 쿠키(`Max-Age=0`)를 + 내려 브라우저가 쿠키를 지우게 한다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "로그아웃 성공(또는 이미 로그아웃된 상태) - 본문 없음"), + @ApiResponse(responseCode = "403", description = "신뢰할 수 없는 오리진에서의 요청 (`Origin`/`Referer`가 허용 목록에 없음)") + }) + ResponseEntity logout( + @Parameter(hidden = true) String refreshToken, + @Parameter(hidden = true) HttpServletRequest request + ); +} diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/controller/DashboardCampaignControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/campaign/controller/DashboardCampaignControllerDocs.java index fa04e89..bdcaecc 100644 --- a/src/main/java/com/shinhan/klljs/domain/campaign/controller/DashboardCampaignControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/campaign/controller/DashboardCampaignControllerDocs.java @@ -31,8 +31,10 @@ * 감싸져 있다. 아래 각 API 설명의 예시 JSON은 이 래퍼를 포함한 전체 응답 바디 기준이다. */ @Tag( - name = "캠페인 대시보드", - description = "홈 대시보드의 캠페인 목록/상세정보/송출정보 조회 API" + name = "홈 대시보드", + description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" + + "성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " + + "캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다." ) public interface DashboardCampaignControllerDocs { diff --git a/src/main/java/com/shinhan/klljs/domain/user/controller/UserController.java b/src/main/java/com/shinhan/klljs/domain/user/controller/UserController.java index cb19b5f..e31ab6e 100644 --- a/src/main/java/com/shinhan/klljs/domain/user/controller/UserController.java +++ b/src/main/java/com/shinhan/klljs/domain/user/controller/UserController.java @@ -17,10 +17,11 @@ */ @RestController @RequiredArgsConstructor -public class UserController { +public class UserController implements UserControllerDocs { private final UserQueryService userQueryService; + @Override @GetMapping("/api/v1/users/me") public ApiResponse me(@AuthenticationPrincipal Jwt jwt) { Long userId = Long.valueOf(jwt.getSubject()); diff --git a/src/main/java/com/shinhan/klljs/domain/user/controller/UserControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/user/controller/UserControllerDocs.java new file mode 100644 index 0000000..bbdfb67 --- /dev/null +++ b/src/main/java/com/shinhan/klljs/domain/user/controller/UserControllerDocs.java @@ -0,0 +1,59 @@ +package com.shinhan.klljs.domain.user.controller; + +import com.shinhan.klljs.domain.user.dto.UserMeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * {@link UserController}의 Swagger(OpenAPI) 문서 전용 인터페이스. + */ +@Tag(name = "사용자", description = "인증된 사용자 자신의 정보를 조회하는 API.") +public interface UserControllerDocs { + + @Operation( + summary = "내 정보 조회", + description = """ + Access Token(`Authorization: Bearer`)이 정상 동작하는지 확인하는 용도로도 쓰는, + 가장 단순한 인증 확인 엔드포인트다. JWT의 `sub` 클레임(발급 시 내부 userId로 채운 + 값)을 그대로 신뢰한다 — 서명 검증을 통과했다는 것 자체가 우리가 발급한 토큰이라는 + 보증이므로 별도로 DB에서 재확인하지 않는다. + + ### `hasTeam` / `teamId` + 현재는 "사용자는 팀 하나에만 속한다"는 단순화된 가정으로 응답을 구성한다 - 데이터 + 모델 자체는 다대다 소속을 허용하지만, 여러 `ACTIVE` 팀에 속해 있어도 그중 첫 번째 + 팀만 대표로 내려준다. 소속 팀이 하나도 없으면 `hasTeam: false`, `teamId: null`. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(mediaType = "application/json", examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "COMMON_200_001", + "message": "성공적으로 요청을 처리했습니다.", + "result": { + "id": 1, + "displayName": "홍길동", + "email": "user@example.com", + "profileImageUrl": "https://k.kakaocdn.net/dn/example/profile.jpg", + "status": "ACTIVE", + "hasTeam": true, + "teamId": 12 + } + } + """)) + ), + @ApiResponse(responseCode = "401", description = "Access Token이 없거나 만료·위조됨") + }) + com.shinhan.klljs.global.apiPayload.ApiResponse me( + @Parameter(hidden = true) Jwt jwt + ); +} diff --git a/src/main/java/com/shinhan/klljs/domain/vision/controller/DashboardMetricsControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/vision/controller/DashboardMetricsControllerDocs.java index 03f7c86..d2bfbb1 100644 --- a/src/main/java/com/shinhan/klljs/domain/vision/controller/DashboardMetricsControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/vision/controller/DashboardMetricsControllerDocs.java @@ -21,10 +21,10 @@ * 매핑 애노테이션은 컨트롤러에 그대로 두고, 여기에는 문서화용 애노테이션만 둔다. */ @Tag( - name = "평균 시청시간·성별연령·시간대 노출도", - description = "평균 시청시간, 성별·연령 시청 비율, 시간·연령별 노출도 3개 API. " + - "campaignId + selectedPeriod/effectivePeriod/periodStatus + aggregationUnit/" + - "aggregationCutoffTime/refreshIntervalSec 틀을 공유한다." + name = "홈 대시보드", + description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" + + "성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " + + "캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다." ) public interface DashboardMetricsControllerDocs { @@ -34,6 +34,10 @@ public interface DashboardMetricsControllerDocs { 도넛 차트로 표시할 평균 시청시간과 시청시간 구간별 분포를 조회한다. `refreshIntervalSec` (60초)에 맞춰 재조회를 권장한다. 1분 단위로 확정된 데이터만 집계한다. + 형제 API 2개(성별·연령 시청 비율, 시간·연령별 노출도)와 `campaignId` + + `selectedPeriod`/`effectivePeriod`/`periodStatus` + `aggregationUnit`/ + `aggregationCutoffTime`/`refreshIntervalSec` 파라미터·응답 틀을 공유한다. + ### 집계 범위 - 선택 기간에 **오늘이 포함**되면, 지금 진행 중인 분(아직 5초 데이터가 다 안 모였을 수 있음)은 제외한다. `aggregationCutoffTime`은 "지금 진행 중인 분의 시작 시각"이 된다 @@ -106,6 +110,9 @@ com.shinhan.klljs.global.apiPayload.ApiResponse getAve 화면 우측의 성별·연령 시청 비율 막대 그래프를 조회한다. LTS(주목인구) 성별·연령 집계 기반이다. `refreshIntervalSec`(3600초)에 맞춰 재조회를 권장한다. + 형제 API 2개(평균 시청시간, 시간·연령별 노출도)와 파라미터·응답 틀을 공유한다 + (평균 시청시간 조회 API 설명 참고). + ### 전체/남성/여성 토글 화면에 토글 버튼이 있지만 **버튼을 눌러도 API를 다시 호출하지 않는다** - 한 번의 응답에 세 뷰에 필요한 값을 모두 담아 내려주고, 프론트는 토글 상태에 따라 `ageGroups[]`의 @@ -184,6 +191,9 @@ com.shinhan.klljs.global.apiPayload.ApiResponse ge 시간대·연령대별 노출도 히트맵을 조회한다. OTS(노출인구) 성별·연령 집계 기반이다. `refreshIntervalSec`(3600초)에 맞춰 재조회를 권장한다. + 형제 API 2개(평균 시청시간, 성별·연령 시청 비율)와 파라미터·응답 틀을 공유한다 + (평균 시청시간 조회 API 설명 참고). + ### 시간 축 집계 방식 날짜는 버리고 **하루 중 시간대(0~23시)** 기준으로 묶는다 - 예를 들어 선택 기간이 3일이면, 그 3일의 "14시" 데이터를 전부 합쳐 하나의 14시 셀로 보여준다("하루 중 diff --git a/src/main/java/com/shinhan/klljs/domain/vision/controller/FunnelControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/vision/controller/FunnelControllerDocs.java index 6ef968b..95c459d 100644 --- a/src/main/java/com/shinhan/klljs/domain/vision/controller/FunnelControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/vision/controller/FunnelControllerDocs.java @@ -19,8 +19,10 @@ * 매핑 애노테이션은 컨트롤러에 그대로 두고, 여기에는 문서화용 애노테이션만 둔다. */ @Tag( - name = "깔대기 그래프", - description = "홈 대시보드의 깔대기 그래프 카드(유동/노출/주목 인구, 주목 전환률) 조회 API" + name = "홈 대시보드", + description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" + + "성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " + + "캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다." ) public interface FunnelControllerDocs { diff --git a/src/main/java/com/shinhan/klljs/domain/vision/controller/RealtimeGraphControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/vision/controller/RealtimeGraphControllerDocs.java index f4df62f..77e4cba 100644 --- a/src/main/java/com/shinhan/klljs/domain/vision/controller/RealtimeGraphControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/vision/controller/RealtimeGraphControllerDocs.java @@ -22,9 +22,10 @@ * (DashboardCampaignControllerDocs와 동일한 분리 방식). */ @Tag( - name = "노출·주목 흐름 그래프", - description = "홈 화면의 '노출/주목 흐름 그래프' 카드용 API 2종(5-1 실시간, 5-2 시간별 누적). " + - "같은 화면 카드를 나눠 담당하며, 프론트는 아래 각 API 설명의 조건에 따라 둘 중 하나만 호출한다." + name = "홈 대시보드", + description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" + + "성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " + + "캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다." ) public interface RealtimeGraphControllerDocs { diff --git a/src/main/java/com/shinhan/klljs/global/config/SwaggerConfig.java b/src/main/java/com/shinhan/klljs/global/config/SwaggerConfig.java index 7d5f7dc..95d4e28 100644 --- a/src/main/java/com/shinhan/klljs/global/config/SwaggerConfig.java +++ b/src/main/java/com/shinhan/klljs/global/config/SwaggerConfig.java @@ -5,12 +5,35 @@ import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.tags.Tag; +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Comparator; +import java.util.List; + @Configuration public class SwaggerConfig { + /** + * Swagger UI의 태그(도메인) 목록 순서. springdoc이 컨트롤러를 스캔하는 순서는 클래스패스 + * 스캔 순서에 좌우돼 예측할 수 없으므로, 도메인 단위로 읽히도록 순서를 직접 고정한다. + * 여기 없는 태그가 나중에 추가되면 이 목록 뒤에 나타난다(springdoc 기본 동작 유지). + */ + private static final List TAG_ORDER = List.of( + "인증", + "사용자", + "팀", + "팀원 관리", + "사업자등록증", + "매체", + "캠페인 등록", + "캠페인 페이지", + "홈 대시보드", + "유동인구 적재(관리자)" + ); + @Bean public OpenAPI openAPI() { // 1. 문서 기본 정보 설정 @@ -35,4 +58,19 @@ public OpenAPI openAPI() { .addSecurityItem(securityRequirement) .components(components); } + + /** 태그를 TAG_ORDER 순서로 정렬해 Swagger UI에서 도메인 단위로 그룹지어 보이게 한다. */ + @Bean + public OpenApiCustomizer tagOrderCustomizer() { + return openApi -> { + List tags = openApi.getTags(); + if (tags == null) { + return; + } + tags.sort(Comparator.comparingInt(tag -> { + int index = TAG_ORDER.indexOf(tag.getName()); + return index < 0 ? Integer.MAX_VALUE : index; + })); + }; + } } \ No newline at end of file