From a1e1788ce0d44db9edb2969feef1bc70089e6f7e Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 6 Apr 2026 11:59:14 +0900 Subject: [PATCH 01/17] 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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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/17] =?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 + + + + + +