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
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse<TokenResponse>> refresh(
@CookieValue(name = RefreshTokenService.COOKIE_NAME, required = false) String refreshToken,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<TokenResponse>>`로 감싼다 — 회전된 새 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<com.shinhan.klljs.global.apiPayload.ApiResponse<TokenResponse>> refresh(
@Parameter(hidden = true) String refreshToken,
@Parameter(hidden = true) HttpServletRequest request
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void> logout(
@CookieValue(name = RefreshTokenService.COOKIE_NAME, required = false) String refreshToken,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void> logout(
@Parameter(hidden = true) String refreshToken,
@Parameter(hidden = true) HttpServletRequest request
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
* 감싸져 있다. 아래 각 API 설명의 예시 JSON은 이 래퍼를 포함한 전체 응답 바디 기준이다.
*/
@Tag(
name = "캠페인 대시보드",
description = "홈 대시보드의 캠페인 목록/상세정보/송출정보 조회 API"
name = "홈 대시보드",
description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" +
"성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " +
"캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다."
)
public interface DashboardCampaignControllerDocs {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserMeResponse> me(@AuthenticationPrincipal Jwt jwt) {
Long userId = Long.valueOf(jwt.getSubject());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserMeResponse> me(
@Parameter(hidden = true) Jwt jwt
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
* 매핑 애노테이션은 컨트롤러에 그대로 두고, 여기에는 문서화용 애노테이션만 둔다.
*/
@Tag(
name = "평균 시청시간·성별연령·시간대 노출도",
description = "평균 시청시간, 성별·연령 시청 비율, 시간·연령별 노출도 3개 API. " +
"campaignId + selectedPeriod/effectivePeriod/periodStatus + aggregationUnit/" +
"aggregationCutoffTime/refreshIntervalSec 틀을 공유한다."
name = "홈 대시보드",
description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" +
"성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " +
"캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다."
)
public interface DashboardMetricsControllerDocs {

Expand All @@ -34,6 +34,10 @@ public interface DashboardMetricsControllerDocs {
도넛 차트로 표시할 평균 시청시간과 시청시간 구간별 분포를 조회한다. `refreshIntervalSec`
(60초)에 맞춰 재조회를 권장한다. 1분 단위로 확정된 데이터만 집계한다.

형제 API 2개(성별·연령 시청 비율, 시간·연령별 노출도)와 `campaignId` +
`selectedPeriod`/`effectivePeriod`/`periodStatus` + `aggregationUnit`/
`aggregationCutoffTime`/`refreshIntervalSec` 파라미터·응답 틀을 공유한다.

### 집계 범위
- 선택 기간에 **오늘이 포함**되면, 지금 진행 중인 분(아직 5초 데이터가 다 안 모였을 수 있음)은
제외한다. `aggregationCutoffTime`은 "지금 진행 중인 분의 시작 시각"이 된다
Expand Down Expand Up @@ -106,6 +110,9 @@ com.shinhan.klljs.global.apiPayload.ApiResponse<AverageWatchTimeResponse> getAve
화면 우측의 성별·연령 시청 비율 막대 그래프를 조회한다. LTS(주목인구) 성별·연령
집계 기반이다. `refreshIntervalSec`(3600초)에 맞춰 재조회를 권장한다.

형제 API 2개(평균 시청시간, 시간·연령별 노출도)와 파라미터·응답 틀을 공유한다
(평균 시청시간 조회 API 설명 참고).

### 전체/남성/여성 토글
화면에 토글 버튼이 있지만 **버튼을 눌러도 API를 다시 호출하지 않는다** - 한 번의 응답에
세 뷰에 필요한 값을 모두 담아 내려주고, 프론트는 토글 상태에 따라 `ageGroups[]`의
Expand Down Expand Up @@ -184,6 +191,9 @@ com.shinhan.klljs.global.apiPayload.ApiResponse<DemographicViewRatioResponse> ge
시간대·연령대별 노출도 히트맵을 조회한다. OTS(노출인구) 성별·연령 집계 기반이다.
`refreshIntervalSec`(3600초)에 맞춰 재조회를 권장한다.

형제 API 2개(평균 시청시간, 성별·연령 시청 비율)와 파라미터·응답 틀을 공유한다
(평균 시청시간 조회 API 설명 참고).

### 시간 축 집계 방식
날짜는 버리고 **하루 중 시간대(0~23시)** 기준으로 묶는다 - 예를 들어 선택 기간이
3일이면, 그 3일의 "14시" 데이터를 전부 합쳐 하나의 14시 셀로 보여준다("하루 중
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
* 매핑 애노테이션은 컨트롤러에 그대로 두고, 여기에는 문서화용 애노테이션만 둔다.
*/
@Tag(
name = "깔대기 그래프",
description = "홈 대시보드의 깔대기 그래프 카드(유동/노출/주목 인구, 주목 전환률) 조회 API"
name = "홈 대시보드",
description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" +
"성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " +
"캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다."
)
public interface FunnelControllerDocs {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@
* (DashboardCampaignControllerDocs와 동일한 분리 방식).
*/
@Tag(
name = "노출·주목 흐름 그래프",
description = "홈 화면의 '노출/주목 흐름 그래프' 카드용 API 2종(5-1 실시간, 5-2 시간별 누적). " +
"같은 화면 카드를 나눠 담당하며, 프론트는 아래 각 API 설명의 조건에 따라 둘 중 하나만 호출한다."
name = "홈 대시보드",
description = "홈 화면(대시보드)의 캠페인 목록/상세/송출정보 조회와 4개 카드(평균 시청시간·" +
"성별연령·시간대 노출도, 깔대기 그래프, 노출·주목 흐름 그래프) API를 모두 모았다. " +
"캠페인 자체의 수정·삭제는 '캠페인 페이지' 태그에서 다룬다."
)
public interface RealtimeGraphControllerDocs {

Expand Down
Loading
Loading