From a1e1788ce0d44db9edb2969feef1bc70089e6f7e Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 6 Apr 2026 11:59:14 +0900 Subject: [PATCH 01/19] Update deploy.yml --- .github/workflows/deploy.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7d53a79..afab210 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,22 @@ on: - main - dev + # 수동 실행을 가능하게 하는 설정 + workflow_dispatch: + inputs: + environment: + description: '배포 환경 선택' + required: true + default: 'dev' + type: choice + options: + - dev + - prod + reason: + description: '배포 사유를 입력하세요' + required: false + type: string + jobs: deploy: runs-on: ubuntu-latest From 6ad2aa9b0c49d372544e1af11741ab30e0fae941 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 7 Apr 2026 08:55:52 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=ED=99=98=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `application-prod.yml`, `application-alpha.yml`에 `project.logging.env` 추가 - Logback 설정 간소화 및 기본 설정 파일(`base-logback.xml`) 포함 - `GlobalExceptionHandler`에서 예외 처리 시 로그 수준 분리 (4xx는 info, 기타는 error) --- .../auth/web/common/GlobalExceptionHandler.kt | 7 ++- src/main/resources/application-alpha.yml | 4 ++ src/main/resources/application-prod.yml | 4 ++ src/main/resources/logback-spring.xml | 60 ++++++------------- 4 files changed, 30 insertions(+), 45 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt index 9f94cac..914e448 100644 --- a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt @@ -21,9 +21,12 @@ class GlobalExceptionHandler { @ExceptionHandler(ApiException::class) fun handleApiException(e: ApiException): ResponseEntity> { - - log.error(e.extractExceptionLocation() + e.message) val status = HttpStatus.valueOf(e.code.status) + if (status.is4xxClientError) { + log.info("[{}] {} - {}", status.value(), e.code, e.message) + } else { + log.error(e.extractExceptionLocation() + e.message) + } val body = CommonResponse.fail(e.code) return ResponseEntity.status(status).body(body) } diff --git a/src/main/resources/application-alpha.yml b/src/main/resources/application-alpha.yml index 84bbdad..8b7bb2e 100644 --- a/src/main/resources/application-alpha.yml +++ b/src/main/resources/application-alpha.yml @@ -1,4 +1,8 @@ # alpha — 알파 서버 (DDL update, 운영에 가까운 쿠키) +project: + logging: + env: alpha + spring: config: activate: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 625a362..abbc31c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,4 +1,8 @@ # prod — 운영 (RDS SSL, DDL validate, 쿠키 Strict) +project: + logging: + env: prod + spring: config: activate: diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 33fd440..332b9a9 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,48 +1,22 @@ - + + - - - - - - - - - - UTC - - - - - - - - - - { - "trace_id": "%mdc{trace_id}" - } - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + From 0f23c3a38035ac2eb0935e6fa51f89ea479917da Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Wed, 8 Apr 2026 15:38:30 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20API=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 명세 문서 작성 및 저장소에 추가(`docs/api-명세서.md`) - GitHub Pages 경로(`/api-명세서/`) 설정 및 문서화 - 저장소 디렉토리 구조 및 실행 가이드 갱신 --- README.md | 428 ++++++------------ ...i-\353\252\205\354\204\270\354\204\234.md" | 333 ++++++++++++++ 2 files changed, 475 insertions(+), 286 deletions(-) create mode 100644 "docs/api-\353\252\205\354\204\270\354\204\234.md" diff --git a/README.md b/README.md index 0477e95..a7551c0 100644 --- a/README.md +++ b/README.md @@ -1,345 +1,201 @@ -# Auth-BE +# auth-api -Spring Boot 기반의 인증/소셜 로그인 및 계정 연동(링크) 백엔드입니다. +Spring Boot 기반 **인증·인가** 백엔드입니다. 소셜 로그인(Google, Kakao, Naver), 이메일 인증 로그인, 계정 연동, JWT·HttpOnly 쿠키, API Gateway용 `introspect` 등을 제공합니다. -## 목차 -- [주요 기능](#주요-기능) -- [기술 스택](#기술-스택) -- [빠른 시작](#빠른-시작) -- [배포 (EC2 Docker)](#배포-ec2-docker) -- [환경 변수 설정](#환경-변수-설정) -- [API 엔드포인트](#api-엔드포인트) -- [인증 플로우](#인증-플로우) -- [OAuth/PKCE 규칙](#oauthpkce-규칙) -- [보안 고려사항](#보안-고려사항) - -## 주요 기능 - -- **소셜 로그인**: Google, Kakao, Naver OAuth2 지원 -- **이메일 인증**: 인증코드 기반 이메일 로그인/가입 -- **계정 연동**: 기존 계정에 소셜 계정 추가 링크 -- **PKCE 지원**: Authorization Code Flow with PKCE -- **보안 강화**: HttpOnly 쿠키 기반 RefreshToken 관리 -- **토큰 갱신**: AccessToken 자동 갱신 지원 +## 문서 위치 -## 기술 스택 +| 구분 | 내용 | +|------|------| +| **이 README** | 빠른 시작, 환경 변수, 배포, 인증 요약, GitHub Pages 안내 | +| **API 명세** | [`docs/api-명세서.md`](docs/api-명세서.md) | +| **CI 환경** | [`docs/GITHUB-ENVIRONMENTS.md`](docs/GITHUB-ENVIRONMENTS.md) | -- **언어**: Kotlin -- **프레임워크**: Spring Boot 3, Spring Web, Spring Security -- **인증**: OAuth2 (Google, Kakao, Naver) -- **검증**: Validation -- **로깅**: Kotlin Logging -- **직렬화**: Jackson -- **HTTP 클라이언트**: RestTemplate -- **빌드**: Gradle (KTS) +--- -## 빠른 시작 +## 목차 -### 요구사항 -- JDK 17+ -- Gradle 8+ +- [문서 위치](#문서-위치) +- [역할 한눈에](#역할-한눈에) +- [기술 스택](#기술-스택) +- [저장소 구조](#저장소-구조) +- [실행 방법](#실행-방법) +- [GitHub Pages](#github-pages) +- [배포 (Docker / EC2)](#배포-docker--ec2) +- [환경 변수](#환경-변수) +- [인증·토큰 요약](#인증토큰-요약) +- [OAuth / PKCE 요약](#oauth--pkce-요약) +- [보안·운영 참고](#보안운영-참고) -### 실행 +--- -```bash -# 개발 환경 실행 -./gradlew bootRun +## 역할 한눈에 -# 빌드 -./gradlew build +| 영역 | 내용 | +|------|------| +| 소셜 로그인 | OAuth2 인가 코드 + PKCE, 범용·제공자별 엔드포인트 | +| 이메일 | 인증 코드 발송/검증, 이메일 로그인·가입, 로그인 후 이메일 연동 | +| 토큰 | JWT Access / Refresh, Refresh는 DB 저장, 웹은 HttpOnly 쿠키 중심 | +| 클라이언트 | `X-Client-Type`(`web` / `app`)으로 쿠키 vs 본문 토큰 분기 | +| 게이트웨이 | `GET /api/v1/auth/introspect`, 응답 헤더 `X-User-Id`, 사일런트 리프레시 | -# JAR 실행 -java -jar build/libs/auth-be-0.0.1-SNAPSHOT.jar -``` +--- -## 배포 (EC2 Docker) +## 기술 스택 -배포는 **Docker** 방식으로 수행하며, systemd/JAR 직접 실행은 사용하지 않습니다. +| 구분 | 사용 | +|------|------| +| 언어 | Kotlin | +| 런타임 | JDK 25 (Gradle toolchain) | +| 프레임워크 | Spring Boot 4, Spring Web, Spring Security, Spring Data JPA | +| DB | PostgreSQL (런타임), H2 (테스트 등) | +| 인증 | OAuth2 연동, JWT (jjwt) | +| API 탐색 | springdoc OpenAPI 3 (`/v3/api-docs`, UI는 `SWAGGER_PATH`) | +| 기타 | Bucket4j(레이트 리밋), 메일 발송 | -- **main** 브랜치 push 시 GitHub Actions가 Docker 이미지를 빌드·푸시한 뒤 EC2에 SSH로 접속해 컨테이너를 갱신합니다. -- EC2에서는 env를 **단일 파일**로만 사용합니다. GitHub Secret **`ENV_FILE`**(전체 .env 내용)을 CI가 **`~/env/auth-be.env`** 에 복사하고, 컨테이너는 `--env-file ~/env/auth-be.env` 로 실행합니다. (다른 도커 서비스와 구분을 위해 패키지명.env 형식 사용.) +--- -실행 예: +## 저장소 구조 -```bash -docker run -d --restart unless-stopped --name auth-be -p 9000:9000 --env-file ~/env/auth-be.env /auth-server:latest ``` +src/main/kotlin/com/wq/auth/ +├── AuthApplication.kt +├── api/ +│ ├── controller/ # REST +│ ├── domain/ +│ └── external/oauth/ +├── security/ +├── shared/ +└── web/common/ -## 환경 변수 설정 +src/main/resources/ +├── application.yml +├── application-{local,alpha,prod}.yml +├── application-jwt.yml +└── application-oauth.yml -환경 변수는 다음 파일에 매핑됩니다: -- `src/main/resources/application.yml` -- `src/main/resources/application-jwt.yml` -- `src/main/resources/application-dev.yml` -- `src/main/resources/application-prod.yml` -- `src/main/resources/application-oauth.yml` - -### 필수 환경 변수 - -#### 공통 -```properties -# CORS 설정 -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 - -# JWT 설정 -JWT_SECRET_KEY=your-secret-key-min-256-bits -JWT_ACCESS_TOKEN_EXPIRATION=3600000 # 1시간 (ms) -JWT_REFRESH_TOKEN_EXPIRATION=604800000 # 7일 (ms) +docs/ +├── _config.yml # GitHub Pages (Jekyll) +├── api-명세서.md # API 명세 +└── GITHUB-ENVIRONMENTS.md # 배포 CI Environment ``` -#### Google OAuth -```properties -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_REDIRECT_URI=http://localhost:5173/auth/google/callback -``` +--- -#### Kakao OAuth -```properties -KAKAO_CLIENT_ID=your-kakao-rest-api-key -KAKAO_CLIENT_SECRET=your-kakao-client-secret -KAKAO_REDIRECT_URI=http://localhost:5173/auth/kakao/callback -``` +## 실행 방법 -#### Naver OAuth -```properties -NAVER_CLIENT_ID=your-naver-client-id -NAVER_CLIENT_SECRET=your-naver-client-secret -NAVER_REDIRECT_URI=http://localhost:5173/auth/naver/callback -``` +**요구:** JDK 17 이상(프로젝트는 **25** 툴체인), Gradle 래퍼. -### 선택 환경 변수 -```properties -# 이메일 인증 (사용 시) -MAIL_HOST=smtp.gmail.com -MAIL_PORT=587 -MAIL_USERNAME=your-email@gmail.com -MAIL_PASSWORD=your-app-password +```bash +./gradlew bootRun +./gradlew build +java -jar build/libs/auth-api-0.0.1-SNAPSHOT.jar ``` -## API 엔드포인트 +- 앱 이름: `auth-api` (`spring.application.name`) +- 기본 포트: **9000** +- 로컬 프로필: 기본 `local` + `jwt` + `oauth` (그룹은 `application.yml` 참고) -### 소셜 로그인/연동 (`SocialLoginController`) - -| Method | Endpoint | 설명 | 인증 | -|--------|----------|------|------| -| POST | `/api/v1/auth/google/login` | Google 로그인 | 불필요 | -| POST | `/api/v1/auth/kakao/login` | Kakao 로그인 | 불필요 | -| POST | `/api/v1/auth/naver/login` | Naver 로그인 | 불필요 | -| POST | `/api/v1/auth/link/google` | Google 계정 연동 | **필요** | -| POST | `/api/v1/auth/link/kakao` | Kakao 계정 연동 | **필요** | -| POST | `/api/v1/auth/link/naver` | Naver 계정 연동 | **필요** | - -**요청 바디 필드 (Provider별)** -- **Google/Kakao**: `authCode`, `codeVerifier` (필수). `redirectUri`는 서버 환경변수 사용. -- **Naver**: `authCode`, `state`, `codeVerifier` (세 필수 모두 필요). 인가 요청 시 사용한 `state`와 동일한 값 전달. - -**요청 예시** (Google 로그인): -```json -POST /api/v1/auth/google/login -Content-Type: application/json +--- -{ - "authCode": "4/0AfJohXmx...", - "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" -} -``` +## GitHub Pages -**요청 예시** (Naver 로그인): -```json -POST /api/v1/auth/naver/login -Content-Type: application/json +GitHub에서 정적 사이트로 **`docs/`** 폴더를 게시합니다. -{ - "authCode": "네이버에서_받은_인가코드", - "state": "인가_요청시_사용한_state_값과_동일", - "codeVerifier": "PKCE_코드_검증자" -} -``` +1. 저장소 **Settings → Pages** +2. **Build and deployment**: Branch **`main`**, 폴더 **`/docs`** +3. 저장 후 몇 분 뒤 Pages가 빌드됩니다. -> AccessToken은 Authorization 헤더로 자동 설정됩니다. -> RefreshToken은 HttpOnly 쿠키로 자동 설정됩니다. +**API 명세 (Pages):** 사이트에서 **`/api-명세서/`** 로 열립니다 (`docs/api-명세서.md`의 `permalink`). 루트 URL(`/`)에는 별도 `index`가 없어 **404일 수 있음**에 유의하세요. -### 이메일 인증/로그인 (`AuthEmailController`, `AuthController`) +**API 명세 (저장소에서 보기):** `https://github.com///blob/main/docs/api-명세서.md` -| Method | Endpoint | 설명 | 인증 | -|--------|----------|------|------| -| POST | `/api/v1/auth/email/request` | 이메일 인증코드 발송 | 불필요 | -| POST | `/api/v1/auth/email/verify` | 이메일 인증코드 검증 | 불필요 | -| POST | `/api/v1/auth/members/email-login` | 이메일 로그인/가입 | 불필요 | -| POST | `/api/v1/auth/members/logout` | 로그아웃 | 불필요 | -| POST | `/api/v1/auth/members/refresh` | AccessToken 재발급 | 불필요* | +랜딩 페이지가 필요하면 `docs/index.md`를 다시 두면 됩니다. -\* RefreshToken 쿠키 필요 +--- -**요청 예시** (이메일 인증 요청): -```json -POST /api/v1/auth/email/request -Content-Type: application/json +## 배포 (Docker / EC2) -{ - "email": "user@example.com" -} -``` +- **`main`** push 시 GitHub Actions로 이미지 빌드·푸시 후 EC2에서 컨테이너 갱신. +- EC2에서는 env를 **단일 파일**로 씁니다. Secret **`ENV_FILE`**(또는 CI에서 쓰는 이름) 전체를 **`~/env/auth-be.env`** 에 두고 `--env-file` 로 실행하는 흐름을 전제로 합니다. -**요청 예시** (토큰 재발급): -```http -POST /api/v1/auth/members/refresh -Cookie: refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +```bash +docker run -d --restart unless-stopped --name auth-be -p 9000:9000 \ + --env-file ~/env/auth-be.env /auth-server:latest ``` -### 회원 관리 (`MemberController`) - -| Method | Endpoint | 설명 | 인증 | -|--------|----------|------|------| -| GET | `/api/v1/auth/members/user-info` | 내 정보 조회 | **필요** | -| GET | `/api/v1/members` | 회원 목록 조회 | 불필요 | -| GET | `/api/v1/members/{id}` | 회원 단건 조회 | 불필요 | -| POST | `/api/v1/members` | 회원 생성 | 불필요 | -| PUT | `/api/v1/members/{id}/nickname` | 닉네임 변경 | 불필요 | -| DELETE | `/api/v1/members/{id}` | 회원 삭제 | 불필요 | +**GitHub Environments**(`production` / `alpha`)와 Secret 이름 표는 [`docs/GITHUB-ENVIRONMENTS.md`](docs/GITHUB-ENVIRONMENTS.md)를 따릅니다. -> 내 정보 조회 이외에는 테스트용 CRUD 엔드포인트입니다. 실제 운영 시 권한 설정 필요. - -**인증 헤더 형식**: -```http -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -``` - -### 보안 테스트 (`TestSecurityController`) +--- -개발/테스트 전용 엔드포인트: +## 환경 변수 -| Method | Endpoint | 설명 | 권한 | -|--------|----------|------|------| -| GET | `/api/public/test` | 공개 API 테스트 | 없음 | -| GET | `/api/test` | 인증 API 테스트 | USER | -| GET | `/api/admin/test` | 관리자 API 테스트 | ADMIN | -| GET | `/api/public/token` | 테스트용 JWT 발급 | 없음 | +설정은 주로 다음에 매핑됩니다. -## 인증 플로우 +- `application.yml` +- `application-jwt.yml` — `JWT_SECRET`, 토큰 만료 (`JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION` 등, Duration 형식 예: `30m`, `P7D`) +- `application-oauth.yml` — OAuth 클라이언트 ID/Secret, redirect URI +- `application-{local,alpha,prod}.yml` — 프로필별 -### 소셜 로그인 (PKCE) 전체 플로우 +### 자주 쓰는 예시 +```properties +JWT_SECRET=BASE64_OR_RAW_SECRET +JWT_ACCESS_TOKEN_EXPIRATION=30m +JWT_REFRESH_TOKEN_EXPIRATION=P7D + +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=authdb +DB_USERNAME=postgres +DB_PASSWORD=postgres + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +KAKAO_CLIENT_ID= +KAKAO_CLIENT_SECRET= +KAKAO_REDIRECT_URI= +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= +NAVER_REDIRECT_URI= + +MAIL_USERNAME= +MAIL_PASSWORD= +SWAGGER_PATH=/ +APP_COOKIE_DOMAIN= ``` -┌─────────┐ ┌──────────┐ ┌──────────┐ -│ Client │ │ Auth-BE │ │ OAuth │ -│(Browser)│ │ (Backend)│ │ Provider │ -└────┬────┘ └─────┬────┘ └─────┬────┘ - │ │ │ - │ 1. Generate code_verifier │ │ - │ & code_challenge │ │ - ├──────────────────────────>│ │ - │ │ │ - │ 2. Redirect to OAuth │ │ - ├───────────────────────────┼──────────────────────────>│ - │ (with code_challenge) │ │ - │ │ │ - │ 3. User Authentication │ │ - │<──────────────────────────┼───────────────────────────┤ - │ │ │ - │ 4. Redirect with code │ │ - │<──────────────────────────┼───────────────────────────┤ - │ │ │ - │ 5. POST /auth/{provider} │ │ - │ (code + code_verifier) │ │ - ├──────────────────────────>│ │ - │ │ 6. Exchange code for token│ - │ │ (with code_verifier) │ - │ ├──────────────────────────>│ - │ │ │ - │ │ 7. Access Token │ - │ │<──────────────────────────┤ - │ │ │ - │ │ 8. Get User Info │ - │ ├──────────────────────────>│ - │ │<──────────────────────────┤ - │ │ │ - │ 9. JWT Tokens │ │ - │ + RefreshToken Cookie │ │ - │<──────────────────────────┤ │ - │ │ │ -``` - -## OAuth/PKCE 규칙 - -### PKCE (Proof Key for Code Exchange) -- **Authorization 요청**: `code_challenge` (SHA-256 해시) 전송 -- **Token 교환**: 동일한 `code_verifier` 사용 -- **보안**: Authorization Code 탈취 공격 방지 - -### Authorization Code -- **1회용**: 사용 후 즉시 무효화 -- **유효시간**: 매우 짧음 (보통 10분 이내) -- **재사용 시**: `400 invalid_grant` 에러 발생 -- **권장사항**: 획득 즉시 토큰으로 교환 -### Redirect URI -- **일치 필수**: Authorization과 Token 교환 시 **완전히 동일**해야 함 -- **쿼리 파라미터**: 포함 시 정확히 일치 필요 -- **백엔드 동작**: 환경변수(`*_REDIRECT_URI`)에 설정된 값 사용 -- **화이트리스트**: 운영 환경에서 반드시 검증 필요 +--- -### Naver 특이사항 -- **State 파라미터**: Authorization과 Token 교환 시 동일 값 필수 -- **CSRF 방어**: State 값으로 요청 위변조 방지 +## 인증·토큰 요약 -### Provider별 Scope +- **Access Token:** 웹은 `accessToken` HttpOnly 쿠키; 앱은 로그인/갱신 응답 본문 등(`X-Client-Type: app`). +- **Refresh Token:** 웹은 `refreshToken` 쿠키; 앱은 요청/응답 본문. +- **호출 시 읽기 순서** (`JwtAuthenticationFilter`): + 1) `accessToken` 쿠키가 있으면 **Authorization 헤더 무시** + 2) 없으면 `Authorization: Bearer` -#### Google -``` -openid email profile -``` +**엔드포인트·DTO 표**는 [`docs/api-명세서.md`](docs/api-명세서.md)를 보세요. -#### Kakao -``` -account_email profile_nickname -``` +--- -#### Naver -``` -email name -``` +## OAuth / PKCE 요약 -## 보안 고려사항 - -### 토큰 관리 -- **AccessToken**: AUTHORIZATION 헤더 저장 (짧은 만료시간) -- **RefreshToken**: HttpOnly 쿠키로만 전달 (XSS 공격 방지) -- **Cookie 속성**: - - `HttpOnly`: JavaScript 접근 차단 - - `Secure`: HTTPS에서만 전송 (운영 환경) - - `SameSite=Strict`: CSRF 공격 방지 - -### CORS 설정 -```yaml -cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS} - allowed-methods: GET, POST, PUT, DELETE, OPTIONS - allowed-headers: "*" - allow-credentials: true -``` +- Authorization Code는 **1회용**, 받은 뒤 곧바로 교환. +- **Redirect URI**는 인가 요청과 토큰 요청에서 **동일**해야 함. +- **Naver**는 `state` 일치 필요. +- PKCE: `codeVerifier` 등은 DTO 및 제공자 문서를 따름. -### 환경별 쿠키 설정 -```kotlin -// 개발 환경 -secure = false -sameSite = Lax +참고: [OAuth 2.0](https://tools.ietf.org/html/rfc6749), [PKCE](https://tools.ietf.org/html/rfc7636), [Google](https://developers.google.com/identity/protocols/oauth2), [Kakao](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api), [Naver](https://developers.naver.com/docs/login/api/api.md) -// 운영 환경 -secure = true -sameSite = Strict -``` +--- -## 추가 자료 +## 보안·운영 참고 -- [OAuth 2.0 RFC](https://tools.ietf.org/html/rfc6749) -- [PKCE RFC](https://tools.ietf.org/html/rfc7636) -- [Google OAuth 문서](https://developers.google.com/identity/protocols/oauth2) -- [Kakao OAuth 문서](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api) -- [Naver OAuth 문서](https://developers.naver.com/docs/login/api/api.md) +- 본 서비스 `SecurityConfig`에서는 **CORS를 끈 상태**이며, 보통 **API Gateway에서 CORS**를 처리합니다. 로컬에서 브라우저로 직접 호출할 때는 게이트웨이·프록시 또는 허용 정책을 맞춥니다. +- 운영에서는 쿠키 `Secure` / `SameSite` 등을 프로필에 맞게 유지합니다. --- -**Maintained by**: GrowGrammers Team -**Last Updated**: 2025-10-16 +**Maintained by:** GrowGrammers Team +**Last updated:** 2026-04-08 diff --git "a/docs/api-\353\252\205\354\204\270\354\204\234.md" "b/docs/api-\353\252\205\354\204\270\354\204\234.md" new file mode 100644 index 0000000..a3c233e --- /dev/null +++ "b/docs/api-\353\252\205\354\204\270\354\204\234.md" @@ -0,0 +1,333 @@ +--- +layout: default +title: API 명세서 +permalink: /api-명세서/ +--- + +# Auth API 명세서 + +본 문서는 **auth-api** (`com.wq.auth`) REST API의 계약을 정리합니다. 구현 기준은 `src/main/kotlin` 컨트롤러 및 DTO입니다. + +> **위치:** 저장소 `docs/api-명세서.md`. GitHub Pages(`/docs`)에서도 동일 경로로 렌더링됩니다. + +## 목차 + +- [공통 규약](#공통-규약) +- [응답 래퍼 `CommonResponse`](#응답-래퍼-commonresponse) +- [인증·토큰 전달](#인증토큰-전달) +- [클라이언트 구분 `X-Client-Type`](#클라이언트-구분-x-client-type) +- [API 목록](#api-목록) +- [OpenAPI (Swagger)](#openapi-swagger) + +--- + +## 공통 규약 + +| 항목 | 값 | +|------|-----| +| 기본 경로 | `/api/v1` (일부 레거시·테스트용은 `/api/v1/members`, `/api/public` 등) | +| Content-Type | `application/json` (본문이 있는 요청) | +| 서버 포트 (기본) | `9000` (`application.yml`) | + +--- + +## 응답 래퍼 `CommonResponse` + +대부분의 JSON 응답은 아래 형태입니다. + +| 필드 | 타입 | 설명 | +|------|------|------| +| `success` | `boolean` | 성공 여부 | +| `code` | `string` | 성공 시 `"SUCCESS"`, 실패 시 오류 코드 문자열 | +| `message` | `string` | 사용자/클라이언트용 메시지 | +| `data` | `T \| null` | 페이로드 (없으면 `null`) | + +**성공 예시** + +```json +{ + "success": true, + "code": "SUCCESS", + "message": "로그인에 성공했습니다.", + "data": null +} +``` + +예외는 `GlobalExceptionHandler`에서 HTTP 상태와 함께 `CommonResponse` 형태로 반환됩니다. + +--- + +## 인증·토큰 전달 + +### JWT 추출 우선순위 (`JwtAuthenticationFilter`) + +1. **`accessToken` 쿠키**가 있으면 그 값만 사용 (이 경우 `Authorization` 헤더는 **무시**). +2. 쿠키가 없으면 **`Authorization: Bearer `**. + +### 소셜 로그인·이메일 로그인 성공 시 + +- **`Set-Cookie`**: `accessToken`, `refreshToken` (HttpOnly 등은 환경·`CookieFactory` 설정에 따름). + +### 인증이 필요한 API (`@AuthenticatedApi`) + +- 웹: 보통 `accessToken` 쿠키 + `credentials` 포함 요청. +- 앱: `Authorization: Bearer` 또는 정책에 맞는 방식(쿠키 미사용 시 헤더). + +--- + +## 클라이언트 구분 `X-Client-Type` + +일부 API는 **필수 헤더**입니다. + +| 값 | 의미 | +|----|------| +| `web` | 브라우저: 리프레시 토큰은 **쿠키**(`refreshToken`), 응답 `data`는 종종 생략 | +| `web` 이외 (예: `app`) | 네이티브 앱: 리프레시 토큰은 **요청 본문**으로 전달, 응답 `data`에 토큰 포함 | + +해당 헤더가 필요한 엔드포인트는 아래 표에 명시합니다. + +--- + +## API 목록 + +### 1. 소셜 로그인 (`SocialLoginController`) + +| Method | Path | 인증 | 설명 | +|--------|------|------|------| +| POST | `/api/v1/auth/social/login` | 불필요 | 범용 소셜 로그인 (`providerType`: GOOGLE / KAKAO / NAVER) | +| POST | `/api/v1/auth/google/login` | 불필요 | Google 로그인 | +| POST | `/api/v1/auth/kakao/login` | 불필요 | Kakao 로그인 | +| POST | `/api/v1/auth/naver/login` | 불필요 | Naver 로그인 | +| POST | `/api/v1/auth/link/google` | **필요** | Google 계정 연동 | +| POST | `/api/v1/auth/link/kakao` | **필요** | Kakao 계정 연동 | +| POST | `/api/v1/auth/link/naver` | **필요** | Naver 계정 연동 | + +**Rate limit (참고)** +로그인: 분당 10회(10분 윈도우) / 연동: 분당 5회(10분 윈도우) — 컨트롤러 `@RateLimit` 기준. + +#### 1.1 범용 소셜 로그인 `POST /api/v1/auth/social/login` + +**Body — `SocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | OAuth 인가 코드 | +| `codeVerifier` | DTO상 필수 문자열 | PKCE용 (Naver 등에서도 필드 존재) | +| `state` | 조건부 | **Naver** 시 인가 요청과 동일한 값 | +| `providerType` | 예 | `GOOGLE`, `KAKAO`, `NAVER` | +| `redirectUri` | 아니오 | 허용 목록에 있을 때만 사용, 없으면 서버 기본값 | + +#### 1.2 Google `POST /api/v1/auth/google/login` + +**Body — `GoogleSocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | 인가 코드 | +| `codeVerifier` | 예 | PKCE 코드 검증자 | +| `redirectUri` | 아니오 | 선택, 서버 기본값 대체 | + +#### 1.3 Kakao `POST /api/v1/auth/kakao/login` + +**Body — `KakaoSocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | 인가 코드 | +| `codeVerifier` | 예 | PKCE (권장) | +| `redirectUri` | 아니오 | 선택 | + +#### 1.4 Naver `POST /api/v1/auth/naver/login` + +**Body — `NaverSocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | 인가 코드 | +| `state` | 예 | CSRF용, 인가 요청 시 사용한 값과 동일 | +| `codeVerifier` | 예 | PKCE 코드 검증자 | +| `redirectUri` | 아니오 | 선택 | + +#### 1.5 소셜 계정 연동 (Google / Kakao / Naver) + +로그인된 사용자의 `accessToken`(쿠키 또는 정책에 맞는 인증) 필요. + +- **Google / Kakao**: `authCode`, `codeVerifier` 필수, `redirectUri` 선택. +- **Naver**: `authCode`, `state`, `codeVerifier` 필수, `redirectUri` 선택. + +**성공 시** +`CommonResponse` 메시지 문자열만 반환 (토큰 재발급 없음). + +--- + +### 2. 이메일 인증 (`AuthEmailController`) + +| Method | Path | 인증 | 설명 | +|--------|------|------|------| +| POST | `/api/v1/auth/email/request` | 불필요 | 인증 코드 이메일 발송 | +| POST | `/api/v1/auth/email/verify` | 불필요 | 인증 코드 검증 | + +**`POST .../request` Body — `EmailRequestDto`** + +| 필드 | 타입 | +|------|------| +| `email` | string | + +**`POST .../verify` Body — `EmailVerifyRequestDto`** + +| 필드 | 타입 | +|------|------| +| `email` | string | +| `verifyCode` | string | + +Rate limit: 요청 3회/10분, 검증 10회/5분 (컨트롤러 기준). + +--- + +### 3. 인증·세션 (`AuthController`) + +| Method | Path | 인증 | `X-Client-Type` | +|--------|------|------|-----------------| +| POST | `/api/v1/auth/members/email-login` | 불필요 | **필수** | +| POST | `/api/v1/auth/link/email-login` | **필요** | 불필요 | +| POST | `/api/v1/auth/members/logout` | 불필요 (리프레시 토큰으로 식별) | **필수** | +| POST | `/api/v1/auth/members/refresh` | 불필요 (리프레시 토큰) | **필수** | +| GET | `/api/v1/auth/introspect` | 불필요 (토큰으로 검증) | 선택 | + +#### 3.1 이메일 로그인/가입 `POST /api/v1/auth/members/email-login` + +**Headers** + +- `X-Client-Type`: `web` \| `app` (필수) + +**Body — `EmailLoginRequestDto`** + +| 필드 | 타입 | 설명 | +|------|------|------| +| `email` | string | | +| `verifyCode` | string | 이메일 인증 코드 | +| `deviceId` | string \| null | 선택 | + +**동작** + +- 성공 시 `Set-Cookie`로 `accessToken`, `refreshToken` 설정. +- `X-Client-Type: web` → `data`는 `null`. +- `app` → `data`에 `LoginResponseDto` (`refreshToken` 포함). + +#### 3.2 이메일 계정 연동 `POST /api/v1/auth/link/email-login` + +**Body — `EmailLoginLinkRequestDto`** + +| 필드 | 설명 | +|------|------| +| `email` | 이메일 | +| `verifyCode` | 6자리 숫자 | + +#### 3.3 로그아웃 `POST /api/v1/auth/members/logout` + +**Headers** + +- `X-Client-Type`: **필수** + +**Body — `LogoutRequestDto` (선택)** + +| 필드 | 설명 | +|------|------| +| `refreshToken` | `app`일 때 본문으로 전달. `web`은 `Cookie: refreshToken` | + +**동작** + +- `web`: 서버가 리프레시 삭제 후 `accessToken`/`refreshToken` 쿠키 만료 응답. + +#### 3.4 액세스 토큰 재발급 `POST /api/v1/auth/members/refresh` + +**Headers** + +- `X-Client-Type`: **필수** + +**Body — `RefreshAccessTokenRequestDto` (선택)** + +| 필드 | 설명 | +|------|------| +| `refreshToken` | `app`일 때 필수에 가깝게 사용 | +| `deviceId` | 선택 | + +**동작** + +- `web`: `refreshToken` 쿠키 사용. +- 성공 시 새 토큰 `Set-Cookie`. +- `web` → `data` null; `app` → `data`에 `RefreshAccessTokenResponseDto` (`refreshToken`). + +#### 3.5 토큰 introspect (Gateway 연동) `GET /api/v1/auth/introspect` + +**목적**: Access Token 검증 후 사용자 식별자를 헤더로 전달. + +**토큰 출처 (구현상 `JwtAuthenticationFilter`와 정합)** + +- 우선 `accessToken` 쿠키, 없으면 `Authorization: Bearer`. + +**동작 요약** + +- 유효한 AT로부터 사용자 UUID(`opaqueId`)를 구해 응답 헤더에 설정. +- AT 남은 시간이 5분 미만이거나 만료된 경우, `refreshToken` 쿠키로 **사일런트 리프레시** 시도 후 새 쿠키 `Set-Cookie`. +- 실패 시 401 및 쿠키 제거 가능. + +**성공 응답 헤더** + +| 헤더 | 설명 | +|------|------| +| `X-User-Id` | 사용자 UUID (opaqueId) | + +응답 본문은 컨트롤러에서 별도 JSON을 쓰지 않을 수 있음(상태 200 + 헤더 중심). + +--- + +### 4. 회원 (`MemberController`) + +| Method | Path | 인증 | 설명 | +|--------|------|------|------| +| GET | `/api/v1/auth/members/user-info` | **필요** | 로그인 사용자 정보 | +| GET | `/api/v1/members` | 설정상 인증 필요 | 전체 회원 목록 (운영 시 권한 검토 권장) | +| GET | `/api/v1/members/{id}` | 설정상 인증 필요 | 단건 조회 | +| POST | `/api/v1/members` | 설정상 인증 필요 | 회원 생성 | +| PUT | `/api/v1/members/{id}/nickname` | 설정상 인증 필요 | 닉네임 변경 (`{"nickname":"..."}`) | +| DELETE | `/api/v1/members/{id}` | 설정상 인증 필요 | 삭제 | + +#### 4.1 내 정보 `GET /api/v1/auth/members/user-info` + +**성공 시 `data` — `UserInfoResponseDto`** + +| 필드 | 타입 | +|------|------| +| `userId` | string (UUID, opaqueId) | +| `nickname` | string | +| `email` | string | +| `linkedProviders` | `ProviderType[]` (예: GOOGLE, KAKAO, NAVER, EMAIL) | + +--- + +### 5. 보안 테스트용 (`TestSecurityController`) + +개발·테스트용이며 운영에서는 제거·차단 검토. + +| Method | Path | 인증 | +|--------|------|------| +| GET | `/api/public/test` | 불필요 | +| GET | `/api/test` | 필요 (`@AuthenticatedApi`) | +| GET | `/api/public/token` | 불필요 (테스트용 JWT 발급, `opaqueId` 쿼리 가능) | + +--- + +## OpenAPI (Swagger) + +- API 문서 JSON: `/v3/api-docs` +- Swagger UI 경로: `springdoc.swagger-ui.path` — 환경변수 **`SWAGGER_PATH`** 로 설정 (`application.yml`). + +로컬 예시: `http://localhost:9000/swagger-ui/index.html` (설정에 따라 경로 변동 가능) + +--- + +## 참고 + +- 배포·환경 변수·EC2 Docker는 저장소 **README** 및 [`GITHUB-ENVIRONMENTS.md`](GITHUB-ENVIRONMENTS.md)를 참고하세요. +- API Gateway 연동 시 `GET /api/v1/auth/introspect`와 응답 헤더 `X-User-Id`를 활용할 수 있습니다. From 055300c99a09f518967eac56cec2e02fa197c7f6 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 11 May 2026 13:50:16 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20V2=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/api/v2/auth/*/login` 경로 및 컨트롤러(SocialLoginV2Controller) 추가 - X-Client-Id 헤더 기반 응답 분기를 통해 웹/앱 클라이언트 지원 - OAuth 제공자별 Google, Kakao, Naver 소셜 로그인 V2 API 구현 - SecurityConfig에서 V2 로그인 경로 추가 - SocialLoginResponseDto 클래스 추가를 통한 앱 응답 전용 DTO 제공 - AuthController에 AT/RT 처리 방식 개선 (웹/앱 구분 로직 추가) - deploy.yml 경로 설정에 문서 변경 무시 규칙 추가 --- .github/workflows/deploy.yml | 4 + .../api/controller/auth/AuthController.kt | 27 +- .../controller/auth/SocialLoginController.kt | 15 -- .../auth/SocialLoginV2Controller.kt | 238 ++++++++++++++++++ .../auth/response/SocialLoginResponseDto.kt | 12 + .../wq/auth/shared/config/SecurityConfig.kt | 3 +- 6 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index afab210..100d8d5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,6 +12,10 @@ on: branches: - main - dev + # 문서·README만 변경된 경우 이미지 빌드/EC2 배포 불필요 + paths-ignore: + - "README.md" + - "docs/**" # 수동 실행을 가능하게 하는 설정 workflow_dispatch: diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index caf0415..e1e3d73 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -262,7 +262,7 @@ class AuthController( @RequestBody req: RefreshAccessTokenRequestDto?, ): CommonResponse { - val currentRefreshToken : String? = if(clientType == "web") { + val currentRefreshToken: String? = if (clientType == "web") { refreshToken } else { req?.refreshToken @@ -344,32 +344,43 @@ class AuthController( throw JwtException(JwtExceptionCode.TOKEN_MISSING) } + // X-Client-Id가 "web"이면 웹, 그 외(easy-snap-app 등)면 앱 + val isApp = request.getHeader("X-Client-Id") != "web" + // AT가 만료(-1)되었거나 남은 시간이 5분(300초) 미만이면 사일런트 리프레시 시도 val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) val opaqueId: String = if (remainingSeconds < 300) { - val refreshToken = request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + val refreshToken = if (isApp) { + request.getHeader("X-Refresh-Token") + } else { + request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + } if (refreshToken.isNullOrBlank()) { - clearAuthCookies(response) + if (!isApp) clearAuthCookies(response) throw JwtException(JwtExceptionCode.TOKEN_MISSING) } try { - // 만료된 AT에서도 claims를 읽어 deviceId를 추출합니다. val claims = jwtProvider.getClaimsEvenIfExpired(token) val deviceId = claims["deviceId"] as? String val tokenResult = authService.refreshAccessToken(refreshToken, deviceId) - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) + if (isApp) { + response.setHeader("X-New-AT", tokenResult.accessToken) + response.setHeader("X-New-RT", tokenResult.refreshToken) + } else { + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) + } - log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds)" } + log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds, isApp=$isApp)" } jwtProvider.getOpaqueId(tokenResult.accessToken) } catch (e: Exception) { log.warn { "사일런트 리프레시 실패: ${e.message}" } - clearAuthCookies(response) + if (!isApp) clearAuthCookies(response) throw JwtException(JwtExceptionCode.EXPIRED) } } else { diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt index bb3ac5e..a8f8ab8 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt @@ -101,9 +101,7 @@ class SocialLoginController( response: HttpServletResponse ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("소셜 로그인이 완료되었습니다") } @@ -173,11 +171,8 @@ class SocialLoginController( @Valid @RequestBody request: GoogleSocialLoginRequestDto, response: HttpServletResponse ): CommonResponse { - val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("Google 로그인이 완료되었습니다") } @@ -245,11 +240,8 @@ class SocialLoginController( @Valid @RequestBody request: KakaoSocialLoginRequestDto, response: HttpServletResponse ): CommonResponse { - val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("카카오 로그인이 완료되었습니다") } @@ -320,9 +312,7 @@ class SocialLoginController( response: HttpServletResponse ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("Naver 로그인이 완료되었습니다") } @@ -550,10 +540,6 @@ class SocialLoginController( /** * AccessToken/RefreshToken을 HttpOnly 쿠키로 설정합니다. - * - * @param response HTTP 응답 객체 - * @param accessToken 액세스 토큰 - * @param refreshToken 리프레시 토큰 */ private fun setTokenCookies( response: HttpServletResponse, @@ -562,7 +548,6 @@ class SocialLoginController( ) { val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(refreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) } diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt new file mode 100644 index 0000000..2e9055d --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt @@ -0,0 +1,238 @@ +package com.wq.auth.api.controller.auth + +import com.wq.auth.api.controller.auth.request.GoogleSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.KakaoSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.NaverSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.SocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.toDomain +import com.wq.auth.api.controller.auth.response.SocialLoginResponseDto +import com.wq.auth.api.domain.auth.SocialLoginService +import com.wq.auth.api.domain.auth.response.SocialLoginResult +import com.wq.auth.security.annotation.PublicApi +import com.wq.auth.shared.config.CookieFactory +import com.wq.auth.shared.rateLimiter.annotation.RateLimit +import com.wq.auth.web.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +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.HttpServletResponse +import jakarta.validation.Valid +import org.springframework.http.HttpHeaders +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController +import java.util.concurrent.TimeUnit + +/** + * 소셜 로그인 V2 컨트롤러 + * + * X-Client-Id 헤더를 기반으로 웹/앱 응답을 분기합니다. + * - web (X-Client-Id: web): HttpOnly 쿠키로 토큰 반환 + * - app (X-Client-Id: easy-snap-app 등): JSON body에 accessToken / refreshToken 반환 + * + * 기존 V1(/api/v1/auth)은 유지되며, 신규 클라이언트는 이 V2를 사용하세요. + */ +@Tag(name = "소셜 로그인 V2", description = "X-Client-Id 기반 웹/앱 분기 소셜 로그인 API") +@RestController +class SocialLoginV2Controller( + private val socialLoginService: SocialLoginService, + private val cookieFactory: CookieFactory, +) { + + @Operation( + summary = "범용 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "소셜 제공자 API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @PublicApi("소셜 로그인 V2") + @PostMapping("/api/v2/auth/social/login") + fun socialLogin( + @Valid @RequestBody request: SocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "소셜 로그인이 완료되었습니다") + } + + @Operation( + summary = "Google 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + + **PKCE 필수:** + - codeVerifier는 Google 인증 요청 시 사용한 code_verifier와 동일해야 합니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Google 로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "Google 인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "Google API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("Google 소셜 로그인 V2") + @PostMapping("/api/v2/auth/google/login") + fun googleLogin( + @Valid @RequestBody request: GoogleSocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "Google 로그인이 완료되었습니다") + } + + @Operation( + summary = "카카오 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "카카오 로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "카카오 인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "카카오 API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("카카오 소셜 로그인 V2") + @PostMapping("/api/v2/auth/kakao/login") + fun kakaoLogin( + @Valid @RequestBody request: KakaoSocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "카카오 로그인이 완료되었습니다") + } + + @Operation( + summary = "Naver 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Naver 로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "Naver 인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "Naver API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("Naver 소셜 로그인 V2") + @PostMapping("/api/v2/auth/naver/login") + fun naverLogin( + @Valid @RequestBody request: NaverSocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "Naver 로그인이 완료되었습니다") + } + + /** + * X-Client-Id 값에 따라 토큰 반환 방식을 분기합니다. + * + * - "web": HttpOnly 쿠키 설정, body data null + * - 그 외 (앱 식별자): 쿠키 미설정, body에 accessToken / refreshToken 반환 + */ + private fun buildLoginResponse( + clientId: String, + loginResult: SocialLoginResult, + response: HttpServletResponse, + message: String, + ): CommonResponse { + return if (clientId == "web") { + val accessTokenCookie = cookieFactory.createAccessTokenCookie(loginResult.accessToken) + val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(loginResult.refreshToken) + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + CommonResponse.success(message = message, data = null) + } else { + CommonResponse.success( + message = message, + data = SocialLoginResponseDto( + accessToken = loginResult.accessToken, + refreshToken = loginResult.refreshToken, + ) + ) + } + } +} diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt new file mode 100644 index 0000000..d6256db --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt @@ -0,0 +1,12 @@ +package com.wq.auth.api.controller.auth.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "소셜 로그인 앱 응답 (X-Client-Id가 web이 아닌 경우 반환)") +data class SocialLoginResponseDto( + @get:Schema(description = "JWT 액세스 토큰") + val accessToken: String, + + @get:Schema(description = "JWT 리프레시 토큰") + val refreshToken: String, +) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index 5010091..a391023 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -57,7 +57,8 @@ class SecurityConfig( "/api/v1/auth/email/verify", // 이메일 인증 코드 검증 (로그인 전 호출) "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 "/api/public/**", // 공개 API - "/api/v1/auth/*/login", // 소셜 로그인 API + "/api/v1/auth/*/login", // 소셜 로그인 API (V1) + "/api/v2/auth/*/login", // 소셜 로그인 API (V2) "/api/v1/auth/members/logout", //로그아웃 "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI From 0e19ed3cd41e9af45ddc14e5a87931870ba4265b Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 11 May 2026 14:17:27 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20AT/RT=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=82=AC?= =?UTF-8?q?=EC=9D=BC=EB=9F=B0=ED=8A=B8=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AT/RT 처리 로직에서 사일런트 리프레시 분리 및 재사용성 강화 - 토큰 유효 여부, 만료 처리 방식 변경 및 관련 로직 최적화 - 웹/앱 클라이언트 구분에 따른 쿠키/헤더 처리 방식 개선 - 서비스 응답 헤더 설정 로직 리팩토링 --- .../api/controller/auth/AuthController.kt | 89 +++++++++++-------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index e1e3d73..bee524c 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -338,56 +338,69 @@ class AuthController( request: HttpServletRequest, response: HttpServletResponse, ) { - val token = JwtAuthenticationFilter.extractToken(request) - // 쿠키·헤더 모두 없거나, accessToken 쿠키가 빈 값인 경우 401 - if (token.isNullOrBlank()) { - throw JwtException(JwtExceptionCode.TOKEN_MISSING) - } - // X-Client-Id가 "web"이면 웹, 그 외(easy-snap-app 등)면 앱 val isApp = request.getHeader("X-Client-Id") != "web" - // AT가 만료(-1)되었거나 남은 시간이 5분(300초) 미만이면 사일런트 리프레시 시도 - val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) - val opaqueId: String = if (remainingSeconds < 300) { - val refreshToken = if (isApp) { - request.getHeader("X-Refresh-Token") - } else { - request.cookies?.firstOrNull { it.name == "refreshToken" }?.value - } + val token = JwtAuthenticationFilter.extractToken(request) - if (refreshToken.isNullOrBlank()) { - if (!isApp) clearAuthCookies(response) - throw JwtException(JwtExceptionCode.TOKEN_MISSING) + val opaqueId: String = if (token.isNullOrBlank()) { + // AT 없음 → RT로 silent refresh 시도 + silentRefresh(request, response, isApp, deviceId = null) + } else { + val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) + if (remainingSeconds < 300) { + // AT 만료 임박 또는 만료됨 → RT로 silent refresh 시도 + val deviceId = runCatching { jwtProvider.getClaimsEvenIfExpired(token)["deviceId"] as? String }.getOrNull() + silentRefresh(request, response, isApp, deviceId) + } else { + // AT 유효 + jwtProvider.getOpaqueId(token) } + } - try { - val claims = jwtProvider.getClaimsEvenIfExpired(token) - val deviceId = claims["deviceId"] as? String + response.setHeader("X-User-Id", opaqueId) + } - val tokenResult = authService.refreshAccessToken(refreshToken, deviceId) + /** + * RT로 AT/RT를 재발급하고 플랫폼에 맞게 응답에 설정합니다. + * - web: Set-Cookie + * - app: X-New-AT / X-New-RT 헤더 + */ + private fun silentRefresh( + request: HttpServletRequest, + response: HttpServletResponse, + isApp: Boolean, + deviceId: String?, + ): String { + val refreshToken = if (isApp) { + request.getHeader("X-Refresh-Token") + } else { + request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + } - if (isApp) { - response.setHeader("X-New-AT", tokenResult.accessToken) - response.setHeader("X-New-RT", tokenResult.refreshToken) - } else { - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) - } + if (refreshToken.isNullOrBlank()) { + if (!isApp) clearAuthCookies(response) + throw JwtException(JwtExceptionCode.TOKEN_MISSING) + } - log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds, isApp=$isApp)" } + return try { + val tokenResult = authService.refreshAccessToken(refreshToken, deviceId) - jwtProvider.getOpaqueId(tokenResult.accessToken) - } catch (e: Exception) { - log.warn { "사일런트 리프레시 실패: ${e.message}" } - if (!isApp) clearAuthCookies(response) - throw JwtException(JwtExceptionCode.EXPIRED) + if (isApp) { + response.setHeader("X-New-AT", tokenResult.accessToken) + response.setHeader("X-New-RT", tokenResult.refreshToken) + } else { + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) } - } else { - jwtProvider.getOpaqueId(token) - } - response.setHeader("X-User-Id", opaqueId) + log.debug { "사일런트 리프레시 성공 (isApp=$isApp)" } + jwtProvider.getOpaqueId(tokenResult.accessToken) + } catch (e: Exception) { + log.warn { "사일런트 리프레시 실패: ${e.message}" } + if (!isApp) clearAuthCookies(response) + throw JwtException(JwtExceptionCode.EXPIRED) + } } /** From 6515393366b1bdc9136d63266b2f502945f2f1c0 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 11 May 2026 17:24:30 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20Google=20=EC=95=B1=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Credential Manager에서 발급받은 ID Token 검증 로직 구현 - 자체 JWT 발급 및 Google ID Token 검증 처리 로직 추가 - `GoogleAppLoginService` 클래스 및 관련 엔티티, 예외 처리 로직 구성 - 로그 추가를 통해 인증 및 검증 흐름 추적 가능 --- .../api/domain/auth/GoogleAppLoginService.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt new file mode 100644 index 0000000..2f39548 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt @@ -0,0 +1,52 @@ +package com.wq.auth.api.domain.auth + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.auth.response.SocialLoginResult +import com.wq.auth.api.domain.oauth.OAuthUser +import com.wq.auth.api.domain.oauth.error.SocialLoginException +import com.wq.auth.api.domain.oauth.error.SocialLoginExceptionCode +import com.wq.auth.api.external.oauth.GoogleOAuthProperties +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +/** + * 안드로이드 앱 전용 Google ID Token 로그인 서비스 + * + * 앱에서 Google Credential Manager로 발급받은 ID Token을 검증하고, + * 자체 JWT(AT/RT)를 발급합니다. + */ +@Service +class GoogleAppLoginService( + private val googleOAuthProperties: GoogleOAuthProperties, + private val socialLoginMemberProcessor: SocialLoginMemberProcessor, +) { + private val log = KotlinLogging.logger {} + + fun login(idTokenString: String): SocialLoginResult { + log.info { "Google 앱 로그인 처리 시작" } + + val verifier = GoogleIdTokenVerifier.Builder(NetHttpTransport(), GsonFactory()) + .setAudience(listOf(googleOAuthProperties.clientId)) + .build() + + val idToken = verifier.verify(idTokenString) + ?: throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_INVALID_ID_TOKEN) + + val payload = idToken.payload + val oauthUser = OAuthUser( + providerId = payload.subject, + email = payload.email, + verifiedEmail = payload["email_verified"] as? Boolean ?: false, + name = payload["name"] as? String, + givenName = payload["given_name"] as? String, + providerType = ProviderType.GOOGLE, + ) + + log.info { "Google ID Token 검증 완료: ${oauthUser.email}" } + + return socialLoginMemberProcessor.processMemberAndIssueTokens(oauthUser, ProviderType.GOOGLE) + } +} From 6cfd4f39da90aa243c965c3869af566ef74b7acd Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 12 May 2026 17:51:05 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=EC=95=88=EB=93=9C=EB=A1=9C?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=95=B1=20=EC=A0=84=EC=9A=A9=20Google=20?= =?UTF-8?q?ID=20Token=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/GoogleAppLoginController.kt | 85 +++++++++++++++++++ .../auth/request/GoogleAppLoginRequestDto.kt | 14 +++ .../oauth/error/SocialLoginExceptionCode.kt | 1 + 3 files changed, 100 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt new file mode 100644 index 0000000..ae2a2e7 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt @@ -0,0 +1,85 @@ +package com.wq.auth.api.controller.auth + +import com.wq.auth.api.controller.auth.request.GoogleAppLoginRequestDto +import com.wq.auth.api.controller.auth.response.SocialLoginResponseDto +import com.wq.auth.api.domain.auth.GoogleAppLoginService +import com.wq.auth.security.annotation.PublicApi +import com.wq.auth.shared.rateLimiter.annotation.RateLimit +import com.wq.auth.web.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +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.validation.Valid +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController +import java.util.concurrent.TimeUnit + +/** + * 안드로이드 앱 전용 Google 로그인 컨트롤러 + * + * 웹의 인가 코드(Code) 방식과 달리, 앱에서 Google Credential Manager로 + * 발급받은 ID Token을 직접 검증하여 JWT(AT/RT)를 JSON Body로 반환합니다. + */ +@Tag(name = "Google 앱 로그인", description = "안드로이드 앱 전용 Google ID Token 로그인 API") +@RestController +class GoogleAppLoginController( + private val googleAppLoginService: GoogleAppLoginService, +) { + + @Operation( + summary = "Google 앱 로그인", + description = """ + 안드로이드 앱 전용 Google 로그인 엔드포인트입니다. + + 앱은 Google Credential Manager를 통해 발급받은 **ID Token**을 그대로 전달합니다. + 서버는 Google 라이브러리를 사용해 ID Token을 직접 검증하며, 구글 서버와의 추가 통신이 불필요합니다. + + **필수 헤더:** + - `X-Client-Id: easy-snap-and-app` + + **응답:** + - 인증 성공 시 자체 JWT(accessToken, refreshToken)를 JSON Body에 반환합니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", description = "로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "400", description = "idToken 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "401", description = "유효하지 않은 Google ID Token", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("Google 앱 로그인") + @PostMapping("/api/v1/auth/google/login/app") + fun loginWithApp( + @RequestHeader("X-Client-Id") clientId: String, + @Valid @RequestBody request: GoogleAppLoginRequestDto, + ): CommonResponse { + val loginResult = googleAppLoginService.login(request.idToken) + return CommonResponse.success( + message = "Google 로그인이 완료되었습니다", + data = SocialLoginResponseDto( + accessToken = loginResult.accessToken, + refreshToken = loginResult.refreshToken, + ) + ) + } +} diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt new file mode 100644 index 0000000..f5442b9 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt @@ -0,0 +1,14 @@ +package com.wq.auth.api.controller.auth.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +@Schema(description = "안드로이드 앱 전용 Google ID Token 로그인 요청 바디") +data class GoogleAppLoginRequestDto( + @field:NotBlank(message = "idToken은 필수입니다") + @field:Schema( + description = "Google Credential Manager에서 발급받은 ID Token", + example = "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..." + ) + val idToken: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt b/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt index bb487c9..8ca7e7e 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt @@ -17,6 +17,7 @@ enum class SocialLoginExceptionCode( GOOGLE_TOKEN_REQUEST_FAILED(400, "Google 액세스 토큰 요청이 실패했습니다"), GOOGLE_USER_INFO_REQUEST_FAILED(400, "Google 사용자 정보 조회가 실패했습니다"), GOOGLE_INVALID_ACCESS_TOKEN(401, "유효하지 않은 Google 액세스 토큰입니다"), + GOOGLE_INVALID_ID_TOKEN(401, "유효하지 않은 Google ID Token입니다"), GOOGLE_SERVER_ERROR(502, "Google 서버에서 일시적인 오류가 발생했습니다"), // 카카오 OAuth 관련 예외 From de4b6b9ac6dc030b76418fefccd9a5bc38b5884a Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 12 May 2026 19:41:58 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20Google=20=EC=95=B1=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20API=EC=97=90=EC=84=9C=20X-Client-Id=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google ID Token 검증 과정에서 불필요한 X-Client-Id 헤더 제거 - 관련 API 명세 문서에서 X-Client-Id 헤더 설명 삭제 - 컨트롤러 메서드 파라미터 수정 및 필요 없는 코드 정리 --- .../wq/auth/api/controller/auth/GoogleAppLoginController.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt index ae2a2e7..cc07a60 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RestController import java.util.concurrent.TimeUnit @@ -39,9 +38,6 @@ class GoogleAppLoginController( 앱은 Google Credential Manager를 통해 발급받은 **ID Token**을 그대로 전달합니다. 서버는 Google 라이브러리를 사용해 ID Token을 직접 검증하며, 구글 서버와의 추가 통신이 불필요합니다. - **필수 헤더:** - - `X-Client-Id: easy-snap-and-app` - **응답:** - 인증 성공 시 자체 JWT(accessToken, refreshToken)를 JSON Body에 반환합니다. """ @@ -70,7 +66,6 @@ class GoogleAppLoginController( @PublicApi("Google 앱 로그인") @PostMapping("/api/v1/auth/google/login/app") fun loginWithApp( - @RequestHeader("X-Client-Id") clientId: String, @Valid @RequestBody request: GoogleAppLoginRequestDto, ): CommonResponse { val loginResult = googleAppLoginService.login(request.idToken) From 82a0928edf0ac8824cf9a3d11538c197efeac46d Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 12 May 2026 19:52:52 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20=EC=95=88=EB=93=9C=EB=A1=9C?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=95=B1=20=EC=A0=84=EC=9A=A9=20Google=20?= =?UTF-8?q?ID=20Token=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/api/v1/auth/google/login/app` 경로 SecurityConfig에 추가 - Google ID Token 기반 로그인 지원 강화 --- src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index a391023..e52e637 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -58,6 +58,7 @@ class SecurityConfig( "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 "/api/public/**", // 공개 API "/api/v1/auth/*/login", // 소셜 로그인 API (V1) + "/api/v1/auth/google/login/app", // 안드로이드 앱 전용 Google ID Token 로그인 "/api/v2/auth/*/login", // 소셜 로그인 API (V2) "/api/v1/auth/members/logout", //로그아웃 "/actuator/health", // 헬스체크 From 482c9ca416ca670c5b72395b559fd1c05508a73e Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 14 May 2026 02:45:20 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20=EB=82=B4=EB=B6=80=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/internal-api/**` 경로 SecurityConfig에 허용 추가 (X-Internal-Secret으로 보호) - `application.yml`에 INTERNAL_API_SECRET 환경 변수 설정 추가 --- src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt | 1 + src/main/resources/application.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index e52e637..f59b124 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -51,6 +51,7 @@ class SecurityConfig( auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용 // 공개 엔드포인트 (인증 불필요) + .requestMatchers("/internal-api/**").permitAll() // 서비스 간 내부 통신 (X-Internal-Secret으로 보호) .requestMatchers( "/api/v1/auth/members/email-login", // 이메일 로그인 "/api/v1/auth/email/request", // 이메일 인증 코드 요청 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6cdb334..7ea7a30 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,6 +52,8 @@ app: default-zone: ${APP_DEFAULT_ZONE:Asia/Seoul} cookie: domain: ${APP_COOKIE_DOMAIN:localhost} + internal: + secret: ${INTERNAL_API_SECRET} project: logging: From 3494612cb6e77d14680ccc9916a735c7891a377c Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 14 May 2026 03:15:07 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=EB=82=B4=EB=B6=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/internal-api/v1/members/{userId}` 경로 및 컨트롤러 추가 - 요청 헤더(X-Internal-Secret)를 통한 인증 및 내부 API 접근 보호 - 회원 정보(userId, email, nickname) 응답 DTO 구성 - 서비스 호출 및 예외 처리 로직 구현 --- .../internal/InternalMemberController.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt new file mode 100644 index 0000000..3307ead --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt @@ -0,0 +1,40 @@ +package com.wq.auth.api.controller.internal + +import com.wq.auth.api.domain.member.MemberService +import com.wq.auth.web.common.response.CommonResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/internal-api/v1/members") +class InternalMemberController( + private val memberService: MemberService, + @Value("\${app.internal.secret}") private val internalSecret: String, +) { + + @GetMapping("/{userId}") + fun getUserInfo( + @PathVariable userId: String, + @RequestHeader("X-Internal-Secret") secret: String, + ): CommonResponse { + if (secret != internalSecret) { + throw SecurityException("내부 API 접근 권한이 없습니다.") + } + val userInfo = memberService.getUserInfo(userId) + return CommonResponse.success(data = UserInfoResponse( + userId = userInfo.userId, + email = userInfo.email, + nickname = userInfo.nickname, + )) + } + + data class UserInfoResponse( + val userId: String, + val email: String, + val nickname: String, + ) +} From d9ab72fa46c7a5f33640d0f747958a4ead00c05b Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 14 May 2026 09:25:56 +0900 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MemberService`에 SLF4J 로거 추가 및 주요 메서드에 디버그/워닝/에러 로그 추가 - `GlobalExceptionHandler`에서 예상하지 못한 예외 발생 시 스택 트레이스를 포함한 에러 로그 출력 - `InternalMemberController`에 로거 추가 및 요청/응답 과정 로그 기록 - 내부 API 인증 실패 시 워닝 로그 추가 및 검증 흐름 로그 개선 --- .../internal/InternalMemberController.kt | 8 ++++++++ .../wq/auth/api/domain/member/MemberService.kt | 16 +++++++++++++++- .../wq/auth/web/common/GlobalExceptionHandler.kt | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt index 3307ead..81311c3 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt @@ -2,6 +2,7 @@ package com.wq.auth.api.controller.internal import com.wq.auth.api.domain.member.MemberService import com.wq.auth.web.common.response.CommonResponse +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -16,15 +17,22 @@ class InternalMemberController( @Value("\${app.internal.secret}") private val internalSecret: String, ) { + companion object { + private val log = LoggerFactory.getLogger(InternalMemberController::class.java) + } + @GetMapping("/{userId}") fun getUserInfo( @PathVariable userId: String, @RequestHeader("X-Internal-Secret") secret: String, ): CommonResponse { + log.info("[internal-api] getUserInfo 요청 - userId={}", userId) if (secret != internalSecret) { + log.warn("[internal-api] 인증 실패 - X-Internal-Secret 불일치, userId={}", userId) throw SecurityException("내부 API 접근 권한이 없습니다.") } val userInfo = memberService.getUserInfo(userId) + log.info("[internal-api] getUserInfo 성공 - userId={}", userId) return CommonResponse.success(data = UserInfoResponse( userId = userInfo.userId, email = userInfo.email, diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index 42f13c9..fcda734 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -5,6 +5,7 @@ import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.domain.member.entity.MemberEntity import com.wq.auth.api.domain.member.error.MemberException import com.wq.auth.api.domain.member.error.MemberExceptionCode +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,6 +15,10 @@ class MemberService( private val authProviderRepository: AuthProviderRepository, ) { + companion object { + private val log = LoggerFactory.getLogger(MemberService::class.java) + } + data class UserInfoResult( val userId: String, val nickname: String, @@ -34,15 +39,24 @@ class MemberService( @Transactional(readOnly = true) fun getUserInfo(opaqueId: String): UserInfoResult { + log.debug("[MemberService] getUserInfo - opaqueId={}", opaqueId) + val member = memberRepository.findByOpaqueId(opaqueId) - .orElseThrow { MemberException(MemberExceptionCode.USER_INFO_RETRIEVE_FAILED)} + .orElseThrow { + log.warn("[MemberService] 회원 없음 - opaqueId={}", opaqueId) + MemberException(MemberExceptionCode.USER_INFO_RETRIEVE_FAILED) + } val authProviders = authProviderRepository.findByMember(member) if (authProviders.isEmpty()) { + log.warn("[MemberService] authProvider 없음 - opaqueId={}, memberId={}", opaqueId, member.id) throw MemberException(MemberExceptionCode.USER_INFO_RETRIEVE_FAILED) } val email = member.primaryEmail + if (email == null) { + log.error("[MemberService] primaryEmail이 null - opaqueId={}, memberId={}", opaqueId, member.id) + } val providers = authProviders.map { it.providerType } //TODO diff --git a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt index 914e448..e92e147 100644 --- a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt @@ -25,7 +25,7 @@ class GlobalExceptionHandler { if (status.is4xxClientError) { log.info("[{}] {} - {}", status.value(), e.code, e.message) } else { - log.error(e.extractExceptionLocation() + e.message) + log.error(e.extractExceptionLocation() + e.message, e) } val body = CommonResponse.fail(e.code) return ResponseEntity.status(status).body(body) @@ -42,7 +42,7 @@ class GlobalExceptionHandler { // 예상 못 한 예외 처리 @ExceptionHandler(Exception::class) fun handleUnexpected(e: Exception): ResponseEntity> { - log.error("[예상치 못한 예외 발생] $e") + log.error("[예상치 못한 예외 발생] ${e.message}", e) val status = HttpStatus.INTERNAL_SERVER_ERROR val body = CommonResponse.fail( CommonExceptionCode.INTERNAL_SERVER_ERROR From 7b40d9c01ae774d24c33382d38af38657d71434a Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 21 May 2026 19:44:52 +0900 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20X-Client-Type=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=9B=B9/=EC=95=B1=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EB=B6=84=20=EB=B0=A9=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 `X-Client-Id` 헤더 제거 및 `X-Client-Type` 헤더로 변경 - API Gateway를 통한 클라이언트 타입 기반 웹/앱 구분 로직 개선 --- .../kotlin/com/wq/auth/api/controller/auth/AuthController.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index bee524c..2862372 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -338,8 +338,8 @@ class AuthController( request: HttpServletRequest, response: HttpServletResponse, ) { - // X-Client-Id가 "web"이면 웹, 그 외(easy-snap-app 등)면 앱 - val isApp = request.getHeader("X-Client-Id") != "web" + // X-Client-Type이 "web"이면 웹, 그 외(app 등)면 앱 (API Gateway가 RT 출처 기반으로 주입) + val isApp = request.getHeader("X-Client-Type") != "web" val token = JwtAuthenticationFilter.extractToken(request) From 40831e9a8c7f8fe9758075b0d172cec3d6f480a8 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 9 Jul 2026 15:26:00 +0900 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EB=B2=88=ED=98=B8=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20?= =?UTF-8?q?user-info=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 네이버 소셜로그인 시 전화번호 저장 및 user-info 응답 추가 네이버 UserInfo 응답의 mobile 값을 하이픈 제거 형태로 정규화하여 회원 전화번호로 저장하고, user-info 응답(공개/내부)에 내려준다. - OAuthUser에 phoneNumber 필드 추가 (기본값 null로 타 provider 영향 없음) - NaverUserInfoResponse.getNormalizedMobile()로 "010-1234-5678" -> "01012345678" 정규화 - NaverOAuthClient가 mobile을 OAuthUser.phoneNumber로 매핑 - MemberEntity.phoneNumber를 var로 변경, createSocialMember 파라미터 및 updatePhoneNumber() 추가 - SocialLoginMemberProcessor: 신규 회원 저장 및 재로그인 시 최신 값으로 갱신 - MemberService.UserInfoResult, UserInfoResponseDto, 내부 UserInfoResponse에 phoneNumber 추가 또한 InternalMemberController의 클래스 레벨 @RequestMapping prefix를 제거하고 메서드에 전체 경로(/internal-api/v1/members/{userId})를 명시하도록 정리한다. * test: 전화번호 기능 단위 테스트 추가 및 기존 단위 테스트 API 정합성 수정 - NaverUserInfoResponseTest: mobile 정규화 검증 (신규) - SocialLoginMemberProcessorTest: 신규 저장/재로그인 갱신/mobile 없을 때 유지 검증 (신규) - MemberServiceTest, MemberEntityTest: phoneNumber 관련 검증 추가 - JwtProviderTest, AuthServiceTest, JwtPropertiesBindingTest: 현재 시그니처 (Role 제거, memberStatsService 추가, createAccessToken extraClaims)에 맞게 수정 integration 테스트는 별도 수정이 필요해 이 커밋에서 제외한다. --- .../internal/InternalMemberController.kt | 6 +- .../api/controller/member/MemberController.kt | 1 + .../member/response/UserInfoResponseDto.kt | 1 + .../domain/auth/SocialLoginMemberProcessor.kt | 19 ++- .../auth/api/domain/member/MemberService.kt | 2 + .../api/domain/member/entity/MemberEntity.kt | 13 +- .../com/wq/auth/api/domain/oauth/OAuthUser.kt | 1 + .../api/external/oauth/NaverOAuthClient.kt | 1 + .../oauth/dto/NaverUserInfoResponse.kt | 8 ++ .../com/wq/auth/unit/AuthServiceTest.kt | 44 ++++--- .../wq/auth/unit/JwtPropertiesBindingTest.kt | 39 +++--- .../com/wq/auth/unit/JwtProviderTest.kt | 22 ++-- .../com/wq/auth/unit/MemberEntityTest.kt | 35 ++++-- .../com/wq/auth/unit/MemberServiceTest.kt | 3 + .../wq/auth/unit/NaverUserInfoResponseTest.kt | 30 +++++ .../unit/SocialLoginMemberProcessorTest.kt | 118 ++++++++++++++++++ 16 files changed, 279 insertions(+), 64 deletions(-) create mode 100644 src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt create mode 100644 src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt index 81311c3..a7ea7c3 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt @@ -7,11 +7,9 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestHeader -import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/internal-api/v1/members") class InternalMemberController( private val memberService: MemberService, @Value("\${app.internal.secret}") private val internalSecret: String, @@ -21,7 +19,7 @@ class InternalMemberController( private val log = LoggerFactory.getLogger(InternalMemberController::class.java) } - @GetMapping("/{userId}") + @GetMapping("/internal-api/v1/members/{userId}") fun getUserInfo( @PathVariable userId: String, @RequestHeader("X-Internal-Secret") secret: String, @@ -37,6 +35,7 @@ class InternalMemberController( userId = userInfo.userId, email = userInfo.email, nickname = userInfo.nickname, + phoneNumber = userInfo.phoneNumber, )) } @@ -44,5 +43,6 @@ class InternalMemberController( val userId: String, val email: String, val nickname: String, + val phoneNumber: String?, ) } diff --git a/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt index 90535de..d01baf6 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt @@ -55,6 +55,7 @@ class MemberController( userId = result.userId, nickname = result.nickname, email = result.email, + phoneNumber = result.phoneNumber, linkedProviders = result.providers ) return CommonResponse.success(message = "회원 정보 조회 성공", data = resp) diff --git a/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt b/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt index 4f4f8f9..1bd01cc 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/member/response/UserInfoResponseDto.kt @@ -7,5 +7,6 @@ data class UserInfoResponseDto( val userId: String, val nickname: String, val email: String, + val phoneNumber: String?, val linkedProviders: List ) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt index 6708123..7db10d4 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt @@ -56,13 +56,16 @@ class SocialLoginMemberProcessor( providerType )?.let { existingAuthProvider -> log.info { "기존 회원 발견: ${existingAuthProvider.member.opaqueId}" } - Pair(existingAuthProvider.member, false) + val existingMember = existingAuthProvider.member + updatePhoneNumberIfChanged(existingMember, oauthUser) + Pair(existingMember, false) } ?: run { log.info { "신규 회원 생성: ${oauthUser.email}" } val newMember = MemberEntity.createSocialMember( nickname = oauthUser.getNickname(), isEmailVerified = oauthUser.verifiedEmail, - primaryEmail = oauthUser.email + primaryEmail = oauthUser.email, + phoneNumber = oauthUser.phoneNumber ) val savedMember = memberRepository.save(newMember) log.info { "신규 회원 생성 완료: ${savedMember.opaqueId}" } @@ -70,6 +73,18 @@ class SocialLoginMemberProcessor( } } + /** + * 소셜 제공자가 전달한 전화번호가 있으면 항상 최신 값으로 갱신합니다. + */ + private fun updatePhoneNumberIfChanged(member: MemberEntity, oauthUser: OAuthUser) { + val phoneNumber = oauthUser.phoneNumber + if (!phoneNumber.isNullOrBlank() && member.phoneNumber != phoneNumber) { + member.updatePhoneNumber(phoneNumber) + memberRepository.save(member) + log.info { "회원 전화번호 갱신 완료: ${member.opaqueId}" } + } + } + private fun createOrUpdateAuthProvider( member: MemberEntity, oauthUser: OAuthUser, diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index fcda734..8706141 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -23,6 +23,7 @@ class MemberService( val userId: String, val nickname: String, val email: String, + val phoneNumber: String?, val providers: List, ) @@ -65,6 +66,7 @@ class MemberService( userId = member.opaqueId, nickname = member.nickname, email = email!!, + phoneNumber = member.phoneNumber, providers = providers ) } diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt index 25477c9..1454c53 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt @@ -21,7 +21,7 @@ open class MemberEntity protected constructor( val primaryEmail: String? = null, @Column(name = "phone_number", length = 20, nullable = true) - val phoneNumber: String? = null, + var phoneNumber: String? = null, @Column(name = "opaque_id", nullable = false, unique = true, length = 36) val opaqueId: String, @@ -69,6 +69,7 @@ open class MemberEntity protected constructor( nickname: String, isEmailVerified: Boolean = true, primaryEmail: String, + phoneNumber: String? = null, ): MemberEntity { require(nickname.isNotBlank()) { "닉네임은 필수입니다" } require(nickname.length <= 100) { "닉네임은 100자를 초과할 수 없습니다" } @@ -77,7 +78,8 @@ open class MemberEntity protected constructor( opaqueId = UuidCreator.getTimeOrdered().toString(), nickname = nickname.trim(), isEmailVerified = isEmailVerified, - primaryEmail = primaryEmail + primaryEmail = primaryEmail, + phoneNumber = phoneNumber ) } } @@ -89,6 +91,13 @@ open class MemberEntity protected constructor( this.isEmailVerified = true } + /** + * 전화번호 업데이트 + */ + fun updatePhoneNumber(phoneNumber: String) { + this.phoneNumber = phoneNumber + } + /** * 최근 로그인 시간 업데이트 */ diff --git a/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt b/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt index 472b72a..5abb1b2 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/oauth/OAuthUser.kt @@ -14,6 +14,7 @@ data class OAuthUser( val verifiedEmail: Boolean, val name: String?, val givenName: String? = null, + val phoneNumber: String? = null, val providerType: ProviderType ) { /** diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt index 29b4b5c..797a2e6 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/NaverOAuthClient.kt @@ -160,6 +160,7 @@ class NaverOAuthClient( verifiedEmail = naverUserInfo.response.email != null, name = naverUserInfo.response.name, givenName = naverUserInfo.response.nickname, // 네이버는 givenName이 없으므로 nickname 사용 + phoneNumber = naverUserInfo.response.getNormalizedMobile(), providerType = ProviderType.NAVER ) } diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt index 2a329e4..5d4cbc2 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt @@ -70,4 +70,12 @@ data class NaverUserInfo( * Naver 제공자 ID를 반환합니다. */ fun getProviderId(): String = id + + /** + * mobile 값을 숫자만 남긴 형태로 정규화하여 반환합니다. (예: "010-1234-5678" -> "01012345678") + */ + fun getNormalizedMobile(): String? { + val digits = mobile?.filter { it.isDigit() } + return if (digits.isNullOrBlank()) null else digits + } } \ No newline at end of file diff --git a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt index 5307004..c8d9582 100644 --- a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt @@ -8,6 +8,7 @@ import com.wq.auth.api.domain.auth.AuthProviderRepository import com.wq.auth.api.domain.auth.AuthService import com.wq.auth.api.domain.auth.MemberConnector import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberStatsService import com.wq.auth.api.domain.auth.RefreshTokenRepository import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity import com.wq.auth.api.domain.auth.error.AuthException @@ -35,6 +36,7 @@ class AuthServiceTest : DescribeSpec({ lateinit var jwtProvider: JwtProvider lateinit var nicknameGenerator: NicknameGenerator lateinit var memberConnector: MemberConnector + lateinit var memberStatsService: MemberStatsService beforeEach { authProviderRepository = mock() @@ -44,6 +46,7 @@ class AuthServiceTest : DescribeSpec({ jwtProvider = mock() nicknameGenerator = mock() memberConnector = mock() + memberStatsService = mock() authService = AuthService( authEmailService = authEmailService, @@ -53,6 +56,7 @@ class AuthServiceTest : DescribeSpec({ jwtProvider = jwtProvider, nicknameGenerator = nicknameGenerator, memberConnector = memberConnector, + memberStatsService = memberStatsService, ) } @@ -79,7 +83,7 @@ class AuthServiceTest : DescribeSpec({ whenever(mockAuthProvider.member).thenReturn(mockMember) whenever(authProviderRepository.findByEmailAndProviderType(email,ProviderType.EMAIL)).thenReturn(mockAuthProvider) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -94,7 +98,7 @@ class AuthServiceTest : DescribeSpec({ result.refreshToken shouldBe refreshToken verify(authProviderRepository).findByEmailAndProviderType(email, ProviderType.EMAIL) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) verify(refreshTokenRepository, times(1)).save(any()) } @@ -119,7 +123,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) whenever(jwtProvider.getRefreshTokenExpiredAt(refreshToken)).thenReturn(futureTime) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(newAccessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(newRefreshToken) whenever(jwtProvider.getJti(newRefreshToken)).thenReturn(newJti) @@ -137,7 +141,7 @@ class AuthServiceTest : DescribeSpec({ verify(jwtProvider, times(1)).getJti(refreshToken) verify(jwtProvider, times(1)).getOpaqueId(refreshToken) verify(refreshTokenRepository, times(1)).findActiveByOpaqueIdAndJti(opaqueId, jti) - verify(jwtProvider, times(1)).createAccessToken(any(), any(), any()) + verify(jwtProvider, times(1)).createAccessToken(any(), any()) verify(jwtProvider, times(1)).createRefreshToken(any(), any()) verify(refreshTokenRepository, times(1)).softDeleteByOpaqueIdAndJti(any(), any(), any()) verify(refreshTokenRepository, times(1)).save(any()) @@ -215,7 +219,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("new-access-token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("new-access-token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("new-refresh-token") whenever(jwtProvider.getJti("new-refresh-token")).thenReturn("new-jti") @@ -243,7 +247,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("new-access-token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("new-access-token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("new-refresh-token") whenever(jwtProvider.getJti("new-refresh-token")).thenReturn("new-jti") @@ -274,7 +278,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(newAccessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(newRefreshToken) whenever(jwtProvider.getJti(newRefreshToken)).thenReturn(newJti) @@ -314,7 +318,7 @@ class AuthServiceTest : DescribeSpec({ whenever(mockAuthProvider.member).thenReturn(mockMember) whenever(authProviderRepository.findByEmailAndProviderType(email,ProviderType.EMAIL)).thenReturn(mockAuthProvider) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -330,7 +334,7 @@ class AuthServiceTest : DescribeSpec({ verify(authProviderRepository).findByEmailAndProviderType(email, ProviderType.EMAIL) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) verify(refreshTokenRepository).save(any()) } @@ -353,7 +357,7 @@ class AuthServiceTest : DescribeSpec({ whenever(authProviderRepository.findByEmailAndProviderType(email,ProviderType.EMAIL)).thenReturn(mockAuthProvider) whenever(refreshTokenRepository.findActiveByMemberAndDeviceId(mockMember, deviceId)).thenReturn(existingRefreshToken) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("access-token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("access-token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh-token") whenever(jwtProvider.getJti("refresh-token")).thenReturn("jti") @@ -388,7 +392,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -433,7 +437,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -452,7 +456,7 @@ class AuthServiceTest : DescribeSpec({ verify(memberRepository).existsByNickname(nickname) verify(memberRepository).save(any()) verify(authProviderRepository).save(any()) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) } @@ -478,7 +482,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) @@ -497,7 +501,7 @@ class AuthServiceTest : DescribeSpec({ verify(memberRepository).existsByNickname(nickname) verify(memberRepository).save(any()) verify(authProviderRepository).save(any()) - verify(jwtProvider).createAccessToken(any(), any(), any()) + verify(jwtProvider).createAccessToken(any(), any()) verify(jwtProvider).createRefreshToken(any(), any()) } @@ -526,7 +530,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(uniqueNickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(accessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(accessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(refreshToken) whenever(jwtProvider.getJti(refreshToken)).thenReturn("jti") @@ -564,7 +568,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(uniqueNickname)).thenReturn(false) whenever(memberRepository.save(any())).thenReturn(mockMember) whenever(authProviderRepository.save(any())).thenReturn(mock()) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh") whenever(jwtProvider.getJti("refresh")).thenReturn("jti") @@ -614,7 +618,7 @@ class AuthServiceTest : DescribeSpec({ whenever(memberRepository.existsByNickname(nickname)).thenReturn(false) whenever(memberRepository.save(memberCaptor.capture())).thenAnswer { mockMember } whenever(authProviderRepository.save(providerCaptor.capture())).thenAnswer { it.arguments[0] as AuthProviderEntity } - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn("token") + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("token") whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh") whenever(jwtProvider.getJti("refresh")).thenReturn("jti") @@ -717,7 +721,7 @@ class AuthServiceTest : DescribeSpec({ whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(refreshTokenEntity) - whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn(newAccessToken) whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn(newRefreshToken) whenever(jwtProvider.getJti(newRefreshToken)).thenReturn(newJti) @@ -736,7 +740,7 @@ class AuthServiceTest : DescribeSpec({ verify(jwtProvider, times(1)).getJti(refreshToken) verify(jwtProvider, times(1)).getOpaqueId(refreshToken) verify(refreshTokenRepository, times(1)).findActiveByOpaqueIdAndJti(opaqueId, jti) - verify(jwtProvider, times(1)).createAccessToken(any(), any(), any()) + verify(jwtProvider, times(1)).createAccessToken(any(), any()) verify(jwtProvider, times(1)).createRefreshToken(any(), any()) verify(refreshTokenRepository, times(1)).softDeleteByOpaqueIdAndJti(any(), any(), any()) verify(refreshTokenRepository, times(1)).save(any()) diff --git a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt index d67c12f..7f9a4e0 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt @@ -1,16 +1,30 @@ package com.wq.auth.unit import com.wq.auth.security.jwt.JwtProperties -import io.kotest.core.spec.style.FunSpec -import io.kotest.extensions.spring.SpringExtension -import io.kotest.matchers.shouldBe +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.TestPropertySource import java.time.Duration -@SpringBootTest +/** + * JwtProperties 바인딩 테스트 + * Kotest-extensions-spring 이 Kotest 6.x 를 지원하지 않으므로 + * JUnit 5 기반 Spring 테스트로 작성합니다. + */ +@SpringBootTest( + properties = [ + "spring.datasource.url=jdbc:h2:mem:jwt-props-test;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "INTERNAL_API_SECRET=test-internal-secret" + ] +) @ConfigurationPropertiesScan @TestPropertySource(properties = [ // 32바이트(256bit) Base64 시크릿 예시 @@ -18,18 +32,15 @@ import java.time.Duration "jwt.access-exp=15m", "jwt.refresh-exp=14d" ]) -class JwtPropertiesBindingTest : FunSpec() { - - override fun extensions() = listOf(SpringExtension) +class JwtPropertiesBindingTest { @Autowired lateinit var props: JwtProperties - init { - test("JwtProperties 가 yml 값으로 정상 바인딩된다") { - props.secret shouldBe "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE===" - props.accessExp shouldBe Duration.ofMinutes(15) - props.refreshExp shouldBe Duration.ofDays(14) - } + @Test + fun `JwtProperties 가 yml 값으로 정상 바인딩된다`() { + assertThat(props.secret).isEqualTo("MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE===") + assertThat(props.accessExp).isEqualTo(Duration.ofMinutes(15)) + assertThat(props.refreshExp).isEqualTo(Duration.ofDays(14)) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt index 478d369..ab0fc59 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt @@ -1,6 +1,5 @@ package com.wq.auth.unit -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.security.jwt.JwtProperties import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException @@ -33,15 +32,14 @@ class JwtProviderTest : StringSpec({ "간소화된 AccessToken을 발급하면 opaqueId 파싱이 정상 동작한다" { val opaqueId = "550e8400-e29b-41d4-a716-446655440000" - val token = provider.createAccessToken(opaqueId, Role.MEMBER) + val token = provider.createAccessToken(opaqueId) provider.getOpaqueId(token) shouldBe opaqueId - provider.getRole(token) shouldBe Role.MEMBER } - "간소화된 AccessToken에 role claim이 실제로 들어간다" { + "AccessToken에 extraClaims가 실제로 들어간다" { val opaqueId = "550e8400-e29b-41d4-a716-446655440000" - val token = provider.createAccessToken(opaqueId, Role.ADMIN) - + val token = provider.createAccessToken(opaqueId, mapOf("role" to "ADMIN")) + val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(props.secret)) val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload claims["role"] shouldBe "ADMIN" @@ -54,7 +52,7 @@ class JwtProviderTest : StringSpec({ val providerWithAnotherKey = JwtProvider( JwtProperties(secret = keyB, accessExp = Duration.ofMinutes(5), refreshExp = Duration.ofDays(14)) ) - val tokenSignedByB = providerWithAnotherKey.createAccessToken("550e8400-e29b-41d4-a716-446655440000", Role.MEMBER) + val tokenSignedByB = providerWithAnotherKey.createAccessToken("550e8400-e29b-41d4-a716-446655440000") val ex = shouldThrow { provider.validateOrThrow(tokenSignedByB) @@ -71,7 +69,7 @@ class JwtProviderTest : StringSpec({ ) val shortExpProvider = JwtProvider(shortExpProps) - val token = shortExpProvider.createAccessToken("550e8400-e29b-41d4-a716-446655440000", Role.MEMBER) + val token = shortExpProvider.createAccessToken("550e8400-e29b-41d4-a716-446655440000") Thread.sleep(200) // 100ms 대기 val ex = shouldThrow { shortExpProvider.validateOrThrow(token) } ex.jwtCode shouldBe JwtExceptionCode.EXPIRED @@ -117,8 +115,8 @@ class JwtProviderTest : StringSpec({ "토큰 생성 시 올바른 구조와 클레임이 포함된다" { val opaqueId = "550e8400-e29b-41d4-a716-446655440000" - val role = Role.ADMIN - val token = provider.createAccessToken(opaqueId, role) + val roleName = "ADMIN" + val token = provider.createAccessToken(opaqueId, mapOf("role" to roleName)) // 토큰 구조 검증 (3개 세그먼트) val segments = token.split(".") @@ -129,13 +127,13 @@ class JwtProviderTest : StringSpec({ val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload claims.subject shouldBe opaqueId - claims["role"] shouldBe role.name + claims["role"] shouldBe roleName claims.issuedAt shouldNotBe null claims.expiration shouldNotBe null } "토큰 유효성 검증이 정상 동작한다" { - val validToken = provider.createAccessToken("test-user", Role.MEMBER) + val validToken = provider.createAccessToken("test-user") // 예외 없이 통과해야 함 provider.validateOrThrow(validToken) diff --git a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt index 22332fd..33fea5a 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt @@ -1,7 +1,6 @@ package com.wq.auth.unit import com.wq.auth.api.domain.member.entity.MemberEntity -import com.wq.auth.api.domain.member.entity.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -13,13 +12,11 @@ class MemberEntityTest : StringSpec({ "MemberEntity.create()를 통해 정상적으로 생성된다" { // Given & When val member = MemberEntity.create( - nickname = "테스트사용자", - role = Role.MEMBER + nickname = "테스트사용자" ) // Then member.nickname shouldBe "테스트사용자" - member.role shouldBe Role.MEMBER member.opaqueId shouldNotBe null UUID.fromString(member.opaqueId) // UUID 형식 검증 member.isEmailVerified shouldBe false @@ -62,17 +59,33 @@ class MemberEntityTest : StringSpec({ member.isEmailVerified shouldBe true } - "관리자 권한 확인이 정상 작동한다" { + "전화번호 업데이트가 정상 작동한다" { // Given - val adminMember = MemberEntity.create(nickname = "관리자", role = Role.ADMIN) - val regularMember = MemberEntity.create(nickname = "일반사용자", role = Role.MEMBER) + val member = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com", + phoneNumber = "01011112222" + ) - // When & Then - adminMember.isAdmin() shouldBe true - regularMember.isAdmin() shouldBe false + // When + member.updatePhoneNumber("01012345678") + + // Then + member.phoneNumber shouldBe "01012345678" + } + + "createSocialMember는 phoneNumber 없이도 생성된다" { + // Given & When + val member = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com" + ) + + // Then + member.phoneNumber shouldBe null } - /* + /* // 다음 코드는 컴파일 에러가 발생해야 함 (protected constructor) "외부에서 직접 생성자 호출 시도" { // 이 코드는 컴파일되지 않아야 함 diff --git a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt index ff7b814..def2e59 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt @@ -33,6 +33,7 @@ class MemberServiceTest : DescribeSpec({ val opaqueId = "validOpaqueId" val nickname = "testUser" val email = "test@email.com" + val phoneNumber = "01012345678" val mockMember = mock() val mockAuthProvider = mock() @@ -42,6 +43,7 @@ class MemberServiceTest : DescribeSpec({ whenever(mockAuthProvider.member).thenReturn(mockMember) whenever(mockAuthProvider.providerType).thenReturn(ProviderType.EMAIL) whenever(mockMember.primaryEmail).thenReturn(email) + whenever(mockMember.phoneNumber).thenReturn(phoneNumber) whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(mockMember)) whenever(authProviderRepository.findByMember(mockMember)).thenReturn(listOf(mockAuthProvider)) @@ -53,6 +55,7 @@ class MemberServiceTest : DescribeSpec({ result.userId shouldBe opaqueId result.nickname shouldBe nickname result.email shouldBe email + result.phoneNumber shouldBe phoneNumber verify(memberRepository).findByOpaqueId(opaqueId) verify(authProviderRepository).findByMember(mockMember) diff --git a/src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt b/src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt new file mode 100644 index 0000000..22c1604 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/NaverUserInfoResponseTest.kt @@ -0,0 +1,30 @@ +package com.wq.auth.unit + +import com.wq.auth.api.external.oauth.dto.NaverUserInfo +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class NaverUserInfoResponseTest : StringSpec({ + + fun naverUserInfo(mobile: String?) = NaverUserInfo( + id = "naver-id", + email = "test@naver.com", + mobile = mobile + ) + + "mobile의 하이픈이 제거되어 정규화된다" { + naverUserInfo("010-1234-5678").getNormalizedMobile() shouldBe "01012345678" + } + + "mobile이 null이면 null을 반환한다" { + naverUserInfo(null).getNormalizedMobile() shouldBe null + } + + "mobile이 빈 문자열이면 null을 반환한다" { + naverUserInfo("").getNormalizedMobile() shouldBe null + } + + "국가번호가 포함된 mobile도 숫자만 남는다" { + naverUserInfo("+82 10-1234-5678").getNormalizedMobile() shouldBe "821012345678" + } +}) diff --git a/src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt b/src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt new file mode 100644 index 0000000..f7db964 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/SocialLoginMemberProcessorTest.kt @@ -0,0 +1,118 @@ +package com.wq.auth.unit + +import com.wq.auth.api.domain.auth.AuthProviderRepository +import com.wq.auth.api.domain.auth.RefreshTokenRepository +import com.wq.auth.api.domain.auth.SocialLoginMemberProcessor +import com.wq.auth.api.domain.auth.entity.AuthProviderEntity +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberStatsService +import com.wq.auth.api.domain.member.entity.MemberEntity +import com.wq.auth.api.domain.oauth.OAuthUser +import com.wq.auth.security.jwt.JwtProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.mockito.kotlin.* + +class SocialLoginMemberProcessorTest : DescribeSpec({ + + lateinit var authProviderRepository: AuthProviderRepository + lateinit var memberRepository: MemberRepository + lateinit var jwtProvider: JwtProvider + lateinit var refreshTokenRepository: RefreshTokenRepository + lateinit var memberStatsService: MemberStatsService + lateinit var processor: SocialLoginMemberProcessor + + fun naverOAuthUser(phoneNumber: String?) = OAuthUser( + providerId = "naver-provider-id", + email = "test@naver.com", + verifiedEmail = true, + name = "테스트", + givenName = null, + phoneNumber = phoneNumber, + providerType = ProviderType.NAVER + ) + + beforeEach { + authProviderRepository = mock() + memberRepository = mock() + jwtProvider = mock() + refreshTokenRepository = mock() + memberStatsService = mock() + processor = SocialLoginMemberProcessor( + authProviderRepository, + memberRepository, + jwtProvider, + refreshTokenRepository, + memberStatsService, + ) + + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("access-token") + whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("refresh-token") + whenever(jwtProvider.getJti(any())).thenReturn("jti") + whenever(jwtProvider.getOpaqueId(any())).thenReturn("opaque-id") + whenever(memberRepository.save(any())).thenAnswer { it.arguments[0] } + } + + describe("전화번호 저장 및 갱신") { + + it("신규 회원 가입 시 phoneNumber가 저장된다") { + // given + whenever(authProviderRepository.findByProviderIdAndProviderType(any(), any())).thenReturn(null) + whenever(authProviderRepository.findByMemberAndProviderType(any(), any())).thenReturn(null) + whenever(authProviderRepository.save(any())).thenAnswer { it.arguments[0] } + + // when + processor.processMemberAndIssueTokens(naverOAuthUser("01012345678"), ProviderType.NAVER) + + // then + val captor = argumentCaptor() + verify(memberRepository).save(captor.capture()) + captor.firstValue.phoneNumber shouldBe "01012345678" + } + + it("기존 회원 재로그인 시 phoneNumber가 항상 최신 값으로 갱신된다") { + // given + val existingMember = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com", + phoneNumber = "01011112222" + ) + val existingAuthProvider = mock() + whenever(existingAuthProvider.member).thenReturn(existingMember) + whenever(authProviderRepository.findByProviderIdAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + whenever(authProviderRepository.findByMemberAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + + // when + processor.processMemberAndIssueTokens(naverOAuthUser("01099998888"), ProviderType.NAVER) + + // then + existingMember.phoneNumber shouldBe "01099998888" + verify(memberRepository).save(existingMember) + } + + it("소셜 응답에 phoneNumber가 없으면 기존 값이 유지된다") { + // given + val existingMember = MemberEntity.createSocialMember( + nickname = "테스트", + primaryEmail = "test@naver.com", + phoneNumber = "01011112222" + ) + val existingAuthProvider = mock() + whenever(existingAuthProvider.member).thenReturn(existingMember) + whenever(authProviderRepository.findByProviderIdAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + whenever(authProviderRepository.findByMemberAndProviderType(any(), any())) + .thenReturn(existingAuthProvider) + + // when + processor.processMemberAndIssueTokens(naverOAuthUser(null), ProviderType.NAVER) + + // then + existingMember.phoneNumber shouldBe "01011112222" + verify(memberRepository, never()).save(any()) + } + } +}) From 6364865cc19961c242764e70b3af564043aefbc5 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 23 Jul 2026 01:41:24 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20alpha/prod=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?SameSite=EB=A5=BC=20Strict=EC=97=90=EC=84=9C=20Lax=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소셜 로그인 콜백 등 cross-site에서 시작된 최상위 네비게이션에서 Strict 쿠키가 전송되지 않아 로그인 직후 expired 리다이렉트가 발생. Lax는 최상위 GET 네비게이션에 쿠키를 허용해 이를 해결한다. --- src/main/resources/application-alpha.yml | 2 +- src/main/resources/application-prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-alpha.yml b/src/main/resources/application-alpha.yml index 8b7bb2e..d7e213a 100644 --- a/src/main/resources/application-alpha.yml +++ b/src/main/resources/application-alpha.yml @@ -26,4 +26,4 @@ app: cookie: domain: ${APP_COOKIE_DOMAIN} secure: true - same-site: Strict + same-site: Lax diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 70375d0..96010b6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,4 +32,4 @@ app: cookie: domain: ${APP_COOKIE_DOMAIN} secure: true - same-site: Strict + same-site: Lax From 2255f0591aaf17deb089471af428083d47847e92 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 3 Aug 2026 10:16:28 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4(=EA=B3=84=EC=A0=95=20=EC=A6=89=EC=8B=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C)=20API=20+=20SB4=C2=B7kotest6=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: Spring Boot 4·kotest 6 테스트 인프라 마이그레이션 - kotest-extensions-spring을 kotest 6 좌표(io.kotest:kotest-extensions-spring)로 교체, SpringExtension 인스턴스화 방식으로 전환 - spring-boot-webmvc-test 의존성 추가(SB4에서 @WebMvcTest/@AutoConfigureMockMvc 분리) - JacksonTimeConfig를 Jackson 3.x(tools.jackson.*) ValueSerializer/Deserializer로 포팅(SB4 Instant 직렬화 복구) - @WebMvcTest 슬라이스용 인증 헬퍼(WebMvcTestSecurityConfig) 추가 및 컨트롤러 의존성 목 정합성 수정 - 제거된 Role enum 참조를 통합 테스트에서 정리 - springdoc.swagger-ui.path에 기본값 부여(테스트 컨텍스트 로딩 복구) - 로그인/토큰 재발급 시 app 클라이언트에는 쿠키 미발급(바디 토큰 사용)로 정합성 확보 * feat: 회원탈퇴(계정 즉시 삭제) API 구현 - DELETE /api/v1/auth/members/me 추가(@AuthenticatedApi, 본인 계정 즉시 삭제) - MemberService.withdraw: 감사기록 저장 후 refresh_token→auth_provider→member 순으로 하드 삭제(FK 안전), 이미 탈퇴 시 멱등 처리 - web 클라이언트는 accessToken/refreshToken 쿠키 만료, app은 토큰 폐기 - MemberWithdrawalAuditEntity 추가(opaqueId·시각·출처만, PII 미포함) - AuthProviderRepository/RefreshTokenRepository에 deleteByMember 추가 - 기존 무인증 DELETE /api/v1/members/{id} 및 MemberService.delete 제거 - 단위/통합 테스트 추가(삭제 순서·멱등성·web/app 쿠키 분기·미인증 401) --- build.gradle.kts | 6 +- .../api/controller/auth/AuthController.kt | 18 +- .../api/controller/member/MemberController.kt | 40 ++- .../api/domain/auth/AuthProviderRepository.kt | 6 + .../api/domain/auth/RefreshTokenRepository.kt | 4 + .../auth/api/domain/member/MemberService.kt | 28 ++- .../member/MemberWithdrawalAuditRepository.kt | 6 + .../entity/MemberWithdrawalAuditEntity.kt | 21 ++ .../wq/auth/shared/time/JacksonTimeConfig.kt | 28 ++- src/main/resources/application.yml | 2 +- .../AuthControllerIntegrationTest.kt | 93 +++++-- .../JacksonInstantIntegrationTest.kt | 156 ++++++------ .../MemberWithdrawControllerTest.kt | 119 +++++++++ .../integration/SocialLinkControllerTest.kt | 23 +- .../integration/WebMvcTestSecurityConfig.kt | 122 +++++++++ .../SecurityAuthorizationIntegrationTest.kt | 237 ++++++++---------- .../wq/auth/unit/JacksonInstantModuleTest.kt | 23 +- .../com/wq/auth/unit/MemberServiceTest.kt | 2 +- .../wq/auth/unit/MemberWithdrawServiceTest.kt | 98 ++++++++ src/test/resources/logback-test.xml | 17 ++ 20 files changed, 768 insertions(+), 281 deletions(-) create mode 100644 src/main/kotlin/com/wq/auth/api/domain/member/MemberWithdrawalAuditRepository.kt create mode 100644 src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberWithdrawalAuditEntity.kt create mode 100644 src/test/kotlin/com/wq/auth/integration/MemberWithdrawControllerTest.kt create mode 100644 src/test/kotlin/com/wq/auth/integration/WebMvcTestSecurityConfig.kt create mode 100644 src/test/kotlin/com/wq/auth/unit/MemberWithdrawServiceTest.kt create mode 100644 src/test/resources/logback-test.xml diff --git a/build.gradle.kts b/build.gradle.kts index 96711b8..3da3ae1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,7 +71,11 @@ dependencies { testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testImplementation("io.kotest:kotest-assertions-json:$kotestVersion") testImplementation("io.kotest:kotest-property:$kotestVersion") - testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3") + // kotest 6.x용 Spring 확장(메인 io.kotest 그룹, kotest 버전과 정렬) + testImplementation("io.kotest:kotest-extensions-spring:$kotestVersion") + + // Spring Boot 4: WebMvcTest / AutoConfigureMockMvc 는 별도 모듈로 분리 + testImplementation("org.springframework.boot:spring-boot-webmvc-test") // Mockito-Kotlin testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index 2862372..d628580 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -84,12 +84,11 @@ class AuthController( deviceId = req.deviceId, ) - val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) - val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - if (clientType == "web") { + val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) + val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) return CommonResponse.success(message = "로그인에 성공했습니다.", data = null) } @@ -276,12 +275,11 @@ class AuthController( currentRefreshToken, req?.deviceId ) - val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) - val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - if (clientType == "web") { + val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) + val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = null) } diff --git a/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt index d01baf6..ead84f3 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/member/MemberController.kt @@ -5,6 +5,7 @@ import com.wq.auth.api.domain.member.entity.MemberEntity import com.wq.auth.api.domain.member.MemberService import com.wq.auth.security.annotation.AuthenticatedApi import com.wq.auth.security.principal.PrincipalDetails +import com.wq.auth.shared.config.CookieFactory import com.wq.auth.shared.rateLimiter.annotation.RateLimit import com.wq.auth.web.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -13,6 +14,8 @@ import io.swagger.v3.oas.annotations.media.Schema 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.HttpServletResponse +import org.springframework.http.HttpHeaders import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.util.concurrent.TimeUnit @@ -21,6 +24,7 @@ import java.util.concurrent.TimeUnit @RestController class MemberController( private val memberService: MemberService, + private val cookieFactory: CookieFactory, ) { @Operation( @@ -73,10 +77,38 @@ class MemberController( fun create(@RequestBody member: MemberEntity): CommonResponse = CommonResponse.success("회원 생성 성공", memberService.create(member)) - @DeleteMapping("/api/v1/members/{id}") - fun delete(@PathVariable id: Long): CommonResponse { - memberService.delete(id) - return CommonResponse.success("회원 삭제 성공") + @Operation( + summary = "회원 탈퇴", + description = "인증된 본인 계정을 즉시 하드 삭제합니다. 소셜 연동 정보 및 리프레시 토큰도 함께 삭제됩니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "회원 탈퇴 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "401", + description = "인증 실패 또는 로그인 필요", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ) + ] + ) + @RateLimit(limit = 5, duration = 1, timeUnit = TimeUnit.MINUTES) + @DeleteMapping("/api/v1/auth/members/me") + @AuthenticatedApi + fun withdraw( + @AuthenticationPrincipal principalDetail: PrincipalDetails, + @RequestHeader("X-Client-Type") clientType: String, + response: HttpServletResponse, + ): CommonResponse { + memberService.withdraw(principalDetail.opaqueId, clientType) + if (clientType == "web") { + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.expireAccessTokenCookie().toString()) + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.expireRefreshTokenCookie().toString()) + } + return CommonResponse.success("회원 탈퇴 성공") } @PutMapping("/api/v1/members/{id}/nickname") diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthProviderRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthProviderRepository.kt index 98adce4..2f5a296 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthProviderRepository.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthProviderRepository.kt @@ -4,6 +4,8 @@ import com.wq.auth.api.domain.auth.entity.AuthProviderEntity import com.wq.auth.api.domain.member.entity.MemberEntity import com.wq.auth.api.domain.auth.entity.ProviderType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.transaction.annotation.Transactional interface AuthProviderRepository : JpaRepository { fun findByEmailAndProviderType(email: String, providerType: ProviderType): AuthProviderEntity? @@ -19,4 +21,8 @@ interface AuthProviderRepository : JpaRepository { member: MemberEntity, providerType: ProviderType ): AuthProviderEntity? + + @Modifying + @Transactional + fun deleteByMember(member: MemberEntity) } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt index 0c2a0b7..bb5ade1 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt @@ -30,4 +30,8 @@ interface RefreshTokenRepository : JpaRepository { @Query("SELECT r FROM RefreshTokenEntity r WHERE r.member = :member AND r.deviceId = :deviceId AND r.deletedAt IS NULL") fun findActiveByMemberAndDeviceId(@Param("member") member: MemberEntity, @Param("deviceId") deviceId: String?): RefreshTokenEntity? + @Modifying + @Transactional + fun deleteByMember(member: MemberEntity) + } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index 8706141..b980b4b 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -1,18 +1,23 @@ package com.wq.auth.api.domain.member import com.wq.auth.api.domain.auth.AuthProviderRepository +import com.wq.auth.api.domain.auth.RefreshTokenRepository import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.domain.member.entity.MemberEntity +import com.wq.auth.api.domain.member.entity.MemberWithdrawalAuditEntity import com.wq.auth.api.domain.member.error.MemberException import com.wq.auth.api.domain.member.error.MemberExceptionCode import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Instant @Service class MemberService( private val memberRepository: MemberRepository, private val authProviderRepository: AuthProviderRepository, + private val refreshTokenRepository: RefreshTokenRepository, + private val memberWithdrawalAuditRepository: MemberWithdrawalAuditRepository, ) { companion object { @@ -77,7 +82,28 @@ class MemberService( fun create(member: MemberEntity): MemberEntity = memberRepository.save(member) - fun delete(id: Long) = memberRepository.deleteById(id) + @Transactional + fun withdraw(opaqueId: String, sourceClient: String?) { + val member = memberRepository.findByOpaqueId(opaqueId).orElse(null) + if (member == null) { + log.warn("[MemberService] 이미 탈퇴한 회원 - opaqueId={}", opaqueId) + return + } + + memberWithdrawalAuditRepository.save( + MemberWithdrawalAuditEntity( + opaqueId = opaqueId, + withdrawnAt = Instant.now(), + sourceClient = sourceClient, + ) + ) + + refreshTokenRepository.deleteByMember(member) + authProviderRepository.deleteByMember(member) + memberRepository.delete(member) + + log.info("[MemberService] 회원 탈퇴 완료 - opaqueId={}, sourceClient={}", opaqueId, sourceClient) + } fun updateNickname(id: Long, newNickname: String): MemberEntity? { val member = memberRepository.findById(id).orElse(null) diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberWithdrawalAuditRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberWithdrawalAuditRepository.kt new file mode 100644 index 0000000..f80faa8 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberWithdrawalAuditRepository.kt @@ -0,0 +1,6 @@ +package com.wq.auth.api.domain.member + +import com.wq.auth.api.domain.member.entity.MemberWithdrawalAuditEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberWithdrawalAuditRepository : JpaRepository diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberWithdrawalAuditEntity.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberWithdrawalAuditEntity.kt new file mode 100644 index 0000000..b398d17 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberWithdrawalAuditEntity.kt @@ -0,0 +1,21 @@ +package com.wq.auth.api.domain.member.entity + +import jakarta.persistence.* +import java.time.Instant + +@Entity +@Table(name = "member_withdrawal_audit") +class MemberWithdrawalAuditEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @Column(name = "opaque_id", nullable = false, length = 36) + val opaqueId: String, + + @Column(name = "withdrawn_at", nullable = false) + val withdrawnAt: Instant, + + @Column(name = "source_client", nullable = true, length = 50) + val sourceClient: String? = null, +) diff --git a/src/main/kotlin/com/wq/auth/shared/time/JacksonTimeConfig.kt b/src/main/kotlin/com/wq/auth/shared/time/JacksonTimeConfig.kt index 5bf86de..be807ee 100644 --- a/src/main/kotlin/com/wq/auth/shared/time/JacksonTimeConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/time/JacksonTimeConfig.kt @@ -1,13 +1,14 @@ package com.wq.auth.shared.time -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.module.SimpleModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import tools.jackson.core.JsonGenerator +import tools.jackson.core.JsonParser +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.SerializationContext +import tools.jackson.databind.ValueDeserializer +import tools.jackson.databind.ValueSerializer +import tools.jackson.databind.module.SimpleModule import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -19,8 +20,8 @@ private val ISO_WITH_SECONDS: DateTimeFormatter = /** Instant -> ISO-8601(+offset)로 ZoneProvider 기준 변환 */ class InstantToZoneSerializer( private val zoneProvider: ZoneProvider -) : JsonSerializer() { // Instant -> JSON 문자열로 변환하는 클래스 - override fun serialize(value: Instant?, gen: JsonGenerator, serializers: SerializerProvider) { +) : ValueSerializer() { + override fun serialize(value: Instant?, gen: JsonGenerator, ctxt: SerializationContext) { if (value == null) { gen.writeNull(); return } val offset = value .atZone(ZoneOffset.UTC) @@ -31,9 +32,9 @@ class InstantToZoneSerializer( } /** ISO 문자열 -> Instant (요청 바디 수신 시) */ -class InstantFromIsoDeserializer : JsonDeserializer() { - override fun deserialize(p: com.fasterxml.jackson.core.JsonParser, ctxt: DeserializationContext): Instant { - val s = p.text +class InstantFromIsoDeserializer : ValueDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant { + val s = p.getString() return runCatching { OffsetDateTime.parse(s).toInstant() } .getOrElse { Instant.parse(s) } } @@ -41,6 +42,9 @@ class InstantFromIsoDeserializer : JsonDeserializer() { /** * Jackson에 커스텀 Serializer/Deserializer를 등록하는 설정 클래스 + * + * Spring Boot 4는 Jackson 3.x(tools.jackson.*)를 사용하므로, + * ValueSerializer / ValueDeserializer 및 tools.jackson.databind.module.SimpleModule을 사용합니다. */ @Configuration class JacksonTimeConfig(private val zoneProvider: ZoneProvider) { @@ -50,4 +54,4 @@ class JacksonTimeConfig(private val zoneProvider: ZoneProvider) { addSerializer(Instant::class.java, InstantToZoneSerializer(zoneProvider)) addDeserializer(Instant::class.java, InstantFromIsoDeserializer()) } -} \ No newline at end of file +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ea7a30..186597e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,7 +45,7 @@ springdoc: api-docs: enabled: true swagger-ui: - path: ${SWAGGER_PATH} + path: ${SWAGGER_PATH:/swagger-ui.html} app: time: diff --git a/src/test/kotlin/com/wq/auth/integration/AuthControllerIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/AuthControllerIntegrationTest.kt index 07af42d..bac41bd 100644 --- a/src/test/kotlin/com/wq/auth/integration/AuthControllerIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/AuthControllerIntegrationTest.kt @@ -3,27 +3,31 @@ package com.wq.auth.integration import com.wq.auth.api.controller.auth.AuthController import com.wq.auth.api.domain.email.AuthEmailService import com.wq.auth.api.domain.auth.AuthService +import com.wq.auth.api.domain.member.MemberService import com.wq.auth.security.jwt.JwtProperties import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.jwt.error.JwtExceptionCode import com.wq.auth.security.principal.PrincipalDetails +import com.wq.auth.shared.config.CookieFactory import io.kotest.core.spec.style.DescribeSpec -import io.kotest.extensions.spring.SpringTestExtension +import io.kotest.extensions.spring.SpringExtension import org.mockito.BDDMockito.given import org.springframework.http.MediaType +import org.springframework.http.ResponseCookie import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import jakarta.servlet.http.Cookie import org.hamcrest.Matchers +import org.hamcrest.Matchers.hasItem import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc import java.time.Duration import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.shared.rateLimiter.RateLimiterInterceptor import org.mockito.Mockito.* import org.mockito.kotlin.whenever @@ -32,9 +36,10 @@ import org.mockito.kotlin.any @WebMvcTest( controllers = [AuthController::class], properties = ["app.cookie.same-site=Strict"]) +@Import(WebMvcTestSecurityConfig::class) class AuthControllerIntegrationTest : DescribeSpec() { - override fun extensions() = listOf(SpringTestExtension()) + override val extensions = listOf(SpringExtension()) @Autowired lateinit var mockMvc: MockMvc @@ -45,6 +50,12 @@ class AuthControllerIntegrationTest : DescribeSpec() { @MockitoBean lateinit var authEmailService: AuthEmailService + @MockitoBean + lateinit var memberService: MemberService + + @MockitoBean + lateinit var cookieFactory: CookieFactory + @MockitoBean lateinit var jwtProperties: JwtProperties @@ -65,15 +76,31 @@ class AuthControllerIntegrationTest : DescribeSpec() { // validateOrThrow는 void이므로 doNothing 사용 doNothing().whenever(jwtProvider).validateOrThrow(any()) - // getOpaqueId와 getRole Mock 설정 + // getOpaqueId Mock 설정 whenever(jwtProvider.getOpaqueId(any())).thenReturn("opaqueId") - whenever(jwtProvider.getRole(any())).thenReturn(Role.MEMBER) + + // CookieFactory 기본 스텁 설정 (NPE 방지) + whenever(cookieFactory.createAccessTokenCookie(any())).thenAnswer { invocation -> + val token = invocation.arguments[0] as String + ResponseCookie.from("accessToken", token) + .httpOnly(true).path("/").sameSite("Strict").build() + } + whenever(cookieFactory.createRefreshTokenCookie(any())).thenAnswer { invocation -> + val token = invocation.arguments[0] as String + ResponseCookie.from("refreshToken", token) + .httpOnly(true).path("/").sameSite("Strict").build() + } + whenever(cookieFactory.expireAccessTokenCookie()).thenReturn( + ResponseCookie.from("accessToken", "").maxAge(0).path("/").sameSite("Strict").build() + ) + whenever(cookieFactory.expireRefreshTokenCookie()).thenReturn( + ResponseCookie.from("refreshToken", "").maxAge(0).path("/").sameSite("Strict").build() + ) } describe("POST /api/v1/auth/members/refresh") { val principal = PrincipalDetails( - opaqueId = "opaqueId", - role = Role.MEMBER + opaqueId = "opaqueId" ) context("Web 클라이언트에서 유효한 요청이 주어졌을 때") { @@ -112,15 +139,25 @@ class AuthControllerIntegrationTest : DescribeSpec() { .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("AccessToken 재발급에 성공했습니다.")) - // web 응답은 data null, 헤더만 검증 + // web 응답: Set-Cookie 헤더가 여러 개(accessToken, refreshToken) 존재하므로 stringValues로 검증 .andExpect( - header().string( + header().stringValues( "Set-Cookie", - Matchers.containsString("refreshToken=$newRefreshToken") + hasItem(Matchers.containsString("refreshToken=$newRefreshToken")) + ) + ) + .andExpect( + header().stringValues( + "Set-Cookie", + hasItem(Matchers.containsString("HttpOnly")) + ) + ) + .andExpect( + header().stringValues( + "Set-Cookie", + hasItem(Matchers.containsString("SameSite=Strict")) ) ) - .andExpect(header().string("Set-Cookie", Matchers.containsString("HttpOnly"))) - .andExpect(header().string("Set-Cookie", Matchers.containsString("SameSite=Strict"))) verify(authService).refreshAccessToken(refreshToken, deviceId) } @@ -251,11 +288,11 @@ class AuthControllerIntegrationTest : DescribeSpec() { .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("로그인에 성공했습니다.")) - .andExpect(header().string("Authorization", Matchers.containsString("Bearer "))) + // web 응답: Set-Cookie 헤더가 여러 개이므로 stringValues로 검증 .andExpect( - header().string( + header().stringValues( "Set-Cookie", - Matchers.containsString("refreshToken=$refreshToken") + hasItem(Matchers.containsString("refreshToken=$refreshToken")) ) ) @@ -312,8 +349,7 @@ class AuthControllerIntegrationTest : DescribeSpec() { val refreshToken = "valid-refresh-token" val clientType = "web" val principal = PrincipalDetails( - opaqueId = "opaqueId", - role = Role.MEMBER + opaqueId = "opaqueId" ) val requestBody = """{}""" @@ -330,14 +366,21 @@ class AuthControllerIntegrationTest : DescribeSpec() { .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("로그아웃에 성공했습니다.")) - .andExpect(jsonPath("$.data").isEmpty) + // data가 null이면 @JsonInclude(NON_NULL)에 의해 JSON에 포함되지 않음 + .andExpect(jsonPath("$.data").doesNotExist()) + // web 응답: Set-Cookie 헤더가 여러 개이므로 stringValues로 검증 + .andExpect( + header().stringValues( + "Set-Cookie", + hasItem(Matchers.containsString("refreshToken=")) + ) + ) .andExpect( - header().string( + header().stringValues( "Set-Cookie", - Matchers.containsString("refreshToken=") + hasItem(Matchers.containsString("Max-Age=0")) ) ) - .andExpect(header().string("Set-Cookie", Matchers.containsString("Max-Age=0"))) verify(authService).logout(refreshToken) } @@ -349,8 +392,7 @@ class AuthControllerIntegrationTest : DescribeSpec() { val refreshToken = "valid-refresh-token" val clientType = "app" val principal = PrincipalDetails( - opaqueId = "opaqueId", - role = Role.MEMBER + opaqueId = "opaqueId" ) val requestBody = """{"refreshToken": "$refreshToken"}""" @@ -367,7 +409,8 @@ class AuthControllerIntegrationTest : DescribeSpec() { .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("로그아웃에 성공했습니다.")) - .andExpect(jsonPath("$.data").isEmpty) + // data가 null이면 @JsonInclude(NON_NULL)에 의해 JSON에 포함되지 않음 + .andExpect(jsonPath("$.data").doesNotExist()) .andExpect(header().doesNotExist("Set-Cookie")) verify(authService).logout(refreshToken) diff --git a/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt index 4e3099c..73b22ac 100644 --- a/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt @@ -1,12 +1,12 @@ package com.wq.auth.integration -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.wq.auth.api.domain.member.entity.Role +import tools.jackson.databind.JsonNode +import tools.jackson.databind.json.JsonMapper import com.wq.auth.integration._tnote._TNote import com.wq.auth.integration._tnote._TNoteRepository import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.principal.PrincipalDetails +import com.wq.auth.shared.rateLimiter.RateLimiterInterceptor import io.kotest.core.spec.style.StringSpec import io.kotest.extensions.spring.SpringExtension import io.kotest.matchers.shouldBe @@ -14,15 +14,14 @@ import io.kotest.matchers.string.shouldContain import org.mockito.kotlin.any import org.mockito.kotlin.doNothing import org.mockito.kotlin.whenever -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import com.wq.auth.shared.rateLimiter.RateLimiterInterceptor +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc import org.springframework.http.MediaType import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get -import org.springframework.web.servlet.HandlerInterceptor import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -31,80 +30,91 @@ import java.time.ZoneOffset properties = [ "jwt.secret=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", "jwt.access-exp=15m", - "jwt.refresh-exp=14d" + "jwt.refresh-exp=14d", + "INTERNAL_API_SECRET=test-internal-secret", + "spring.datasource.url=jdbc:h2:mem:jackson-instant-test;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.mail.host=localhost", + "spring.mail.port=25", + "spring.mail.username=test", + "spring.mail.password=test", + "spring.mail.properties.mail.smtp.auth=false", + "spring.mail.properties.mail.smtp.starttls.enable=false" ] ) @AutoConfigureMockMvc -class JacksonInstantIntegrationTest( - private val repo: _TNoteRepository, - private val mockMvc: MockMvc, - private val objectMapper: ObjectMapper, - - @MockitoBean - private val jwtProvider: JwtProvider, - - @MockitoBean - private val rateLimiterInterceptor: RateLimiterInterceptor +class JacksonInstantIntegrationTest : StringSpec() { -) : StringSpec({ + override val extensions = listOf(SpringExtension()) - beforeTest { - whenever(rateLimiterInterceptor.preHandle(any(), any(), any())).thenReturn(true) + @Autowired + lateinit var repo: _TNoteRepository - // JWT 검증을 통과하도록 설정 - // validateOrThrow는 void이므로 doNothing 사용 - doNothing().whenever(jwtProvider).validateOrThrow(any()) + @Autowired + lateinit var mockMvc: MockMvc - // getOpaqueId와 getRole Mock 설정 - whenever(jwtProvider.getOpaqueId(any())).thenReturn("opaqueId") - whenever(jwtProvider.getRole(any())).thenReturn(Role.MEMBER) - } - - "UTC Instant가 저장/조회 시 동일하다" { - // given - val utc = Instant.parse("2025-08-12T00:00:00Z") - val saved = repo.save(_TNote(title = "hello", publishedAt = utc)) - - // when - val found = repo.findById(saved.id!!).orElseThrow() + @Autowired + lateinit var objectMapper: JsonMapper - // then - found.publishedAt shouldBe utc - } + @MockitoBean + lateinit var jwtProvider: JwtProvider - "응답 JSON은 +09:00 및 :ss로 나가고 시점은 동일하다" { - // given - val utc = Instant.parse("2025-08-12T00:00:00Z") - val saved = repo.save(_TNote(title = "hello", publishedAt = utc)) - val accessToken = "valid-access-token" - - val principal = PrincipalDetails( - opaqueId = "opaqueId", - role = Role.MEMBER - ) - - - // when - val res = mockMvc.get("/test-notes/{id}", saved.id!!) { - accept = MediaType.APPLICATION_JSON - header("Authorization", "Bearer $accessToken") - with(user(principal)) - }.andReturn().response - res.status shouldBe 200 - - // then: 포맷 문자열 검증 (+09:00 & :ss) - val body = res.contentAsString - body shouldContain "\"publishedAt\":\"2025-08-12T09:00:00+09:00\"" - - // 의미 검증: 파싱하여 오프셋/Instant 동일성 확인 - val node: JsonNode = objectMapper.readTree(body) - val iso = node["publishedAt"]?.asText() - ?: error("publishedAt 필드가 없음.") - val parsed = OffsetDateTime.parse(iso) - - parsed.offset shouldBe ZoneOffset.ofHours(9) // 응답은 +09:00 - parsed.toInstant() shouldBe utc // 시점은 동일(UTC) + @MockitoBean + lateinit var rateLimiterInterceptor: RateLimiterInterceptor + + init { + beforeTest { + whenever(rateLimiterInterceptor.preHandle(any(), any(), any())).thenReturn(true) + doNothing().whenever(jwtProvider).validateOrThrow(any()) + whenever(jwtProvider.getOpaqueId(any())).thenReturn("opaqueId") + } + + "UTC Instant가 저장/조회 시 동일하다" { + // given + val utc = Instant.parse("2025-08-12T00:00:00Z") + val saved = repo.save(_TNote(title = "hello", publishedAt = utc)) + + // when + val found = repo.findById(saved.id!!).orElseThrow() + + // then + found.publishedAt shouldBe utc + } + + "응답 JSON은 +09:00 및 :ss로 나가고 시점은 동일하다" { + // given + val utc = Instant.parse("2025-08-12T00:00:00Z") + val saved = repo.save(_TNote(title = "hello", publishedAt = utc)) + val accessToken = "valid-access-token" + + val principal = PrincipalDetails( + opaqueId = "opaqueId" + ) + + // when + val res = mockMvc.get("/test-notes/{id}", saved.id!!) { + accept = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $accessToken") + with(user(principal)) + }.andReturn().response + res.status shouldBe 200 + + // then: 포맷 문자열 검증 (+09:00 & :ss) + val body = res.contentAsString + body shouldContain "\"publishedAt\":\"2025-08-12T09:00:00+09:00\"" + + // 의미 검증: 파싱하여 오프셋/Instant 동일성 확인 + val node: JsonNode = objectMapper.readTree(body) + val iso = node["publishedAt"]?.asText() + ?: error("publishedAt 필드가 없음.") + val parsed = OffsetDateTime.parse(iso) + + parsed.offset shouldBe ZoneOffset.ofHours(9) // 응답은 +09:00 + parsed.toInstant() shouldBe utc // 시점은 동일(UTC) + } } -}) { - override fun extensions() = listOf(SpringExtension) -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/wq/auth/integration/MemberWithdrawControllerTest.kt b/src/test/kotlin/com/wq/auth/integration/MemberWithdrawControllerTest.kt new file mode 100644 index 0000000..bd27423 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/integration/MemberWithdrawControllerTest.kt @@ -0,0 +1,119 @@ +package com.wq.auth.integration + +import com.wq.auth.api.controller.member.MemberController +import com.wq.auth.api.domain.member.MemberService +import com.wq.auth.security.jwt.JwtProvider +import com.wq.auth.security.principal.PrincipalDetails +import com.wq.auth.shared.config.CookieFactory +import com.wq.auth.shared.rateLimiter.RateLimiterInterceptor +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.extensions.spring.SpringExtension +import org.hamcrest.Matchers +import org.mockito.kotlin.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.http.ResponseCookie +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* + +@WebMvcTest( + controllers = [MemberController::class], + properties = ["app.cookie.same-site=Strict"] +) +@Import(WebMvcTestSecurityConfig::class) +class MemberWithdrawControllerTest : DescribeSpec() { + + @Autowired + lateinit var mockMvc: MockMvc + + @MockitoBean + lateinit var memberService: MemberService + + @MockitoBean + lateinit var cookieFactory: CookieFactory + + @MockitoBean + lateinit var jwtProvider: JwtProvider + + @MockitoBean + lateinit var rateLimiterInterceptor: RateLimiterInterceptor + + override val extensions = listOf(SpringExtension()) + + init { + beforeTest { + reset(memberService, cookieFactory) + whenever(rateLimiterInterceptor.preHandle(any(), any(), any())).thenReturn(true) + doNothing().whenever(jwtProvider).validateOrThrow(any()) + whenever(jwtProvider.getOpaqueId(any())).thenReturn("test-opaque-id") + } + + describe("DELETE /api/v1/auth/members/me") { + + context("인증된 web 클라이언트 요청") { + it("탈퇴 성공 후 쿠키 만료 Set-Cookie 헤더를 반환한다") { + val principal = PrincipalDetails(opaqueId = "test-opaque-id") + + whenever(cookieFactory.expireAccessTokenCookie()).thenReturn( + ResponseCookie.from("accessToken", "").maxAge(0).build() + ) + whenever(cookieFactory.expireRefreshTokenCookie()).thenReturn( + ResponseCookie.from("refreshToken", "").maxAge(0).build() + ) + + mockMvc.perform( + delete("/api/v1/auth/members/me") + .header("X-Client-Type", "web") + .with(csrf()) + .with(user(principal)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("회원 탈퇴 성공")) + .andExpect(header().string("Set-Cookie", Matchers.containsString("Max-Age=0"))) + + verify(memberService).withdraw("test-opaque-id", "web") + verify(cookieFactory).expireAccessTokenCookie() + verify(cookieFactory).expireRefreshTokenCookie() + } + } + + context("인증된 app 클라이언트 요청") { + it("탈퇴 성공하고 Set-Cookie 헤더가 없다") { + val principal = PrincipalDetails(opaqueId = "test-opaque-id") + + mockMvc.perform( + delete("/api/v1/auth/members/me") + .header("X-Client-Type", "app") + .with(csrf()) + .with(user(principal)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("회원 탈퇴 성공")) + .andExpect(header().doesNotExist("Set-Cookie")) + + verify(memberService).withdraw("test-opaque-id", "app") + verify(cookieFactory, never()).expireAccessTokenCookie() + verify(cookieFactory, never()).expireRefreshTokenCookie() + } + } + + context("미인증 요청") { + it("401 Unauthorized를 반환한다") { + mockMvc.perform( + delete("/api/v1/auth/members/me") + .header("X-Client-Type", "web") + .with(csrf()) + ) + .andExpect(status().isUnauthorized) + } + } + } + } +} diff --git a/src/test/kotlin/com/wq/auth/integration/SocialLinkControllerTest.kt b/src/test/kotlin/com/wq/auth/integration/SocialLinkControllerTest.kt index 6e54ecf..f0b857c 100644 --- a/src/test/kotlin/com/wq/auth/integration/SocialLinkControllerTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/SocialLinkControllerTest.kt @@ -1,22 +1,23 @@ package com.wq.auth.integration -import com.fasterxml.jackson.databind.ObjectMapper +import tools.jackson.databind.json.JsonMapper import com.wq.auth.api.controller.auth.SocialLoginController import com.wq.auth.api.domain.auth.SocialLinkService import com.wq.auth.api.domain.auth.SocialLoginService -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.api.domain.oauth.error.SocialLoginException import com.wq.auth.api.domain.oauth.error.SocialLoginExceptionCode import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.principal.PrincipalDetails +import com.wq.auth.shared.config.CookieFactory import com.wq.auth.shared.rateLimiter.RateLimiterInterceptor import io.kotest.core.spec.style.DescribeSpec -import io.kotest.extensions.spring.SpringTestExtension +import io.kotest.extensions.spring.SpringExtension import jakarta.servlet.http.Cookie import org.mockito.BDDMockito.given import org.mockito.kotlin.* import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user @@ -30,15 +31,16 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status * 소셜 계정 연동 Controller 단위 테스트 (Kotest + Mockito) */ @WebMvcTest(controllers = [SocialLoginController::class]) +@Import(WebMvcTestSecurityConfig::class) class SocialLinkControllerTest : DescribeSpec() { - override fun extensions() = listOf(SpringTestExtension()) + override val extensions = listOf(SpringExtension()) @Autowired lateinit var mockMvc: MockMvc @Autowired - lateinit var objectMapper: ObjectMapper + lateinit var objectMapper: JsonMapper @MockitoBean lateinit var socialLoginService: SocialLoginService @@ -46,6 +48,9 @@ class SocialLinkControllerTest : DescribeSpec() { @MockitoBean lateinit var socialLinkService: SocialLinkService + @MockitoBean + lateinit var cookieFactory: CookieFactory + @MockitoBean lateinit var jwtProvider: JwtProvider @@ -63,9 +68,8 @@ class SocialLinkControllerTest : DescribeSpec() { // validateOrThrow는 void이므로 doNothing 사용 doNothing().whenever(jwtProvider).validateOrThrow(any()) - // getOpaqueId와 getRole Mock 설정 + // getOpaqueId Mock 설정 whenever(jwtProvider.getOpaqueId(any())).thenReturn("opaqueId") - whenever(jwtProvider.getRole(any())).thenReturn(Role.MEMBER) } describe("POST /api/v1/auth/link/{provider}") { @@ -76,8 +80,7 @@ class SocialLinkControllerTest : DescribeSpec() { val accessToken = "valid-access-token" val principal = PrincipalDetails( - opaqueId = "opaqueId", - role = Role.MEMBER + opaqueId = "opaqueId" ) context("Google 계정 연동 - 신규 연동 성공") { diff --git a/src/test/kotlin/com/wq/auth/integration/WebMvcTestSecurityConfig.kt b/src/test/kotlin/com/wq/auth/integration/WebMvcTestSecurityConfig.kt new file mode 100644 index 0000000..e52283e --- /dev/null +++ b/src/test/kotlin/com/wq/auth/integration/WebMvcTestSecurityConfig.kt @@ -0,0 +1,122 @@ +package com.wq.auth.integration + +import com.wq.auth.security.jwt.error.JwtExceptionCode +import com.wq.auth.security.principal.PrincipalDetails +import com.wq.auth.web.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.core.MethodParameter +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.InsufficientAuthenticationException +import org.springframework.security.core.Authentication +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +/** + * @WebMvcTest 슬라이스에서 @AuthenticationPrincipal PrincipalDetails 파라미터 해석을 위한 + * 테스트 전용 설정. + * + * Spring Boot 4 / Spring Security 7 환경에서 @WebMvcTest 컨텍스트에는 + * FilterChainProxy가 MockMvc 필터 체인에 포함되지 않아 SecurityContextHolderFilter가 + * 동작하지 않습니다. 그 결과 SecurityContextHolder가 비어 있어 + * AuthenticationPrincipalArgumentResolver가 null을 반환합니다. + * + * 해결책: SecurityMockMvcRequestPostProcessors.user()는 인증 정보를 + * HttpSession의 "SPRING_SECURITY_CONTEXT" 속성에 저장합니다(HttpSessionSecurityContextRepository 폴백). + * 이를 직접 읽어 PrincipalDetails를 추출합니다. + * + * 미인증 요청(인증 정보 없음) → InsufficientAuthenticationException 발생 → + * WebMvcTestAuthExceptionHandler가 401을 반환합니다. + */ +@TestConfiguration +class WebMvcTestSecurityConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(0, PrincipalDetailsArgumentResolver()) + } + + @Bean + fun webMvcTestAuthExceptionHandler(): WebMvcTestAuthExceptionHandler = + WebMvcTestAuthExceptionHandler() +} + +/** + * @AuthenticationPrincipal 어노테이션이 달린 PrincipalDetails 파라미터를 해석합니다. + * + * 인증 정보 탐색 순서: + * 1. SecurityContextHolder (필터 체인이 동작하는 경우) + * 2. HttpSession의 SPRING_SECURITY_CONTEXT 속성 + * (SecurityMockMvcRequestPostProcessors.user() 폴백 경로) + * + * 인증 정보가 없으면 InsufficientAuthenticationException을 발생시켜 + * 컨트롤러 메서드 실행 전에 401 응답이 반환되도록 합니다. + */ +class PrincipalDetailsArgumentResolver : HandlerMethodArgumentResolver { + companion object { + private const val SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT" + } + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) && + PrincipalDetails::class.java.isAssignableFrom(parameter.parameterType) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Any? { + val authentication = findAuthentication(webRequest) + ?: throw InsufficientAuthenticationException("인증 정보가 없습니다.") + val principal = authentication.principal + return if (principal is PrincipalDetails) principal + else throw InsufficientAuthenticationException("유효한 PrincipalDetails가 없습니다.") + } + + private fun findAuthentication(webRequest: NativeWebRequest): Authentication? { + // 1. SecurityContextHolder 확인 (필터 체인이 완전히 동작할 때) + val holderAuth = SecurityContextHolder.getContext().authentication + if (holderAuth != null) return holderAuth + + // 2. HttpSession 확인 (SecurityMockMvcRequestPostProcessors.user() 폴백) + val request = webRequest.getNativeRequest(HttpServletRequest::class.java) ?: return null + val session = request.getSession(false) ?: return null + val context = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY) as? SecurityContext ?: return null + return context.authentication + } +} + +/** + * 테스트 전용: InsufficientAuthenticationException → 401 Unauthorized 변환기. + * + * 프로덕션 환경에서는 FilterChainProxy → ExceptionTranslationFilter → + * JwtAuthenticationEntryPoint 경로로 401이 처리됩니다. + * @WebMvcTest 슬라이스에서는 필터 체인이 없으므로, 이 어드바이스가 대신 처리합니다. + * + * @Order(HIGHEST_PRECEDENCE)로 GlobalExceptionHandler보다 먼저 탐색되어 + * InsufficientAuthenticationException을 가로채 401을 반환합니다. + */ +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +class WebMvcTestAuthExceptionHandler { + + @ExceptionHandler(InsufficientAuthenticationException::class) + fun handleInsufficientAuthentication( + e: InsufficientAuthenticationException + ): ResponseEntity> { + val body = CommonResponse.fail(JwtExceptionCode.TOKEN_MISSING) + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body) + } +} diff --git a/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt index ea246d7..2533968 100644 --- a/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt @@ -1,172 +1,143 @@ package com.wq.auth.integration.security -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.security.jwt.JwtProvider import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.extensions.spring.SpringExtension import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc import org.springframework.http.MediaType -import org.springframework.test.context.TestConstructor import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get /** * JWT 기반 Spring Security 통합 테스트 - * + * * 테스트 시나리오: * 1. 공개 API - 토큰 없이 접근 가능 * 2. 인증 API - 유효한 토큰 필요 - * 3. 관리자 API - ADMIN 역할 필요 - * 4. JWT 토큰 형식 검증 (Bearer, 만료, 잘못된 형식) - * 5. 권한별 접근 제어 (401/403 응답) + * 3. JWT 토큰 형식 검증 (Bearer, 만료, 잘못된 형식) + * 4. 권한별 접근 제어 (401 응답) + * + * 참고: Role/Admin 기능은 현재 API에서 제거되어 관련 케이스는 제외됨 */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "INTERNAL_API_SECRET=test-internal-secret", + "spring.datasource.url=jdbc:h2:mem:security-test;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "jwt.secret=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", + "jwt.access-exp=15m", + "jwt.refresh-exp=14d", + "spring.mail.host=localhost", + "spring.mail.port=25", + "spring.mail.username=test", + "spring.mail.password=test", + "spring.mail.properties.mail.smtp.auth=false", + "spring.mail.properties.mail.smtp.starttls.enable=false" + ] +) @AutoConfigureMockMvc -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) -class JwtSpringSecurityIntegrationTest( - private val mockMvc: MockMvc, - private val jwtProvider: JwtProvider -) : BehaviorSpec({ - - given("JWT 기반 Spring Security 시스템에서") { - - `when`("공개 API에 토큰 없이 접근하면") { - then("200 OK 응답을 받아야 한다") { - val result = mockMvc.perform( - get("/api/public/test") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() - - result.response.status shouldBe 200 - } - } +class JwtSpringSecurityIntegrationTest : BehaviorSpec() { - `when`("인증된 사용자 API에 유효한 MEMBER 토큰으로 접근하면") { - then("200 OK 응답을 받아야 한다") { - // Given: MEMBER 역할 토큰 생성 - val memberToken = jwtProvider.createAccessToken( - opaqueId = "550e8400-e29b-41d4-a716-446655440000", - role = Role.MEMBER - ) - - val result = mockMvc.perform( - get("/api/test") - .header("Authorization", "Bearer $memberToken") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() - - result.response.status shouldBe 200 - } - } + override val extensions = listOf(SpringExtension()) - `when`("인증된 사용자 API에 유효한 ADMIN 토큰으로 접근하면") { - then("200 OK 응답을 받아야 한다") { - // Given: ADMIN 역할 토큰 생성 - val adminToken = jwtProvider.createAccessToken( - opaqueId = "660e8400-e29b-41d4-a716-446655440001", - role = Role.ADMIN - ) - - val result = mockMvc.perform( - get("/api/test") - .header("Authorization", "Bearer $adminToken") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() - - result.response.status shouldBe 200 - } - } + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var jwtProvider: JwtProvider - `when`("관리자 API에 ADMIN 토큰으로 접근하면") { - then("200 OK 응답을 받아야 한다") { - // Given: ADMIN 역할 토큰 생성 - val adminToken = jwtProvider.createAccessToken( - opaqueId = "660e8400-e29b-41d4-a716-446655440001", - role = Role.ADMIN - ) - - val result = mockMvc.perform( - get("/api/admin/test") - .header("Authorization", "Bearer $adminToken") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() - - result.response.status shouldBe 200 + init { + given("JWT 기반 Spring Security 시스템에서") { + + `when`("공개 API에 토큰 없이 접근하면") { + then("200 OK 응답을 받아야 한다") { + val result = mockMvc.perform( + get("/api/public/test") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn() + + result.response.status shouldBe 200 + } } - } - `when`("관리자 API에 MEMBER 토큰으로 접근하면") { - then("403 Forbidden 응답을 받아야 한다") { - // Given: MEMBER 역할 토큰 생성 - val memberToken = jwtProvider.createAccessToken( - opaqueId = "550e8400-e29b-41d4-a716-446655440000", - role = Role.MEMBER - ) - - val result = mockMvc.perform( - get("/api/admin/test") - .header("Authorization", "Bearer $memberToken") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() - - result.response.status shouldBe 403 + `when`("인증된 사용자 API에 유효한 토큰으로 접근하면") { + then("200 OK 응답을 받아야 한다") { + // Given: 토큰 생성 + val memberToken = jwtProvider.createAccessToken( + opaqueId = "550e8400-e29b-41d4-a716-446655440000" + ) + + val result = mockMvc.perform( + get("/api/test") + .header("Authorization", "Bearer $memberToken") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn() + + result.response.status shouldBe 200 + } } - } - `when`("인증된 사용자 API에 토큰 없이 접근하면") { - then("401 Unauthorized 응답을 받아야 한다") { - val result = mockMvc.perform( - get("/api/authenticated/test") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() + `when`("인증된 사용자 API에 토큰 없이 접근하면") { + then("401 Unauthorized 응답을 받아야 한다") { + val result = mockMvc.perform( + get("/api/authenticated/test") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn() - result.response.status shouldBe 401 + result.response.status shouldBe 401 + } } - } - `when`("잘못된 JWT 토큰으로 접근하면") { - then("401 Unauthorized 응답을 받아야 한다") { - val result = mockMvc.perform( - get("/api/authenticated/test") - .header("Authorization", "Bearer invalid.token.here") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() + `when`("잘못된 JWT 토큰으로 접근하면") { + then("401 Unauthorized 응답을 받아야 한다") { + val result = mockMvc.perform( + get("/api/authenticated/test") + .header("Authorization", "Bearer invalid.token.here") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn() - result.response.status shouldBe 401 + result.response.status shouldBe 401 + } } - } - `when`("Bearer 없는 토큰으로 접근하면") { - then("401 Unauthorized 응답을 받아야 한다") { - val memberToken = jwtProvider.createAccessToken( - opaqueId = "550e8400-e29b-41d4-a716-446655440000", - role = Role.MEMBER - ) + `when`("Bearer 없는 토큰으로 접근하면") { + then("401 Unauthorized 응답을 받아야 한다") { + val memberToken = jwtProvider.createAccessToken( + opaqueId = "550e8400-e29b-41d4-a716-446655440000" + ) - val result = mockMvc.perform( - get("/api/authenticated/test") - .header("Authorization", memberToken) // Bearer 없이 - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() + val result = mockMvc.perform( + get("/api/authenticated/test") + .header("Authorization", memberToken) // Bearer 없이 + .contentType(MediaType.APPLICATION_JSON) + ).andReturn() - result.response.status shouldBe 401 + result.response.status shouldBe 401 + } } - } - `when`("만료된 JWT 토큰으로 접근하면") { - then("401 Unauthorized 응답을 받아야 한다") { - // Given: 만료된 토큰 (과거 시간으로 설정) - val expiredToken = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJpYXQiOjE2MDAwMDAwMDAsImV4cCI6MTYwMDAwMDAwMX0.invalid" + `when`("만료된 JWT 토큰으로 접근하면") { + then("401 Unauthorized 응답을 받아야 한다") { + // Given: 만료된 토큰 (과거 시간으로 설정) + val expiredToken = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJpYXQiOjE2MDAwMDAwMDAsImV4cCI6MTYwMDAwMDAwMX0.invalid" - val result = mockMvc.perform( - get("/api/authenticated/test") - .header("Authorization", "Bearer $expiredToken") - .contentType(MediaType.APPLICATION_JSON) - ).andReturn() + val result = mockMvc.perform( + get("/api/authenticated/test") + .header("Authorization", "Bearer $expiredToken") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn() - result.response.status shouldBe 401 + result.response.status shouldBe 401 + } } } } -}) +} diff --git a/src/test/kotlin/com/wq/auth/unit/JacksonInstantModuleTest.kt b/src/test/kotlin/com/wq/auth/unit/JacksonInstantModuleTest.kt index 24bf1b8..8323d68 100644 --- a/src/test/kotlin/com/wq/auth/unit/JacksonInstantModuleTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JacksonInstantModuleTest.kt @@ -1,21 +1,25 @@ package com.wq.auth.unit -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.wq.auth.shared.time.ZoneProvider import io.kotest.core.spec.style.StringSpec import java.time.Instant import java.time.ZoneId -import com.fasterxml.jackson.databind.module.SimpleModule +import tools.jackson.databind.module.SimpleModule +import tools.jackson.databind.json.JsonMapper import com.wq.auth.shared.time.InstantFromIsoDeserializer import com.wq.auth.shared.time.InstantToZoneSerializer import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +/** + * InstantToZoneSerializer / InstantFromIsoDeserializer 단위 테스트. + * + * Spring Boot 4는 Jackson 3.x(tools.jackson.*)를 사용하므로 JsonMapper와 + * tools.jackson.databind.module.SimpleModule을 직접 사용합니다. + */ class JacksonInstantModuleTest : StringSpec({ - fun mapperWith(zoneId: String): ObjectMapper { + fun mapperWith(zoneId: String): JsonMapper { val zoneProvider = object : ZoneProvider { override fun zoneId(): ZoneId = ZoneId.of(zoneId) } @@ -23,10 +27,9 @@ class JacksonInstantModuleTest : StringSpec({ addSerializer(Instant::class.java, InstantToZoneSerializer(zoneProvider)) addDeserializer(Instant::class.java, InstantFromIsoDeserializer()) } - return ObjectMapper() - .registerModule(JavaTimeModule()) - .registerModule(module) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + return JsonMapper.builder() + .addModule(module) + .build() } "Instant → JSON 직렬화 시 Asia/Seoul(+09:00)로 변환된다" { @@ -64,4 +67,4 @@ class JacksonInstantModuleTest : StringSpec({ inst shouldBe Instant.parse("2025-08-12T00:00:00Z") } -}) \ No newline at end of file +}) diff --git a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt index def2e59..4b1f2c3 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt @@ -23,7 +23,7 @@ class MemberServiceTest : DescribeSpec({ beforeEach { memberRepository = mock() authProviderRepository = mock() - memberService = MemberService(memberRepository, authProviderRepository) + memberService = MemberService(memberRepository, authProviderRepository, mock(), mock()) } describe("사용자 정보 조회 테스트") { diff --git a/src/test/kotlin/com/wq/auth/unit/MemberWithdrawServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberWithdrawServiceTest.kt new file mode 100644 index 0000000..0478f8b --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/MemberWithdrawServiceTest.kt @@ -0,0 +1,98 @@ +package com.wq.auth.unit + +import com.wq.auth.api.domain.auth.AuthProviderRepository +import com.wq.auth.api.domain.auth.RefreshTokenRepository +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberService +import com.wq.auth.api.domain.member.MemberWithdrawalAuditRepository +import com.wq.auth.api.domain.member.entity.MemberEntity +import com.wq.auth.api.domain.member.entity.MemberWithdrawalAuditEntity +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.mockito.ArgumentCaptor +import org.mockito.kotlin.* +import java.util.Optional + +class MemberWithdrawServiceTest : DescribeSpec({ + + lateinit var memberRepository: MemberRepository + lateinit var authProviderRepository: AuthProviderRepository + lateinit var refreshTokenRepository: RefreshTokenRepository + lateinit var memberWithdrawalAuditRepository: MemberWithdrawalAuditRepository + lateinit var memberService: MemberService + + beforeEach { + memberRepository = mock() + authProviderRepository = mock() + refreshTokenRepository = mock() + memberWithdrawalAuditRepository = mock() + memberService = MemberService( + memberRepository, + authProviderRepository, + refreshTokenRepository, + memberWithdrawalAuditRepository, + ) + } + + describe("withdraw - 회원 탈퇴") { + + it("정상적인 opaqueId가 주어지면 자식 먼저 부모 나중 순서로 삭제한다") { + // given + val opaqueId = "test-opaque-id" + val mockMember = mock() + whenever(mockMember.opaqueId).thenReturn(opaqueId) + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(mockMember)) + + val auditCaptor = ArgumentCaptor.forClass(MemberWithdrawalAuditEntity::class.java) + whenever(memberWithdrawalAuditRepository.save(any())).thenAnswer { it.arguments[0] } + + // when + memberService.withdraw(opaqueId, "app") + + // then + verify(memberWithdrawalAuditRepository).save(auditCaptor.capture()) + val audit = auditCaptor.value + audit.opaqueId shouldBe opaqueId + audit.sourceClient shouldBe "app" + + val inOrder = inOrder(memberWithdrawalAuditRepository, refreshTokenRepository, authProviderRepository, memberRepository) + inOrder.verify(memberWithdrawalAuditRepository).save(any()) + inOrder.verify(refreshTokenRepository).deleteByMember(mockMember) + inOrder.verify(authProviderRepository).deleteByMember(mockMember) + inOrder.verify(memberRepository).delete(mockMember) + } + + it("존재하지 않는 opaqueId가 주어지면 예외 없이 멱등 성공한다") { + // given + val opaqueId = "already-withdrawn-id" + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.empty()) + + // when - 예외가 발생하지 않아야 함 + memberService.withdraw(opaqueId, "app") + + // then + verify(memberRepository).findByOpaqueId(opaqueId) + verify(memberWithdrawalAuditRepository, never()).save(any()) + verify(refreshTokenRepository, never()).deleteByMember(any()) + verify(authProviderRepository, never()).deleteByMember(any()) + verify(memberRepository, never()).delete(any()) + } + + it("sourceClient가 null이어도 정상 처리된다") { + // given + val opaqueId = "test-opaque-id" + val mockMember = mock() + whenever(mockMember.opaqueId).thenReturn(opaqueId) + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(mockMember)) + whenever(memberWithdrawalAuditRepository.save(any())).thenAnswer { it.arguments[0] } + + // when + memberService.withdraw(opaqueId, null) + + // then + val captor = ArgumentCaptor.forClass(MemberWithdrawalAuditEntity::class.java) + verify(memberWithdrawalAuditRepository).save(captor.capture()) + captor.value.sourceClient shouldBe null + } + } +}) diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..31da29c --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + From dacb9fe34066f392447630feff028616dd241cd9 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 3 Aug 2026 16:38:07 +0900 Subject: [PATCH 17/19] =?UTF-8?q?chore:=20actuator=20health=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=C2=B7=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=EB=A6=AC=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: actuator health 엔드포인트 및 로깅 프로파일 설정 추가 - spring-boot-starter-actuator 의존성 추가, /actuator/health 노출(show-details never, probes 활성화) - logback: alpha/prod는 JSON 파일+stdout(Alloy 수집), local은 plain text 콘솔 프로파일 분리 * docs: 문서 정리 (옛 가이드 정리 및 README 축소) - 흩어진 옛 가이드 6종 제거(API Gateway/공통응답/CORS/ENV/OAuth redirect/소셜로그인 스펙) - README를 핵심 위주로 축소 - GITHUB-ENVIRONMENTS, google-login 가이드 추가 --- README.md | 196 +--------------- build.gradle.kts | 1 + ...1_\352\260\200\354\235\264\353\223\234.md" | 62 ----- ...5_\352\260\200\354\235\264\353\223\234.md" | 131 ----------- ...4_\352\260\200\354\235\264\353\223\234.md" | 218 ------------------ docs/ENV.md | 105 --------- docs/GITHUB-ENVIRONMENTS.md | 27 +++ ..._redirect_URI_\354\240\204\353\236\265.md" | 171 -------------- docs/google-login-guide.md | 95 ++++++++ ...24\354\262\255\354\212\244\355\216\231.md" | 83 ------- src/main/resources/application.yml | 12 + src/main/resources/logback-spring.xml | 16 +- 12 files changed, 157 insertions(+), 960 deletions(-) delete mode 100644 "docs/API_GATEWAY_\354\227\260\353\217\231_\352\260\200\354\235\264\353\223\234.md" delete mode 100644 "docs/API_\352\263\265\355\206\265_\354\235\221\353\213\265_\352\260\200\354\235\264\353\223\234.md" delete mode 100644 "docs/CORS_\353\254\270\354\240\234_\352\260\200\354\235\264\353\223\234.md" delete mode 100644 docs/ENV.md create mode 100644 docs/GITHUB-ENVIRONMENTS.md delete mode 100644 "docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" create mode 100644 docs/google-login-guide.md delete mode 100644 "docs/\354\206\214\354\205\234\353\241\234\352\267\270\354\235\270_API_\354\232\224\354\262\255\354\212\244\355\216\231.md" diff --git a/README.md b/README.md index a7551c0..e76f19b 100644 --- a/README.md +++ b/README.md @@ -1,201 +1,21 @@ # auth-api -Spring Boot 기반 **인증·인가** 백엔드입니다. 소셜 로그인(Google, Kakao, Naver), 이메일 인증 로그인, 계정 연동, JWT·HttpOnly 쿠키, API Gateway용 `introspect` 등을 제공합니다. +Kotlin / Spring Boot 기반 인증 API입니다. 소셜 로그인(Google, Kakao, Naver), 이메일 인증, JWT·쿠키, `GET /api/v1/auth/introspect` 등을 제공합니다. -## 문서 위치 +## 문서 -| 구분 | 내용 | -|------|------| -| **이 README** | 빠른 시작, 환경 변수, 배포, 인증 요약, GitHub Pages 안내 | -| **API 명세** | [`docs/api-명세서.md`](docs/api-명세서.md) | -| **CI 환경** | [`docs/GITHUB-ENVIRONMENTS.md`](docs/GITHUB-ENVIRONMENTS.md) | +- [API 명세](docs/api-명세서.md) +- [GitHub Environments / 배포 CI](docs/GITHUB-ENVIRONMENTS.md) ---- - -## 목차 - -- [문서 위치](#문서-위치) -- [역할 한눈에](#역할-한눈에) -- [기술 스택](#기술-스택) -- [저장소 구조](#저장소-구조) -- [실행 방법](#실행-방법) -- [GitHub Pages](#github-pages) -- [배포 (Docker / EC2)](#배포-docker--ec2) -- [환경 변수](#환경-변수) -- [인증·토큰 요약](#인증토큰-요약) -- [OAuth / PKCE 요약](#oauth--pkce-요약) -- [보안·운영 참고](#보안운영-참고) - ---- - -## 역할 한눈에 - -| 영역 | 내용 | -|------|------| -| 소셜 로그인 | OAuth2 인가 코드 + PKCE, 범용·제공자별 엔드포인트 | -| 이메일 | 인증 코드 발송/검증, 이메일 로그인·가입, 로그인 후 이메일 연동 | -| 토큰 | JWT Access / Refresh, Refresh는 DB 저장, 웹은 HttpOnly 쿠키 중심 | -| 클라이언트 | `X-Client-Type`(`web` / `app`)으로 쿠키 vs 본문 토큰 분기 | -| 게이트웨이 | `GET /api/v1/auth/introspect`, 응답 헤더 `X-User-Id`, 사일런트 리프레시 | - ---- - -## 기술 스택 - -| 구분 | 사용 | -|------|------| -| 언어 | Kotlin | -| 런타임 | JDK 25 (Gradle toolchain) | -| 프레임워크 | Spring Boot 4, Spring Web, Spring Security, Spring Data JPA | -| DB | PostgreSQL (런타임), H2 (테스트 등) | -| 인증 | OAuth2 연동, JWT (jjwt) | -| API 탐색 | springdoc OpenAPI 3 (`/v3/api-docs`, UI는 `SWAGGER_PATH`) | -| 기타 | Bucket4j(레이트 리밋), 메일 발송 | - ---- - -## 저장소 구조 - -``` -src/main/kotlin/com/wq/auth/ -├── AuthApplication.kt -├── api/ -│ ├── controller/ # REST -│ ├── domain/ -│ └── external/oauth/ -├── security/ -├── shared/ -└── web/common/ - -src/main/resources/ -├── application.yml -├── application-{local,alpha,prod}.yml -├── application-jwt.yml -└── application-oauth.yml - -docs/ -├── _config.yml # GitHub Pages (Jekyll) -├── api-명세서.md # API 명세 -└── GITHUB-ENVIRONMENTS.md # 배포 CI Environment -``` - ---- - -## 실행 방법 - -**요구:** JDK 17 이상(프로젝트는 **25** 툴체인), Gradle 래퍼. +## 실행 ```bash ./gradlew bootRun -./gradlew build -java -jar build/libs/auth-api-0.0.1-SNAPSHOT.jar ``` -- 앱 이름: `auth-api` (`spring.application.name`) -- 기본 포트: **9000** -- 로컬 프로필: 기본 `local` + `jwt` + `oauth` (그룹은 `application.yml` 참고) - ---- - -## GitHub Pages - -GitHub에서 정적 사이트로 **`docs/`** 폴더를 게시합니다. - -1. 저장소 **Settings → Pages** -2. **Build and deployment**: Branch **`main`**, 폴더 **`/docs`** -3. 저장 후 몇 분 뒤 Pages가 빌드됩니다. - -**API 명세 (Pages):** 사이트에서 **`/api-명세서/`** 로 열립니다 (`docs/api-명세서.md`의 `permalink`). 루트 URL(`/`)에는 별도 `index`가 없어 **404일 수 있음**에 유의하세요. - -**API 명세 (저장소에서 보기):** `https://github.com///blob/main/docs/api-명세서.md` - -랜딩 페이지가 필요하면 `docs/index.md`를 다시 두면 됩니다. - ---- - -## 배포 (Docker / EC2) - -- **`main`** push 시 GitHub Actions로 이미지 빌드·푸시 후 EC2에서 컨테이너 갱신. -- EC2에서는 env를 **단일 파일**로 씁니다. Secret **`ENV_FILE`**(또는 CI에서 쓰는 이름) 전체를 **`~/env/auth-be.env`** 에 두고 `--env-file` 로 실행하는 흐름을 전제로 합니다. - -```bash -docker run -d --restart unless-stopped --name auth-be -p 9000:9000 \ - --env-file ~/env/auth-be.env /auth-server:latest -``` - -**GitHub Environments**(`production` / `alpha`)와 Secret 이름 표는 [`docs/GITHUB-ENVIRONMENTS.md`](docs/GITHUB-ENVIRONMENTS.md)를 따릅니다. - ---- +기본 포트 **9000**, 프로필은 `application.yml`의 `spring.profiles` 그룹을 따릅니다. ## 환경 변수 -설정은 주로 다음에 매핑됩니다. - -- `application.yml` -- `application-jwt.yml` — `JWT_SECRET`, 토큰 만료 (`JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION` 등, Duration 형식 예: `30m`, `P7D`) -- `application-oauth.yml` — OAuth 클라이언트 ID/Secret, redirect URI -- `application-{local,alpha,prod}.yml` — 프로필별 - -### 자주 쓰는 예시 - -```properties -JWT_SECRET=BASE64_OR_RAW_SECRET -JWT_ACCESS_TOKEN_EXPIRATION=30m -JWT_REFRESH_TOKEN_EXPIRATION=P7D - -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=authdb -DB_USERNAME=postgres -DB_PASSWORD=postgres - -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI= -KAKAO_CLIENT_ID= -KAKAO_CLIENT_SECRET= -KAKAO_REDIRECT_URI= -NAVER_CLIENT_ID= -NAVER_CLIENT_SECRET= -NAVER_REDIRECT_URI= - -MAIL_USERNAME= -MAIL_PASSWORD= -SWAGGER_PATH=/ -APP_COOKIE_DOMAIN= -``` - ---- - -## 인증·토큰 요약 - -- **Access Token:** 웹은 `accessToken` HttpOnly 쿠키; 앱은 로그인/갱신 응답 본문 등(`X-Client-Type: app`). -- **Refresh Token:** 웹은 `refreshToken` 쿠키; 앱은 요청/응답 본문. -- **호출 시 읽기 순서** (`JwtAuthenticationFilter`): - 1) `accessToken` 쿠키가 있으면 **Authorization 헤더 무시** - 2) 없으면 `Authorization: Bearer` - -**엔드포인트·DTO 표**는 [`docs/api-명세서.md`](docs/api-명세서.md)를 보세요. - ---- - -## OAuth / PKCE 요약 - -- Authorization Code는 **1회용**, 받은 뒤 곧바로 교환. -- **Redirect URI**는 인가 요청과 토큰 요청에서 **동일**해야 함. -- **Naver**는 `state` 일치 필요. -- PKCE: `codeVerifier` 등은 DTO 및 제공자 문서를 따름. - -참고: [OAuth 2.0](https://tools.ietf.org/html/rfc6749), [PKCE](https://tools.ietf.org/html/rfc7636), [Google](https://developers.google.com/identity/protocols/oauth2), [Kakao](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api), [Naver](https://developers.naver.com/docs/login/api/api.md) - ---- - -## 보안·운영 참고 - -- 본 서비스 `SecurityConfig`에서는 **CORS를 끈 상태**이며, 보통 **API Gateway에서 CORS**를 처리합니다. 로컬에서 브라우저로 직접 호출할 때는 게이트웨이·프록시 또는 허용 정책을 맞춥니다. -- 운영에서는 쿠키 `Secure` / `SameSite` 등을 프로필에 맞게 유지합니다. - ---- - -**Maintained by:** GrowGrammers Team -**Last updated:** 2026-04-08 +`application.yml`, `application-jwt.yml`, `application-oauth.yml`, 프로필별 `application-*.yml`에 매핑됩니다. +필요한 키는 위 API 명세와 설정 파일을 참고하세요. diff --git a/build.gradle.kts b/build.gradle.kts index 3da3ae1..7b81dd5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.springframework.boot:spring-boot-starter-webflux") diff --git "a/docs/API_GATEWAY_\354\227\260\353\217\231_\352\260\200\354\235\264\353\223\234.md" "b/docs/API_GATEWAY_\354\227\260\353\217\231_\352\260\200\354\235\264\353\223\234.md" deleted file mode 100644 index 18fff00..0000000 --- "a/docs/API_GATEWAY_\354\227\260\353\217\231_\352\260\200\354\235\264\353\223\234.md" +++ /dev/null @@ -1,62 +0,0 @@ -# API Gateway 연동 가이드 (auth-be 팀용) - -auth-be가 API Gateway와 어떻게 연결되는지, 경로·인증 연동 방식을 요약한 문서입니다. - ---- - -## 1. 연결 구조 - -- 외부 클라이언트는 **`/api` 로 시작하는 요청을 API Gateway(기본 포트 8080)** 로 보냅니다. -- Gateway가 경로에 따라 백엔드로 라우팅합니다. - -``` -[클라이언트] → [API Gateway :8080] → [auth-be :9000] (또는 wedding 등) -``` - ---- - -## 2. auth-be 로 라우팅되는 경로 - -| 외부 경로 | Gateway 동작 | 연결 대상 | -|-----------|----------------|-----------| -| `/api/v1/auth/**` | 경로 그대로 전달 (rewrite 없음) | **auth-be** (`AUTH_SERVER_URL`, 기본 9000) | - -- Gateway 설정 예: `AUTH_SERVER_URL=http://auth-be호스트:9000` (같은 서버면 `http://localhost:9000`) -- auth-be는 **`/api/v1/auth/...`** 형태 그대로 요청을 받습니다. - ---- - -## 3. 인증 필요 경로에서의 연동 (introspect) - -`/api/v1/wedding-editor/**` 등 **인증이 필요한 경로**로 요청이 오면: - -1. Gateway가 **auth-be의 introspect API**를 먼저 호출합니다. -2. 호출 경로: `GET {AUTH_SERVER_URL}{AUTH_SERVER_INTROSPECT_PATH}` - 기본값: `GET {AUTH_SERVER_URL}/api/v1/auth/introspect` -3. Gateway가 클라이언트의 `Authorization` 헤더를 그대로 auth-be에 전달합니다. -4. auth-be가 **2xx + `X-User-Id`, `X-Auth-Provider` 헤더**로 응답하면 Gateway가 이 헤더를 붙여 다운스트림으로 전달합니다. -5. auth-be가 **401/403**을 반환하면 Gateway가 클라이언트에게 401/403을 그대로 반환합니다. - ---- - -## 4. auth-be 측에서 제공하는 것 - -| 항목 | 내용 | -|------|------| -| 경로 | `/api/v1/auth/**` 로 요청 처리 (Gateway가 path rewrite 하지 않음) | -| Introspect API | `GET /api/v1/auth/introspect` | -| Introspect 요청 | `Authorization` 헤더에 JWT가 담긴 요청을 받음 | -| Introspect 성공 시 | 2xx + 응답 헤더 `X-User-Id`, `X-Auth-Provider` (연동된 경우) | -| Introspect 실패 시 | 401/403 → Gateway가 그대로 클라이언트에 반환 | - ---- - -## 5. 요청 흐름 요약 - -- **인증 불필요 경로** (예: `GET /api/v1/auth/...` 중 로그인 등) - `[외부] → [Gateway :8080] → [auth-be :9000]` 경로 그대로 전달. - -- **인증 필요 경로** (예: `/api/v1/wedding-editor/**`) - `[외부] → [Gateway :8080] → (1) auth-be introspect 호출 → (2) 성공 시 헤더 전파 후 다운스트림으로 전달`. - -상세 구조·필터·에러 코드는 Gateway 팀의 `API-GATEWAY-구조.md` 등을 참고하면 됩니다. diff --git "a/docs/API_\352\263\265\355\206\265_\354\235\221\353\213\265_\352\260\200\354\235\264\353\223\234.md" "b/docs/API_\352\263\265\355\206\265_\354\235\221\353\213\265_\352\260\200\354\235\264\353\223\234.md" deleted file mode 100644 index 851d9da..0000000 --- "a/docs/API_\352\263\265\355\206\265_\354\235\221\353\213\265_\352\260\200\354\235\264\353\223\234.md" +++ /dev/null @@ -1,131 +0,0 @@ -## API 공통 응답 가이드 (`ApiCode` 기반) - -### 1. 개요 - -이번 작업에서는 API 응답을 **일관성 있게 관리**하기 위해 -`ApiCode`, `ApiException`, `BaseResponse`, `Responses` 구조를 추가했습니다. - ---- - -### 2. 구성 요소 - -#### **2.1 ApiCode (Interface)** - -```kotlin -interface ApiCode { - fun getStatus(): Int // HTTP Status Code - fun getMessage(): String? // 사용자 표시 메시지 -} -``` - -* 모든 API 응답 코드(성공/실패)를 한 곳에서 관리 -* Enum으로 구현하여 **코드 재사용성**과 **타입 안전성** 확보 - -#### **2.2 ApiException** - -```kotlin -class ApiException(val code: ApiCode) : RuntimeException(code.getMessage()) { - val className: String - val methodName: String - val lineNumber: Int -} -``` - -* `ApiResponseCode` 기반 예외 -* 발생 위치(클래스, 메서드, 라인)를 함께 기록 → **로그 분석 편의성** - -#### **2.3 BaseResponse & Responses** - -```kotlin -/** - * 공통 API 응답 구조 - */ -data class BaseResponse ( - val success: Boolean, - val message: String, - val data: T?, - val error: String? = null -) - -/** - * 공통 success, fail 메서드. - */ -object Responses { - fun success( - message: String = "요청에 성공적으로 응답하였습니다.", - data: T? = null - ) : BaseResponse = - BaseResponse(true, message, data, null) - - fun fail(code: ApiCode) : BaseResponse = - BaseResponse(false, code.getMessage() ?: "오류가 발생했습니다.", null, code.toString()) -} -``` - -* 성공/실패 응답 구조 통일 - ---- - -### 3. 사용 예시 - -#### **3.1 ApiCode Enum 예시** - -```kotlin -enum class JwtResponseCode( - private val status: Int, - private val msg: String -) : ApiResponseCode { - - TOKEN_ISSUED_SUCCESS(200, "토큰이 발급되었습니다"), // 성공 응답 - - TOKEN_EXPIRED(401, "토큰이 만료되었습니다"), - TOKEN_INVALID(401, "유효하지 않은 토큰입니다"), - TOKEN_MISSING(401, "토큰이 누락되었습니다"); - - override fun getStatus() = status - override fun getMessage() = msg -} -``` - -* 상속받아서 재활용해서 각 클래스에 맞게 code 변환해서 사용. - -#### **3.2 컨트롤러에서 사용** - -```kotlin -@GetMapping("/hello") -fun hello(): BaseResponse { - return Responses.success(message = "Hello World!") -} -``` - -#### **3.3 예외 발생 시** - -```kotlin -@GetMapping("/secure") -fun secure(): BaseResponse { - throw ApiException(CommonCode.INVALID_TOKEN) -} -``` - -* code만 매핑 해주면, 해당 메세지로 에러 응답 반환. - ---- - -### 4. 유의사항 - -1. **성공 응답도 `ApiResponseCode`상속해서 관리** - - * 성공 코드도 `ApiResponseCode` 상속받은 enum 클래스에서 정의하면 - 메시지와 상태 코드를 한 곳에서 관리할 수 있음. - -2. **`ApiException`은 서비스/도메인 계층에서 던지고, 컨트롤러 레벨에서 잡지 않음** - - * `GlobalExceptionHandler`에서 처리하도록 일원화. - -3. **Swagger 연동 시** - - * `BaseResponse`를 응답 타입으로 지정하면 문서에서 일관성 유지. - -4. **로그 분석** - - * `ApiException.extractExceptionLocation()`을 사용하면 예외 발생 지점을 간결하게 로깅 가능. diff --git "a/docs/CORS_\353\254\270\354\240\234_\352\260\200\354\235\264\353\223\234.md" "b/docs/CORS_\353\254\270\354\240\234_\352\260\200\354\235\264\353\223\234.md" deleted file mode 100644 index e427d0d..0000000 --- "a/docs/CORS_\353\254\270\354\240\234_\352\260\200\354\235\264\353\223\234.md" +++ /dev/null @@ -1,218 +0,0 @@ -# CORS 오류 상세 가이드 (auth-BE / API Gateway 연동) - -브라우저에서 `auth.easyappfactory.com` → `api.easyappfactory.com` 로 API 요청 시 발생하는 CORS 오류의 원인, 동작 방식, 해결 방법을 정리한 문서입니다. - ---- - -## 1. CORS란 무엇인가 - -### 1.1 Same-Origin Policy (동일 출처 정책) - -브라우저는 보안을 위해 **다른 출처(Origin)** 로의 요청과 응답을 제한합니다. - -- **Origin** = 프로토콜 + 호스트 + 포트 - 예: `https://auth.easyappfactory.com` 과 `https://api.easyappfactory.com` 은 **서로 다른 Origin**입니다. -- JavaScript에서 `fetch()` 또는 `XMLHttpRequest` 로 다른 Origin으로 요청하면, **서버가 허용하지 않으면** 브라우저가 응답을 클라이언트 코드에 넘기지 않고 막습니다. - -### 1.2 CORS (Cross-Origin Resource Sharing) - -**CORS**는 “다른 Origin에서 온 요청을 허용할지”를 서버가 **HTTP 응답 헤더**로 브라우저에게 알려 주는 메커니즘입니다. - -- 서버가 응답에 `Access-Control-Allow-Origin: https://auth.easyappfactory.com` 등을 붙이면, 브라우저는 “이 Origin에서의 요청은 괜찮다”고 판단하고 응답을 JS에 노출합니다. -- 이 헤더가 없거나, Origin이 허용 목록에 없으면 브라우저는 **응답을 막고** 콘솔/네트워크 탭에 CORS 에러를 냅니다. - -### 1.3 Preflight (사전 요청) - -`Content-Type: application/json` 이나 커스텀 헤더를 쓰는 요청은 브라우저가 먼저 **OPTIONS** 요청(preflight)을 보냅니다. - -- 브라우저 → 서버: `OPTIONS /api/v1/auth/email/request` (실제 본문 없음) -- 서버는 **OPTIONS에 대해** `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` 등을 붙여 200으로 응답해야 합니다. -- 이 preflight가 성공해야 브라우저가 **실제 POST** 요청을 보냅니다. -- Preflight가 실패(4xx/5xx 또는 CORS 헤더 없음)하면 **실제 POST는 아예 보내지 않고**, 개발자 도구에는 “Provisional headers are shown” + CORS 에러만 보입니다. - ---- - -## 2. 현재 아키텍처에서의 요청 흐름 - -``` -[브라우저] (Origin: https://auth.easyappfactory.com) - | - | POST /api/v1/auth/email/request (또는 먼저 OPTIONS) - v -[API Gateway] api.easyappfactory.com:443 - | - | 프록시 → auth-BE - v -[auth-BE] (예: localhost:9000 또는 내부 주소) - | - | 200 + JSON (CORS 헤더 없음; Gateway에서만 부여) - v -[API Gateway] → CORS 헤더 추가 후 브라우저로 전달 - | - v -[브라우저] ← 여기서 받는 응답에 CORS 헤더가 있어야 함 -``` - -- 브라우저 입장에서는 **응답을 준 쪽이 `api.easyappfactory.com`** 입니다. -- 따라서 **CORS 헤더는 `api.easyappfactory.com` 이 내려주는 최종 응답**에 포함되어 있어야 합니다. -- auth-BE가 CORS 헤더를 붙여도, **Gateway가 그 헤더를 전달하지 않거나 덮어쓰면** 브라우저에는 CORS 미허용으로 보입니다. - ---- - -## 3. 증상과 그 의미 - -### 3.1 개발자 도구에서 보이는 것 - -- **Request URL**: `https://api.easyappfactory.com/api/v1/auth/email/request` -- **Referer**: `https://auth.easyappfactory.com/` -- **"Provisional headers are shown"**: 실제 응답을 받기 전에 요청이 막혔거나, 응답에 CORS 헤더가 없어 브라우저가 응답을 버린 경우에 자주 나타납니다. -- **Response Headers가 비어 있음**: 브라우저가 응답을 “보안상” JS에 노출하지 않아서, 개발자 도구에도 최종 응답 헤더가 안 보일 수 있습니다. - -### 3.2 서버 측에서는 - -- auth-BE는 **이메일 발송 후 200 + JSON** 을 정상적으로 반환합니다. -- 즉, **이메일은 보내졌을 가능성이 높고**, “응답을 안 내려준다”가 아니라 **“브라우저가 그 응답을 클라이언트 코드에 넘기지 않는”** 상황입니다. - ---- - -## 4. 원인 정리 - -| 구분 | 설명 | -|------|------| -| **실제 원인** | 브라우저가 보는 **최종 응답**(api.easyappfactory.com이 내려주는 응답)에 CORS 허용 헤더가 없거나, preflight(OPTIONS)가 실패함. | -| **가능한 원인 1** | API Gateway가 **OPTIONS** 요청을 auth-BE로 넘기지 않거나, OPTIONS 응답에 CORS 헤더를 붙이지 않음. | -| **가능한 원인 2** | API Gateway가 auth-BE의 **응답 헤더**(`Access-Control-*`)를 제거하거나 덮어씀. | -| **가능한 원인 3** | API Gateway가 CORS를 전혀 처리하지 않고, auth-BE 응답을 그대로 전달하는데, 프록시 과정에서 CORS 헤더가 빠짐. | -| **CORS 헤더 중복** | Gateway와 auth-BE **둘 다** CORS 헤더를 붙이면 `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`, `Access-Control-Expose-Headers` 등이 **각각 두 번** 전송됨. 동일 헤더 중복 시 브라우저가 올바르게 해석하지 못해 CORS 오류가 발생할 수 있음. | - ---- - -## 5. 어떻게 처리해야 하는가 - -### 5.1 담당 구분 (CORS 단일 책임) - -- **API Gateway (api.easyappfactory.com) 담당** - - OPTIONS(preflight) 처리 - - **최종 응답에 CORS 헤더를 한 번만** 포함 (Gateway에서만 CORS 담당) - -- **auth-BE 담당** - - **CORS를 설정하지 않음.** 배포 환경에서는 항상 API Gateway를 거쳐만 노출되므로, auth-BE는 CORS 헤더를 붙이지 않고 Gateway에서만 CORS를 처리함. 이렇게 하면 CORS 헤더 중복이 사라짐. - -### 5.2 API Gateway에서 할 작업 (권장) - -1. **OPTIONS 요청 처리** - - `OPTIONS /api/v1/auth/**` (및 실제 사용하는 경로)에 대해: - - **방안 A**: auth-BE로 그대로 프록시하고, auth-BE가 내려준 CORS 헤더가 클라이언트까지 전달되도록 설정. - - **방안 B**: Gateway에서 직접 200 응답 + CORS 헤더만 내려주고, 본문은 비워 둠. - -2. **CORS 응답 헤더** - - 최종 응답(200, 4xx, 5xx 모두)에 아래 헤더가 포함되도록 합니다. - - `Access-Control-Allow-Origin: https://auth.easyappfactory.com` - (필요하면 `https://www.growgrammers.store` 등 여러 Origin을 동적으로 허용) - - `Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH` - - `Access-Control-Allow-Headers: *` (또는 필요한 헤더만 나열) - - `Access-Control-Allow-Credentials: true` (쿠키/인증 정보를 보낼 경우) - - `Access-Control-Max-Age: 3600` (preflight 캐시, 선택) - -3. **auth-BE 응답 전달 시** - - auth-BE는 **CORS 헤더를 붙이지 않음** (Gateway 단일 책임). Gateway가 위 CORS 헤더를 **한 번만** 붙여서 클라이언트에 전달하면 됨. - -### 5.3 auth-BE (현재 상태) - -- auth-BE에서는 CORS 설정을 제거했으며, **Gateway에서만 CORS를 처리**하는 구조로 정리됨. -- 로컬에서 프론트(localhost:5173)가 auth-BE(예: localhost:8080)를 **직접** 호출하는 경우에는 CORS가 필요함. 그 경우 로컬에서도 Gateway를 경유하거나, 로컬 개발 시에만 CORS를 켜는 방식을 고려할 수 있음. - ---- - -## 6. 검증 방법 - -### 6.1 Preflight(OPTIONS) 확인 - -```bash -curl -X OPTIONS "https://api.easyappfactory.com/api/v1/auth/email/request" \ - -H "Origin: https://auth.easyappfactory.com" \ - -H "Access-Control-Request-Method: POST" \ - -H "Access-Control-Request-Headers: Content-Type" \ - -v -``` - -- 응답이 **200**이고, 헤더에 `Access-Control-Allow-Origin: https://auth.easyappfactory.com` 등이 있으면 preflight는 정상. - -### 6.2 실제 POST 후 응답 헤더 확인 - -```bash -curl -X POST "https://api.easyappfactory.com/api/v1/auth/email/request" \ - -H "Origin: https://auth.easyappfactory.com" \ - -H "Content-Type: application/json" \ - -d '{"email":"test@example.com"}' \ - -v -``` - -- 응답 헤더에 `Access-Control-Allow-Origin` 이 있는지 확인. - -### 6.3 브라우저에서 - -- CORS 수정 후 개발자 도구 → Network 탭에서 해당 요청 선택. -- **Response Headers**에 `Access-Control-Allow-Origin` 이 **한 번만** 보이고, Console에 CORS 에러가 사라지면 해결된 것임. - -### 6.4 CORS 헤더 중복 확인 - -- 응답 헤더에 `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`, `Access-Control-Expose-Headers: Authorization` 가 **각각 두 번** 나오면 Gateway와 auth-BE 둘 다 CORS를 붙이고 있는 상태임. auth-BE에서 CORS를 제거했는지, Gateway에서만 한 번 붙이는지 확인할 것. - ---- - -## 7. OAuth 응답과 CORS - -### 7.1 OAuth에서 "응답을 받는" 부분 - -- **리다이렉트**: 사용자가 카카오/구글/네이버 로그인 후 브라우저가 리다이렉트되는 곳은 **프론트 URL** (예: `https://www.growgrammers.store/auth/kakao/callback?code=...`) 임. 이건 탑 레벨 내비게이션이므로 **CORS와 무관**함. -- **실제로 CORS가 적용되는 부분**: 프론트 페이지에서 **fetch/XHR** 로 `POST https://api.easyappfactory.com/api/v1/auth/social/login` (또는 `/api/v1/auth/kakao/login` 등)을 호출하고, **JSON 응답 + Authorization 헤더 + Set-Cookie** 를 받을 때임. - 이 응답은 **api.easyappfactory.com(Gateway)** 가 내려주므로, **Gateway 응답에 CORS 헤더가 한 번만** 있으면 브라우저가 정상적으로 JS에 응답을 넘김. - -### 7.2 auth-BE CORS 제거 후 OAuth 동작 - -- 모든 클라이언트 요청이 **Gateway를 거치므로**: - - 소셜 로그인 URL 조회 (GET), 인가 코드로 로그인 (POST), 토큰 재발급, 로그아웃 등 **모든 API 응답**은 Gateway가 내려줌. - - Gateway에서만 `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`, `Access-Control-Expose-Headers: Authorization` 를 **한 번씩만** 붙이면, OAuth 로그인 후 응답(토큰, 쿠키)을 받는 부분도 동일하게 동작함. - -### 7.3 OAuth 검증 포인트 - -1. **소셜 로그인 POST** - `POST /api/v1/auth/social/login` 또는 `POST /api/v1/auth/kakao/login` 호출 시 응답 헤더에 CORS 관련 헤더가 **한 번만** 있는지, 브라우저 콘솔에 CORS 에러가 없는지 확인. -2. **Authorization 헤더 노출** - `Access-Control-Expose-Headers: Authorization` 가 Gateway 응답에 **한 번만** 있어야 프론트에서 `response.headers.get('Authorization')` 를 읽을 수 있음. -3. **쿠키(Refresh Token)** - `credentials: 'include'` 로 요청했다면 `Access-Control-Allow-Credentials: true` 와 `Access-Control-Allow-Origin` 이 **한 번씩만** 있어야 쿠키가 정상 저장/전송됨. - ---- - -## 8. CORS 헤더 중복과 auth-BE CORS 제거 (적용 내용) - -### 8.1 원인 - -- API Gateway와 auth-BE **둘 다** 동일한 CORS 헤더를 붙이면, 최종 응답에 `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`, `Access-Control-Expose-Headers: Authorization` 등이 **각각 두 번** 전송됨. -- 동일 헤더가 중복되면 브라우저가 올바르게 해석하지 못해 CORS 오류가 발생할 수 있음. - -### 8.2 해결 - -- **auth-BE**: CORS 설정 제거 (`WebConfig`의 `addCorsMappings`, `corsConfigurationSource` Bean 제거, `SecurityConfig`에서 `cors.disable()`). 배포 환경에서는 항상 API Gateway를 거쳐만 노출되므로 auth-BE가 CORS 헤더를 붙일 필요가 없음. -- **Gateway**: 최종 응답에 CORS 헤더를 **한 번만** 붙이도록 설정. OPTIONS 처리 및 `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`, `Access-Control-Expose-Headers: Authorization` 등을 Gateway에서만 담당. - -### 8.3 OAuth - -- 프론트가 받는 모든 API 응답(소셜 로그인 POST 포함)은 **Gateway를 경유**하므로, Gateway에서만 CORS를 처리하면 OAuth 로그인 후 토큰/쿠키 응답 수신이 정상 동작함. - ---- - -## 9. 요약 - -| 항목 | 내용 | -|------|------| -| **증상** | auth.easyappfactory.com에서 api.easyappfactory.com 호출 시 CORS 에러, "Provisional headers are shown", 응답 헤더 비어 보임. 또는 CORS 헤더가 두 번씩 나와 오동작. | -| **원인** | 브라우저가 보는 최종 응답(api.easyappfactory.com)에 CORS 허용 헤더가 없거나, OPTIONS 처리 미비. 또는 **Gateway와 auth-BE 둘 다 CORS 헤더를 붙여 중복** 발생. | -| **서버 동작** | auth-BE는 200 + JSON을 정상 반환. CORS는 브라우저/응답 헤더 이슈. | -| **조치** | API Gateway에서 OPTIONS 처리 및 모든 응답에 CORS 헤더 **한 번만** 추가. auth-BE에서는 CORS 비활성화. | -| **auth-BE** | CORS 설정 제거 완료. Gateway 단일 책임. | -| **OAuth** | 소셜 로그인 POST 등 모든 API 응답이 Gateway 경유이므로, Gateway CORS만으로 OAuth 응답(토큰/쿠키) 정상 수신 가능. | - -이 문서는 auth-BE 팀이 CORS 오류 원인을 설명하고, Gateway 팀에 전달할 때 함께 참고할 수 있도록 작성되었습니다. diff --git a/docs/ENV.md b/docs/ENV.md deleted file mode 100644 index 35cba4d..0000000 --- a/docs/ENV.md +++ /dev/null @@ -1,105 +0,0 @@ -# 환경변수 분리 설계 (auth-be) - -애플리케이션 설정값을 빌드 타임 / 런타임(민감) / 일반 설정으로 나누어 관리합니다. - -## 요약 - -| 구분 | 저장소 | 용도 | -|------|--------|------| -| **빌드 타임** | GitHub Actions Secrets | Docker 이미지 빌드 시 필요한 값 (SonarQube 토큰, 프라이빗 레포 인증 등) | -| **런타임 (민감)** | AWS Secrets Manager 또는 GitHub Secret `ENV_FILE` | DB 비밀번호, JWT 시크릿, 메일 비밀번호, OAuth client secret 등 | -| **일반 설정** | Docker `env_file` 또는 ECS/EC2 환경변수 | 프로필, 포트, 비민감 설정 | - -### EC2 Docker 배포 시 단일 env 파일 (ENV_FILE → ~/env/auth-be.env) - -배포는 **Docker 방식**으로 수행하며, 환경변수는 **단일 파일**로만 사용합니다. - -- **GitHub**: 전체 .env 내용을 **한 개의 Secret**에 넣어 둠. 시크릿 이름: **`ENV_FILE`**. (Repository Settings → Secrets and variables → Actions에서 추가 후, 로컬 .env 파일 전체를 복사·붙여넣기.) -- **EC2**: 배포 시 CI가 `ENV_FILE` 값을 **`~/env/auth-be.env`** 에 복사. 파일명은 패키지명.env 형식으로, 다른 도커 서비스와 구분. -- **실행**: 컨테이너는 `docker run --env-file ~/env/auth-be.env` 로 해당 파일을 로드. - ---- - -## 1. 빌드 타임 변수 (GitHub Actions Secrets) - -Docker `docker build` 시 `--build-arg`로 전달하는 값. Dockerfile에는 선택적 ARG로 선언. - -| 변수명 | 설명 | 비고 | -|--------|------|------| -| `SONAR_TOKEN` | SonarQube 분석 토큰 | SonarQube 연동 시 사용 | -| (기타) | 프라이빗 Maven/레포 인증 | 필요 시 추가 | - -현재 auth-be 빌드에 필수인 빌드 타임 변수는 없음. CI에서 `docker build` 시 필요 시에만 Secrets에 등록 후 `build-args`로 전달. - ---- - -## 2. 런타임 변수 (AWS Secrets Manager) - -민감 정보. ECS/EC2 등에서 컨테이너 실행 전에 Secrets Manager에서 조회해 환경변수로 주입. - -| 변수명 | 설명 | -|--------|------| -| `DB_PASSWORD` | DB 접속 비밀번호 | -| `JWT_SECRET` | JWT 서명용 비밀키 | -| `MAIL_PASSWORD` | 메일 발송용 비밀번호 | -| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | -| `KAKAO_CLIENT_SECRET` | Kakao OAuth client secret | -| `NAVER_CLIENT_SECRET` | Naver OAuth client secret | - ---- - -## 3. 일반 설정값 (Docker Compose `env_file` 또는 ECS task 정의) - -프로필, 포트, 비민감 연결 정보 등. `env_file` 또는 task definition 환경변수로 주입. - -| 변수명 | 설명 | 예시 | -|--------|------|------| -| `SPRING_PROFILES_ACTIVE` | 활성 프로필 | `prod,jwt,oauth` | -| `SERVER_PORT` | 서버 포트 (선택) | `9000` (application.yml에 이미 9000 설정됨) | -| `DB_HOST` | DB 호스트 | | -| `DB_PORT` | DB 포트 | | -| `DB_NAME` | DB 이름 | | -| `DB_USERNAME` | DB 사용자명 | | -| `MAIL_USERNAME` | 메일 계정 (이메일) | | -| `SWAGGER_PATH` | Swagger UI 경로 | | -| `APP_DEFAULT_ZONE` | 기본 타임존 | `Asia/Seoul` | -| `CORS_ALLOWED_ORIGINS` | CORS 허용 오리진 목록 | | -| `GOOGLE_CLIENT_ID` | Google OAuth client id | | -| `GOOGLE_REDIRECT_URI` | Google OAuth redirect URI | | -| `KAKAO_CLIENT_ID` | Kakao OAuth client id | | -| `KAKAO_REDIRECT_URI` | Kakao OAuth redirect URI | | -| `NAVER_CLIENT_ID` | Naver OAuth client id | | -| `NAVER_REDIRECT_URI` | Naver OAuth redirect URI | | -| `JWT_ACCESS_TOKEN_EXPIRATION` | 액세스 토큰 만료 (Duration 형식) | | -| `JWT_REFRESH_TOKEN_EXPIRATION` | 리프레시 토큰 만료 (Duration 형식) | | - ---- - -## Docker 실행 예시 - -### EC2 배포 (CI에서 ENV_FILE → ~/env/auth-be.env 사용) - -CI(GitHub Actions)가 `ENV_FILE` Secret 내용을 EC2의 `~/env/auth-be.env`에 쓴 뒤, 아래처럼 실행합니다. - -```bash -docker run -d \ - --restart unless-stopped \ - --name auth-be \ - -p 9000:9000 \ - --env-file ~/env/auth-be.env \ - /auth-server:latest -``` - -`~/env/auth-be.env`는 CI가 GitHub Secret `ENV_FILE` 내용을 EC2에 써 넣은 파일이며, 다른 서비스와 구분하기 위해 패키지명.env 형식(auth-be.env)을 사용합니다. - -### 로컬/수동 실행 (env_file + 개별 변수) - -```bash -# env_file로 일반 설정 로드, Secrets Manager 값은 별도 주입 -docker run -d \ - --env-file .env.general \ - -e DB_PASSWORD="$(aws secretsmanager get-secret-value --secret-id prod/auth-be/db --query SecretString --output text)" \ - -e JWT_SECRET="..." \ - -p 9000:9000 \ - /auth-server:latest -``` diff --git a/docs/GITHUB-ENVIRONMENTS.md b/docs/GITHUB-ENVIRONMENTS.md new file mode 100644 index 0000000..9838df8 --- /dev/null +++ b/docs/GITHUB-ENVIRONMENTS.md @@ -0,0 +1,27 @@ +# GitHub Environments (배포 CI) + +배포 워크플로(`.github/workflows/deploy.yml`)는 **GitHub Environments**의 `production` / `alpha`를 사용합니다. +`main` 브랜치 → `production`, 그 외(`dev`, `deploy/alpha` 등) → `alpha`. + +## 설정 절차 + +1. 저장소 **Settings → Environments** +2. **New environment** 로 `production`, `alpha` 각각 생성 +3. 각 환경에 아래 **이름이 동일한** Environment secrets 등록 + +## Environment secrets (이름 통일) + +| Secret 이름 | 설명 | +|-------------|------| +| `DOCKERHUB_USERNAME` | Docker Hub 사용자명 | +| `DOCKERHUB_TOKEN` | Docker Hub Access Token | +| `EC2_HOST` | EC2 SSH 호스트 | +| `EC2_USER` | EC2 SSH 사용자 | +| `EC2_KEY` | SSH 비밀키 — **PEM 원문** 또는 **Base64** | +| `AUTH_BE_ENV_FILE` | 앱 런타임용 `.env` 전체 (멀티라인) | + +## 기존 Secret에서 이전할 때 + +- `PROD_AUTH_BE_ENV_FILE` / `ALPHA_AUTH_BE_ENV_FILE` → 각 환경의 `AUTH_BE_ENV_FILE` +- `PROD_EC2_KEY` / `ALPHA_EC2_KEY` → 각 환경의 `EC2_KEY` +- `PROD_DOCKERHUB_*` / `ALPHA_DOCKERHUB_*` → 각 환경의 `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` diff --git "a/docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" "b/docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" deleted file mode 100644 index d508086..0000000 --- "a/docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" +++ /dev/null @@ -1,171 +0,0 @@ -# OAuth redirect_uri 전략 (auth vs wedding) - -auth.easyappfactory.com(로그인 데모)과 wedding.easyappfactory.com(실제 서비스)에서 같은 auth-BE로 OAuth 가입/로그인을 할 때, **redirect_uri를 하나로 둘지, 두 개로 둘지**와 **각각의 동작 방식**을 정리한 문서입니다. - ---- - -## 1. 전제 - -- **auth.easyappfactory.com**: 로그인 데모/테스트용 페이지 -- **wedding.easyappfactory.com**: 실제 웨딩 서비스 -- **auth-BE**: redirect_uri를 **환경 변수 하나**만 사용 (토큰 교환 시 항상 그 값으로 요청) -- **OAuth 제공자**(Google/Kakao/Naver): 인가 요청 시 사용한 `redirect_uri`와 토큰 요청 시 사용한 `redirect_uri`가 **완전히 같아야** 코드를 인정함 - ---- - -## 2. 방법 A: redirect_uri 하나 (auth 도메인만 사용) - -### 2.1 설정 - -- **등록/사용하는 redirect_uri**: `https://auth.easyappfactory.com/auth/naver/callback` (Google/Kakao도 동일 패턴) -- **auth-BE 환경 변수**: `NAVER_REDIRECT_URI=https://auth.easyappfactory.com/auth/naver/callback` 등 **한 개만** 설정 -- Google/Kakao/Naver 개발자 콘솔에도 **이 URL 하나만** 등록 - -### 2.2 흐름 (wedding에서 로그인하는 경우) - -``` -1. 사용자: wedding.easyappfactory.com 접속 -2. "네이버로 로그인" 클릭 -3. [프론트] 현재 origin 저장 (예: state 또는 session에 "returnUrl=wedding.easyappfactory.com" 등) -4. [프론트] 네이버 인가 URL로 이동 - - redirect_uri = https://auth.easyappfactory.com/auth/naver/callback (항상 auth 도메인) - - state = (CSRF용 랜덤) + (선택) returnUrl 정보 -5. 사용자가 네이버에서 로그인/동의 -6. 네이버가 사용자를 리다이렉트 - → https://auth.easyappfactory.com/auth/naver/callback?code=xxx&state=xxx -7. [auth 도메인 콜백 페이지] - - code, state 수신 - - POST api.easyappfactory.com/api/v1/auth/naver/login { authCode, state, codeVerifier } - - 백엔드는 NAVER_REDIRECT_URI(auth 쪽)로 토큰 요청 → 성공 - - AccessToken(헤더) + RefreshToken(쿠키) 수신 -8. [콜백 페이지] "wedding에서 왔는지" state/returnUrl 등으로 판단 - - wedding에서 왔으면 → window.location = "https://wedding.easyappfactory.com?loggedIn=1" 등으로 이동 - - auth 데모에서 왔으면 → auth 쪽 대시/데모 페이지로 유지 -9. wedding.easyappfactory.com 도메인으로 이동했을 때 - - 쿠키는 도메인마다 다르므로, wedding에서는 RefreshToken 쿠키가 없을 수 있음 - - 이 경우 "토큰을 wedding에 전달"하는 방식을 하나 정해야 함 (아래 참고) -``` - -### 2.3 wedding으로 “로그인 결과” 전달하는 방법 - -콜백이 **auth.easyappfactory.com** 이라서, wedding과는 **쿠키/스토리지가 공유되지 않습니다**. 그래서 다음 중 하나가 필요합니다. - -- **A-1. URL fragment/query로 토큰 전달** - - auth 콜백에서 `https://wedding.easyappfactory.com/auth/callback#access_token=xxx` 또는 `?token=xxx` 로 리다이렉트 - - wedding의 `/auth/callback` 페이지가 토큰을 읽어서 자체 쿠키/메모리에 저장 - - 단점: URL에 토큰이 잠깐 노출·히스토리 남음 (가능하면 fragment + 짧은 유효시간 권장) - -- **A-2. postMessage / popup** - - wedding에서 로그인 시 **auth.easyappfactory.com** 을 팝업으로 열고, OAuth 후 auth 콜백에서 `opener.postMessage({ accessToken, ... }, "https://wedding.easyappfactory.com")` 로 전달 - - wedding은 `message` 이벤트로 토큰 수신 후 팝업 닫기 - - 단점: 팝업 차단·UX 고려 필요 - -- **A-3. wedding에서도 API 호출은 api.easyappfactory.com으로, 쿠키는 공유하지 않음** - - auth 콜백에서 wedding으로 리다이렉트할 때 **토큰을 URL(fragment 등)로 한 번만 전달** - - wedding은 그 토큰으로 API 호출하고, 필요하면 **자체 세션/쿠키**만 관리 - - 즉 “로그인 결과”를 받는 건 wedding 한 번뿐이고, 이후는 wedding이 갖고 있는 토큰/세션만 사용 - -실제 서비스가 wedding이면, **A-1 또는 A-2**로 “auth 콜백 → wedding으로 토큰 전달” 한 번 정의해 두고, 이후는 wedding만 쓰는 구조가 자연스럽습니다. - -### 2.4 방법 A 정리 - -| 장점 | 단점 | -|------|------| -| redirect_uri 하나만 등록·관리 | wedding으로 넘길 때 토큰 전달 방식 필요 | -| auth-BE 수정 불필요 (현재 구조 그대로) | auth 콜백 페이지가 “returnUrl/state 처리 + wedding 리다이렉트” 로직 필요 | -| 제공자 콘솔 설정 단순 | | - ---- - -## 3. 방법 B: redirect_uri 두 개 (auth + wedding 각각) - -### 3.1 설정 - -- **등록하는 redirect_uri** - - `https://auth.easyappfactory.com/auth/naver/callback` - - `https://wedding.easyappfactory.com/auth/naver/callback` -- Google/Kakao/Naver 개발자 콘솔에 **두 URL 모두** 등록 -- **auth-BE**: 토큰 교환 시 “인가 요청에서 썼던 redirect_uri”를 그대로 써야 하므로, **클라이언트가 썼던 redirect_uri를 API로 받아서** 토큰 요청에 넣어줘야 함 (지금은 환경 변수 하나만 사용) - -### 3.2 흐름 (wedding에서 로그인하는 경우) - -``` -1. 사용자: wedding.easyappfactory.com 에서 "네이버로 로그인" -2. [프론트] redirect_uri = https://wedding.easyappfactory.com/auth/naver/callback -3. 네이버 인가 → 로그인 후 wedding 도메인으로 리다이렉트 -4. wedding.easyappfactory.com/auth/naver/callback 에서 code 수신 -5. [프론트] POST /api/v1/auth/naver/login - - body: { authCode, state, codeVerifier, redirectUri: "https://wedding.easyappfactory.com/auth/naver/callback" } ← 추가 필요 -6. [auth-BE] Naver 토큰 요청 시 이 redirectUri 사용 (현재는 미지원) -7. 토큰 발급 후 쿠키/헤더 설정 - - 응답이 wedding 도메인 요청으로 오므로, Set-Cookie 도 wedding 도메인 기준 (같은 API 서버라면 보통 api.easyappfactory.com; 쿠키는 그 도메인에 설정됨) -``` - -### 3.3 auth-BE 변경 필요 사항 - -- **NaverSocialLoginRequestDto** 등에 `redirectUri: String?` (또는 필수) 추가 -- **NaverOAuthClient.getAccessToken** (및 Google/Kakao 동일) 호출 시, 클라이언트가 보낸 `redirectUri`를 사용하거나, 없으면 환경 변수 fallback -- 제공자별로 “등록된 redirect_uri 목록” 검증을 넣으면 보안상 좋음 (지금은 생략 가능) - -### 3.4 방법 B 정리 - -| 장점 | 단점 | -|------|------| -| wedding에서 로그인 시 콜백이 wedding이라, 토큰/쿠키를 같은 도메인에서 바로 처리하기 쉬움 | auth-BE 수정 필요 (redirect_uri를 요청에서 받아서 토큰 교환에 사용) | -| “auth로 갔다가 다시 wedding으로 보내기” 로직 불필요 | 제공자 콘솔에 redirect_uri 두 개 등록·갱신 필요 | -| 데모(auth)와 실제 서비스(wedding) 경로가 분리됨 | | - ---- - -## 4. 어떤 방법이 더 좋은가 - -### 4.1 상황 정리 - -- **auth.easyappfactory.com** = 로그인 **데모** -- **wedding.easyappfactory.com** = **실제 서비스** -- 현재 auth-BE는 **redirect_uri 하나**만 지원 - -### 4.2 추천: **방법 A (redirect_uri 하나, auth 도메인)** - -이유 요약: - -1. **데모는 부가 기능** - 실제 서비스는 wedding이므로, “데모(auth)와 실제(wedding)가 같은 redirect_uri를 쓰고, wedding은 콜백 후 한 번만 토큰을 받으면 된다”로 정리하는 편이 단순합니다. - -2. **백엔드 수정 없음** - 방법 B는 DTO·OAuth 클라이언트·검증 로직을 건드려야 합니다. 방법 A는 프론트/데모 쪽만 정하면 됩니다. - -3. **등록/운영 단순** - 제공자 콘솔에 redirect_uri를 **한 개만** 두고, 만료/변경 시에도 한 곳만 관리하면 됩니다. - -4. **데모의 역할이 명확** - auth는 “로그인 플로우 보여주기 + 테스트”용이고, 실제 로그인 완료·토큰 보관은 wedding에서만 하면 됩니다. - wedding에서 로그인할 때도 “인가 요청·콜백은 auth URL로 통일 → auth 콜백에서 wedding으로 한 번 리다이렉트하며 토큰 전달”이면 됩니다. - -### 4.3 방법 A로 갈 때 구현 요약 - -- **공통** - - 모든 OAuth 시작 시 `redirect_uri = https://auth.easyappfactory.com/auth/{google|kakao|naver}/callback` 로 고정 - - auth-BE `*_REDIRECT_URI` 와 제공자 콘솔도 위 URL 하나로 통일 - -- **auth.easyappfactory.com (데모)** - - 위 콜백 URL의 페이지가 code/state 수신 → auth-BE 로그인 API 호출 → 받은 토큰으로 데모 UI 표시 - - “returnUrl” 없으면 데모 페이지에 그대로 머무름 - -- **wedding.easyappfactory.com (실제 서비스)** - - 로그인 시작 시 state(또는 별도 저장)에 `returnUrl=https://wedding.easyappfactory.com/...` 포함 - - OAuth 완료 후 auth 콜백에서 `returnUrl` 확인 → wedding으로 리다이렉트하면서 **토큰 전달** - - 토큰 전달 방식: fragment (`#access_token=...`) 또는 postMessage 중 하나로 통일하고, wedding은 그걸 받아서 저장 후 사용 - -이렇게 하면 **redirect URL은 하나만 두고**, auth(데모)와 wedding(실제 서비스) 모두에서 가입/로그인할 수 있으며, “어떤 방법이 더 좋은지”에는 **방법 A(단일 redirect_uri + auth 콜백에서 wedding으로 전달)** 를 추천합니다. - ---- - -## 5. 요약 표 - -| 구분 | 방법 A (redirect_uri 1개) | 방법 B (redirect_uri 2개) | -|------|---------------------------|----------------------------| -| redirect_uri | auth.easyappfactory.com 만 사용 | auth + wedding 각각 등록 | -| auth-BE 변경 | 없음 | redirect_uri를 요청에서 받아서 사용하도록 수정 | -| wedding 로그인 후 | auth 콜백 → wedding으로 리다이렉트 + 토큰 전달 | wedding 콜백에서 바로 처리 | -| 추천 | **데모(auth) + 실제(wedding) 구조에 적합** | wedding 단독 앱처럼 쓸 때 유리 | diff --git a/docs/google-login-guide.md b/docs/google-login-guide.md new file mode 100644 index 0000000..9d9ef47 --- /dev/null +++ b/docs/google-login-guide.md @@ -0,0 +1,95 @@ +안드로이드 앱 전용 구글 로그인 기능 구현을 위해 백엔드(Spring Boot) 개발자가 진행해야 할 전체 프로세스를 요약해 드립니다. + +--- + +## 1. 아키텍처 및 정책 요약 + +* **방식:** 웹의 인가 코드(Code) 방식이 아닌, 앱에서 직접 구글의 **ID Token**을 받아와 백엔드로 전달하는 방식 (구글 Credential Manager 활용). +* **API 엔드포인트:** `POST /api/v1/auth/google/login/app` +* **플랫폼 식별 헤더:** `X-Client-Id: easy-snap-and-app` +* **응답 정책:** 앱이므로 인증 성공 시 JWT(AT, RT)를 **JSON Body**에 담아 응답합니다. (웹은 쿠키 사용) +* **토큰 수명 (권장):** App 특성을 고려해 AT는 1~2시간, RT는 14~30일 이상으로 웹보다 길게 설정하고 **Sliding Window(슬라이딩 윈도우)** 연장 방식을 적용합니다. + +--- + +## 2. 사전 준비 (Google Console & 라이브러리) + +1. **Google Cloud Console 확인:** +* 앱 인증이라도 서버에서 ID Token을 검증하기 위해서는 보통 **Web Client ID**가 필요합니다. 이 ID 값을 서버 코드에 복사해 둡니다. + + +2. **의존성 추가 (`build.gradle.kts`):** +* 구글의 ID Token을 서버에서 자체 검증하기 위해 공식 라이브러리를 추가합니다. + + +```kotlin +implementation("com.google.api-client:google-api-client:2.2.0") + +``` + + + +--- + +## 3. 백엔드 핵심 구현 단계 (Hexagonal Architecture 권장) + +### Step 1: 클라이언트 식별 처리 (Interceptor / Proxy) + +* 모든 요청에서 `X-Client-Id`를 확인합니다. +* `easy-snap-and-app`이 들어왔을 때 유효한 앱 클라이언트인지 검증하고, 이 요청은 "토큰을 Body로 내려줘야 하는 요청"임을 서버 내부 컨텍스트에 기록합니다. + +### Step 2: API 엔드포인트 생성 (Inbound Adapter) + +* `@RequestMapping`을 사용하지 않고 전체 경로를 메서드에 명시합니다. + +```kotlin +@PostMapping("/api/v1/auth/google/login/app") +fun loginWithApp( + @RequestHeader("X-Client-Id") clientId: String, + @RequestBody request: GoogleAppLoginRequest // idToken을 포함하는 DTO +): ResponseEntity { ... } + +``` + +### Step 3: 구글 ID Token 검증 (Application Service) + +* 앱에서 넘겨받은 긴 문자열(`idToken`)이 조작되지 않았는지 구글 라이브러리로 검증합니다. 서버가 구글 서버로 다시 요청을 보낼 필요가 없습니다. + +```kotlin +val verifier = GoogleIdTokenVerifier.Builder(NetHttpTransport(), GsonFactory()) + .setAudience(listOf("구글_콘솔에서_가져온_Web_Client_ID")) + .build() + +val idToken: GoogleIdToken = verifier.verify(idTokenString) + ?: throw InvalidTokenException("유효하지 않은 구글 토큰입니다.") + +``` + +### Step 4: 사용자 정보 추출 및 매핑 (Business Logic) + +* 검증이 끝난 토큰에서 사용자 정보(Payload)를 꺼냅니다. + +```kotlin +val payload = idToken.payload +val email = payload.email +val googleSub = payload.subject // 구글 고유 유니크 ID + +``` + +* 해당 이메일로 기존 DB를 조회하여, 이미 가입된 회원이면 로그인을 진행하고, 없다면 새로 회원가입 처리를 합니다. + +### Step 5: 자체 토큰 발급 및 응답 생성 + +* 인증이 완료된 사용자에 대해 우리 서비스만의 Access Token(AT)과 Refresh Token(RT)을 생성합니다. +* 토큰 정보가 담긴 `LoginResponse` DTO를 **JSON Body**로 묶어 클라이언트(안드로이드 앱)에 반환합니다. + +--- + +## 4. 기존 시스템과의 공존 (Migration Strategy) + +* **웹 (기존):** 여전히 브라우저 기반의 인가 코드(Code) 방식을 사용하며, 서버가 구글 서버와 통신 후 응답은 쿠키로 내려줍니다. +* **앱 (신규):** `/login/app` 경로를 통해 **ID Token** 검증 방식을 독립적으로 수행하므로 기존 웹 로그인 로직(`auth/google/login` 등)의 코드를 건드릴 필요가 없습니다. + +``` + +``` \ No newline at end of file diff --git "a/docs/\354\206\214\354\205\234\353\241\234\352\267\270\354\235\270_API_\354\232\224\354\262\255\354\212\244\355\216\231.md" "b/docs/\354\206\214\354\205\234\353\241\234\352\267\270\354\235\270_API_\354\232\224\354\262\255\354\212\244\355\216\231.md" deleted file mode 100644 index bbc8275..0000000 --- "a/docs/\354\206\214\354\205\234\353\241\234\352\267\270\354\235\270_API_\354\232\224\354\262\255\354\212\244\355\216\231.md" +++ /dev/null @@ -1,83 +0,0 @@ -# 소셜 로그인 API 요청 스펙 (400 에러 방지) - -`POST /api/v1/auth/{google|kakao|naver}/login` 호출 시 **요청 바디 필드명/필수값**이 다르면 `@Valid` 검증 실패로 **400 Bad Request**가 발생합니다. -클라이언트는 아래 스펙과 **완전히 동일한** 필드명을 사용해야 합니다. - -## redirect_uri (선택) - -- **요청 body**에 `redirectUri`가 들어오면 그 값을 토큰 교환 시 사용합니다. -- **null이거나 비어 있으면** 서버 기본값(환경 변수 `GOOGLE_REDIRECT_URI` / `KAKAO_REDIRECT_URI` / `NAVER_REDIRECT_URI`)을 사용합니다. - ---- - -## Naver 로그인 `POST /api/v1/auth/naver/login` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| **authCode** | string | O | Naver OAuth2 인가 코드 (쿼리 `code`) | -| **state** | string | O | 인가 요청 시 보냈던 `state`와 동일한 값 (CSRF 방지) | -| **codeVerifier** | string | O | PKCE용 코드 검증자 | -| **redirectUri** | string | X | 인가 요청 시 사용한 redirect_uri. 없으면 서버 기본값 사용. | - -**예시** -```json -{ - "authCode": "네이버에서_받은_인가코드", - "state": "인가_요청시_사용한_state_값", - "codeVerifier": "PKCE_코드_검증자", - "redirectUri": "https://wedding.easyappfactory.com/auth/naver/callback" -} -``` - -- `code`가 아니라 **`authCode`** 여야 합니다. -- **`state`** 를 빼면 400 발생 (Naver 전용 필수). - ---- - -## Google 로그인 `POST /api/v1/auth/google/login` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| **authCode** | string | O | Google OAuth2 인가 코드 | -| **codeVerifier** | string | O | PKCE용 코드 검증자 | -| **redirectUri** | string | X | 인가 요청 시 사용한 redirect_uri. 없으면 서버 기본값 사용. | - -**예시** -```json -{ - "authCode": "4/0AfJohXmx...", - "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", - "redirectUri": "https://wedding.easyappfactory.com/auth/google/callback" -} -``` - -- `code`, `redirectUri`가 아니라 **`authCode`** 만 사용. `redirectUri`는 서버 환경변수로 처리. - ---- - -## Kakao 로그인 `POST /api/v1/auth/kakao/login` - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| **authCode** | string | O | Kakao 인가 코드 | -| **codeVerifier** | string | O | PKCE용 코드 검증자 | -| **redirectUri** | string | X | 인가 요청 시 사용한 redirect_uri. 없으면 서버 기본값 사용. | - -**예시** -```json -{ - "authCode": "9d8fYl7x2zQ...", - "codeVerifier": "NgAfIySigI...IVxKxbmrpg", - "redirectUri": "https://wedding.easyappfactory.com/auth/kakao/callback" -} -``` - ---- - -## 400이 나는 흔한 원인 - -1. **필드명 불일치**: `code` 로 보내면 안 되고 **`authCode`** 로 보내야 함. -2. **Naver에서 `state` 누락**: Naver만 `state` 필수. -3. **빈 문자열/누락**: `authCode`, `codeVerifier`(및 Naver의 `state`)가 비어 있거나 null이면 400. - -응답 본문에 `authCode는 필수입니다`, `state는 필수입니다`, `codeVerifier는 필수입니다` 등의 메시지가 오면 위 스펙을 다시 확인하면 됩니다. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 186597e..07afccf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,6 +55,18 @@ app: internal: secret: ${INTERNAL_API_SECRET} +management: + endpoints: + web: + exposure: + include: health + base-path: /actuator + endpoint: + health: + show-details: never + probes: + enabled: true + project: logging: enabled: true diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 332b9a9..ff9e8fb 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,13 +3,13 @@ + + - - @@ -19,4 +19,16 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + From e99895da18f5062507cedba009619933be0b8ea3 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Sun, 30 Aug 2026 18:00:52 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat(auth):=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=EC=A6=89=EC=8B=9C=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=C2=B7=20RT=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=ED=83=90=EC=A7=80?= =?UTF-8?q?=20=C2=B7=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): 임의 사용자 AT를 발급하던 테스트 컨트롤러 제거 인증 없이 opaqueId만 넘기면 유효 서명 AT를 발급하는 /api/public/token이 코드에 남아 있었다(주석에 '나중에 제거 예정'). 게이트웨이가 /api/public/**을 라우팅하지 않아 인터넷에서 직접 닿지는 않으나, 내부망에 닿기만 하면 전 계정 탈취가 된다. SSRF 하나, 컨테이너 침해 하나, 라우트 한 줄이면 실현된다. 컨트롤러 파일을 삭제하고 SecurityConfig의 permitAll 목록에서도 /api/public/**을 제거한다. 이 엔드포인트에 의존하던 통합 테스트 2건을 옮겼다. - 인증 필요 경로: 토큰을 발급하지 않는 테스트 전용 프로브 컨트롤러 (src/test 에만 존재하므로 배포 아티팩트에 포함되지 않는다) - permitAll 경로: /v3/api-docs 작업 중 확인한 것 — permitAll 경로로 /actuator/health를 먼저 시도했으나 테스트 환경에서 health 인디케이터가 DOWN이라 503이 났다. 보안 통과 여부가 아니라 인프라 상태를 재는 테스트가 되어 버려 외부 의존이 없는 /v3/api-docs로 바꿨다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * feat(auth): 토큰 폐기 시각(tokens_invalid_before) 기록 로그아웃 시 RT만 폐기되고 AT는 만료(30분)까지 그대로 유효했다. 세션 전체를 stateful 하게 만들지 않기 위해 사용자당 타임스탬프 하나만 두고 로그아웃·탈퇴 시각을 기록한다. 이 커밋은 값을 쌓기만 한다 — 읽는 쪽이 아직 없어 동작 변화가 없다. introspect의 폐기 판정은 다음 커밋에서 붙인다. logout·withdraw 모두 @Transactional이라 dirty checking으로 반영된다(save 불필요). 탈퇴는 회원 행 자체를 삭제하므로 이 값이 남지 않는다. 삭제 트랜잭션 커밋 전후의 짧은 창과 게이트웨이 introspect 캐시를 위해 기록하되, 삭제 이후의 판정은 다음 커밋의 '회원 부재 = 폐기' 규칙이 담당한다. ddl-auto: update(local·alpha·prod 전부)라 마이그레이션 파일은 없다. 기존 행은 NULL(폐기 이력 없음)이 된다. 롤백해도 컬럼만 남고 무해하다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * feat(auth): introspect에 토큰 폐기 확인 추가 로그아웃·탈퇴 후에도 옛 AT가 만료(30분)까지 통과하던 문제를 닫는다. 두 가지를 모두 거른다. - 회원 행 부재(=탈퇴): 탈퇴는 hard delete라 tokens_invalid_before를 읽을 수 없다. 행 부재 자체를 폐기 신호로 쓴다. - iat <= tokens_invalid_before: 로그아웃·탈퇴 이전 발급분. iat는 초 단위라 같은 초에 발급된 토큰도 거부하도록 isAfter의 부정으로 비교한다. 설계 문서와 달라진 점 — 설계 초안의 판정은 findTokensInvalidBeforeByOpaqueId(...) ?: return 형태였는데, 탈퇴가 회원 행을 삭제하므로 조회 결과가 null이 되어 그대로 통과했다. 즉 탈퇴 계정의 옛 AT가 살아남는다(설계서 S8 시나리오가 성립하지 않음). 이를 막기 위해 리포지토리 반환형을 Instant?가 아니라 List로 두었다 — 스칼라 프로젝션에서 Instant?를 쓰면 '회원 없음'과 '폐기 이력 없음'이 둘 다 null로 뭉개져 구분할 수 없기 때문이다. 판정은 컨트롤러가 아니라 AuthService에 두었다. AuthController는 리포지토리를 주입받지 않으며, 기존 계층(controller → service → repository)을 지킨다. silent refresh 경로에는 넣지 않았다. 로그아웃 시 RT가 softDelete되어 findActiveByOpaqueIdAndJti가 먼저 실패하므로 두 방어가 서로 보완한다. 성능: AT 유효 경로에 DB 읽기가 하나 늘어난다(opaque_id unique 인덱스 단일 조회, idx_member_opaque_id 존재 확인함). 게이트웨이 introspect 캐시(TTL 45초)가 앞단에서 막아 캐시 미스에서만 발생한다. 배포 후 응답시간을 관찰할 것. 테스트 8건 추가(getIssuedAt 3건, 폐기 판정 5건). Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * feat(auth): RT 재사용 탐지 및 토큰 패밀리 폐기 회전(rotation)은 하고 있었으나 탐지가 없었다. 도난된 RT를 공격자가 먼저 쓰면 ① 공격자는 새 AT/RT 쌍을 얻고 ② 정상 사용자의 다음 갱신만 실패해 정상 사용자만 로그아웃되며 ③ 도난 사실은 아무도 모른다. active가 없을 때 '존재한 적 없는 jti'와 '이미 회전되어 폐기된 jti'를 구분한다. 후자면 도난 정황으로 보고 해당 사용자의 RT 전량과 AT(tokens_invalid_before)를 폐기한다. 정상 사용자도 재로그인해야 하지만 계정이 탈취된 상황에서는 그게 옳다. 구분을 위해 리포지토리 메서드 2개를 추가했다 — 기존에는 삭제분 포함 조회와 패밀리 전체 폐기가 없었다. - findByOpaqueIdAndJtiIncludingDeleted - softDeleteAllByOpaqueId 보안 알림 채널 통지는 TODO로 남겼다 — 전송 대상 미정(운영 협의 필요). 단순 오류·만료 토큰까지 패밀리를 폐기하지 않도록 테스트로 고정했다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * fix(rate-limit): 인증 전 요청이 anonymous 버킷 하나를 공유하던 문제 AT가 만료되어 silent refresh를 타는 introspect 요청은 SecurityContext가 비어 있어 버킷 키가 전부 'anonymous'였다. 60회/분 버킷을 전 사용자가 나눠 쓰는 셈이라, AT 만료 직후 트래픽이 몰리면 무관한 사용자가 429를 맞는다. 인증 전에는 클라이언트 IP를 키로 쓴다. 이 서비스는 게이트웨이 뒤에 있어 remoteAddr이 게이트웨이 IP로 고정되므로 X-Forwarded-For 첫 값을 먼저 본다. 그러지 않으면 다시 버킷 하나를 공유하게 된다. AT 수명 단축을 검토하게 되면 이 변경이 선행 조건이다 — AT를 짧게 하면 refresh 트래픽이 몇 배로 늘고 그 경로가 바로 이 버킷을 탄다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * fix(security): internal-api 시크릿 확인을 컨트롤러에서 필터로 승격 X-Internal-Secret 확인이 InternalMemberController 메서드 안에 있어, 새 내부 컨트롤러를 추가하면서 빠뜨리면 그대로 노출되는 구조였다. SecurityConfig에서 해당 경로가 permitAll이기 때문이다. 경로 단위로 강제되도록 OncePerRequestFilter로 올린다. 비교는 상수시간(MessageDigest.isEqual)으로 한다 — 일반 문자열 비교는 앞에서부터 다른 문자를 만나면 즉시 반환하므로 응답 시간 차이로 시크릿을 한 글자씩 추측당할 수 있다. 컨트롤러 내부의 기존 검사는 이중 방어로 남긴다. 작업 중 걸린 것 — KDoc 안에 내부 API 경로를 와일드카드까지 적었더니 'Unclosed comment' 컴파일 에러가 났다. Kotlin 블록 주석은 Java와 달리 중첩되므로 주석 안의 슬래시+별표가 nested comment opener로 읽힌다. 주석에서는 와일드카드를 빼고 표기했다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * fix(auth): 계정 병합으로 soft delete 된 계정의 토큰도 폐기 MemberEntity.softDelete()가 실제로 호출되는 곳이 있다 — MemberConnector가 계정 병합 시 흡수된 회원을 soft delete 한다. 행은 남고 isDeleted=true만 된다. 폐기 판정이 isDeleted를 보지 않아, 흡수된 opaqueId의 AT가 만료(30분)까지 계속 통과했다. 탈퇴 hard delete 건과 같은 유형의 구멍이다. 남의 계정 사칭은 아니고 '없어졌어야 할 자기 옛 신원이 더 살아 있는' 상황이지만, 방치하면 다음에 soft delete를 쓰는 코드에서 또 뚫린다. 두 겹으로 막는다. 1. 사건이 일어난 곳에서 기록 — MemberConnector가 softDelete() 직전에 revokeTokens()를 호출한다. 2. 판정에서도 확인 — assertTokenNotRevoked가 isDeleted를 거른다. 앞으로 1을 빠뜨리는 코드가 생겨도 이 방어선이 막는다(internal-api 시크릿 검사를 컨트롤러에서 필터로 올린 것과 같은 논리). 판정에 isDeleted까지 필요해져 조회 반환형을 List에서 MemberRevocationState?(data class)로 바꿨다. null = 회원 없음이 타입으로 드러나고, 판정 로직은 Kotlin에 남아 단위 테스트로 분기를 고정할 수 있다. 조건을 JPQL에 넣는 방식은 로직이 mock 뒤로 숨어 단위 테스트가 무의미해지므로 택하지 않았다. 작업 중 걸린 것 — 처음에 인터페이스 프로젝션으로 만들었더니 isDeleted가 항상 null로 들어와 Kotlin non-null 타입에서 NPE가 났다. Kotlin의 val isDeleted는 게터가 isDeleted()로 컴파일되고 Spring Data는 프로퍼티명을 'deleted'로 해석하는데, 쿼리 별칭은 isDeleted였다. 매칭 실패가 예외가 아니라 조용한 null로 나타난다. 이름 매칭에 의존하지 않는 생성자 표현식으로 바꿨다. 이 실패는 리포지토리를 mock하는 단위 테스트로는 잡히지 않는다. 실제 DB(H2)를 타는 통합 테스트 4건을 함께 추가했고, 그 테스트가 문제를 잡아냈다. 단위 테스트도 병합 케이스 2건을 포함해 7건으로 늘렸다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * fix(auth): RT 재사용 판정에 유예 창 추가 — 동시 요청이 로그아웃을 유발하던 문제 직전 커밋의 재사용 탐지가 정상 사용자를 로그아웃시킬 수 있었다. 경위 — AT 수명은 30분이고 잔여 5분 미만이면 모든 introspect 가 silent refresh 를 탄다. 즉 활성 사용 중 25~30분마다 한 번씩 갱신 구간에 들어간다. 그 구간에 페이지가 API 를 여러 개 동시 호출하면 같은 RT 로 갱신이 겹치고, 하나만 성공한 뒤 나머지는 '이미 회전된 jti'로 보인다. 도난으로 판정하면 토큰 패밀리가 폐기되어 사용자가 재로그인해야 한다. 더 중요한 것은 실패가 조용하지 않다는 점이다. silentRefresh 는 실패 시 clearAuthCookies() 로 쿠키를 지운다. 그래서 '패밀리는 두고 401만' 으로는 로그아웃을 막지 못한다 — 유예 창 안에서는 정상 발급까지 이어가야 한다. active 가 없을 때를 세 갈래로 나눈다. 1. 존재한 적 없는 jti → 401, 패밀리 유지 2. 유예 창 내에 회전된 jti → 정상 발급 (사용자에게 보이지 않음) 3. 그보다 오래된 jti → 도난 정황: 패밀리 폐기 + 401 유예는 app.auth.refresh-reuse-grace-seconds (기본 30초)로 뺐다. 운영 로그를 보고 env 만 고쳐 조정할 수 있다. 0 으로 두면 유예 없이 동작한다. 대가 — 유예 창 안에서는 도난 토큰도 통과한다. 다만 공격자가 정상 회전 직후 몇 초 안에 써야 하므로 창이 매우 좁고, 실제 도난은 대개 한참 뒤에 나타난다. 정상 사용자가 주기적으로 튕기는 쪽이 더 큰 피해라고 판단했다. 테스트 3건으로 세 갈래를 고정했다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * feat(auth): RT 재사용 감지 시 Google Chat 으로 보안 알림 전송 탐지는 되는데 log.warn 에만 남아 아무도 모르는 상태였다. 계정 탈취 정황이므로 운영 채널로 알린다. 알림은 절대 본 요청을 방해하지 않도록 만들었다. - @Async — 인증 요청 스레드를 잡지 않는다. 동기로 부르면 채널이 느릴 때 로그인·갱신이 함께 느려지고, 호출부가 @Transactional 이라 DB 트랜잭션이 네트워크 왕복만큼 열린 채 유지된다. - 어떤 예외도 밖으로 내보내지 않는다. 알림 실패가 인증 실패가 되면 안 된다. - 타임아웃은 기존 RestClient 빈이 갖고 있다(연결 3초 / 응답 3초). auth-api 는 이미 네이버·구글·카카오 OAuth 로 외부 호출을 하고 있어 egress 가 새로 열리는 것은 아니다. 메시지에 자격증명을 담지 않는다. opaqueId 와 jti(토큰 값이 아님), 시각만 싣는다. 웹훅 URL 자체가 시크릿이라 로그에도 남기지 않는다. 설정이 없으면 전송을 건너뛰고 경고만 남긴다 — env 없이도 기동한다. 알림은 도난 정황일 때만 보낸다. 유예 창 안의 동시 요청까지 울리면 신호가 오염되어 진짜 도난을 놓친다. 테스트로 고정했다. 전송 검증은 목킹이 아니라 로컬 스텁 HTTP 서버로 했다. 실제로 어떤 본문이 어떤 헤더로 나가는지 확인해야 Google Chat 의 {"text": ...} 계약을 고정할 수 있다. 5xx 응답·연결 불가·URL 미설정 세 경우 모두 예외가 새지 않는 것도 확인했다. 설정 2개 추가: - app.auth.refresh-reuse-grace-seconds (AUTH_REFRESH_REUSE_GRACE_SECONDS, 기본 30) - app.alert.google-chat-webhook-url (GOOGLE_CHAT_WEBHOOK_URL, 기본 비어 있음) Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * refactor(alert): 보안 알림 env 이름을 SECURITY_ALERT_CHAT_WEBHOOK_URL 로 분리 보안 전용 채널을 따로 쓰기로 하면서 GOOGLE_CHAT_WEBHOOK_URL 이라는 이름이 문제가 됐다. wedding 저장소가 같은 이름을 일반 운영 알림(Sentry 변환·캐시 워밍)에 쓰고 있어서, 이름이 같으면 저장소 간에 값을 옮기다가 보안 경보가 일반 채널로 새거나 그 반대가 되기 쉽다. 같은 이름이 저장소마다 다른 채널을 가리키는 상태는 설정 실수를 부른다. app.alert.security-chat-webhook-url / SECURITY_ALERT_CHAT_WEBHOOK_URL 로 바꿨다. 이름만으로 어느 채널인지 드러난다. 미설정 시 경고 로그에도 정확한 변수명을 넣어, 알림이 안 올 때 무엇을 설정해야 하는지 로그만 보고 알 수 있게 했다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * fix(alert): 보안 알림 제목에 환경 표시 alpha 와 prod 가 같은 웹훅 채널을 공유하기로 해서, 어느 환경 사고인지 한눈에 구분돼야 한다. 기존에는 환경이 본문 둘째 줄에 있어 목록에서 훑을 때 보이지 않았다. 제목 맨 앞으로 옮기고 대문자로 표시한다. 🚨 *[PROD] RT 재사용 감지 — 계정 탈취 정황* 🚨 *[ALPHA] RT 재사용 감지 — 계정 탈취 정황* 환경값은 spring.profiles.active 를 그대로 쓴다. 배포 워크플로가 main → prod, dev → alpha 로 넣어주므로 별도 설정이 필요 없다. 테스트에 환경 표시 검증을 추가했다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL * refactor(config): 비밀·환경 식별 설정의 하드코딩 기본값 제거 기본값이 있으면 설정 주입이 빠져도 애플리케이션이 잘못된 값으로 조용히 뜬다. 없으면 기동 자체가 실패해 배포 시점에 바로 드러난다. 후자가 원인 추적이 쉽다. 제거한 것 (전부 공개돼선 안 되거나 환경마다 달라야 하는 값) - JWT_SECRET: 기본값이 'jwt-secret' 이었다. env 주입이 빠지면 코드에 적힌 알려진 키로 토큰을 서명·검증하게 된다 — 저장소를 본 사람이면 누구나 토큰을 위조할 수 있다. 이 파일의 access-exp·refresh-exp 는 이미 기본값이 없어 일관성도 맞지 않았다. - DB_PASSWORD: 기본값 'postgres'. 자격증명은 코드에 두지 않는다. - INTERNAL_LOGGING_SECRET: 기본값 'default-secret'. - SECURITY_ALERT_CHAT_WEBHOOK_URL: 웹훅 URL 자체가 시크릿이다. 유지한 것 — 공개돼도 문제없고 누락 시 조용히 잘못 동작하지 않는 값. 포트·프로파일·로그레벨·타임존·스웨거 경로·DB 호스트/포트/이름/사용자, 그리고 순수 튜닝 상수(캐시 TTL, RT 재사용 유예 30초). 테스트를 자립적으로 만들었다. 이전에는 gitignore 된 로컬 .env 에 값이 있어야 통과했다 — 새로 클론한 개발자나 CI 에서는 재현되지 않는 실패가 난다. SpringBootTest 4곳에 필수 설정을 명시했고, .env 를 치운 상태에서 115건 통과를 확인했다. 이제 그 목록이 '기동에 필요한 설정'의 문서 역할을 한다. 배포 전 필요 — 두 GitHub Environment(production/alpha)의 AUTH_BE_ENV_FILE 에 INTERNAL_LOGGING_SECRET 과 SECURITY_ALERT_CHAT_WEBHOOK_URL 이 있어야 한다. 없으면 기동에 실패한다(의도된 동작). Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL --- .../api/controller/TestSecurityController.kt | 56 ------ .../api/controller/auth/AuthController.kt | 8 +- .../wq/auth/api/domain/auth/AuthService.kt | 93 +++++++++- .../auth/api/domain/auth/MemberConnector.kt | 3 + .../api/domain/auth/RefreshTokenRepository.kt | 22 +++ .../api/domain/member/MemberRepository.kt | 25 +++ .../domain/member/MemberRevocationState.kt | 28 +++ .../auth/api/domain/member/MemberService.kt | 5 + .../api/domain/member/entity/MemberEntity.kt | 21 +++ .../wq/auth/security/InternalSecretFilter.kt | 46 +++++ .../com/wq/auth/security/jwt/JwtProvider.kt | 18 ++ .../shared/alert/SecurityAlertNotifier.kt | 81 ++++++++ .../wq/auth/shared/config/SecurityConfig.kt | 5 +- .../rateLimiter/RateLimiterInterceptor.kt | 30 ++- src/main/resources/application-jwt.yml | 2 +- src/main/resources/application.yml | 17 +- .../JacksonInstantIntegrationTest.kt | 2 + .../MemberRevocationStateQueryTest.kt | 86 +++++++++ .../SecurityAuthorizationIntegrationTest.kt | 14 +- .../security/_SecurityProbeController.kt | 19 ++ .../com/wq/auth/unit/AuthServiceTest.kt | 174 +++++++++++++++++- .../wq/auth/unit/JwtPropertiesBindingTest.kt | 4 +- .../com/wq/auth/unit/JwtProviderTest.kt | 32 +++- .../com/wq/auth/unit/MemberEntityTest.kt | 33 ++++ .../wq/auth/unit/SecurityAlertNotifierTest.kt | 118 ++++++++++++ 25 files changed, 867 insertions(+), 75 deletions(-) delete mode 100644 src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt create mode 100644 src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt create mode 100644 src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt create mode 100644 src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt create mode 100644 src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt create mode 100644 src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt create mode 100644 src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt b/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt deleted file mode 100644 index 9b3e397..0000000 --- a/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.wq.auth.api.controller - -import com.wq.auth.security.jwt.JwtProvider -import com.wq.auth.security.annotation.AuthenticatedApi -import com.wq.auth.security.annotation.PublicApi -import com.wq.auth.web.common.response.CommonResponse -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -/** - * Security 테스트용 컨트롤러 - * 개발 및 테스트 환경에서 JWT 인증 동작을 확인하기 위한 엔드포인트 제공 - * todo: 나중에 제거 예정 - */ -@RestController -class TestSecurityController( - private val jwtProvider: JwtProvider -) { - - @PublicApi - @GetMapping("/api/public/test") - fun publicTestEndpoint(): CommonResponse> { - val data = mapOf( - "endpoint" to "/api/public/test", - "accessLevel" to "PUBLIC", - "description" to "누구나 접근 가능한 공개 API" - ) - return CommonResponse.success("공개 API 접근 성공", data) - } - - @AuthenticatedApi - @GetMapping("/api/test") - fun authenticatedEndpoint(): CommonResponse> { - val data = mapOf( - "endpoint" to "/api/test", - "accessLevel" to "AUTHENTICATED", - "description" to "로그인한 사용자만 접근 가능한 API" - ) - return CommonResponse.success("인증된 사용자 API 접근 성공", data) - } - - @PublicApi - @GetMapping("/api/public/token") - fun generateTestToken( - @RequestParam(defaultValue = "550e8400-e29b-41d4-a716-446655440000") opaqueId: String, - ): CommonResponse> { - val token = jwtProvider.createAccessToken(opaqueId) - val data = mapOf( - "token" to token, - "opaqueId" to opaqueId, - "usage" to "Authorization: Bearer $token" - ) - return CommonResponse.success("JWT 토큰 발급 성공", data) - } -} diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index d628580..95da1e0 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -351,8 +351,12 @@ class AuthController( val deviceId = runCatching { jwtProvider.getClaimsEvenIfExpired(token)["deviceId"] as? String }.getOrNull() silentRefresh(request, response, isApp, deviceId) } else { - // AT 유효 - jwtProvider.getOpaqueId(token) + // AT 유효 — 서명은 맞지만 로그아웃·탈퇴로 폐기됐을 수 있다. + // 폐기 확인은 여기서만 한다. silent refresh 경로는 RT 가 softDelete 되어 + // findActiveByOpaqueIdAndJti 가 먼저 실패하므로 중복 확인이 불필요하다. + val id = jwtProvider.getOpaqueId(token) + authService.assertTokenNotRevoked(token, id) + id } } diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt index e3222a6..1f14950 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt @@ -15,10 +15,13 @@ import com.wq.auth.api.domain.member.error.MemberExceptionCode import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.jwt.error.JwtExceptionCode +import com.wq.auth.shared.alert.SecurityAlertNotifier import com.wq.auth.shared.utils.NicknameGenerator import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Duration import java.time.Instant @Service @@ -31,6 +34,17 @@ class AuthService( private val nicknameGenerator: NicknameGenerator, private val memberConnector: MemberConnector, private val memberStatsService: MemberStatsService, + private val securityAlertNotifier: SecurityAlertNotifier, + + /** + * RT 재사용 유예 창(초). + * + * 이 시간 안에 회전된 RT 가 다시 오면 **도난이 아니라 동시 요청·재시도**로 본다. + * 0 으로 두면 유예 없이 즉시 도난으로 판정한다(정상 사용자 로그아웃 위험). + * 운영 로그를 보고 조정할 수 있도록 설정값으로 뺐다. + */ + @Value("\${app.auth.refresh-reuse-grace-seconds:30}") + private val refreshReuseGraceSeconds: Long = 30, ) { private val log = KotlinLogging.logger {} @@ -128,6 +142,43 @@ class AuthService( log.info { "이메일 연동 완료: $currentOpaqueId -> ${request.email}" } } + /** + * 폐기된 토큰이면 [JwtException]을 던집니다. 유효하면 조용히 반환합니다. + * + * 세 가지를 거릅니다. 서명이 유효해도 그 신원이 이미 없어졌을 수 있기 때문입니다. + * ① **회원 행이 없음** = 탈퇴(hard delete)한 계정. `tokens_invalid_before` 를 읽을 + * 수조차 없으므로 행 부재 자체를 폐기 신호로 씁니다. + * ② **soft delete 된 계정** = 계정 병합으로 흡수된 회원([MemberConnector]). + * 행은 남지만 그 신원으로는 더 이상 로그인할 수 없으므로 토큰도 무효여야 합니다. + * ③ **iat <= tokens_invalid_before** = 로그아웃·탈퇴·RT 재사용 탐지 이전 발급분. + * + * ②는 병합 시점에도 [MemberEntity.revokeTokens] 로 기록하지만, 여기서도 확인합니다. + * 앞으로 soft delete 를 쓰는 코드가 기록을 빠뜨려도 이 방어선이 막습니다. + * + * iat 는 초 단위 정밀도라 같은 초에 발급된 토큰도 거부해야 합니다 — + * 그래서 `isAfter` 의 부정으로 비교합니다. + * + * **성능** — introspect 의 AT 유효 경로에서 호출되므로 DB 읽기가 하나 추가됩니다. + * opaque_id unique 인덱스 단일 조회이고 컬럼 두 개만 읽습니다. 게이트웨이 + * introspect 캐시가 앞단에서 대부분을 막아 실제로는 캐시 미스에서만 발생합니다. + */ + fun assertTokenNotRevoked(token: String, opaqueId: String) { + val state = memberRepository.findRevocationStateByOpaqueId(opaqueId) + if (state == null) { + log.info { "폐기된 토큰: 회원 없음(탈퇴) opaqueId=$opaqueId" } + throw JwtException(JwtExceptionCode.EXPIRED) + } + if (state.isDeleted) { + log.info { "폐기된 토큰: 삭제된 계정(병합 등) opaqueId=$opaqueId" } + throw JwtException(JwtExceptionCode.EXPIRED) + } + val invalidBefore = state.tokensInvalidBefore ?: return // 폐기 이력 없음 — 정상 + if (!jwtProvider.getIssuedAt(token).isAfter(invalidBefore)) { + log.info { "폐기된 토큰: 폐기 시각 이전 발급 opaqueId=$opaqueId" } + throw JwtException(JwtExceptionCode.EXPIRED) + } + } + @Transactional fun logout(refreshToken: String?) { if (refreshToken.isNullOrBlank()) { @@ -140,6 +191,10 @@ class AuthService( val opaqueId = jwtProvider.getOpaqueId(refreshToken) val jti = jwtProvider.getJti(refreshToken) refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) + // RT 만 지우면 AT 는 만료(30분)까지 그대로 유효하다. + // 폐기 시각을 남겨 introspect 가 옛 AT 를 거부하게 한다. + // @Transactional 이라 dirty checking 으로 반영된다 — save() 불필요. + memberRepository.findByOpaqueId(opaqueId).ifPresent { it.revokeTokens() } } catch (e: JwtException) { log.info { "만료된 refreshToken으로 로그아웃: ${e.message}" } } catch (ex: Exception) { @@ -154,8 +209,42 @@ class AuthService( val jti = jwtProvider.getJti(refreshToken) val opaqueId = jwtProvider.getOpaqueId(refreshToken) - refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti) - ?: throw JwtException(JwtExceptionCode.MALFORMED) + // active 가 없는 이유가 세 가지다. 구분해야 도난만 골라낼 수 있다. + if (refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti) == null) { + val used = refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti) + // ① 존재한 적 없는 jti — 잘못된 토큰. 패밀리는 건드리지 않는다. + ?: throw JwtException(JwtExceptionCode.MALFORMED) + + val rotatedAt = used.deletedAt + val withinGrace = rotatedAt != null && + Duration.between(rotatedAt, Instant.now()).seconds <= refreshReuseGraceSeconds + + if (withinGrace) { + // ② 방금 회전된 jti — 동시 요청이나 네트워크 재시도다. + // + // AT 잔여 5분 미만이면 모든 introspect 가 갱신을 타는데(AT 수명 30분), + // 그 구간에 페이지가 API 를 여러 개 동시 호출하면 같은 RT 로 갱신이 겹친다. + // 여기서 실패시키면 silentRefresh 가 쿠키를 지워(clearAuthCookies) + // **정상 사용자가 로그아웃된다.** 그래서 아래 정상 발급 경로로 이어간다. + // + // 대가: 유예 창 안에서는 도난 토큰도 통과한다. 다만 공격자가 정상 회전 + // 직후 몇 초 안에 써야 하므로 창이 매우 좁고, 그 대가로 정상 사용자가 + // 주기적으로 튕기는 문제를 막는다. + log.info { "RT 재사용(유예 창 내) — 동시 요청·재시도로 판단: opaqueId=$opaqueId jti=$jti" } + } else { + // ③ 한참 전에 폐기된 jti 가 다시 나타났다 = 도난 정황. + // 회전만 하고 탐지가 없으면, 공격자가 먼저 쓴 경우 공격자는 새 토큰 쌍을 얻고 + // 정상 사용자만 로그아웃되며 아무도 도난 사실을 모른다. + // 계정이 탈취된 상황이므로 정상 사용자도 재로그인시키는 것이 옳다. + log.warn { "RT 재사용 감지(도난 정황): opaqueId=$opaqueId jti=$jti rotatedAt=$rotatedAt" } + refreshTokenRepository.softDeleteAllByOpaqueId(opaqueId, Instant.now()) + memberRepository.findByOpaqueId(opaqueId).ifPresent { it.revokeTokens() } + // 로그에만 남기면 아무도 모른다. 계정 탈취 정황이므로 운영 채널로 알린다. + // 비동기이고 실패해도 예외를 내보내지 않으므로 이 경로를 지연시키지 않는다. + securityAlertNotifier.refreshTokenReuseDetected(opaqueId, jti, rotatedAt) + throw JwtException(JwtExceptionCode.MALFORMED) + } + } if (jwtProvider.getRefreshTokenExpiredAt(refreshToken).isBefore(Instant.now())) { refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt index 2f63fb9..1f3d5b6 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt @@ -125,6 +125,9 @@ class MemberConnector( } // 연동된 회원을 soft delete 처리 + // 이 신원으로는 더 이상 로그인할 수 없으므로 이미 발급된 토큰도 함께 폐기한다. + // (기록하지 않으면 흡수된 opaqueId 의 AT 가 만료까지 계속 통과한다) + linkedMember.revokeTokens() linkedMember.softDelete() memberRepository.save(linkedMember) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt index bb5ade1..95afe3b 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt @@ -34,4 +34,26 @@ interface RefreshTokenRepository : JpaRepository { @Transactional fun deleteByMember(member: MemberEntity) + /** + * soft delete 된 것까지 포함해 조회합니다. + * + * "존재한 적 없는 jti"와 "이미 회전되어 폐기된 jti"를 구분하기 위한 것으로, + * 후자는 **RT 도난 정황**입니다. findActiveByOpaqueIdAndJti 만으로는 둘 다 null 이라 + * 구분할 수 없습니다. + */ + @Query("SELECT r FROM RefreshTokenEntity r WHERE r.member.opaqueId = :opaqueId AND r.jti = :jti") + fun findByOpaqueIdAndJtiIncludingDeleted( + @Param("opaqueId") opaqueId: String, + @Param("jti") jti: String + ): RefreshTokenEntity? + + /** 해당 사용자의 살아 있는 RT 를 모두 폐기합니다 (토큰 패밀리 전체 폐기). */ + @Modifying + @Transactional + @Query("UPDATE RefreshTokenEntity r SET r.deletedAt = :deletedAt WHERE r.member.opaqueId = :opaqueId AND r.deletedAt IS NULL") + fun softDeleteAllByOpaqueId( + @Param("opaqueId") opaqueId: String, + @Param("deletedAt") deletedAt: Instant + ): Int + } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt index 4787467..747e7f8 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.Instant import java.util.* @Repository @@ -27,4 +28,28 @@ interface MemberRepository : JpaRepository { ): Optional fun findByOpaqueIdAndIsDeletedFalse(opaqueId: String): Optional + + /** + * 토큰 폐기 판정에 필요한 상태만 조회합니다. + * + * 세 가지 상황을 구분해야 합니다. + * ① **결과가 `null`** — 회원 행이 없음 = 탈퇴(hard delete)한 계정 + * ② **`isDeleted = true`** — soft delete 된 계정 (계정 병합으로 흡수된 회원) + * ③ **`tokensInvalidBefore`** — 로그아웃·탈퇴·RT 재사용 탐지 시각 + * + * 스칼라 값 하나만 뽑으면 ①과 "폐기 이력 없음"이 둘 다 `null` 로 뭉개져 구분할 수 없습니다. + * 판정 로직은 [MemberRevocationState] 를 받아 서비스에서 수행합니다 — + * 조건을 JPQL 에 넣으면 단위 테스트가 분기를 검증할 수 없기 때문입니다. + * + * 엔티티를 통째로 로딩하지 않는 이유는 이 쿼리가 introspect 핫패스에서 돌기 때문입니다. + * opaque_id 에는 unique 인덱스(idx_member_opaque_id)가 있습니다. + */ + @Query( + """ + select new com.wq.auth.api.domain.member.MemberRevocationState(m.isDeleted, m.tokensInvalidBefore) + from MemberEntity m + where m.opaqueId = :opaqueId + """ + ) + fun findRevocationStateByOpaqueId(@Param("opaqueId") opaqueId: String): MemberRevocationState? } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt new file mode 100644 index 0000000..62e7162 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt @@ -0,0 +1,28 @@ +package com.wq.auth.api.domain.member + +import java.time.Instant + +/** + * 토큰 폐기 판정에 필요한 최소 상태. + * + * **엔티티를 통째로 로딩하지 않는 이유** — 이 조회는 introspect 핫패스에서 돈다. + * 필요한 것은 두 값뿐이다. + * + * **조회 결과가 `null` 이라는 것 자체가 신호다** — 회원 행이 없다는 뜻이고 + * 탈퇴(hard delete)한 계정을 의미한다. 스칼라 값 하나만 뽑으면 "회원 없음"과 + * "폐기 이력 없음"이 둘 다 `null` 로 뭉개져 구분할 수 없다. + * + * **인터페이스 프로젝션이 아니라 생성자 표현식(`select new ...`)을 쓴다.** + * 인터페이스 프로젝션은 별칭과 게터 이름을 매칭하는데, Kotlin 의 `is` 접두사 프로퍼티는 + * `isDeleted()` 게터가 되어 Spring Data 가 프로퍼티명을 `deleted` 로 해석한다. + * 별칭을 `isDeleted` 로 두면 매칭에 실패해 **조용히 null 이 들어오고**, + * 그 결과 Kotlin non-null 타입에서 NPE 가 난다(실제로 겪었다). + * 생성자 표현식은 위치 기반이라 이런 이름 매칭 실패가 원천적으로 없다. + */ +data class MemberRevocationState( + /** soft delete 여부. 계정 병합으로 흡수된 회원이 여기에 해당한다. */ + val isDeleted: Boolean, + + /** 이 시각 이전(=이하)에 발급된 토큰은 폐기된 것으로 본다. null 이면 폐기 이력 없음. */ + val tokensInvalidBefore: Instant?, +) diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index b980b4b..920c0f4 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -98,6 +98,11 @@ class MemberService( ) ) + // 회원 행은 바로 아래에서 삭제되지만, 삭제 트랜잭션 커밋 전후의 짧은 창과 + // 게이트웨이 introspect 캐시 때문에 폐기 시각도 함께 남긴다. + // 삭제된 이후의 판정은 introspect 의 "회원 부재 = 폐기" 규칙이 담당한다. + member.revokeTokens() + refreshTokenRepository.deleteByMember(member) authProviderRepository.deleteByMember(member) memberRepository.delete(member) diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt index 1454c53..872f04e 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt @@ -3,6 +3,7 @@ package com.wq.auth.api.domain.member.entity import com.wq.auth.shared.entity.BaseEntity import com.github.f4b6a3.uuid.UuidCreator import jakarta.persistence.* +import java.time.Instant import java.time.LocalDateTime @Entity @@ -41,6 +42,16 @@ open class MemberEntity protected constructor( @Column(name = "is_deleted", nullable = false) var isDeleted: Boolean = false, + /** + * 이 시각 이전(=이하)에 발급된 토큰은 폐기된 것으로 본다. + * 로그아웃·탈퇴·RT 재사용 탐지 시 기록한다. null 이면 폐기 이력 없음. + * + * 세션 전체를 stateful 하게 관리하지 않기 위해 사용자당 타임스탬프 하나만 둔다. + * 실제 판정은 introspect 가 토큰의 iat 와 비교해 수행한다. + */ + @Column(name = "tokens_invalid_before", nullable = true) + var tokensInvalidBefore: Instant? = null, + ) : BaseEntity() { companion object { @@ -113,6 +124,16 @@ open class MemberEntity protected constructor( this.isDeleted = true } + /** + * 이 회원이 지금까지 발급받은 토큰을 모두 폐기 대상으로 표시합니다. + * + * AT 는 만료(30분)까지 서명이 유효하므로 RT 폐기만으로는 즉시 로그아웃이 되지 않습니다. + * 이 시각을 남겨 두면 introspect 가 iat 를 비교해 옛 AT 를 거부합니다. + */ + fun revokeTokens(at: Instant = Instant.now()) { + this.tokensInvalidBefore = at + } + override fun toString(): String { return "MemberEntity(id=$id, opaqueId='$opaqueId', nickname='$nickname')" } diff --git a/src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt b/src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt new file mode 100644 index 0000000..93d2b3e --- /dev/null +++ b/src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt @@ -0,0 +1,46 @@ +package com.wq.auth.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.security.MessageDigest + +/** + * `/internal-api/` 하위 전체를 `X-Internal-Secret` 헤더로 강제 보호한다. + * + * 기존에는 이 확인이 [com.wq.auth.api.controller.internal.InternalMemberController] 의 + * **메서드 안에** 있었다. 컨트롤러가 하나뿐인 지금은 문제가 없지만, 새 내부 컨트롤러를 + * 추가하면서 확인을 빠뜨리면 그대로 노출되는 구조다. SecurityConfig 에서 + * `/internal-api/` 하위 전체는 permitAll 이기 때문이다. + * + * 경로 단위로 강제되도록 필터로 올린다. 컨트롤러 내부의 기존 검사는 이중 방어로 남긴다. + */ +@Component +class InternalSecretFilter( + @Value("\${app.internal.secret}") private val secret: String, +) : OncePerRequestFilter() { + + override fun shouldNotFilter(request: HttpServletRequest): Boolean = + !request.requestURI.startsWith("/internal-api/") + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val provided = request.getHeader("X-Internal-Secret")?.toByteArray() ?: ByteArray(0) + // 문자열 != 가 아니라 상수시간 비교를 쓴다. + // 일반 비교는 앞에서부터 다른 문자를 만나면 즉시 반환하므로, + // 응답 시간 차이로 시크릿을 한 글자씩 추측당할 수 있다. + if (!MessageDigest.isEqual(provided, secret.toByteArray())) { + logger.warn("[internal-api] X-Internal-Secret 불일치 - ${request.requestURI}") + response.status = HttpStatus.FORBIDDEN.value() + return + } + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt index f14f0aa..12311cc 100644 --- a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt +++ b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt @@ -82,6 +82,24 @@ class JwtProvider( .payload .id + /** + * JWT 토큰에서 발급 시각(iat)을 추출합니다. + * + * iat 는 JWT 표준상 **초 단위** 정밀도입니다. 폐기 판정에서 이 점이 중요합니다 — + * 같은 초에 발급된 토큰까지 거부하려면 호출부가 `iat > invalidBefore` 가 아니라 + * 그 부정(`!isAfter`)으로 비교해야 합니다. + * + * 서명을 검증하므로 위조된 토큰이면 예외를 던집니다. + * + * @param token 대상 JWT 토큰 + * @return 발급 시각 + */ + fun getIssuedAt(token: String): Instant = + Jwts.parser().verifyWith(key) + .build().parseSignedClaims(token) + .payload + .issuedAt.toInstant() + /** * JWT 토큰에서 모든 클레임을 추출합니다. * @param token 대상 JWT 토큰 diff --git a/src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt b/src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt new file mode 100644 index 0000000..1752100 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt @@ -0,0 +1,81 @@ +package com.wq.auth.shared.alert + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.time.Instant + +/** + * 보안 이벤트를 운영 알림 채널(Google Chat)로 보낸다. + * + * **설계 원칙 — 알림은 절대 본 요청을 방해하지 않는다.** + * - `@Async` — 인증 요청 스레드를 잡지 않는다. 동기로 부르면 채널이 느릴 때 + * 사용자의 로그인·갱신이 함께 느려지고, 호출부가 `@Transactional` 이라 + * **DB 트랜잭션이 네트워크 왕복만큼 열린 채로 유지된다.** + * - **어떤 예외도 밖으로 내보내지 않는다.** 알림 실패가 인증 실패가 되면 안 된다. + * - 타임아웃은 주입되는 [RestClient] 빈이 갖고 있다(연결 3초 / 응답 3초). + * + * **웹훅 URL 은 그 자체가 시크릿이다.** 아는 사람은 누구나 그 채널에 글을 쓸 수 있다. + * 로그에 찍지 않으며, 비어 있으면 전송을 건너뛰고 경고만 남긴다(설정 없이도 기동한다). + * + * **메시지에 자격증명을 넣지 않는다.** 토큰 값·시크릿은 담지 않는다. + * 알림 채널로 자격증명이 새면 알림이 사고가 된다. + */ +@Component +class SecurityAlertNotifier( + private val restClient: RestClient, + @Value("\${app.alert.security-chat-webhook-url}") + private val webhookUrl: String, + @Value("\${spring.profiles.active:local}") + private val environment: String, +) { + private val log = KotlinLogging.logger {} + + /** + * RT 재사용(도난 정황) 통지. + * + * @param opaqueId 대상 사용자 식별자 (UUID — 이메일·이름 등은 담지 않는다) + * @param jti 재사용된 RefreshToken 의 식별자 (토큰 값이 아니다) + * @param rotatedAt 그 jti 가 회전(폐기)된 시각. null 이면 알 수 없음 + */ + fun refreshTokenReuseDetected(opaqueId: String, jti: String, rotatedAt: Instant?) { + val text = buildString { + // 환경을 제목 맨 앞에 둔다. alpha 와 prod 가 같은 채널을 공유하므로 + // 목록에서 훑을 때 어느 환경 사고인지 즉시 구분돼야 한다. + append("🚨 *[${environment.uppercase()}] RT 재사용 감지 — 계정 탈취 정황*\n") + append("사용자: `$opaqueId`\n") + append("재사용된 jti: `$jti`\n") + append("해당 jti 회전 시각: `${rotatedAt ?: "알 수 없음"}`\n") + append("감지 시각: `${Instant.now()}`\n") + append("\n") + append("이미 자동 조치됨 — 해당 계정의 RefreshToken 전량 폐기 + AccessToken 무효화. ") + append("사용자는 재로그인이 필요합니다.\n") + append("확인할 것: 같은 사용자에게 반복되는지, *여러 사용자에게 동시다발인지*. ") + append("후자면 토큰 유출 경로 자체를 의심해야 합니다.") + } + send(text) + } + + /** 전송 실패는 로그로만 남긴다. 호출부로 예외가 나가지 않는다. */ + @Async + fun send(text: String) { + if (webhookUrl.isBlank()) { + log.warn { "보안 알림 미전송 — app.alert.security-chat-webhook-url(SECURITY_ALERT_CHAT_WEBHOOK_URL)이 설정되지 않았습니다." } + return + } + try { + restClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("text" to text)) + .retrieve() + .toBodilessEntity() + } catch (e: Exception) { + // URL 은 시크릿이므로 로그에 남기지 않는다. + log.error(e) { "보안 알림 전송 실패: ${e.message}" } + } + } +} diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index f59b124..15eaa7f 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -51,13 +51,14 @@ class SecurityConfig( auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용 // 공개 엔드포인트 (인증 불필요) - .requestMatchers("/internal-api/**").permitAll() // 서비스 간 내부 통신 (X-Internal-Secret으로 보호) + // 서비스 간 내부 통신. Spring Security 는 통과시키되, + // InternalSecretFilter 가 경로 단위로 X-Internal-Secret 을 강제한다. + .requestMatchers("/internal-api/**").permitAll() .requestMatchers( "/api/v1/auth/members/email-login", // 이메일 로그인 "/api/v1/auth/email/request", // 이메일 인증 코드 요청 "/api/v1/auth/email/verify", // 이메일 인증 코드 검증 (로그인 전 호출) "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 - "/api/public/**", // 공개 API "/api/v1/auth/*/login", // 소셜 로그인 API (V1) "/api/v1/auth/google/login/app", // 안드로이드 앱 전용 Google ID Token 로그인 "/api/v2/auth/*/login", // 소셜 로그인 API (V2) diff --git a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt index fd69783..671f1f5 100644 --- a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt +++ b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt @@ -37,9 +37,14 @@ class RateLimiterInterceptor( val rateLimit = handler.getMethodAnnotation(RateLimit::class.java) ?: return true - // 유저 OpaqueId 가져오기 - val userOpaqueId = SecurityContextHolder.getContext() - .authentication?.name ?: "anonymous" + // 버킷 키. 인증된 요청은 opaqueId 를 쓴다. + // + // 인증 전 요청(만료된 AT 로 introspect → silent refresh)은 SecurityContext 가 비어 있다. + // 그대로 "anonymous" 를 쓰면 전 사용자가 버킷 하나(60회/분)를 공유하게 되어, + // AT 만료 직후 트래픽이 몰리면 무관한 사용자들이 429 를 맞는다. 그래서 IP 로 나눈다. + val bucketKey = SecurityContextHolder.getContext() + .authentication?.name + ?: clientIpOf(request) // Duration 변환. 기본은 분 val duration = when (rateLimit.timeUnit) { @@ -49,7 +54,7 @@ class RateLimiterInterceptor( else -> Duration.ofMinutes(rateLimit.duration) } - return if (rateLimiter.allowRequest(userOpaqueId, rateLimit.limit, duration)) { + return if (rateLimiter.allowRequest(bucketKey, rateLimit.limit, duration)) { true } else { //429 @@ -65,8 +70,23 @@ class RateLimiterInterceptor( response.writer.write(jsonMapper.writeValueAsString(failResponse)) - log.info{"Rate limit exceeded: userOpaqueId=$userOpaqueId, endpoint=${request.requestURI}"} + log.info{"Rate limit exceeded: bucketKey=$bucketKey, endpoint=${request.requestURI}"} false } } + + /** + * 인증 전 요청의 레이트리밋 버킷 키. + * + * 이 서비스는 게이트웨이 뒤에 있어 remoteAddr 이 게이트웨이 IP 로 고정된다. + * 그러면 다시 전 사용자가 버킷 하나를 공유하게 되므로 X-Forwarded-For 를 먼저 본다. + */ + private fun clientIpOf(request: HttpServletRequest): String = + request.getHeader("X-Forwarded-For") + ?.split(",") + ?.firstOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: request.remoteAddr + ?: "anonymous" } diff --git a/src/main/resources/application-jwt.yml b/src/main/resources/application-jwt.yml index 9d4ecd3..f6f8ac0 100644 --- a/src/main/resources/application-jwt.yml +++ b/src/main/resources/application-jwt.yml @@ -1,4 +1,4 @@ jwt: - secret: ${JWT_SECRET:jwt-secret} + secret: ${JWT_SECRET} access-exp: ${JWT_ACCESS_TOKEN_EXPIRATION} refresh-exp: ${JWT_REFRESH_TOKEN_EXPIRATION} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 07afccf..ac9ff9f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,7 +16,7 @@ spring: url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:authdb} driver-class-name: org.postgresql.Driver username: ${DB_USERNAME:postgres} - password: ${DB_PASSWORD:postgres} + password: ${DB_PASSWORD} jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect @@ -54,6 +54,19 @@ app: domain: ${APP_COOKIE_DOMAIN:localhost} internal: secret: ${INTERNAL_API_SECRET} + auth: + # RT 재사용 유예 창(초). 이 시간 안에 회전된 RT 가 다시 오면 동시 요청·재시도로 본다. + # 0 이면 유예 없음(정상 사용자가 로그아웃될 수 있다). + refresh-reuse-grace-seconds: ${AUTH_REFRESH_REUSE_GRACE_SECONDS:30} + alert: + # 보안 이벤트 전용 알림 채널. 비어 있으면 전송을 건너뛰고 경고 로그만 남긴다. + # + # 변수 이름을 GOOGLE_CHAT_WEBHOOK_URL 로 두지 않는다. wedding 저장소가 같은 이름을 + # 일반 운영 알림(Sentry 변환·캐시 워밍)에 쓰고 있어서, 이름이 같으면 저장소 간에 + # 값을 옮기다가 보안 경보가 일반 채널로 새거나 그 반대가 되기 쉽다. + # + # 이 URL 자체가 시크릿이다 — 아는 사람은 누구나 해당 채널에 글을 쓸 수 있다. + security-chat-webhook-url: ${SECURITY_ALERT_CHAT_WEBHOOK_URL} management: endpoints: @@ -71,4 +84,4 @@ project: logging: enabled: true env: ${SPRING_PROFILES_ACTIVE:local} - internal-secret: ${INTERNAL_LOGGING_SECRET:default-secret} + internal-secret: ${INTERNAL_LOGGING_SECRET} diff --git a/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt index 73b22ac..0c2f650 100644 --- a/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt @@ -32,6 +32,8 @@ import java.time.ZoneOffset "jwt.access-exp=15m", "jwt.refresh-exp=14d", "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=", "spring.datasource.url=jdbc:h2:mem:jackson-instant-test;DB_CLOSE_DELAY=-1", "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa", diff --git a/src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt b/src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt new file mode 100644 index 0000000..2381948 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt @@ -0,0 +1,86 @@ +package com.wq.auth.integration + +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.entity.MemberEntity +import io.kotest.core.spec.style.StringSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.Instant + +/** + * 폐기 판정 쿼리의 **프로젝션 바인딩**을 실제 DB로 검증한다. + * + * 서비스 단위 테스트는 리포지토리를 mock 하므로, 프로젝션 별칭이 잘못돼도 통과한다. + * 이 판정은 인증의 핵심이라 "쿼리가 실제로 값을 채워 오는가"를 별도로 확인한다. + */ +@SpringBootTest( + properties = [ + "jwt.secret=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", + "jwt.access-exp=15m", + "jwt.refresh-exp=14d", + "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=", + "spring.datasource.url=jdbc:h2:mem:revocation-state-test;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.mail.host=localhost", + "spring.mail.port=25", + "spring.mail.username=test", + "spring.mail.password=test", + "spring.mail.properties.mail.smtp.auth=false", + "spring.mail.properties.mail.smtp.starttls.enable=false", + ] +) +class MemberRevocationStateQueryTest : StringSpec() { + + override val extensions = listOf(SpringExtension()) + + @Autowired + lateinit var memberRepository: MemberRepository + + init { + "회원이 없으면 null 을 돌려준다 — 탈퇴(hard delete)를 이 값으로 판정한다" { + memberRepository.findRevocationStateByOpaqueId("존재하지-않는-id") shouldBe null + } + + "폐기 이력이 없는 정상 회원은 isDeleted=false, tokensInvalidBefore=null" { + val member = memberRepository.save(MemberEntity.create(nickname = "정상회원")) + + val state = memberRepository.findRevocationStateByOpaqueId(member.opaqueId) + + state.shouldNotBeNull() + state.isDeleted shouldBe false + state.tokensInvalidBefore shouldBe null + } + + "revokeTokens() 로 기록한 시각이 그대로 조회된다" { + val at = Instant.parse("2026-08-30T12:00:00Z") + val member = MemberEntity.create(nickname = "로그아웃회원").apply { revokeTokens(at) } + memberRepository.save(member) + + val state = memberRepository.findRevocationStateByOpaqueId(member.opaqueId) + + state.shouldNotBeNull() + state.tokensInvalidBefore shouldBe at + } + + "soft delete 된 회원은 행이 남지만 isDeleted=true 로 조회된다" { + // 계정 병합(MemberConnector)이 흡수된 회원을 이렇게 처리한다. + // 행이 남으므로 null 로는 구분할 수 없고, 이 플래그로만 알 수 있다. + val member = MemberEntity.create(nickname = "병합된회원").apply { softDelete() } + memberRepository.save(member) + + val state = memberRepository.findRevocationStateByOpaqueId(member.opaqueId) + + state.shouldNotBeNull() + state.isDeleted shouldBe true + } + } +} diff --git a/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt index 2533968..d1a4edc 100644 --- a/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt @@ -26,6 +26,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = [ "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=", "spring.datasource.url=jdbc:h2:mem:security-test;DB_CLOSE_DELAY=-1", "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa", @@ -59,8 +61,14 @@ class JwtSpringSecurityIntegrationTest : BehaviorSpec() { `when`("공개 API에 토큰 없이 접근하면") { then("200 OK 응답을 받아야 한다") { + // permitAll 경로. 과거에는 TestSecurityController 의 /api/public/test 를 썼으나 + // 그 컨트롤러가 인증 없이 임의 사용자 AT 를 발급해 삭제되면서 옮겼다. + // + // /actuator/health 는 쓰지 않는다 — 테스트 환경에서 health 인디케이터가 DOWN 이라 + // 503 이 나서 "보안 통과 여부"가 아니라 "인프라 상태"를 재는 테스트가 되어 버린다. + // /v3/api-docs 는 permitAll 이면서 외부 의존이 없어 결과가 안정적이다. val result = mockMvc.perform( - get("/api/public/test") + get("/v3/api-docs") .contentType(MediaType.APPLICATION_JSON) ).andReturn() @@ -75,8 +83,10 @@ class JwtSpringSecurityIntegrationTest : BehaviorSpec() { opaqueId = "550e8400-e29b-41d4-a716-446655440000" ) + // 인증 필요 경로. 삭제된 TestSecurityController 의 /api/test 대신 + // 토큰을 발급하지 않는 테스트 전용 프로브(_SecurityProbeController)를 쓴다. val result = mockMvc.perform( - get("/api/test") + get("/api/test-probe/authenticated") .header("Authorization", "Bearer $memberToken") .contentType(MediaType.APPLICATION_JSON) ).andReturn() diff --git a/src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt b/src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt new file mode 100644 index 0000000..f168773 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt @@ -0,0 +1,19 @@ +package com.wq.auth.integration.security + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Spring Security 배선(permitAll vs authenticated)만 확인하기 위한 테스트 전용 컨트롤러. + * + * src/test 에 있으므로 프로덕션 배포 아티팩트에 포함되지 않는다. + * 삭제된 TestSecurityController 와 결정적으로 다른 점은 **토큰을 발급하지 않는다**는 것이다 — + * 그 컨트롤러는 인증 없이 임의 opaqueId 의 유효 서명 AT 를 내주고 있었다. + */ +@RestController +class _SecurityProbeController { + + /** SecurityConfig 의 anyRequest().authenticated() 에 걸린다. 유효 토큰이 있어야 200. */ + @GetMapping("/api/test-probe/authenticated") + fun authenticatedProbe(): Map = mapOf("ok" to "true") +} diff --git a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt index c8d9582..da9855a 100644 --- a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt @@ -8,6 +8,7 @@ import com.wq.auth.api.domain.auth.AuthProviderRepository import com.wq.auth.api.domain.auth.AuthService import com.wq.auth.api.domain.auth.MemberConnector import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberRevocationState import com.wq.auth.api.domain.member.MemberStatsService import com.wq.auth.api.domain.auth.RefreshTokenRepository import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity @@ -16,10 +17,12 @@ import com.wq.auth.api.domain.auth.error.AuthExceptionCode import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.jwt.error.JwtExceptionCode +import com.wq.auth.shared.alert.SecurityAlertNotifier import com.wq.auth.shared.utils.NicknameGenerator import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import org.mockito.kotlin.* import org.springframework.test.context.ActiveProfiles import java.time.Instant @@ -37,6 +40,7 @@ class AuthServiceTest : DescribeSpec({ lateinit var nicknameGenerator: NicknameGenerator lateinit var memberConnector: MemberConnector lateinit var memberStatsService: MemberStatsService + lateinit var securityAlertNotifier: SecurityAlertNotifier beforeEach { authProviderRepository = mock() @@ -47,6 +51,7 @@ class AuthServiceTest : DescribeSpec({ nicknameGenerator = mock() memberConnector = mock() memberStatsService = mock() + securityAlertNotifier = mock() authService = AuthService( authEmailService = authEmailService, @@ -57,6 +62,7 @@ class AuthServiceTest : DescribeSpec({ nicknameGenerator = nicknameGenerator, memberConnector = memberConnector, memberStatsService = memberStatsService, + securityAlertNotifier = securityAlertNotifier, ) } @@ -748,4 +754,170 @@ class AuthServiceTest : DescribeSpec({ } } -}) \ No newline at end of file + describe("assertTokenNotRevoked - 토큰 폐기 판정") { + + val opaqueId = "550e8400-e29b-41d4-a716-446655440000" + val token = "any-access-token" + + /** 폐기 상태 프로젝션 stub */ + fun state(isDeleted: Boolean = false, invalidBefore: Instant? = null) = + MemberRevocationState(isDeleted = isDeleted, tokensInvalidBefore = invalidBefore) + + it("회원 행이 없으면(탈퇴) 폐기로 보고 예외를 던진다") { + // 탈퇴는 hard delete 라 tokens_invalid_before 를 읽을 수조차 없다. + // 행 부재 자체를 폐기 신호로 써야 탈퇴 직후의 옛 AT 를 막을 수 있다. + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)).thenReturn(null) + + val ex = shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + ex.jwtCode shouldBe JwtExceptionCode.EXPIRED + } + + it("soft delete 된 계정이면 폐기로 보고 예외를 던진다") { + // 계정 병합(MemberConnector)이 흡수된 회원을 soft delete 한다. + // 행이 남으므로 null 로는 구분되지 않는다. 그 신원으로는 더 이상 로그인할 수 + // 없으므로 이미 발급된 토큰도 무효여야 한다. + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(isDeleted = true)) + + val ex = shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + ex.jwtCode shouldBe JwtExceptionCode.EXPIRED + } + + it("soft delete 된 계정은 폐기 이력이 없어도 거부한다") { + // 병합 코드가 revokeTokens() 기록을 빠뜨려도 이 방어선이 막아야 한다. + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(isDeleted = true, invalidBefore = null)) + + shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + // 폐기 판정이 이미 끝났으므로 토큰을 파싱할 필요가 없다 + verify(jwtProvider, never()).getIssuedAt(any()) + } + + it("폐기 이력이 없는 정상 회원은 통과한다") { + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state()) + + authService.assertTokenNotRevoked(token, opaqueId) + + // 폐기 이력이 없으면 iat 를 읽을 필요조차 없다 (핫패스 비용 절약) + verify(jwtProvider, never()).getIssuedAt(any()) + } + + it("폐기 시각보다 이전에 발급된 토큰이면 예외를 던진다") { + val invalidBefore = Instant.parse("2026-08-30T12:00:00Z") + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(invalidBefore = invalidBefore)) + whenever(jwtProvider.getIssuedAt(token)).thenReturn(invalidBefore.minusSeconds(1)) + + val ex = shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + ex.jwtCode shouldBe JwtExceptionCode.EXPIRED + } + + it("폐기 시각과 같은 초에 발급된 토큰도 거부한다") { + // iat 는 초 단위라, 로그아웃과 같은 초에 발급된 토큰이 살아남으면 안 된다. + val invalidBefore = Instant.parse("2026-08-30T12:00:00Z") + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(invalidBefore = invalidBefore)) + whenever(jwtProvider.getIssuedAt(token)).thenReturn(invalidBefore) + + shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + } + + it("폐기 시각 이후에 발급된 토큰이면 통과한다") { + val invalidBefore = Instant.parse("2026-08-30T12:00:00Z") + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(invalidBefore = invalidBefore)) + whenever(jwtProvider.getIssuedAt(token)).thenReturn(invalidBefore.plusSeconds(1)) + + authService.assertTokenNotRevoked(token, opaqueId) + } + } + + describe("refreshAccessToken - RT 재사용 탐지") { + + val opaqueId = "550e8400-e29b-41d4-a716-446655440000" + val jti = "reused-jti" + val refreshToken = "any-refresh-token" + + it("이미 회전되어 폐기된 jti가 다시 오면 패밀리 전체를 폐기하고 예외를 던진다") { + // 도난된 RT 를 공격자가 먼저 쓰면, 정상 사용자의 다음 갱신에서 이 분기를 탄다. + val member = MemberEntity.create(nickname = "피해자") + val usedToken: RefreshTokenEntity = mock() + // 유예 창(기본 30초)을 한참 지난 시점에 회전된 토큰 = 도난 정황 + whenever(usedToken.deletedAt).thenReturn(Instant.now().minusSeconds(600)) + + whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) + whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) + whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(null) + whenever(refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti)) + .thenReturn(usedToken) + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(member)) + + shouldThrow { + authService.refreshAccessToken(refreshToken, deviceId = null) + } + + // 계정이 탈취된 상황이므로 정상 사용자도 재로그인시키는 것이 옳다. + verify(refreshTokenRepository, times(1)).softDeleteAllByOpaqueId(eq(opaqueId), any()) + member.tokensInvalidBefore shouldNotBe null + // 로그에만 남기면 아무도 모른다 — 운영 채널로 알려야 한다 + verify(securityAlertNotifier, times(1)).refreshTokenReuseDetected(eq(opaqueId), eq(jti), any()) + } + + it("존재한 적 없는 jti면 패밀리를 건드리지 않고 예외만 던진다") { + whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) + whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) + whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(null) + whenever(refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti)) + .thenReturn(null) + + shouldThrow { + authService.refreshAccessToken(refreshToken, deviceId = null) + } + + // 단순 오류·만료 토큰까지 패밀리를 폐기하면 정상 사용자를 불필요하게 로그아웃시킨다. + verify(refreshTokenRepository, never()).softDeleteAllByOpaqueId(any(), any()) + } + it("유예 창 안에 회전된 jti 면 도난으로 보지 않고 정상 발급한다") { + // AT 잔여 5분 미만 구간에서 페이지가 API 를 여러 개 동시 호출하면 + // 같은 RT 로 갱신이 겹친다. 여기서 실패시키면 silentRefresh 가 쿠키를 지워 + // 정상 사용자가 로그아웃된다. 사용자에게 보이지 않아야 하므로 발급까지 이어간다. + val member = MemberEntity.create(nickname = "정상사용자") + val justRotated: RefreshTokenEntity = mock() + whenever(justRotated.deletedAt).thenReturn(Instant.now().minusSeconds(2)) + + whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) + whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) + whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(null) + whenever(refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti)) + .thenReturn(justRotated) + whenever(jwtProvider.getRefreshTokenExpiredAt(refreshToken)) + .thenReturn(Instant.now().plusSeconds(3600)) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("new-at") + whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("new-rt") + whenever(jwtProvider.getJti("new-rt")).thenReturn("new-jti") + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(member)) + whenever(refreshTokenRepository.save(any())).thenReturn(mock()) + + val result = authService.refreshAccessToken(refreshToken, deviceId = null) + + result.accessToken shouldBe "new-at" + result.refreshToken shouldBe "new-rt" + // 패밀리를 폐기하지 않는다 — 폐기하면 사용자가 재로그인해야 한다 + verify(refreshTokenRepository, never()).softDeleteAllByOpaqueId(any(), any()) + member.tokensInvalidBefore shouldBe null + // 동시 요청까지 경보로 울리면 신호가 오염되어 진짜 도난을 놓친다 + verify(securityAlertNotifier, never()).refreshTokenReuseDetected(any(), any(), anyOrNull()) + } + } +}) diff --git a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt index 7f9a4e0..50c3152 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt @@ -22,7 +22,9 @@ import java.time.Duration "spring.datasource.password=", "spring.jpa.hibernate.ddl-auto=create-drop", "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", - "INTERNAL_API_SECRET=test-internal-secret" + "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=" ] ) @ConfigurationPropertiesScan diff --git a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt index ab0fc59..1d04555 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt @@ -14,6 +14,7 @@ import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import java.time.Duration +import java.time.Instant import java.util.Base64 import javax.crypto.SecretKey @@ -134,8 +135,37 @@ class JwtProviderTest : StringSpec({ "토큰 유효성 검증이 정상 동작한다" { val validToken = provider.createAccessToken("test-user") - + // 예외 없이 통과해야 함 provider.validateOrThrow(validToken) } + + "getIssuedAt()은 발급 시각(iat)을 돌려준다" { + val before = Instant.now().minusSeconds(2) + val token = provider.createAccessToken("550e8400-e29b-41d4-a716-446655440000") + val after = Instant.now().plusSeconds(2) + + val issuedAt = provider.getIssuedAt(token) + + (issuedAt.isAfter(before) && issuedAt.isBefore(after)).shouldBeTrue() + } + + "getIssuedAt()의 iat 는 초 단위다 - 폐기 판정이 이 정밀도에 의존한다" { + val token = provider.createAccessToken("550e8400-e29b-41d4-a716-446655440000") + + val issuedAt = provider.getIssuedAt(token) + + // JWT 표준상 iat 는 초 단위라 밀리초가 잘린다. 그래서 폐기 판정은 + // 같은 초에 발급된 토큰까지 거부하도록 "iat > invalidBefore" 가 아닌 + // "!(iat > invalidBefore)" 형태로 비교해야 한다. + issuedAt.nano shouldBe 0 + } + + "getIssuedAt()은 서명이 위조된 토큰이면 예외를 던진다" { + val forged = provider.createAccessToken("test-user").dropLast(4) + "AAAA" + + shouldThrow { + provider.getIssuedAt(forged) + } + } }) \ No newline at end of file diff --git a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt index 33fea5a..e92bb67 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt @@ -5,6 +5,7 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import java.time.Instant import java.util.* class MemberEntityTest : StringSpec({ @@ -95,5 +96,37 @@ class MemberEntityTest : StringSpec({ // ) } */ + + "새로 만든 회원은 폐기 이력이 없다" { + val member = MemberEntity.create(nickname = "테스터") + + member.tokensInvalidBefore shouldBe null + } + + "revokeTokens()는 전달한 시각을 폐기 기준으로 기록한다" { + // Given + val member = MemberEntity.create(nickname = "테스터") + val at = Instant.parse("2026-08-30T12:00:00Z") + + // When + member.revokeTokens(at) + + // Then + member.tokensInvalidBefore shouldBe at + } + + "revokeTokens()를 인자 없이 부르면 현재 시각으로 기록한다" { + // Given + val member = MemberEntity.create(nickname = "테스터") + val before = Instant.now().minusSeconds(1) + + // When + member.revokeTokens() + + // Then + val recorded = member.tokensInvalidBefore + recorded shouldNotBe null + recorded!!.isAfter(before) shouldBe true + } }) diff --git a/src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt b/src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt new file mode 100644 index 0000000..c484439 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt @@ -0,0 +1,118 @@ +package com.wq.auth.unit + +import com.sun.net.httpserver.HttpServer +import com.wq.auth.shared.alert.SecurityAlertNotifier +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import org.springframework.web.client.RestClient +import java.net.InetSocketAddress +import java.time.Instant +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * 실제 HTTP 요청이 나가는지 로컬 스텁 서버로 확인한다. + * + * 목킹만 하면 "호출했다"까지만 알 수 있고, **실제로 어떤 본문이 어떤 헤더로 나가는지**는 + * 모른다. Google Chat 은 `{"text": "..."}` 형태를 요구하므로 그 계약을 여기서 고정한다. + */ +class SecurityAlertNotifierTest : StringSpec({ + + /** 요청을 받아 본문을 기록하는 최소 스텁 서버 */ + class Stub(status: Int = 200) { + val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + @Volatile var body: String? = null + @Volatile var contentType: String? = null + @Volatile var method: String? = null + val received = CountDownLatch(1) + + init { + server.createContext("/hook") { ex -> + method = ex.requestMethod + contentType = ex.requestHeaders.getFirst("Content-Type") + body = ex.requestBody.readBytes().decodeToString() + ex.sendResponseHeaders(status, -1) + ex.close() + received.countDown() + } + server.start() + } + + val url: String get() = "http://127.0.0.1:${server.address.port}/hook" + fun awaitRequest() = received.await(5, TimeUnit.SECONDS) + fun stop() = server.stop(0) + } + + fun notifier(url: String) = SecurityAlertNotifier( + restClient = RestClient.create(), + webhookUrl = url, + environment = "test", + ) + + "웹훅으로 Google Chat 형식의 JSON 을 POST 한다" { + val stub = Stub() + try { + notifier(stub.url).refreshTokenReuseDetected( + opaqueId = "550e8400-e29b-41d4-a716-446655440000", + jti = "stolen-jti", + rotatedAt = Instant.parse("2026-08-30T12:00:00Z"), + ) + + stub.awaitRequest() shouldBe true + stub.method shouldBe "POST" + stub.contentType shouldContain "application/json" + + val body = stub.body!! + // Google Chat 은 text 필드를 요구한다 + body shouldContain "\"text\"" + body shouldContain "RT 재사용 감지" + // alpha 와 prod 가 같은 채널을 공유하므로 환경이 제목에 드러나야 한다 + body shouldContain "[TEST]" + body shouldContain "550e8400-e29b-41d4-a716-446655440000" + body shouldContain "stolen-jti" + body shouldContain "2026-08-30T12:00:00Z" + // 조사 지침이 메시지에 들어 있어야 알림을 받은 사람이 판단할 수 있다 + body shouldContain "동시다발" + } finally { + stub.stop() + } + } + + "메시지에 웹훅 URL 이나 토큰 값이 실리지 않는다" { + val stub = Stub() + try { + notifier(stub.url).refreshTokenReuseDetected("user-1", "jti-1", Instant.now()) + stub.awaitRequest() shouldBe true + + val body = stub.body!! + // 알림 채널로 자격증명이 새면 알림 자체가 사고가 된다 + body shouldNotContain "127.0.0.1" + body shouldNotContain "/hook" + } finally { + stub.stop() + } + } + + "채널이 5xx 를 돌려줘도 예외를 밖으로 내보내지 않는다" { + // 알림 실패가 인증 실패가 되면 안 된다. + val stub = Stub(status = 500) + try { + notifier(stub.url).refreshTokenReuseDetected("user-1", "jti-1", null) + stub.awaitRequest() shouldBe true + } finally { + stub.stop() + } + } + + "웹훅 URL 이 없으면 전송을 건너뛰고 예외도 던지지 않는다" { + // 설정이 없어도 기동·동작해야 한다. 알림만 빠진다. + notifier("").refreshTokenReuseDetected("user-1", "jti-1", null) + } + + "연결할 수 없는 주소여도 예외를 밖으로 내보내지 않는다" { + // 포트 1은 열려 있지 않다 + notifier("http://127.0.0.1:1/hook").refreshTokenReuseDetected("user-1", "jti-1", null) + } +}) From 8c4c9e15e4db957a4026b6e1976178a6657f1a0e Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Sun, 30 Aug 2026 23:11:08 +0900 Subject: [PATCH 19/19] =?UTF-8?q?fix(config):=20main=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=83=9D=EC=84=B1=EB=90=9C?= =?UTF-8?q?=20management=20=EB=B8=94=EB=A1=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev 와 main 의 application.yml 이 같은 management 블록을 서로 다른 위치에 갖고 있어, git 이 두 추가를 각각 살려 최상위 키가 중복됐다. YAML 은 중복 키를 허용하지 않아 Spring 컨텍스트 로딩이 실패한다 (SpringBootTest 전부 IllegalStateException). 두 블록의 내용이 완전히 동일하므로 하나만 남긴다. Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL --- src/main/resources/application.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b937ace..ac9ff9f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -80,18 +80,6 @@ management: probes: enabled: true -management: - endpoints: - web: - exposure: - include: health - base-path: /actuator - endpoint: - health: - show-details: never - probes: - enabled: true - project: logging: enabled: true