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 96711b8..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") @@ -71,7 +72,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/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/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/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..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( @@ -55,6 +59,7 @@ class MemberController( userId = result.userId, nickname = result.nickname, email = result.email, + phoneNumber = result.phoneNumber, linkedProviders = result.providers ) return CommonResponse.success(message = "회원 정보 조회 성공", data = resp) @@ -72,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/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/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/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..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 { @@ -23,6 +28,7 @@ class MemberService( val userId: String, val nickname: String, val email: String, + val phoneNumber: String?, val providers: List, ) @@ -65,6 +71,7 @@ class MemberService( userId = member.opaqueId, nickname = member.nickname, email = email!!, + phoneNumber = member.phoneNumber, providers = providers ) } @@ -75,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/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/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/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/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-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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ea7a30..07afccf 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: @@ -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 + + + + + + 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/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/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/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..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("사용자 정보 조회 테스트") { @@ -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/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/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()) + } + } +}) 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 + + + + + + + + + + + +