From a1e1788ce0d44db9edb2969feef1bc70089e6f7e Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 6 Apr 2026 11:59:14 +0900 Subject: [PATCH 01/13] Update deploy.yml --- .github/workflows/deploy.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7d53a79..afab210 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,22 @@ on: - main - dev + # 수동 실행을 가능하게 하는 설정 + workflow_dispatch: + inputs: + environment: + description: '배포 환경 선택' + required: true + default: 'dev' + type: choice + options: + - dev + - prod + reason: + description: '배포 사유를 입력하세요' + required: false + type: string + jobs: deploy: runs-on: ubuntu-latest From 6ad2aa9b0c49d372544e1af11741ab30e0fae941 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 7 Apr 2026 08:55:52 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=ED=99=98=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `application-prod.yml`, `application-alpha.yml`에 `project.logging.env` 추가 - Logback 설정 간소화 및 기본 설정 파일(`base-logback.xml`) 포함 - `GlobalExceptionHandler`에서 예외 처리 시 로그 수준 분리 (4xx는 info, 기타는 error) --- .../auth/web/common/GlobalExceptionHandler.kt | 7 ++- src/main/resources/application-alpha.yml | 4 ++ src/main/resources/application-prod.yml | 4 ++ src/main/resources/logback-spring.xml | 60 ++++++------------- 4 files changed, 30 insertions(+), 45 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt index 9f94cac..914e448 100644 --- a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt @@ -21,9 +21,12 @@ class GlobalExceptionHandler { @ExceptionHandler(ApiException::class) fun handleApiException(e: ApiException): ResponseEntity> { - - log.error(e.extractExceptionLocation() + e.message) val status = HttpStatus.valueOf(e.code.status) + if (status.is4xxClientError) { + log.info("[{}] {} - {}", status.value(), e.code, e.message) + } else { + log.error(e.extractExceptionLocation() + e.message) + } val body = CommonResponse.fail(e.code) return ResponseEntity.status(status).body(body) } diff --git a/src/main/resources/application-alpha.yml b/src/main/resources/application-alpha.yml index 84bbdad..8b7bb2e 100644 --- a/src/main/resources/application-alpha.yml +++ b/src/main/resources/application-alpha.yml @@ -1,4 +1,8 @@ # alpha — 알파 서버 (DDL update, 운영에 가까운 쿠키) +project: + logging: + env: alpha + spring: config: activate: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 625a362..abbc31c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,4 +1,8 @@ # prod — 운영 (RDS SSL, DDL validate, 쿠키 Strict) +project: + logging: + env: prod + spring: config: activate: diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 33fd440..332b9a9 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,48 +1,22 @@ - + + - - - - - - - - - - UTC - - - - - - - - - - { - "trace_id": "%mdc{trace_id}" - } - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + From 0f23c3a38035ac2eb0935e6fa51f89ea479917da Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Wed, 8 Apr 2026 15:38:30 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20API=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 명세 문서 작성 및 저장소에 추가(`docs/api-명세서.md`) - GitHub Pages 경로(`/api-명세서/`) 설정 및 문서화 - 저장소 디렉토리 구조 및 실행 가이드 갱신 --- README.md | 428 ++++++------------ ...i-\353\252\205\354\204\270\354\204\234.md" | 333 ++++++++++++++ 2 files changed, 475 insertions(+), 286 deletions(-) create mode 100644 "docs/api-\353\252\205\354\204\270\354\204\234.md" diff --git a/README.md b/README.md index 0477e95..a7551c0 100644 --- a/README.md +++ b/README.md @@ -1,345 +1,201 @@ -# Auth-BE +# auth-api -Spring Boot 기반의 인증/소셜 로그인 및 계정 연동(링크) 백엔드입니다. +Spring Boot 기반 **인증·인가** 백엔드입니다. 소셜 로그인(Google, Kakao, Naver), 이메일 인증 로그인, 계정 연동, JWT·HttpOnly 쿠키, API Gateway용 `introspect` 등을 제공합니다. -## 목차 -- [주요 기능](#주요-기능) -- [기술 스택](#기술-스택) -- [빠른 시작](#빠른-시작) -- [배포 (EC2 Docker)](#배포-ec2-docker) -- [환경 변수 설정](#환경-변수-설정) -- [API 엔드포인트](#api-엔드포인트) -- [인증 플로우](#인증-플로우) -- [OAuth/PKCE 규칙](#oauthpkce-규칙) -- [보안 고려사항](#보안-고려사항) - -## 주요 기능 - -- **소셜 로그인**: Google, Kakao, Naver OAuth2 지원 -- **이메일 인증**: 인증코드 기반 이메일 로그인/가입 -- **계정 연동**: 기존 계정에 소셜 계정 추가 링크 -- **PKCE 지원**: Authorization Code Flow with PKCE -- **보안 강화**: HttpOnly 쿠키 기반 RefreshToken 관리 -- **토큰 갱신**: AccessToken 자동 갱신 지원 +## 문서 위치 -## 기술 스택 +| 구분 | 내용 | +|------|------| +| **이 README** | 빠른 시작, 환경 변수, 배포, 인증 요약, GitHub Pages 안내 | +| **API 명세** | [`docs/api-명세서.md`](docs/api-명세서.md) | +| **CI 환경** | [`docs/GITHUB-ENVIRONMENTS.md`](docs/GITHUB-ENVIRONMENTS.md) | -- **언어**: Kotlin -- **프레임워크**: Spring Boot 3, Spring Web, Spring Security -- **인증**: OAuth2 (Google, Kakao, Naver) -- **검증**: Validation -- **로깅**: Kotlin Logging -- **직렬화**: Jackson -- **HTTP 클라이언트**: RestTemplate -- **빌드**: Gradle (KTS) +--- -## 빠른 시작 +## 목차 -### 요구사항 -- JDK 17+ -- Gradle 8+ +- [문서 위치](#문서-위치) +- [역할 한눈에](#역할-한눈에) +- [기술 스택](#기술-스택) +- [저장소 구조](#저장소-구조) +- [실행 방법](#실행-방법) +- [GitHub Pages](#github-pages) +- [배포 (Docker / EC2)](#배포-docker--ec2) +- [환경 변수](#환경-변수) +- [인증·토큰 요약](#인증토큰-요약) +- [OAuth / PKCE 요약](#oauth--pkce-요약) +- [보안·운영 참고](#보안운영-참고) -### 실행 +--- -```bash -# 개발 환경 실행 -./gradlew bootRun +## 역할 한눈에 -# 빌드 -./gradlew build +| 영역 | 내용 | +|------|------| +| 소셜 로그인 | OAuth2 인가 코드 + PKCE, 범용·제공자별 엔드포인트 | +| 이메일 | 인증 코드 발송/검증, 이메일 로그인·가입, 로그인 후 이메일 연동 | +| 토큰 | JWT Access / Refresh, Refresh는 DB 저장, 웹은 HttpOnly 쿠키 중심 | +| 클라이언트 | `X-Client-Type`(`web` / `app`)으로 쿠키 vs 본문 토큰 분기 | +| 게이트웨이 | `GET /api/v1/auth/introspect`, 응답 헤더 `X-User-Id`, 사일런트 리프레시 | -# JAR 실행 -java -jar build/libs/auth-be-0.0.1-SNAPSHOT.jar -``` +--- -## 배포 (EC2 Docker) +## 기술 스택 -배포는 **Docker** 방식으로 수행하며, systemd/JAR 직접 실행은 사용하지 않습니다. +| 구분 | 사용 | +|------|------| +| 언어 | Kotlin | +| 런타임 | JDK 25 (Gradle toolchain) | +| 프레임워크 | Spring Boot 4, Spring Web, Spring Security, Spring Data JPA | +| DB | PostgreSQL (런타임), H2 (테스트 등) | +| 인증 | OAuth2 연동, JWT (jjwt) | +| API 탐색 | springdoc OpenAPI 3 (`/v3/api-docs`, UI는 `SWAGGER_PATH`) | +| 기타 | Bucket4j(레이트 리밋), 메일 발송 | -- **main** 브랜치 push 시 GitHub Actions가 Docker 이미지를 빌드·푸시한 뒤 EC2에 SSH로 접속해 컨테이너를 갱신합니다. -- EC2에서는 env를 **단일 파일**로만 사용합니다. GitHub Secret **`ENV_FILE`**(전체 .env 내용)을 CI가 **`~/env/auth-be.env`** 에 복사하고, 컨테이너는 `--env-file ~/env/auth-be.env` 로 실행합니다. (다른 도커 서비스와 구분을 위해 패키지명.env 형식 사용.) +--- -실행 예: +## 저장소 구조 -```bash -docker run -d --restart unless-stopped --name auth-be -p 9000:9000 --env-file ~/env/auth-be.env /auth-server:latest ``` +src/main/kotlin/com/wq/auth/ +├── AuthApplication.kt +├── api/ +│ ├── controller/ # REST +│ ├── domain/ +│ └── external/oauth/ +├── security/ +├── shared/ +└── web/common/ -## 환경 변수 설정 +src/main/resources/ +├── application.yml +├── application-{local,alpha,prod}.yml +├── application-jwt.yml +└── application-oauth.yml -환경 변수는 다음 파일에 매핑됩니다: -- `src/main/resources/application.yml` -- `src/main/resources/application-jwt.yml` -- `src/main/resources/application-dev.yml` -- `src/main/resources/application-prod.yml` -- `src/main/resources/application-oauth.yml` - -### 필수 환경 변수 - -#### 공통 -```properties -# CORS 설정 -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 - -# JWT 설정 -JWT_SECRET_KEY=your-secret-key-min-256-bits -JWT_ACCESS_TOKEN_EXPIRATION=3600000 # 1시간 (ms) -JWT_REFRESH_TOKEN_EXPIRATION=604800000 # 7일 (ms) +docs/ +├── _config.yml # GitHub Pages (Jekyll) +├── api-명세서.md # API 명세 +└── GITHUB-ENVIRONMENTS.md # 배포 CI Environment ``` -#### Google OAuth -```properties -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_REDIRECT_URI=http://localhost:5173/auth/google/callback -``` +--- -#### Kakao OAuth -```properties -KAKAO_CLIENT_ID=your-kakao-rest-api-key -KAKAO_CLIENT_SECRET=your-kakao-client-secret -KAKAO_REDIRECT_URI=http://localhost:5173/auth/kakao/callback -``` +## 실행 방법 -#### Naver OAuth -```properties -NAVER_CLIENT_ID=your-naver-client-id -NAVER_CLIENT_SECRET=your-naver-client-secret -NAVER_REDIRECT_URI=http://localhost:5173/auth/naver/callback -``` +**요구:** JDK 17 이상(프로젝트는 **25** 툴체인), Gradle 래퍼. -### 선택 환경 변수 -```properties -# 이메일 인증 (사용 시) -MAIL_HOST=smtp.gmail.com -MAIL_PORT=587 -MAIL_USERNAME=your-email@gmail.com -MAIL_PASSWORD=your-app-password +```bash +./gradlew bootRun +./gradlew build +java -jar build/libs/auth-api-0.0.1-SNAPSHOT.jar ``` -## API 엔드포인트 +- 앱 이름: `auth-api` (`spring.application.name`) +- 기본 포트: **9000** +- 로컬 프로필: 기본 `local` + `jwt` + `oauth` (그룹은 `application.yml` 참고) -### 소셜 로그인/연동 (`SocialLoginController`) - -| Method | Endpoint | 설명 | 인증 | -|--------|----------|------|------| -| POST | `/api/v1/auth/google/login` | Google 로그인 | 불필요 | -| POST | `/api/v1/auth/kakao/login` | Kakao 로그인 | 불필요 | -| POST | `/api/v1/auth/naver/login` | Naver 로그인 | 불필요 | -| POST | `/api/v1/auth/link/google` | Google 계정 연동 | **필요** | -| POST | `/api/v1/auth/link/kakao` | Kakao 계정 연동 | **필요** | -| POST | `/api/v1/auth/link/naver` | Naver 계정 연동 | **필요** | - -**요청 바디 필드 (Provider별)** -- **Google/Kakao**: `authCode`, `codeVerifier` (필수). `redirectUri`는 서버 환경변수 사용. -- **Naver**: `authCode`, `state`, `codeVerifier` (세 필수 모두 필요). 인가 요청 시 사용한 `state`와 동일한 값 전달. - -**요청 예시** (Google 로그인): -```json -POST /api/v1/auth/google/login -Content-Type: application/json +--- -{ - "authCode": "4/0AfJohXmx...", - "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" -} -``` +## GitHub Pages -**요청 예시** (Naver 로그인): -```json -POST /api/v1/auth/naver/login -Content-Type: application/json +GitHub에서 정적 사이트로 **`docs/`** 폴더를 게시합니다. -{ - "authCode": "네이버에서_받은_인가코드", - "state": "인가_요청시_사용한_state_값과_동일", - "codeVerifier": "PKCE_코드_검증자" -} -``` +1. 저장소 **Settings → Pages** +2. **Build and deployment**: Branch **`main`**, 폴더 **`/docs`** +3. 저장 후 몇 분 뒤 Pages가 빌드됩니다. -> AccessToken은 Authorization 헤더로 자동 설정됩니다. -> RefreshToken은 HttpOnly 쿠키로 자동 설정됩니다. +**API 명세 (Pages):** 사이트에서 **`/api-명세서/`** 로 열립니다 (`docs/api-명세서.md`의 `permalink`). 루트 URL(`/`)에는 별도 `index`가 없어 **404일 수 있음**에 유의하세요. -### 이메일 인증/로그인 (`AuthEmailController`, `AuthController`) +**API 명세 (저장소에서 보기):** `https://github.com///blob/main/docs/api-명세서.md` -| Method | Endpoint | 설명 | 인증 | -|--------|----------|------|------| -| POST | `/api/v1/auth/email/request` | 이메일 인증코드 발송 | 불필요 | -| POST | `/api/v1/auth/email/verify` | 이메일 인증코드 검증 | 불필요 | -| POST | `/api/v1/auth/members/email-login` | 이메일 로그인/가입 | 불필요 | -| POST | `/api/v1/auth/members/logout` | 로그아웃 | 불필요 | -| POST | `/api/v1/auth/members/refresh` | AccessToken 재발급 | 불필요* | +랜딩 페이지가 필요하면 `docs/index.md`를 다시 두면 됩니다. -\* RefreshToken 쿠키 필요 +--- -**요청 예시** (이메일 인증 요청): -```json -POST /api/v1/auth/email/request -Content-Type: application/json +## 배포 (Docker / EC2) -{ - "email": "user@example.com" -} -``` +- **`main`** push 시 GitHub Actions로 이미지 빌드·푸시 후 EC2에서 컨테이너 갱신. +- EC2에서는 env를 **단일 파일**로 씁니다. Secret **`ENV_FILE`**(또는 CI에서 쓰는 이름) 전체를 **`~/env/auth-be.env`** 에 두고 `--env-file` 로 실행하는 흐름을 전제로 합니다. -**요청 예시** (토큰 재발급): -```http -POST /api/v1/auth/members/refresh -Cookie: refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +```bash +docker run -d --restart unless-stopped --name auth-be -p 9000:9000 \ + --env-file ~/env/auth-be.env /auth-server:latest ``` -### 회원 관리 (`MemberController`) - -| Method | Endpoint | 설명 | 인증 | -|--------|----------|------|------| -| GET | `/api/v1/auth/members/user-info` | 내 정보 조회 | **필요** | -| GET | `/api/v1/members` | 회원 목록 조회 | 불필요 | -| GET | `/api/v1/members/{id}` | 회원 단건 조회 | 불필요 | -| POST | `/api/v1/members` | 회원 생성 | 불필요 | -| PUT | `/api/v1/members/{id}/nickname` | 닉네임 변경 | 불필요 | -| DELETE | `/api/v1/members/{id}` | 회원 삭제 | 불필요 | +**GitHub Environments**(`production` / `alpha`)와 Secret 이름 표는 [`docs/GITHUB-ENVIRONMENTS.md`](docs/GITHUB-ENVIRONMENTS.md)를 따릅니다. -> 내 정보 조회 이외에는 테스트용 CRUD 엔드포인트입니다. 실제 운영 시 권한 설정 필요. - -**인증 헤더 형식**: -```http -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -``` - -### 보안 테스트 (`TestSecurityController`) +--- -개발/테스트 전용 엔드포인트: +## 환경 변수 -| Method | Endpoint | 설명 | 권한 | -|--------|----------|------|------| -| GET | `/api/public/test` | 공개 API 테스트 | 없음 | -| GET | `/api/test` | 인증 API 테스트 | USER | -| GET | `/api/admin/test` | 관리자 API 테스트 | ADMIN | -| GET | `/api/public/token` | 테스트용 JWT 발급 | 없음 | +설정은 주로 다음에 매핑됩니다. -## 인증 플로우 +- `application.yml` +- `application-jwt.yml` — `JWT_SECRET`, 토큰 만료 (`JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION` 등, Duration 형식 예: `30m`, `P7D`) +- `application-oauth.yml` — OAuth 클라이언트 ID/Secret, redirect URI +- `application-{local,alpha,prod}.yml` — 프로필별 -### 소셜 로그인 (PKCE) 전체 플로우 +### 자주 쓰는 예시 +```properties +JWT_SECRET=BASE64_OR_RAW_SECRET +JWT_ACCESS_TOKEN_EXPIRATION=30m +JWT_REFRESH_TOKEN_EXPIRATION=P7D + +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=authdb +DB_USERNAME=postgres +DB_PASSWORD=postgres + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +KAKAO_CLIENT_ID= +KAKAO_CLIENT_SECRET= +KAKAO_REDIRECT_URI= +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= +NAVER_REDIRECT_URI= + +MAIL_USERNAME= +MAIL_PASSWORD= +SWAGGER_PATH=/ +APP_COOKIE_DOMAIN= ``` -┌─────────┐ ┌──────────┐ ┌──────────┐ -│ Client │ │ Auth-BE │ │ OAuth │ -│(Browser)│ │ (Backend)│ │ Provider │ -└────┬────┘ └─────┬────┘ └─────┬────┘ - │ │ │ - │ 1. Generate code_verifier │ │ - │ & code_challenge │ │ - ├──────────────────────────>│ │ - │ │ │ - │ 2. Redirect to OAuth │ │ - ├───────────────────────────┼──────────────────────────>│ - │ (with code_challenge) │ │ - │ │ │ - │ 3. User Authentication │ │ - │<──────────────────────────┼───────────────────────────┤ - │ │ │ - │ 4. Redirect with code │ │ - │<──────────────────────────┼───────────────────────────┤ - │ │ │ - │ 5. POST /auth/{provider} │ │ - │ (code + code_verifier) │ │ - ├──────────────────────────>│ │ - │ │ 6. Exchange code for token│ - │ │ (with code_verifier) │ - │ ├──────────────────────────>│ - │ │ │ - │ │ 7. Access Token │ - │ │<──────────────────────────┤ - │ │ │ - │ │ 8. Get User Info │ - │ ├──────────────────────────>│ - │ │<──────────────────────────┤ - │ │ │ - │ 9. JWT Tokens │ │ - │ + RefreshToken Cookie │ │ - │<──────────────────────────┤ │ - │ │ │ -``` - -## OAuth/PKCE 규칙 - -### PKCE (Proof Key for Code Exchange) -- **Authorization 요청**: `code_challenge` (SHA-256 해시) 전송 -- **Token 교환**: 동일한 `code_verifier` 사용 -- **보안**: Authorization Code 탈취 공격 방지 - -### Authorization Code -- **1회용**: 사용 후 즉시 무효화 -- **유효시간**: 매우 짧음 (보통 10분 이내) -- **재사용 시**: `400 invalid_grant` 에러 발생 -- **권장사항**: 획득 즉시 토큰으로 교환 -### Redirect URI -- **일치 필수**: Authorization과 Token 교환 시 **완전히 동일**해야 함 -- **쿼리 파라미터**: 포함 시 정확히 일치 필요 -- **백엔드 동작**: 환경변수(`*_REDIRECT_URI`)에 설정된 값 사용 -- **화이트리스트**: 운영 환경에서 반드시 검증 필요 +--- -### Naver 특이사항 -- **State 파라미터**: Authorization과 Token 교환 시 동일 값 필수 -- **CSRF 방어**: State 값으로 요청 위변조 방지 +## 인증·토큰 요약 -### Provider별 Scope +- **Access Token:** 웹은 `accessToken` HttpOnly 쿠키; 앱은 로그인/갱신 응답 본문 등(`X-Client-Type: app`). +- **Refresh Token:** 웹은 `refreshToken` 쿠키; 앱은 요청/응답 본문. +- **호출 시 읽기 순서** (`JwtAuthenticationFilter`): + 1) `accessToken` 쿠키가 있으면 **Authorization 헤더 무시** + 2) 없으면 `Authorization: Bearer` -#### Google -``` -openid email profile -``` +**엔드포인트·DTO 표**는 [`docs/api-명세서.md`](docs/api-명세서.md)를 보세요. -#### Kakao -``` -account_email profile_nickname -``` +--- -#### Naver -``` -email name -``` +## OAuth / PKCE 요약 -## 보안 고려사항 - -### 토큰 관리 -- **AccessToken**: AUTHORIZATION 헤더 저장 (짧은 만료시간) -- **RefreshToken**: HttpOnly 쿠키로만 전달 (XSS 공격 방지) -- **Cookie 속성**: - - `HttpOnly`: JavaScript 접근 차단 - - `Secure`: HTTPS에서만 전송 (운영 환경) - - `SameSite=Strict`: CSRF 공격 방지 - -### CORS 설정 -```yaml -cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS} - allowed-methods: GET, POST, PUT, DELETE, OPTIONS - allowed-headers: "*" - allow-credentials: true -``` +- Authorization Code는 **1회용**, 받은 뒤 곧바로 교환. +- **Redirect URI**는 인가 요청과 토큰 요청에서 **동일**해야 함. +- **Naver**는 `state` 일치 필요. +- PKCE: `codeVerifier` 등은 DTO 및 제공자 문서를 따름. -### 환경별 쿠키 설정 -```kotlin -// 개발 환경 -secure = false -sameSite = Lax +참고: [OAuth 2.0](https://tools.ietf.org/html/rfc6749), [PKCE](https://tools.ietf.org/html/rfc7636), [Google](https://developers.google.com/identity/protocols/oauth2), [Kakao](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api), [Naver](https://developers.naver.com/docs/login/api/api.md) -// 운영 환경 -secure = true -sameSite = Strict -``` +--- -## 추가 자료 +## 보안·운영 참고 -- [OAuth 2.0 RFC](https://tools.ietf.org/html/rfc6749) -- [PKCE RFC](https://tools.ietf.org/html/rfc7636) -- [Google OAuth 문서](https://developers.google.com/identity/protocols/oauth2) -- [Kakao OAuth 문서](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api) -- [Naver OAuth 문서](https://developers.naver.com/docs/login/api/api.md) +- 본 서비스 `SecurityConfig`에서는 **CORS를 끈 상태**이며, 보통 **API Gateway에서 CORS**를 처리합니다. 로컬에서 브라우저로 직접 호출할 때는 게이트웨이·프록시 또는 허용 정책을 맞춥니다. +- 운영에서는 쿠키 `Secure` / `SameSite` 등을 프로필에 맞게 유지합니다. --- -**Maintained by**: GrowGrammers Team -**Last Updated**: 2025-10-16 +**Maintained by:** GrowGrammers Team +**Last updated:** 2026-04-08 diff --git "a/docs/api-\353\252\205\354\204\270\354\204\234.md" "b/docs/api-\353\252\205\354\204\270\354\204\234.md" new file mode 100644 index 0000000..a3c233e --- /dev/null +++ "b/docs/api-\353\252\205\354\204\270\354\204\234.md" @@ -0,0 +1,333 @@ +--- +layout: default +title: API 명세서 +permalink: /api-명세서/ +--- + +# Auth API 명세서 + +본 문서는 **auth-api** (`com.wq.auth`) REST API의 계약을 정리합니다. 구현 기준은 `src/main/kotlin` 컨트롤러 및 DTO입니다. + +> **위치:** 저장소 `docs/api-명세서.md`. GitHub Pages(`/docs`)에서도 동일 경로로 렌더링됩니다. + +## 목차 + +- [공통 규약](#공통-규약) +- [응답 래퍼 `CommonResponse`](#응답-래퍼-commonresponse) +- [인증·토큰 전달](#인증토큰-전달) +- [클라이언트 구분 `X-Client-Type`](#클라이언트-구분-x-client-type) +- [API 목록](#api-목록) +- [OpenAPI (Swagger)](#openapi-swagger) + +--- + +## 공통 규약 + +| 항목 | 값 | +|------|-----| +| 기본 경로 | `/api/v1` (일부 레거시·테스트용은 `/api/v1/members`, `/api/public` 등) | +| Content-Type | `application/json` (본문이 있는 요청) | +| 서버 포트 (기본) | `9000` (`application.yml`) | + +--- + +## 응답 래퍼 `CommonResponse` + +대부분의 JSON 응답은 아래 형태입니다. + +| 필드 | 타입 | 설명 | +|------|------|------| +| `success` | `boolean` | 성공 여부 | +| `code` | `string` | 성공 시 `"SUCCESS"`, 실패 시 오류 코드 문자열 | +| `message` | `string` | 사용자/클라이언트용 메시지 | +| `data` | `T \| null` | 페이로드 (없으면 `null`) | + +**성공 예시** + +```json +{ + "success": true, + "code": "SUCCESS", + "message": "로그인에 성공했습니다.", + "data": null +} +``` + +예외는 `GlobalExceptionHandler`에서 HTTP 상태와 함께 `CommonResponse` 형태로 반환됩니다. + +--- + +## 인증·토큰 전달 + +### JWT 추출 우선순위 (`JwtAuthenticationFilter`) + +1. **`accessToken` 쿠키**가 있으면 그 값만 사용 (이 경우 `Authorization` 헤더는 **무시**). +2. 쿠키가 없으면 **`Authorization: Bearer `**. + +### 소셜 로그인·이메일 로그인 성공 시 + +- **`Set-Cookie`**: `accessToken`, `refreshToken` (HttpOnly 등은 환경·`CookieFactory` 설정에 따름). + +### 인증이 필요한 API (`@AuthenticatedApi`) + +- 웹: 보통 `accessToken` 쿠키 + `credentials` 포함 요청. +- 앱: `Authorization: Bearer` 또는 정책에 맞는 방식(쿠키 미사용 시 헤더). + +--- + +## 클라이언트 구분 `X-Client-Type` + +일부 API는 **필수 헤더**입니다. + +| 값 | 의미 | +|----|------| +| `web` | 브라우저: 리프레시 토큰은 **쿠키**(`refreshToken`), 응답 `data`는 종종 생략 | +| `web` 이외 (예: `app`) | 네이티브 앱: 리프레시 토큰은 **요청 본문**으로 전달, 응답 `data`에 토큰 포함 | + +해당 헤더가 필요한 엔드포인트는 아래 표에 명시합니다. + +--- + +## API 목록 + +### 1. 소셜 로그인 (`SocialLoginController`) + +| Method | Path | 인증 | 설명 | +|--------|------|------|------| +| POST | `/api/v1/auth/social/login` | 불필요 | 범용 소셜 로그인 (`providerType`: GOOGLE / KAKAO / NAVER) | +| POST | `/api/v1/auth/google/login` | 불필요 | Google 로그인 | +| POST | `/api/v1/auth/kakao/login` | 불필요 | Kakao 로그인 | +| POST | `/api/v1/auth/naver/login` | 불필요 | Naver 로그인 | +| POST | `/api/v1/auth/link/google` | **필요** | Google 계정 연동 | +| POST | `/api/v1/auth/link/kakao` | **필요** | Kakao 계정 연동 | +| POST | `/api/v1/auth/link/naver` | **필요** | Naver 계정 연동 | + +**Rate limit (참고)** +로그인: 분당 10회(10분 윈도우) / 연동: 분당 5회(10분 윈도우) — 컨트롤러 `@RateLimit` 기준. + +#### 1.1 범용 소셜 로그인 `POST /api/v1/auth/social/login` + +**Body — `SocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | OAuth 인가 코드 | +| `codeVerifier` | DTO상 필수 문자열 | PKCE용 (Naver 등에서도 필드 존재) | +| `state` | 조건부 | **Naver** 시 인가 요청과 동일한 값 | +| `providerType` | 예 | `GOOGLE`, `KAKAO`, `NAVER` | +| `redirectUri` | 아니오 | 허용 목록에 있을 때만 사용, 없으면 서버 기본값 | + +#### 1.2 Google `POST /api/v1/auth/google/login` + +**Body — `GoogleSocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | 인가 코드 | +| `codeVerifier` | 예 | PKCE 코드 검증자 | +| `redirectUri` | 아니오 | 선택, 서버 기본값 대체 | + +#### 1.3 Kakao `POST /api/v1/auth/kakao/login` + +**Body — `KakaoSocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | 인가 코드 | +| `codeVerifier` | 예 | PKCE (권장) | +| `redirectUri` | 아니오 | 선택 | + +#### 1.4 Naver `POST /api/v1/auth/naver/login` + +**Body — `NaverSocialLoginRequestDto`** + +| 필드 | 필수 | 설명 | +|------|------|------| +| `authCode` | 예 | 인가 코드 | +| `state` | 예 | CSRF용, 인가 요청 시 사용한 값과 동일 | +| `codeVerifier` | 예 | PKCE 코드 검증자 | +| `redirectUri` | 아니오 | 선택 | + +#### 1.5 소셜 계정 연동 (Google / Kakao / Naver) + +로그인된 사용자의 `accessToken`(쿠키 또는 정책에 맞는 인증) 필요. + +- **Google / Kakao**: `authCode`, `codeVerifier` 필수, `redirectUri` 선택. +- **Naver**: `authCode`, `state`, `codeVerifier` 필수, `redirectUri` 선택. + +**성공 시** +`CommonResponse` 메시지 문자열만 반환 (토큰 재발급 없음). + +--- + +### 2. 이메일 인증 (`AuthEmailController`) + +| Method | Path | 인증 | 설명 | +|--------|------|------|------| +| POST | `/api/v1/auth/email/request` | 불필요 | 인증 코드 이메일 발송 | +| POST | `/api/v1/auth/email/verify` | 불필요 | 인증 코드 검증 | + +**`POST .../request` Body — `EmailRequestDto`** + +| 필드 | 타입 | +|------|------| +| `email` | string | + +**`POST .../verify` Body — `EmailVerifyRequestDto`** + +| 필드 | 타입 | +|------|------| +| `email` | string | +| `verifyCode` | string | + +Rate limit: 요청 3회/10분, 검증 10회/5분 (컨트롤러 기준). + +--- + +### 3. 인증·세션 (`AuthController`) + +| Method | Path | 인증 | `X-Client-Type` | +|--------|------|------|-----------------| +| POST | `/api/v1/auth/members/email-login` | 불필요 | **필수** | +| POST | `/api/v1/auth/link/email-login` | **필요** | 불필요 | +| POST | `/api/v1/auth/members/logout` | 불필요 (리프레시 토큰으로 식별) | **필수** | +| POST | `/api/v1/auth/members/refresh` | 불필요 (리프레시 토큰) | **필수** | +| GET | `/api/v1/auth/introspect` | 불필요 (토큰으로 검증) | 선택 | + +#### 3.1 이메일 로그인/가입 `POST /api/v1/auth/members/email-login` + +**Headers** + +- `X-Client-Type`: `web` \| `app` (필수) + +**Body — `EmailLoginRequestDto`** + +| 필드 | 타입 | 설명 | +|------|------|------| +| `email` | string | | +| `verifyCode` | string | 이메일 인증 코드 | +| `deviceId` | string \| null | 선택 | + +**동작** + +- 성공 시 `Set-Cookie`로 `accessToken`, `refreshToken` 설정. +- `X-Client-Type: web` → `data`는 `null`. +- `app` → `data`에 `LoginResponseDto` (`refreshToken` 포함). + +#### 3.2 이메일 계정 연동 `POST /api/v1/auth/link/email-login` + +**Body — `EmailLoginLinkRequestDto`** + +| 필드 | 설명 | +|------|------| +| `email` | 이메일 | +| `verifyCode` | 6자리 숫자 | + +#### 3.3 로그아웃 `POST /api/v1/auth/members/logout` + +**Headers** + +- `X-Client-Type`: **필수** + +**Body — `LogoutRequestDto` (선택)** + +| 필드 | 설명 | +|------|------| +| `refreshToken` | `app`일 때 본문으로 전달. `web`은 `Cookie: refreshToken` | + +**동작** + +- `web`: 서버가 리프레시 삭제 후 `accessToken`/`refreshToken` 쿠키 만료 응답. + +#### 3.4 액세스 토큰 재발급 `POST /api/v1/auth/members/refresh` + +**Headers** + +- `X-Client-Type`: **필수** + +**Body — `RefreshAccessTokenRequestDto` (선택)** + +| 필드 | 설명 | +|------|------| +| `refreshToken` | `app`일 때 필수에 가깝게 사용 | +| `deviceId` | 선택 | + +**동작** + +- `web`: `refreshToken` 쿠키 사용. +- 성공 시 새 토큰 `Set-Cookie`. +- `web` → `data` null; `app` → `data`에 `RefreshAccessTokenResponseDto` (`refreshToken`). + +#### 3.5 토큰 introspect (Gateway 연동) `GET /api/v1/auth/introspect` + +**목적**: Access Token 검증 후 사용자 식별자를 헤더로 전달. + +**토큰 출처 (구현상 `JwtAuthenticationFilter`와 정합)** + +- 우선 `accessToken` 쿠키, 없으면 `Authorization: Bearer`. + +**동작 요약** + +- 유효한 AT로부터 사용자 UUID(`opaqueId`)를 구해 응답 헤더에 설정. +- AT 남은 시간이 5분 미만이거나 만료된 경우, `refreshToken` 쿠키로 **사일런트 리프레시** 시도 후 새 쿠키 `Set-Cookie`. +- 실패 시 401 및 쿠키 제거 가능. + +**성공 응답 헤더** + +| 헤더 | 설명 | +|------|------| +| `X-User-Id` | 사용자 UUID (opaqueId) | + +응답 본문은 컨트롤러에서 별도 JSON을 쓰지 않을 수 있음(상태 200 + 헤더 중심). + +--- + +### 4. 회원 (`MemberController`) + +| Method | Path | 인증 | 설명 | +|--------|------|------|------| +| GET | `/api/v1/auth/members/user-info` | **필요** | 로그인 사용자 정보 | +| GET | `/api/v1/members` | 설정상 인증 필요 | 전체 회원 목록 (운영 시 권한 검토 권장) | +| GET | `/api/v1/members/{id}` | 설정상 인증 필요 | 단건 조회 | +| POST | `/api/v1/members` | 설정상 인증 필요 | 회원 생성 | +| PUT | `/api/v1/members/{id}/nickname` | 설정상 인증 필요 | 닉네임 변경 (`{"nickname":"..."}`) | +| DELETE | `/api/v1/members/{id}` | 설정상 인증 필요 | 삭제 | + +#### 4.1 내 정보 `GET /api/v1/auth/members/user-info` + +**성공 시 `data` — `UserInfoResponseDto`** + +| 필드 | 타입 | +|------|------| +| `userId` | string (UUID, opaqueId) | +| `nickname` | string | +| `email` | string | +| `linkedProviders` | `ProviderType[]` (예: GOOGLE, KAKAO, NAVER, EMAIL) | + +--- + +### 5. 보안 테스트용 (`TestSecurityController`) + +개발·테스트용이며 운영에서는 제거·차단 검토. + +| Method | Path | 인증 | +|--------|------|------| +| GET | `/api/public/test` | 불필요 | +| GET | `/api/test` | 필요 (`@AuthenticatedApi`) | +| GET | `/api/public/token` | 불필요 (테스트용 JWT 발급, `opaqueId` 쿼리 가능) | + +--- + +## OpenAPI (Swagger) + +- API 문서 JSON: `/v3/api-docs` +- Swagger UI 경로: `springdoc.swagger-ui.path` — 환경변수 **`SWAGGER_PATH`** 로 설정 (`application.yml`). + +로컬 예시: `http://localhost:9000/swagger-ui/index.html` (설정에 따라 경로 변동 가능) + +--- + +## 참고 + +- 배포·환경 변수·EC2 Docker는 저장소 **README** 및 [`GITHUB-ENVIRONMENTS.md`](GITHUB-ENVIRONMENTS.md)를 참고하세요. +- API Gateway 연동 시 `GET /api/v1/auth/introspect`와 응답 헤더 `X-User-Id`를 활용할 수 있습니다. From 055300c99a09f518967eac56cec2e02fa197c7f6 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 11 May 2026 13:50:16 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20V2=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B8=B0=EC=A1=B4=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/api/v2/auth/*/login` 경로 및 컨트롤러(SocialLoginV2Controller) 추가 - X-Client-Id 헤더 기반 응답 분기를 통해 웹/앱 클라이언트 지원 - OAuth 제공자별 Google, Kakao, Naver 소셜 로그인 V2 API 구현 - SecurityConfig에서 V2 로그인 경로 추가 - SocialLoginResponseDto 클래스 추가를 통한 앱 응답 전용 DTO 제공 - AuthController에 AT/RT 처리 방식 개선 (웹/앱 구분 로직 추가) - deploy.yml 경로 설정에 문서 변경 무시 규칙 추가 --- .github/workflows/deploy.yml | 4 + .../api/controller/auth/AuthController.kt | 27 +- .../controller/auth/SocialLoginController.kt | 15 -- .../auth/SocialLoginV2Controller.kt | 238 ++++++++++++++++++ .../auth/response/SocialLoginResponseDto.kt | 12 + .../wq/auth/shared/config/SecurityConfig.kt | 3 +- 6 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index afab210..100d8d5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,6 +12,10 @@ on: branches: - main - dev + # 문서·README만 변경된 경우 이미지 빌드/EC2 배포 불필요 + paths-ignore: + - "README.md" + - "docs/**" # 수동 실행을 가능하게 하는 설정 workflow_dispatch: diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index caf0415..e1e3d73 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -262,7 +262,7 @@ class AuthController( @RequestBody req: RefreshAccessTokenRequestDto?, ): CommonResponse { - val currentRefreshToken : String? = if(clientType == "web") { + val currentRefreshToken: String? = if (clientType == "web") { refreshToken } else { req?.refreshToken @@ -344,32 +344,43 @@ class AuthController( throw JwtException(JwtExceptionCode.TOKEN_MISSING) } + // X-Client-Id가 "web"이면 웹, 그 외(easy-snap-app 등)면 앱 + val isApp = request.getHeader("X-Client-Id") != "web" + // AT가 만료(-1)되었거나 남은 시간이 5분(300초) 미만이면 사일런트 리프레시 시도 val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) val opaqueId: String = if (remainingSeconds < 300) { - val refreshToken = request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + val refreshToken = if (isApp) { + request.getHeader("X-Refresh-Token") + } else { + request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + } if (refreshToken.isNullOrBlank()) { - clearAuthCookies(response) + if (!isApp) clearAuthCookies(response) throw JwtException(JwtExceptionCode.TOKEN_MISSING) } try { - // 만료된 AT에서도 claims를 읽어 deviceId를 추출합니다. val claims = jwtProvider.getClaimsEvenIfExpired(token) val deviceId = claims["deviceId"] as? String val tokenResult = authService.refreshAccessToken(refreshToken, deviceId) - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) + if (isApp) { + response.setHeader("X-New-AT", tokenResult.accessToken) + response.setHeader("X-New-RT", tokenResult.refreshToken) + } else { + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) + } - log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds)" } + log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds, isApp=$isApp)" } jwtProvider.getOpaqueId(tokenResult.accessToken) } catch (e: Exception) { log.warn { "사일런트 리프레시 실패: ${e.message}" } - clearAuthCookies(response) + if (!isApp) clearAuthCookies(response) throw JwtException(JwtExceptionCode.EXPIRED) } } else { diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt index bb3ac5e..a8f8ab8 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt @@ -101,9 +101,7 @@ class SocialLoginController( response: HttpServletResponse ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("소셜 로그인이 완료되었습니다") } @@ -173,11 +171,8 @@ class SocialLoginController( @Valid @RequestBody request: GoogleSocialLoginRequestDto, response: HttpServletResponse ): CommonResponse { - val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("Google 로그인이 완료되었습니다") } @@ -245,11 +240,8 @@ class SocialLoginController( @Valid @RequestBody request: KakaoSocialLoginRequestDto, response: HttpServletResponse ): CommonResponse { - val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("카카오 로그인이 완료되었습니다") } @@ -320,9 +312,7 @@ class SocialLoginController( response: HttpServletResponse ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return CommonResponse.success("Naver 로그인이 완료되었습니다") } @@ -550,10 +540,6 @@ class SocialLoginController( /** * AccessToken/RefreshToken을 HttpOnly 쿠키로 설정합니다. - * - * @param response HTTP 응답 객체 - * @param accessToken 액세스 토큰 - * @param refreshToken 리프레시 토큰 */ private fun setTokenCookies( response: HttpServletResponse, @@ -562,7 +548,6 @@ class SocialLoginController( ) { val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(refreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) } diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt new file mode 100644 index 0000000..2e9055d --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginV2Controller.kt @@ -0,0 +1,238 @@ +package com.wq.auth.api.controller.auth + +import com.wq.auth.api.controller.auth.request.GoogleSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.KakaoSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.NaverSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.SocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.toDomain +import com.wq.auth.api.controller.auth.response.SocialLoginResponseDto +import com.wq.auth.api.domain.auth.SocialLoginService +import com.wq.auth.api.domain.auth.response.SocialLoginResult +import com.wq.auth.security.annotation.PublicApi +import com.wq.auth.shared.config.CookieFactory +import com.wq.auth.shared.rateLimiter.annotation.RateLimit +import com.wq.auth.web.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid +import org.springframework.http.HttpHeaders +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController +import java.util.concurrent.TimeUnit + +/** + * 소셜 로그인 V2 컨트롤러 + * + * X-Client-Id 헤더를 기반으로 웹/앱 응답을 분기합니다. + * - web (X-Client-Id: web): HttpOnly 쿠키로 토큰 반환 + * - app (X-Client-Id: easy-snap-app 등): JSON body에 accessToken / refreshToken 반환 + * + * 기존 V1(/api/v1/auth)은 유지되며, 신규 클라이언트는 이 V2를 사용하세요. + */ +@Tag(name = "소셜 로그인 V2", description = "X-Client-Id 기반 웹/앱 분기 소셜 로그인 API") +@RestController +class SocialLoginV2Controller( + private val socialLoginService: SocialLoginService, + private val cookieFactory: CookieFactory, +) { + + @Operation( + summary = "범용 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "소셜 제공자 API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @PublicApi("소셜 로그인 V2") + @PostMapping("/api/v2/auth/social/login") + fun socialLogin( + @Valid @RequestBody request: SocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "소셜 로그인이 완료되었습니다") + } + + @Operation( + summary = "Google 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + + **PKCE 필수:** + - codeVerifier는 Google 인증 요청 시 사용한 code_verifier와 동일해야 합니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Google 로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "Google 인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "Google API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("Google 소셜 로그인 V2") + @PostMapping("/api/v2/auth/google/login") + fun googleLogin( + @Valid @RequestBody request: GoogleSocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "Google 로그인이 완료되었습니다") + } + + @Operation( + summary = "카카오 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "카카오 로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "카카오 인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "카카오 API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("카카오 소셜 로그인 V2") + @PostMapping("/api/v2/auth/kakao/login") + fun kakaoLogin( + @Valid @RequestBody request: KakaoSocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "카카오 로그인이 완료되었습니다") + } + + @Operation( + summary = "Naver 소셜 로그인 V2", + description = """ + X-Client-Id 헤더 값에 따라 토큰 반환 방식이 달라집니다. + + **X-Client-Id: web** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 응답 body data: null + + **X-Client-Id: {앱 식별자} (예: easy-snap-app)** + - 쿠키 미설정 + - 응답 body data: `{ accessToken, refreshToken }` + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Naver 로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "400", description = "X-Client-Id 헤더 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "401", description = "Naver 인가 코드 유효하지 않음", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))]), + ApiResponse(responseCode = "500", description = "Naver API 호출 실패", + content = [Content(schema = Schema(implementation = CommonResponse::class))]) + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("Naver 소셜 로그인 V2") + @PostMapping("/api/v2/auth/naver/login") + fun naverLogin( + @Valid @RequestBody request: NaverSocialLoginRequestDto, + @RequestHeader("X-Client-Id") clientId: String, + response: HttpServletResponse, + ): CommonResponse { + val loginResult = socialLoginService.processSocialLogin(request.toDomain()) + return buildLoginResponse(clientId, loginResult, response, "Naver 로그인이 완료되었습니다") + } + + /** + * X-Client-Id 값에 따라 토큰 반환 방식을 분기합니다. + * + * - "web": HttpOnly 쿠키 설정, body data null + * - 그 외 (앱 식별자): 쿠키 미설정, body에 accessToken / refreshToken 반환 + */ + private fun buildLoginResponse( + clientId: String, + loginResult: SocialLoginResult, + response: HttpServletResponse, + message: String, + ): CommonResponse { + return if (clientId == "web") { + val accessTokenCookie = cookieFactory.createAccessTokenCookie(loginResult.accessToken) + val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(loginResult.refreshToken) + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + CommonResponse.success(message = message, data = null) + } else { + CommonResponse.success( + message = message, + data = SocialLoginResponseDto( + accessToken = loginResult.accessToken, + refreshToken = loginResult.refreshToken, + ) + ) + } + } +} diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt new file mode 100644 index 0000000..d6256db --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/response/SocialLoginResponseDto.kt @@ -0,0 +1,12 @@ +package com.wq.auth.api.controller.auth.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "소셜 로그인 앱 응답 (X-Client-Id가 web이 아닌 경우 반환)") +data class SocialLoginResponseDto( + @get:Schema(description = "JWT 액세스 토큰") + val accessToken: String, + + @get:Schema(description = "JWT 리프레시 토큰") + val refreshToken: String, +) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index 5010091..a391023 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -57,7 +57,8 @@ class SecurityConfig( "/api/v1/auth/email/verify", // 이메일 인증 코드 검증 (로그인 전 호출) "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 "/api/public/**", // 공개 API - "/api/v1/auth/*/login", // 소셜 로그인 API + "/api/v1/auth/*/login", // 소셜 로그인 API (V1) + "/api/v2/auth/*/login", // 소셜 로그인 API (V2) "/api/v1/auth/members/logout", //로그아웃 "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI From 0e19ed3cd41e9af45ddc14e5a87931870ba4265b Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 11 May 2026 14:17:27 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20AT/RT=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=82=AC?= =?UTF-8?q?=EC=9D=BC=EB=9F=B0=ED=8A=B8=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AT/RT 처리 로직에서 사일런트 리프레시 분리 및 재사용성 강화 - 토큰 유효 여부, 만료 처리 방식 변경 및 관련 로직 최적화 - 웹/앱 클라이언트 구분에 따른 쿠키/헤더 처리 방식 개선 - 서비스 응답 헤더 설정 로직 리팩토링 --- .../api/controller/auth/AuthController.kt | 89 +++++++++++-------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index e1e3d73..bee524c 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -338,56 +338,69 @@ class AuthController( request: HttpServletRequest, response: HttpServletResponse, ) { - val token = JwtAuthenticationFilter.extractToken(request) - // 쿠키·헤더 모두 없거나, accessToken 쿠키가 빈 값인 경우 401 - if (token.isNullOrBlank()) { - throw JwtException(JwtExceptionCode.TOKEN_MISSING) - } - // X-Client-Id가 "web"이면 웹, 그 외(easy-snap-app 등)면 앱 val isApp = request.getHeader("X-Client-Id") != "web" - // AT가 만료(-1)되었거나 남은 시간이 5분(300초) 미만이면 사일런트 리프레시 시도 - val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) - val opaqueId: String = if (remainingSeconds < 300) { - val refreshToken = if (isApp) { - request.getHeader("X-Refresh-Token") - } else { - request.cookies?.firstOrNull { it.name == "refreshToken" }?.value - } + val token = JwtAuthenticationFilter.extractToken(request) - if (refreshToken.isNullOrBlank()) { - if (!isApp) clearAuthCookies(response) - throw JwtException(JwtExceptionCode.TOKEN_MISSING) + val opaqueId: String = if (token.isNullOrBlank()) { + // AT 없음 → RT로 silent refresh 시도 + silentRefresh(request, response, isApp, deviceId = null) + } else { + val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) + if (remainingSeconds < 300) { + // AT 만료 임박 또는 만료됨 → RT로 silent refresh 시도 + val deviceId = runCatching { jwtProvider.getClaimsEvenIfExpired(token)["deviceId"] as? String }.getOrNull() + silentRefresh(request, response, isApp, deviceId) + } else { + // AT 유효 + jwtProvider.getOpaqueId(token) } + } - try { - val claims = jwtProvider.getClaimsEvenIfExpired(token) - val deviceId = claims["deviceId"] as? String + response.setHeader("X-User-Id", opaqueId) + } - val tokenResult = authService.refreshAccessToken(refreshToken, deviceId) + /** + * RT로 AT/RT를 재발급하고 플랫폼에 맞게 응답에 설정합니다. + * - web: Set-Cookie + * - app: X-New-AT / X-New-RT 헤더 + */ + private fun silentRefresh( + request: HttpServletRequest, + response: HttpServletResponse, + isApp: Boolean, + deviceId: String?, + ): String { + val refreshToken = if (isApp) { + request.getHeader("X-Refresh-Token") + } else { + request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + } - if (isApp) { - response.setHeader("X-New-AT", tokenResult.accessToken) - response.setHeader("X-New-RT", tokenResult.refreshToken) - } else { - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) - response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) - } + if (refreshToken.isNullOrBlank()) { + if (!isApp) clearAuthCookies(response) + throw JwtException(JwtExceptionCode.TOKEN_MISSING) + } - log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds, isApp=$isApp)" } + return try { + val tokenResult = authService.refreshAccessToken(refreshToken, deviceId) - jwtProvider.getOpaqueId(tokenResult.accessToken) - } catch (e: Exception) { - log.warn { "사일런트 리프레시 실패: ${e.message}" } - if (!isApp) clearAuthCookies(response) - throw JwtException(JwtExceptionCode.EXPIRED) + if (isApp) { + response.setHeader("X-New-AT", tokenResult.accessToken) + response.setHeader("X-New-RT", tokenResult.refreshToken) + } else { + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createAccessTokenCookie(tokenResult.accessToken).toString()) + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.createRefreshTokenCookie(tokenResult.refreshToken).toString()) } - } else { - jwtProvider.getOpaqueId(token) - } - response.setHeader("X-User-Id", opaqueId) + log.debug { "사일런트 리프레시 성공 (isApp=$isApp)" } + jwtProvider.getOpaqueId(tokenResult.accessToken) + } catch (e: Exception) { + log.warn { "사일런트 리프레시 실패: ${e.message}" } + if (!isApp) clearAuthCookies(response) + throw JwtException(JwtExceptionCode.EXPIRED) + } } /** From 6515393366b1bdc9136d63266b2f502945f2f1c0 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 11 May 2026 17:24:30 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20Google=20=EC=95=B1=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Credential Manager에서 발급받은 ID Token 검증 로직 구현 - 자체 JWT 발급 및 Google ID Token 검증 처리 로직 추가 - `GoogleAppLoginService` 클래스 및 관련 엔티티, 예외 처리 로직 구성 - 로그 추가를 통해 인증 및 검증 흐름 추적 가능 --- .../api/domain/auth/GoogleAppLoginService.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt new file mode 100644 index 0000000..2f39548 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleAppLoginService.kt @@ -0,0 +1,52 @@ +package com.wq.auth.api.domain.auth + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.auth.response.SocialLoginResult +import com.wq.auth.api.domain.oauth.OAuthUser +import com.wq.auth.api.domain.oauth.error.SocialLoginException +import com.wq.auth.api.domain.oauth.error.SocialLoginExceptionCode +import com.wq.auth.api.external.oauth.GoogleOAuthProperties +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +/** + * 안드로이드 앱 전용 Google ID Token 로그인 서비스 + * + * 앱에서 Google Credential Manager로 발급받은 ID Token을 검증하고, + * 자체 JWT(AT/RT)를 발급합니다. + */ +@Service +class GoogleAppLoginService( + private val googleOAuthProperties: GoogleOAuthProperties, + private val socialLoginMemberProcessor: SocialLoginMemberProcessor, +) { + private val log = KotlinLogging.logger {} + + fun login(idTokenString: String): SocialLoginResult { + log.info { "Google 앱 로그인 처리 시작" } + + val verifier = GoogleIdTokenVerifier.Builder(NetHttpTransport(), GsonFactory()) + .setAudience(listOf(googleOAuthProperties.clientId)) + .build() + + val idToken = verifier.verify(idTokenString) + ?: throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_INVALID_ID_TOKEN) + + val payload = idToken.payload + val oauthUser = OAuthUser( + providerId = payload.subject, + email = payload.email, + verifiedEmail = payload["email_verified"] as? Boolean ?: false, + name = payload["name"] as? String, + givenName = payload["given_name"] as? String, + providerType = ProviderType.GOOGLE, + ) + + log.info { "Google ID Token 검증 완료: ${oauthUser.email}" } + + return socialLoginMemberProcessor.processMemberAndIssueTokens(oauthUser, ProviderType.GOOGLE) + } +} From 6cfd4f39da90aa243c965c3869af566ef74b7acd Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 12 May 2026 17:51:05 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EC=95=88=EB=93=9C=EB=A1=9C?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=95=B1=20=EC=A0=84=EC=9A=A9=20Google=20?= =?UTF-8?q?ID=20Token=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/GoogleAppLoginController.kt | 85 +++++++++++++++++++ .../auth/request/GoogleAppLoginRequestDto.kt | 14 +++ .../oauth/error/SocialLoginExceptionCode.kt | 1 + 3 files changed, 100 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt new file mode 100644 index 0000000..ae2a2e7 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt @@ -0,0 +1,85 @@ +package com.wq.auth.api.controller.auth + +import com.wq.auth.api.controller.auth.request.GoogleAppLoginRequestDto +import com.wq.auth.api.controller.auth.response.SocialLoginResponseDto +import com.wq.auth.api.domain.auth.GoogleAppLoginService +import com.wq.auth.security.annotation.PublicApi +import com.wq.auth.shared.rateLimiter.annotation.RateLimit +import com.wq.auth.web.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController +import java.util.concurrent.TimeUnit + +/** + * 안드로이드 앱 전용 Google 로그인 컨트롤러 + * + * 웹의 인가 코드(Code) 방식과 달리, 앱에서 Google Credential Manager로 + * 발급받은 ID Token을 직접 검증하여 JWT(AT/RT)를 JSON Body로 반환합니다. + */ +@Tag(name = "Google 앱 로그인", description = "안드로이드 앱 전용 Google ID Token 로그인 API") +@RestController +class GoogleAppLoginController( + private val googleAppLoginService: GoogleAppLoginService, +) { + + @Operation( + summary = "Google 앱 로그인", + description = """ + 안드로이드 앱 전용 Google 로그인 엔드포인트입니다. + + 앱은 Google Credential Manager를 통해 발급받은 **ID Token**을 그대로 전달합니다. + 서버는 Google 라이브러리를 사용해 ID Token을 직접 검증하며, 구글 서버와의 추가 통신이 불필요합니다. + + **필수 헤더:** + - `X-Client-Id: easy-snap-and-app` + + **응답:** + - 인증 성공 시 자체 JWT(accessToken, refreshToken)를 JSON Body에 반환합니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", description = "로그인 성공", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "400", description = "idToken 누락 또는 필수 필드 오류", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "401", description = "유효하지 않은 Google ID Token", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "429", description = "Rate Limit 초과 (10분에 10회)", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ] + ) + @RateLimit(limit = 10, duration = 10, timeUnit = TimeUnit.MINUTES) + @PublicApi("Google 앱 로그인") + @PostMapping("/api/v1/auth/google/login/app") + fun loginWithApp( + @RequestHeader("X-Client-Id") clientId: String, + @Valid @RequestBody request: GoogleAppLoginRequestDto, + ): CommonResponse { + val loginResult = googleAppLoginService.login(request.idToken) + return CommonResponse.success( + message = "Google 로그인이 완료되었습니다", + data = SocialLoginResponseDto( + accessToken = loginResult.accessToken, + refreshToken = loginResult.refreshToken, + ) + ) + } +} diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt new file mode 100644 index 0000000..f5442b9 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleAppLoginRequestDto.kt @@ -0,0 +1,14 @@ +package com.wq.auth.api.controller.auth.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +@Schema(description = "안드로이드 앱 전용 Google ID Token 로그인 요청 바디") +data class GoogleAppLoginRequestDto( + @field:NotBlank(message = "idToken은 필수입니다") + @field:Schema( + description = "Google Credential Manager에서 발급받은 ID Token", + example = "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..." + ) + val idToken: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt b/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt index bb487c9..8ca7e7e 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/oauth/error/SocialLoginExceptionCode.kt @@ -17,6 +17,7 @@ enum class SocialLoginExceptionCode( GOOGLE_TOKEN_REQUEST_FAILED(400, "Google 액세스 토큰 요청이 실패했습니다"), GOOGLE_USER_INFO_REQUEST_FAILED(400, "Google 사용자 정보 조회가 실패했습니다"), GOOGLE_INVALID_ACCESS_TOKEN(401, "유효하지 않은 Google 액세스 토큰입니다"), + GOOGLE_INVALID_ID_TOKEN(401, "유효하지 않은 Google ID Token입니다"), GOOGLE_SERVER_ERROR(502, "Google 서버에서 일시적인 오류가 발생했습니다"), // 카카오 OAuth 관련 예외 From de4b6b9ac6dc030b76418fefccd9a5bc38b5884a Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 12 May 2026 19:41:58 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20Google=20=EC=95=B1=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20API=EC=97=90=EC=84=9C=20X-Client-Id=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google ID Token 검증 과정에서 불필요한 X-Client-Id 헤더 제거 - 관련 API 명세 문서에서 X-Client-Id 헤더 설명 삭제 - 컨트롤러 메서드 파라미터 수정 및 필요 없는 코드 정리 --- .../wq/auth/api/controller/auth/GoogleAppLoginController.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt index ae2a2e7..cc07a60 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/GoogleAppLoginController.kt @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RestController import java.util.concurrent.TimeUnit @@ -39,9 +38,6 @@ class GoogleAppLoginController( 앱은 Google Credential Manager를 통해 발급받은 **ID Token**을 그대로 전달합니다. 서버는 Google 라이브러리를 사용해 ID Token을 직접 검증하며, 구글 서버와의 추가 통신이 불필요합니다. - **필수 헤더:** - - `X-Client-Id: easy-snap-and-app` - **응답:** - 인증 성공 시 자체 JWT(accessToken, refreshToken)를 JSON Body에 반환합니다. """ @@ -70,7 +66,6 @@ class GoogleAppLoginController( @PublicApi("Google 앱 로그인") @PostMapping("/api/v1/auth/google/login/app") fun loginWithApp( - @RequestHeader("X-Client-Id") clientId: String, @Valid @RequestBody request: GoogleAppLoginRequestDto, ): CommonResponse { val loginResult = googleAppLoginService.login(request.idToken) From 82a0928edf0ac8824cf9a3d11538c197efeac46d Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 12 May 2026 19:52:52 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20=EC=95=88=EB=93=9C=EB=A1=9C?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=95=B1=20=EC=A0=84=EC=9A=A9=20Google=20?= =?UTF-8?q?ID=20Token=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/api/v1/auth/google/login/app` 경로 SecurityConfig에 추가 - Google ID Token 기반 로그인 지원 강화 --- src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index a391023..e52e637 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -58,6 +58,7 @@ class SecurityConfig( "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 "/api/public/**", // 공개 API "/api/v1/auth/*/login", // 소셜 로그인 API (V1) + "/api/v1/auth/google/login/app", // 안드로이드 앱 전용 Google ID Token 로그인 "/api/v2/auth/*/login", // 소셜 로그인 API (V2) "/api/v1/auth/members/logout", //로그아웃 "/actuator/health", // 헬스체크 From 482c9ca416ca670c5b72395b559fd1c05508a73e Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 14 May 2026 02:45:20 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=EB=82=B4=EB=B6=80=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/internal-api/**` 경로 SecurityConfig에 허용 추가 (X-Internal-Secret으로 보호) - `application.yml`에 INTERNAL_API_SECRET 환경 변수 설정 추가 --- src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt | 1 + src/main/resources/application.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index e52e637..f59b124 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -51,6 +51,7 @@ class SecurityConfig( auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용 // 공개 엔드포인트 (인증 불필요) + .requestMatchers("/internal-api/**").permitAll() // 서비스 간 내부 통신 (X-Internal-Secret으로 보호) .requestMatchers( "/api/v1/auth/members/email-login", // 이메일 로그인 "/api/v1/auth/email/request", // 이메일 인증 코드 요청 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6cdb334..7ea7a30 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,6 +52,8 @@ app: default-zone: ${APP_DEFAULT_ZONE:Asia/Seoul} cookie: domain: ${APP_COOKIE_DOMAIN:localhost} + internal: + secret: ${INTERNAL_API_SECRET} project: logging: From 3494612cb6e77d14680ccc9916a735c7891a377c Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 14 May 2026 03:15:07 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=EB=82=B4=EB=B6=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/internal-api/v1/members/{userId}` 경로 및 컨트롤러 추가 - 요청 헤더(X-Internal-Secret)를 통한 인증 및 내부 API 접근 보호 - 회원 정보(userId, email, nickname) 응답 DTO 구성 - 서비스 호출 및 예외 처리 로직 구현 --- .../internal/InternalMemberController.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt new file mode 100644 index 0000000..3307ead --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt @@ -0,0 +1,40 @@ +package com.wq.auth.api.controller.internal + +import com.wq.auth.api.domain.member.MemberService +import com.wq.auth.web.common.response.CommonResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/internal-api/v1/members") +class InternalMemberController( + private val memberService: MemberService, + @Value("\${app.internal.secret}") private val internalSecret: String, +) { + + @GetMapping("/{userId}") + fun getUserInfo( + @PathVariable userId: String, + @RequestHeader("X-Internal-Secret") secret: String, + ): CommonResponse { + if (secret != internalSecret) { + throw SecurityException("내부 API 접근 권한이 없습니다.") + } + val userInfo = memberService.getUserInfo(userId) + return CommonResponse.success(data = UserInfoResponse( + userId = userInfo.userId, + email = userInfo.email, + nickname = userInfo.nickname, + )) + } + + data class UserInfoResponse( + val userId: String, + val email: String, + val nickname: String, + ) +} From d9ab72fa46c7a5f33640d0f747958a4ead00c05b Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 14 May 2026 09:25:56 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MemberService`에 SLF4J 로거 추가 및 주요 메서드에 디버그/워닝/에러 로그 추가 - `GlobalExceptionHandler`에서 예상하지 못한 예외 발생 시 스택 트레이스를 포함한 에러 로그 출력 - `InternalMemberController`에 로거 추가 및 요청/응답 과정 로그 기록 - 내부 API 인증 실패 시 워닝 로그 추가 및 검증 흐름 로그 개선 --- .../internal/InternalMemberController.kt | 8 ++++++++ .../wq/auth/api/domain/member/MemberService.kt | 16 +++++++++++++++- .../wq/auth/web/common/GlobalExceptionHandler.kt | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt index 3307ead..81311c3 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/internal/InternalMemberController.kt @@ -2,6 +2,7 @@ package com.wq.auth.api.controller.internal import com.wq.auth.api.domain.member.MemberService import com.wq.auth.web.common.response.CommonResponse +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -16,15 +17,22 @@ class InternalMemberController( @Value("\${app.internal.secret}") private val internalSecret: String, ) { + companion object { + private val log = LoggerFactory.getLogger(InternalMemberController::class.java) + } + @GetMapping("/{userId}") fun getUserInfo( @PathVariable userId: String, @RequestHeader("X-Internal-Secret") secret: String, ): CommonResponse { + log.info("[internal-api] getUserInfo 요청 - userId={}", userId) if (secret != internalSecret) { + log.warn("[internal-api] 인증 실패 - X-Internal-Secret 불일치, userId={}", userId) throw SecurityException("내부 API 접근 권한이 없습니다.") } val userInfo = memberService.getUserInfo(userId) + log.info("[internal-api] getUserInfo 성공 - userId={}", userId) return CommonResponse.success(data = UserInfoResponse( userId = userInfo.userId, email = userInfo.email, diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index 42f13c9..fcda734 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -5,6 +5,7 @@ import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.domain.member.entity.MemberEntity import com.wq.auth.api.domain.member.error.MemberException import com.wq.auth.api.domain.member.error.MemberExceptionCode +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,6 +15,10 @@ class MemberService( private val authProviderRepository: AuthProviderRepository, ) { + companion object { + private val log = LoggerFactory.getLogger(MemberService::class.java) + } + data class UserInfoResult( val userId: String, val nickname: String, @@ -34,15 +39,24 @@ class MemberService( @Transactional(readOnly = true) fun getUserInfo(opaqueId: String): UserInfoResult { + log.debug("[MemberService] getUserInfo - opaqueId={}", opaqueId) + val member = memberRepository.findByOpaqueId(opaqueId) - .orElseThrow { MemberException(MemberExceptionCode.USER_INFO_RETRIEVE_FAILED)} + .orElseThrow { + log.warn("[MemberService] 회원 없음 - opaqueId={}", opaqueId) + MemberException(MemberExceptionCode.USER_INFO_RETRIEVE_FAILED) + } val authProviders = authProviderRepository.findByMember(member) if (authProviders.isEmpty()) { + log.warn("[MemberService] authProvider 없음 - opaqueId={}, memberId={}", opaqueId, member.id) throw MemberException(MemberExceptionCode.USER_INFO_RETRIEVE_FAILED) } val email = member.primaryEmail + if (email == null) { + log.error("[MemberService] primaryEmail이 null - opaqueId={}, memberId={}", opaqueId, member.id) + } val providers = authProviders.map { it.providerType } //TODO diff --git a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt index 914e448..e92e147 100644 --- a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt @@ -25,7 +25,7 @@ class GlobalExceptionHandler { if (status.is4xxClientError) { log.info("[{}] {} - {}", status.value(), e.code, e.message) } else { - log.error(e.extractExceptionLocation() + e.message) + log.error(e.extractExceptionLocation() + e.message, e) } val body = CommonResponse.fail(e.code) return ResponseEntity.status(status).body(body) @@ -42,7 +42,7 @@ class GlobalExceptionHandler { // 예상 못 한 예외 처리 @ExceptionHandler(Exception::class) fun handleUnexpected(e: Exception): ResponseEntity> { - log.error("[예상치 못한 예외 발생] $e") + log.error("[예상치 못한 예외 발생] ${e.message}", e) val status = HttpStatus.INTERNAL_SERVER_ERROR val body = CommonResponse.fail( CommonExceptionCode.INTERNAL_SERVER_ERROR From 7b40d9c01ae774d24c33382d38af38657d71434a Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 21 May 2026 19:44:52 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20X-Client-Type=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=9B=B9/=EC=95=B1=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EB=B6=84=20=EB=B0=A9=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 `X-Client-Id` 헤더 제거 및 `X-Client-Type` 헤더로 변경 - API Gateway를 통한 클라이언트 타입 기반 웹/앱 구분 로직 개선 --- .../kotlin/com/wq/auth/api/controller/auth/AuthController.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index bee524c..2862372 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -338,8 +338,8 @@ class AuthController( request: HttpServletRequest, response: HttpServletResponse, ) { - // X-Client-Id가 "web"이면 웹, 그 외(easy-snap-app 등)면 앱 - val isApp = request.getHeader("X-Client-Id") != "web" + // X-Client-Type이 "web"이면 웹, 그 외(app 등)면 앱 (API Gateway가 RT 출처 기반으로 주입) + val isApp = request.getHeader("X-Client-Type") != "web" val token = JwtAuthenticationFilter.extractToken(request)