diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e2a3ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Build output and caches +build/ +.gradle/ + +# Git and IDE +.git/ +.gitignore +.idea/ +*.iml + +# Documentation (not needed in image) +*.md +docs/ + +# Local env and secrets +.env +.env.* + +# OS and misc +.DS_Store +*.log +*.hprof + +# Test and dev +src/test/ diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..8d85e02 --- /dev/null +++ b/.env.sample @@ -0,0 +1,23 @@ +SPRING_PROFILES_ACTIVE= +DB_HOST= +DB_PORT= +DB_NAME= +DB_USERNAME= +DB_PASSWORD= +JWT_SECRET= +JWT_ACCESS_TOKEN_EXPIRATION= +JWT_REFRESH_TOKEN_EXPIRATION= +MAIL_USERNAME= +MAIL_PASSWORD= +SWAGGER_PATH= +APP_DEFAULT_ZONE=Asia/Seoul +CORS_ALLOWED_ORIGINS= +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= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2934c01..61150a1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,66 +1,97 @@ -name: Deploy to EC2 +name: Deploy (auth-BE) + +# --------------------------------------------------------------------------- +# EC2 원격 경로 (아래 deploy job 의 env.PROJECT_ROOT 만 수정) +# PROJECT_ROOT … 배포 기준 루트. 예: ~, ~/myapp (끝 슬래시 없음) +# 환경변수 파일은 항상 {PROJECT_ROOT}/env/ 아래 (이름 고정: env) +# 배포는 EC2의 PROJECT_ROOT 에서 docker compose (서비스명: auth-be, V2 기준) +# --------------------------------------------------------------------------- + on: push: branches: - - main # main에 push될 때만 실행 + - main + - dev + jobs: deploy: runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }} + env: + PROJECT_ROOT: '~/apps' + steps: - - name: Checkout source - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image tag and Spring profile + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "IMAGE_TAG=prod" >> $GITHUB_ENV + echo "SPRING_PROFILE=prod" >> $GITHUB_ENV + else + echo "IMAGE_TAG=alpha" >> $GITHUB_ENV + echo "SPRING_PROFILE=alpha" >> $GITHUB_ENV + fi + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + secrets: | + "GITHUB_TOKEN=${{ secrets.GIT_TOKEN }}" + build-args: | + GITHUB_ACTOR=${{ github.actor }} + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ env.IMAGE_TAG }} + ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ github.sha }} + - name: Setup SSH run: | + set -euo pipefail mkdir -p ~/.ssh - echo "${{ secrets.EC2_KEY }}" > ~/.ssh/ec2_key.pem + printf '%s' "${{ secrets.EC2_KEY }}" > /tmp/ec2_key_src + if [[ ! -s /tmp/ec2_key_src ]]; then + echo "::error::EC2_KEY secret is empty" >&2 + exit 1 + fi + if head -n 1 /tmp/ec2_key_src | grep -q '^[[:space:]]*-----BEGIN'; then + cp /tmp/ec2_key_src ~/.ssh/ec2_key.pem + else + tr -d '\n\r \t' < /tmp/ec2_key_src | base64 --decode > ~/.ssh/ec2_key.pem || { + echo "::error::EC2_KEY is not PEM and base64 decode failed" >&2 + exit 1 + } + fi + rm -f /tmp/ec2_key_src chmod 600 ~/.ssh/ec2_key.pem + + - name: Create env file and copy to EC2 + run: | + printf '%s\n' "${{ secrets.AUTH_BE_ENV_FILE }}" > auth-be.env + ssh -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} 'mkdir -p ${{ env.PROJECT_ROOT }}/env' + scp -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem auth-be.env ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:${{ env.PROJECT_ROOT }}/env/auth-be.env + - name: Deploy to EC2 run: | - ssh -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF' - cd /home/${USER}/app - git pull origin main - ./gradlew build -x test - - # systemd 서비스 파일에 모든 환경변수 포함 - sudo tee /etc/systemd/system/authBE.service > /dev/null << 'SERVICEEOF' - [Unit] - Description=AuthBE Spring Boot App - After=network.target - - [Service] - Type=simple - User=ec2-user - WorkingDirectory=/home/ec2-user/app - ExecStart=/usr/bin/java -jar /home/ec2-user/app/build/libs/demo-0.0.1-SNAPSHOT.jar - Environment="DB_NAME=${{ secrets.DB_NAME }}" - Environment="DB_USER=${{ secrets.DB_USER }}" - Environment="DB_PASSWORD=${{ secrets.DB_PASSWORD }}" - Environment="DB_HOST=${{ secrets.DB_HOST }}" - Environment="DB_PORT=${{ secrets.DB_PORT }}" - Environment="GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" - Environment="GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" - Environment="GOOGLE_REDIRECT_URI=${{ secrets.GOOGLE_REDIRECT_URI }}" - Environment="KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" - Environment="KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" - Environment="KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }}" - Environment="NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" - Environment="NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" - Environment="NAVER_REDIRECT_URI=${{ secrets.NAVER_REDIRECT_URI }}" - Environment="JWT_ACCESS_TOKEN_EXPIRATION=${{ secrets.JWT_ACCESS_TOKEN_EXPIRATION }}" - Environment="JWT_REFRESH_TOKEN_EXPIRATION=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION }}" - Environment="JWT_SECRET=${{ secrets.JWT_SECRET }}" - Environment="MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}" - Environment="MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" - Environment="SWAGGER_PATH=${{ secrets.SWAGGER_PATH }}" - Environment="USER=${{ secrets.USER }}" - Restart=always - RestartSec=10 - - [Install] - WantedBy=multi-user.target - SERVICEEOF - - sudo systemctl daemon-reload - sudo systemctl restart authBE - sudo systemctl enable authBE - EOF + ssh -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} ' + set -e + export PATH="/usr/bin:/usr/local/bin:$PATH" + export DOCKERHUB_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}" + export IMAGE_TAG="${{ env.IMAGE_TAG }}" + export SPRING_PROFILES_ACTIVE="${{ env.SPRING_PROFILE }}" + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + cd ${{ env.PROJECT_ROOT }} + docker compose stop auth-be || true + docker compose rm -f auth-be || true + docker compose pull auth-be + docker compose up -d --no-deps --force-recreate auth-be + docker logout + ' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9985eba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# ================================ +# Stage 1: Build +# ================================ +FROM eclipse-temurin:25-jdk-alpine AS builder + +# GITHUB_ACTOR는 빌드 시점에 일반 ARG로 넘겨받습니다. +ARG GITHUB_ACTOR=easyappfactory + +WORKDIR /workspace + +# Gradle wrapper and source +COPY gradle gradle +COPY gradlew build.gradle.kts settings.gradle.kts ./ +COPY src src + +# mount 기능을 사용하여 빌드 시점에만 GITHUB_TOKEN을 안전하게 사용합니다. +RUN --mount=type=secret,id=GITHUB_TOKEN \ + GITHUB_TOKEN=$(cat /run/secrets/GITHUB_TOKEN) \ + GITHUB_ACTOR=${GITHUB_ACTOR} \ + ./gradlew bootJar --no-daemon -x test + +FROM eclipse-temurin:25-jre-alpine + +RUN adduser -D -h /app appuser + +WORKDIR /app + +COPY --from=builder /workspace/build/libs/auth-be-0.0.1-SNAPSHOT.jar app.jar + +USER appuser + +EXPOSE 9000 + +ENTRYPOINT ["java", "-Xmx256m", "-jar", "/app/app.jar"] diff --git a/README.md b/README.md index 9756898..0477e95 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GrowGrammers Auth-BE +# Auth-BE Spring Boot 기반의 인증/소셜 로그인 및 계정 연동(링크) 백엔드입니다. @@ -6,6 +6,7 @@ Spring Boot 기반의 인증/소셜 로그인 및 계정 연동(링크) 백엔 - [주요 기능](#주요-기능) - [기술 스택](#기술-스택) - [빠른 시작](#빠른-시작) +- [배포 (EC2 Docker)](#배포-ec2-docker) - [환경 변수 설정](#환경-변수-설정) - [API 엔드포인트](#api-엔드포인트) - [인증 플로우](#인증-플로우) @@ -51,6 +52,19 @@ Spring Boot 기반의 인증/소셜 로그인 및 계정 연동(링크) 백엔 java -jar build/libs/auth-be-0.0.1-SNAPSHOT.jar ``` +## 배포 (EC2 Docker) + +배포는 **Docker** 방식으로 수행하며, systemd/JAR 직접 실행은 사용하지 않습니다. + +- **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 +``` + ## 환경 변수 설정 환경 변수는 다음 파일에 매핑됩니다: @@ -116,15 +130,30 @@ MAIL_PASSWORD=your-app-password | 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 { - "code": "4/0AfJohXmx...", - "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", - "redirectUri": "http://localhost:5173/auth/google/callback" + "authCode": "4/0AfJohXmx...", + "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" +} +``` + +**요청 예시** (Naver 로그인): +```json +POST /api/v1/auth/naver/login +Content-Type: application/json + +{ + "authCode": "네이버에서_받은_인가코드", + "state": "인가_요청시_사용한_state_값과_동일", + "codeVerifier": "PKCE_코드_검증자" } ``` diff --git a/build.gradle.kts b/build.gradle.kts index d5d6c7b..96711b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,10 @@ plugins { - kotlin("jvm") version "2.2.0" - kotlin("plugin.spring") version "1.9.25" - id("org.springframework.boot") version "3.5.4" + kotlin("jvm") version "2.3.0" + kotlin("plugin.spring") version "2.3.0" + kotlin("plugin.jpa") version "2.3.0" + + id("org.springframework.boot") version "4.0.4" id("io.spring.dependency-management") version "1.1.7" - kotlin("plugin.jpa") version "2.2.0" } group = "com.wq" @@ -11,7 +12,7 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } @@ -21,59 +22,59 @@ configurations { } } -repositories { - mavenCentral() -} - -extra["spring-security.version"] = "6.5.3" - 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("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.springframework.boot:spring-boot-starter-mail") - implementation("me.paulschwarz:spring-dotenv:4.0.0") + implementation("org.springframework.boot:spring-boot-starter-webflux") + + // 로깅 스타터 (GitHub Packages) + implementation("com.easyapp.factory:spring-logging-starter:0.0.1-SNAPSHOT") + + // Kotlin 관련 (Kotlin 2.3 대응) + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("io.github.oshai:kotlin-logging-jvm:8.0.01") + + // Database runtimeOnly("com.h2database:h2") - implementation("org.springframework.boot:spring-boot-starter-logging") - implementation("io.github.oshai:kotlin-logging-jvm:5.1.4") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0") - implementation("mysql:mysql-connector-java:8.0.33") + runtimeOnly("org.postgresql:postgresql") + + // Dotenv: Spring Boot 4 전용 Artifact 사용 권장 + implementation("me.paulschwarz:springboot4-dotenv:5.1.0") + + // API Documentation (Spring Boot 4 대응을 위해 3.x 버전 사용) + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2") - // oauth + // OAuth & Google API implementation("com.google.api-client:google-api-client:2.7.0") implementation("com.google.oauth-client:google-oauth-client-jetty:1.36.0") implementation("com.google.apis:google-api-services-oauth2:v2-rev20200213-2.0.0") - implementation("org.springframework.boot:spring-boot-starter-webflux") - // test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") + // JWT (JJWT) + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") - // Kotest 버전 변수로 관리 - val kotestVersion = "5.9.1" + // Rate Limiter: Artifact 이름이 변경되었습니다 + implementation("com.bucket4j:bucket4j_jdk17-core:8.17.0") + // HTTP Client & Utils (Spring Boot BOM이 httpclient5 버전 관리 — TlsSocketStrategy 등 포함) + implementation("org.apache.httpcomponents.client5:httpclient5") + implementation("com.github.f4b6a3:uuid-creator:6.1.1") + + // Testing + val kotestVersion = "6.1.7" + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") 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") - testImplementation("com.fasterxml.jackson.core:jackson-databind:2.17.+") - testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.+") - testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.+") - - // jjwt - implementation("io.jsonwebtoken:jjwt-api:0.12.6") - runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") - runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") - - //rate limiter - token bucket - implementation("com.bucket4j:bucket4j-core:8.7.0") + // Mockito-Kotlin + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") } kotlin { @@ -88,9 +89,10 @@ allOpen { annotation("jakarta.persistence.Embeddable") } +noArg { + annotation("jakarta.persistence.Entity") +} + tasks.withType { useJUnitPlatform() } -noArg { - annotation("jakarta.persistence.Entity") -} \ No newline at end of file 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" new file mode 100644 index 0000000..18fff00 --- /dev/null +++ "b/docs/API_GATEWAY_\354\227\260\353\217\231_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,62 @@ +# 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/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" new file mode 100644 index 0000000..e427d0d --- /dev/null +++ "b/docs/CORS_\353\254\270\354\240\234_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,218 @@ +# 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 new file mode 100644 index 0000000..35cba4d --- /dev/null +++ b/docs/ENV.md @@ -0,0 +1,105 @@ +# 환경변수 분리 설계 (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/OAuth_redirect_URI_\354\240\204\353\236\265.md" "b/docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" new file mode 100644 index 0000000..d508086 --- /dev/null +++ "b/docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" @@ -0,0 +1,171 @@ +# 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/\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" new file mode 100644 index 0000000..bbc8275 --- /dev/null +++ "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" @@ -0,0 +1,83 @@ +# 소셜 로그인 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts index dae155f..b1a06e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,18 @@ -rootProject.name = "demo" +rootProject.name = "auth-be" + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/easyappfactory/shared-libraries") + credentials { + username = System.getenv("GITHUB_ACTOR") ?: "easyappfactory" + password = System.getenv("GITHUB_TOKEN") + } + content { + includeGroup("com.easyapp.factory") + } + } + } +} diff --git a/src/main/kotlin/com/wq/auth/AuthApplication.kt b/src/main/kotlin/com/wq/auth/AuthApplication.kt index 0432ea9..dfb7bf2 100644 --- a/src/main/kotlin/com/wq/auth/AuthApplication.kt +++ b/src/main/kotlin/com/wq/auth/AuthApplication.kt @@ -3,8 +3,10 @@ package com.wq.auth import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableScheduling +@EnableAsync @EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication diff --git a/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt b/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt index 4db217a..9b3e397 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt @@ -1,19 +1,16 @@ package com.wq.auth.api.controller -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.security.jwt.JwtProvider -import com.wq.auth.security.annotation.AdminApi import com.wq.auth.security.annotation.AuthenticatedApi import com.wq.auth.security.annotation.PublicApi -import com.wq.auth.web.common.response.Responses -import com.wq.auth.web.common.response.SuccessResponse +import com.wq.auth.web.common.response.CommonResponse import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController /** * Security 테스트용 컨트롤러 - * 개발 및 테스트 환경에서 JWT 인증/인가 동작을 확인하기 위한 엔드포인트 제공 + * 개발 및 테스트 환경에서 JWT 인증 동작을 확인하기 위한 엔드포인트 제공 * todo: 나중에 제거 예정 */ @RestController @@ -23,51 +20,37 @@ class TestSecurityController( @PublicApi @GetMapping("/api/public/test") - fun publicTestEndpoint(): SuccessResponse> { + fun publicTestEndpoint(): CommonResponse> { val data = mapOf( "endpoint" to "/api/public/test", "accessLevel" to "PUBLIC", "description" to "누구나 접근 가능한 공개 API" ) - return Responses.success("공개 API 접근 성공", data) + return CommonResponse.success("공개 API 접근 성공", data) } @AuthenticatedApi @GetMapping("/api/test") - fun authenticatedEndpoint(): SuccessResponse> { + fun authenticatedEndpoint(): CommonResponse> { val data = mapOf( "endpoint" to "/api/test", "accessLevel" to "AUTHENTICATED", "description" to "로그인한 사용자만 접근 가능한 API" ) - return Responses.success("인증된 사용자 API 접근 성공", data) - } - - @AdminApi - @GetMapping("/api/admin/test") - fun adminEndpoint(): SuccessResponse> { - val data = mapOf( - "endpoint" to "/api/admin/test", - "accessLevel" to "ADMIN", - "description" to "관리자 권한이 필요한 API" - ) - return Responses.success("관리자 API 접근 성공", data) + return CommonResponse.success("인증된 사용자 API 접근 성공", data) } @PublicApi @GetMapping("/api/public/token") fun generateTestToken( @RequestParam(defaultValue = "550e8400-e29b-41d4-a716-446655440000") opaqueId: String, - @RequestParam(defaultValue = "MEMBER") roleString: String - ): SuccessResponse> { - val role = Role.valueOf(roleString) - val token = jwtProvider.createAccessToken(opaqueId, role) + ): CommonResponse> { + val token = jwtProvider.createAccessToken(opaqueId) val data = mapOf( "token" to token, "opaqueId" to opaqueId, - "role" to role.name, "usage" to "Authorization: Bearer $token" ) - return Responses.success("JWT 토큰 발급 성공", data) + return CommonResponse.success("JWT 토큰 발급 성공", data) } } diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index d301b4f..caf0415 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 @@ -8,26 +8,28 @@ import com.wq.auth.api.controller.auth.response.LoginResponseDto import com.wq.auth.api.controller.auth.response.RefreshAccessTokenResponseDto import com.wq.auth.api.domain.auth.AuthService import com.wq.auth.api.domain.email.AuthEmailService +import com.wq.auth.api.domain.member.MemberService +import com.wq.auth.security.JwtAuthenticationFilter import com.wq.auth.security.annotation.AuthenticatedApi import com.wq.auth.security.annotation.PublicApi -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 com.wq.auth.shared.rateLimiter.annotation.RateLimit -import com.wq.auth.web.common.response.BaseResponse -import com.wq.auth.web.common.response.FailResponse -import com.wq.auth.web.common.response.Responses -import com.wq.auth.web.common.response.SuccessResponse +import com.wq.auth.web.common.response.CommonResponse +import io.github.oshai.kotlinlogging.KotlinLogging 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.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.validation.Valid -import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders -import org.springframework.http.ResponseCookie import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.util.concurrent.TimeUnit @@ -37,14 +39,11 @@ import java.util.concurrent.TimeUnit class AuthController( private val authService: AuthService, private val emailService: AuthEmailService, - private val jwtProperties: JwtProperties, - - @Value("\${app.cookie.secure:false}") - private val cookieSecure: Boolean, - - @Value("\${app.cookie.same-site:Strict}") - private val cookieSameSite: String, + private val memberService: MemberService, + private val cookieFactory: CookieFactory, + private val jwtProvider: JwtProvider, ) { + private val log = KotlinLogging.logger {} @Operation( summary = "이메일 로그인", @@ -56,56 +55,49 @@ class AuthController( ApiResponse( responseCode = "200", description = "로그인 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "이메일 인증 실패", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "회원 정보를 저장하는데 실패했습니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 5, duration = 15, timeUnit = TimeUnit.MINUTES) - @PostMapping("api/v1/auth/members/email-login") + @PostMapping("/api/v1/auth/members/email-login") @PublicApi fun emailLogin( response: HttpServletResponse, @RequestHeader("X-Client-Type", required = true) clientType: String, @RequestBody req: EmailLoginRequestDto, - ): SuccessResponse { + ): CommonResponse { emailService.verifyCode(req.email, req.verifyCode) val (accessToken, newRefreshToken) = authService.emailLogin( req.email, deviceId = req.deviceId, ) - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken}") + 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 refreshCookie = ResponseCookie.from("refreshToken", newRefreshToken) - .httpOnly(true) - .secure(cookieSecure) - .path("/") - .maxAge(jwtProperties.refreshExp.toSeconds()) - .domain(".easyappfactory.com") // 모든 서브도메인 포함 - .sameSite("Lax") //SSO 리다이렉트 시 쿠키 전송을 위해 Lax 권장 - .build() - response.addHeader("Set-Cookie", refreshCookie.toString()) - - return Responses.success(message = "로그인에 성공했습니다.", data = null) + return CommonResponse.success(message = "로그인에 성공했습니다.", data = null) } //앱 val resp = LoginResponseDto.forApp( refreshToken = newRefreshToken ) - return Responses.success(message = "로그인에 성공했습니다.", data = resp) + return CommonResponse.success(message = "로그인에 성공했습니다.", data = resp) } @@ -133,7 +125,7 @@ class AuthController( - 인증 코드 삭제 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -142,22 +134,22 @@ class AuthController( ApiResponse( responseCode = "200", description = "이메일 계정 연동 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인증 코드가 존재하지 않거나, 만료되었거나, 일치하지 않음", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "인증되지 않은 사용자", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -167,9 +159,9 @@ class AuthController( fun verifyEmailLink( @AuthenticationPrincipal principalDetail: PrincipalDetails, @Valid @RequestBody request: EmailLoginLinkRequestDto - ): SuccessResponse { + ): CommonResponse { authService.processEmailLoginLink(principalDetail.opaqueId, request.toDomain()) - return Responses.success("이메일 계정이 성공적으로 연동되었습니다.") + return CommonResponse.success("이메일 계정이 성공적으로 연동되었습니다.") } @Operation( @@ -182,24 +174,24 @@ class AuthController( ApiResponse( responseCode = "200", description = "로그아웃 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "로그아웃에 실패했습니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 10, duration = 1, timeUnit = TimeUnit.MINUTES) - @PostMapping("api/v1/auth/members/logout") + @PostMapping("/api/v1/auth/members/logout") @PublicApi fun logout( @CookieValue(name = "refreshToken", required = false) refreshToken: String?, response: HttpServletResponse, @RequestHeader(name = "X-Client-Type", required = true) clientType: String, @RequestBody req: LogoutRequestDto? - ): SuccessResponse { + ): CommonResponse { val currentRefreshToken = when (clientType) { "web" -> refreshToken @@ -210,18 +202,13 @@ class AuthController( authService.logout(currentRefreshToken) if (clientType == "web") { - val refreshCookie = ResponseCookie.from("refreshToken", "") - .httpOnly(true) - .secure(cookieSecure) - .path("/") - .maxAge(0) - .sameSite(cookieSameSite) - .build() - response.addHeader("Set-Cookie", refreshCookie.toString()) - + val expireAccessTokenCookie = cookieFactory.expireAccessTokenCookie() + val expireRefreshTokenCookie = cookieFactory.expireRefreshTokenCookie() + response.addHeader(HttpHeaders.SET_COOKIE, expireAccessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, expireRefreshTokenCookie.toString()) } //앱 - return Responses.success(message = "로그아웃에 성공했습니다.", data = null) + return CommonResponse.success(message = "로그아웃에 성공했습니다.", data = null) } @Operation( @@ -240,10 +227,10 @@ class AuthController( ), ApiResponse( responseCode = "400", - description = "잘못된 요청, 인증 토큰이 없음, Authorization 헤더는 'Bearer ' 형식이어야 합니다.", + description = "잘못된 요청, 인증 토큰이 없음", content = [Content( mediaType = "application/json", - schema = Schema(implementation = BaseResponse::class) + schema = Schema(implementation = CommonResponse::class) )] ), ApiResponse( @@ -251,7 +238,7 @@ class AuthController( description = "유효하지 않은 토큰, 만료된 토큰, 유효하지 않은 서명, 지원되지 않는 토큰", content = [Content( mediaType = "application/json", - schema = Schema(implementation = BaseResponse::class) + schema = Schema(implementation = CommonResponse::class) )] ), ApiResponse( @@ -259,52 +246,144 @@ class AuthController( description = "refreshToken 조회 실패", content = [Content( mediaType = "application/json", - schema = Schema(implementation = BaseResponse::class) + schema = Schema(implementation = CommonResponse::class) )] ) ] ) @RateLimit(limit = 20, duration = 1, timeUnit = TimeUnit.HOURS) - @PostMapping("api/v1/auth/members/refresh") + @PostMapping("/api/v1/auth/members/refresh") @PublicApi fun refreshAccessToken( - @CookieValue(name = "refreshToken", required = false) refreshToken: String, + request: HttpServletRequest, // 헤더/쿠키에서 이전 AT를 읽기 위함 + @CookieValue(name = "refreshToken", required = false) refreshToken: String?, @RequestHeader("X-Client-Type") clientType: String, response: HttpServletResponse, @RequestBody req: RefreshAccessTokenRequestDto?, - ): SuccessResponse { - - val currentRefreshToken : String? - if(clientType == "web") { - currentRefreshToken = refreshToken + ): CommonResponse { + + val currentRefreshToken : String? = if(clientType == "web") { + refreshToken } else { - currentRefreshToken = req?.refreshToken + req?.refreshToken + } + + if (currentRefreshToken.isNullOrBlank()) { + throw JwtException(JwtExceptionCode.TOKEN_MISSING) } + val (accessToken, newRefreshToken) = authService.refreshAccessToken( - currentRefreshToken!!, req?.deviceId, + currentRefreshToken, req?.deviceId ) - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken}") - 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()) - val refreshCookie = ResponseCookie.from("refreshToken", newRefreshToken) - .httpOnly(true) - .secure(cookieSecure) - .path("/") - .maxAge(jwtProperties.refreshExp.toSeconds()) - .sameSite(cookieSameSite) - .build() - response.addHeader("Set-Cookie", refreshCookie.toString()) - - return Responses.success(message = "AccessToken 재발급에 성공했습니다.", data = null) + if (clientType == "web") { + return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = null) } - + //앱 val resp = RefreshAccessTokenResponseDto.forApp( refreshToken = newRefreshToken ) - return Responses.success(message = "AccessToken 재발급에 성공했습니다.", data = resp) + return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = resp) + + } + + @Operation( + summary = "토큰 introspect", + description = """ + API Gateway 연동용 엔드포인트입니다. 요청에 포함된 Access Token을 검증하고, 성공 시 응답 헤더에 사용자 정보를 담아 반환합니다. + + **토큰 탐색 우선순위:** + 1. `accessToken` HttpOnly 쿠키 + - 쿠키 값이 비어있으면 401을 반환합니다. + 2. `Authorization: Bearer ` 헤더 (쿠키가 없을 때만 사용) + + **토큰 반환 방식:** + - Access Token: HttpOnly 쿠키(`accessToken`) + - Refresh Token: HttpOnly 쿠키(`refreshToken`) + - 기존 Authorization 헤더는 더 이상 사용하지 않습니다. + + **사일런트 리프레시 (Silent Refresh):** + - Access Token이 만료되었거나 남은 유효 시간이 5분(300초) 미만이면 `refreshToken` 쿠키로 자동 재발급을 시도합니다. + - 재발급 성공 시 응답의 `Set-Cookie`로 새 토큰을 내려주며 FE/사용자 개입이 불필요합니다. + - 재발급 실패(리프레시 토큰 만료·오류) 시 기존 쿠키를 모두 삭제하고 401을 반환합니다. + + **성공 응답 헤더:** + - `X-User-Id`: 사용자 UUID (opaqueId) + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "인트로스펙트 성공 (X-User-Id 헤더 포함). AT가 임박한 경우 Set-Cookie로 새 토큰도 포함됩니다.", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ), + ApiResponse( + responseCode = "401", + description = "토큰 없음, 빈 쿠키, 리프레시 토큰 만료·오류 등 인증 실패. 쿠키가 존재하던 경우 해당 쿠키는 제거됩니다.", + content = [Content(schema = Schema(implementation = CommonResponse::class))] + ) + ] + ) + @RateLimit(limit = 60, duration = 1, timeUnit = TimeUnit.MINUTES) + @PublicApi + @GetMapping("/api/v1/auth/introspect") + fun introspect( + request: HttpServletRequest, + response: HttpServletResponse, + ) { + val token = JwtAuthenticationFilter.extractToken(request) + // 쿠키·헤더 모두 없거나, accessToken 쿠키가 빈 값인 경우 401 + if (token.isNullOrBlank()) { + throw JwtException(JwtExceptionCode.TOKEN_MISSING) + } + // AT가 만료(-1)되었거나 남은 시간이 5분(300초) 미만이면 사일런트 리프레시 시도 + val remainingSeconds = jwtProvider.getRemainingTimeSeconds(token) + val opaqueId: String = if (remainingSeconds < 300) { + val refreshToken = request.cookies?.firstOrNull { it.name == "refreshToken" }?.value + + if (refreshToken.isNullOrBlank()) { + 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()) + + log.debug { "사일런트 리프레시 성공 (remainingSeconds=$remainingSeconds)" } + + jwtProvider.getOpaqueId(tokenResult.accessToken) + } catch (e: Exception) { + log.warn { "사일런트 리프레시 실패: ${e.message}" } + clearAuthCookies(response) + throw JwtException(JwtExceptionCode.EXPIRED) + } + } else { + jwtProvider.getOpaqueId(token) + } + + response.setHeader("X-User-Id", opaqueId) } + /** + * accessToken, refreshToken HttpOnly 쿠키를 즉시 만료시킵니다. + */ + private fun clearAuthCookies(response: HttpServletResponse) { + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.expireAccessTokenCookie().toString()) + response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.expireRefreshTokenCookie().toString()) + } } 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 ab15213..bb3ac5e 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 @@ -7,10 +7,9 @@ import com.wq.auth.api.domain.auth.SocialLoginService import com.wq.auth.security.annotation.AuthenticatedApi import com.wq.auth.security.annotation.PublicApi 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.FailResponse -import com.wq.auth.web.common.response.Responses -import com.wq.auth.web.common.response.SuccessResponse +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 @@ -19,12 +18,9 @@ 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.beans.factory.annotation.Value import org.springframework.http.HttpHeaders -import org.springframework.http.ResponseCookie import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* -import java.time.Duration import java.util.concurrent.TimeUnit /** @@ -42,16 +38,11 @@ import java.util.concurrent.TimeUnit class SocialLoginController( private val socialLoginService: SocialLoginService, private val socialLinkService: SocialLinkService, - - @Value("\${app.cookie.secure:false}") - private val cookieSecure: Boolean, - - @Value("\${app.cookie.same-site:Strict}") - private val cookieSameSite: String + private val cookieFactory: CookieFactory, ) { /** - * 범용 소셜 로그인 처리 + * 소셜 로그인 처리 * * 프론트엔드에서 소셜 제공자로부터 받은 인가 코드를 사용하여 * 사용자 정보를 조회하고 JWT 토큰을 발급합니다. @@ -70,8 +61,8 @@ class SocialLoginController( - 프론트엔드 환경별로 다른 URI 사용 가능 (개발/스테이징/프로덕션) **토큰 반환 방식:** - - Access Token: Authorization 헤더에 Bearer 방식으로 반환 - - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) + - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 + - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) **지원 소셜 제공자:** - GOOGLE: Google OAuth2 @@ -83,23 +74,23 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + description = "로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "잘못된 요청 (필수 필드 누락, 잘못된 형식 등)", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "인가 코드가 유효하지 않거나 만료됨", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "소셜 제공자 API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -108,16 +99,12 @@ class SocialLoginController( fun socialLogin( @Valid @RequestBody request: SocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - // RefreshToken을 HttpOnly 쿠키에 설정 - setRefreshTokenCookie(response, loginResult.refreshToken) + setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - // Authorization 헤더에 AccessToken 설정 - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") - - return Responses.success("소셜 로그인이 완료되었습니다") + return CommonResponse.success("소셜 로그인이 완료되었습니다") } /** @@ -145,8 +132,9 @@ class SocialLoginController( - 프론트엔드 환경별로 다른 URI 사용 가능 **토큰 반환 방식:** - - Access Token: Authorization 헤더에 Bearer 방식으로 반환 - - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) + - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 + - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) + - 기존 Authorization 헤더 방식은 보안 및 웹 친화적 설계를 위해 제거되었습니다. **쿠키 설정:** - HttpOnly: JavaScript 접근 불가 (XSS 방지) @@ -158,23 +146,23 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Google 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + description = "Google 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인가 코드(code) 파라미터가 누락되거나 잘못된 형식", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "Google 인가 코드가 유효하지 않거나 만료됨", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "Google API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -184,17 +172,13 @@ class SocialLoginController( fun googleLogin( @Valid @RequestBody request: GoogleSocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - // RefreshToken을 HttpOnly 쿠키에 설정 - setRefreshTokenCookie(response, loginResult.refreshToken) - - // Authorization 헤더에 AccessToken 설정 - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") + setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return Responses.success("Google 로그인이 완료되었습니다") + return CommonResponse.success("Google 로그인이 완료되었습니다") } /** @@ -225,31 +209,32 @@ class SocialLoginController( - 보안 강화를 위해 사용 권장 **토큰 반환 방식:** - - Access Token: Authorization 헤더에 Bearer 방식으로 반환 - - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) + - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 + - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) + - 기존 Authorization 헤더 방식은 보안 및 웹 친화적 설계를 위해 제거되었습니다. """ ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "카카오 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + description = "카카오 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인가 코드(code) 파라미터가 누락되거나 잘못된 형식", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "카카오 인가 코드가 유효하지 않거나 만료됨", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "카카오 API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -259,17 +244,13 @@ class SocialLoginController( fun kakaoLogin( @Valid @RequestBody request: KakaoSocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - // RefreshToken을 HttpOnly 쿠키에 설정 - setRefreshTokenCookie(response, loginResult.refreshToken) + setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - // Authorization 헤더에 AccessToken 설정 - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") - - return Responses.success("카카오 로그인이 완료되었습니다") + return CommonResponse.success("카카오 로그인이 완료되었습니다") } /** @@ -297,8 +278,9 @@ class SocialLoginController( - 프론트엔드 환경별로 다른 URI 사용 가능 **토큰 반환 방식:** - - Access Token: Authorization 헤더에 Bearer 방식으로 반환 - - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) + - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 + - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) + - 기존 Authorization 헤더 방식은 보안 및 웹 친화적 설계를 위해 제거되었습니다. **쿠키 설정:** - HttpOnly: JavaScript 접근 불가 (XSS 방지) @@ -310,23 +292,23 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Naver 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + description = "Naver 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인가 코드(code) 파라미터가 누락되거나 잘못된 형식", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "Naver 인가 코드가 유효하지 않거나 만료됨", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "Naver API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -336,16 +318,12 @@ class SocialLoginController( fun naverLogin( @Valid @RequestBody request: NaverSocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) - // RefreshToken을 HttpOnly 쿠키에 설정 - setRefreshTokenCookie(response, loginResult.refreshToken) - - // Authorization 헤더에 AccessToken 설정 - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") + setTokenCookies(response, loginResult.accessToken, loginResult.refreshToken) - return Responses.success("Naver 로그인이 완료되었습니다") + return CommonResponse.success("Naver 로그인이 완료되었습니다") } /** @@ -372,7 +350,7 @@ class SocialLoginController( - 연동 계정이 있는 경우: 두 계정 자동 병합 (기존 회원 정보 유지) **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -381,22 +359,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "Google 계정 연동 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인가 코드(code) 파라미터가 누락되거나 잘못된 형식", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "인증되지 않은 사용자 또는 Google 인가 코드가 유효하지 않음", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "Google API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -406,16 +384,17 @@ class SocialLoginController( fun linkGoogleAccount( @AuthenticationPrincipal principalDetail: PrincipalDetails, @Valid @RequestBody request: GoogleSocialLinkRequestDto - ): SuccessResponse { + ): CommonResponse { val serviceRequest = SocialLinkRequestDto( authCode = request.authCode, codeVerifier = request.codeVerifier, providerType = ProviderType.GOOGLE, + redirectUri = request.redirectUri, ) socialLinkService.processSocialLink(principalDetail.opaqueId, serviceRequest.toDomain()) - return Responses.success("Google 계정 연동이 완료되었습니다") + return CommonResponse.success("Google 계정 연동이 완료되었습니다") } /** @@ -446,7 +425,7 @@ class SocialLoginController( - 보안 강화를 위해 사용 권장 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -455,22 +434,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "카카오 계정 연동 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인가 코드(code) 파라미터가 누락되거나 잘못된 형식", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "인증되지 않은 사용자 또는 카카오 인가 코드가 유효하지 않음", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "카카오 API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -480,16 +459,17 @@ class SocialLoginController( fun linkKakaoAccount( @AuthenticationPrincipal principalDetail: PrincipalDetails, @Valid @RequestBody request: KakaoSocialLinkRequestDto - ): SuccessResponse { + ): CommonResponse { val serviceRequest = SocialLinkRequestDto( authCode = request.authCode, codeVerifier = request.codeVerifier, providerType = ProviderType.KAKAO, + redirectUri = request.redirectUri, ) socialLinkService.processSocialLink(principalDetail.opaqueId, serviceRequest.toDomain()) - return Responses.success("카카오 계정 연동이 완료되었습니다") + return CommonResponse.success("카카오 계정 연동이 완료되었습니다") } /** @@ -520,7 +500,7 @@ class SocialLoginController( - 프론트엔드에서 생성한 state 값을 전달해야 함 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -529,22 +509,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "네이버 계정 연동 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "인가 코드(code) 또는 state 파라미터가 누락되거나 잘못된 형식", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "인증되지 않은 사용자 또는 네이버 인가 코드가 유효하지 않음", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "네이버 API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -554,41 +534,36 @@ class SocialLoginController( fun linkNaverAccount( @AuthenticationPrincipal principalDetail: PrincipalDetails, @Valid @RequestBody request: NaverSocialLinkRequestDto - ): SuccessResponse { + ): CommonResponse { val serviceRequest = SocialLinkRequestDto( authCode = request.authCode, codeVerifier = request.codeVerifier, state = request.state, providerType = ProviderType.NAVER, + redirectUri = request.redirectUri, ) socialLinkService.processSocialLink(principalDetail.opaqueId, serviceRequest.toDomain()) - return Responses.success("네이버 계정 연동이 완료되었습니다") + return CommonResponse.success("네이버 계정 연동이 완료되었습니다") } /** - * RefreshToken을 HttpOnly 쿠키로 설정합니다. - * - * Spring Boot 3.x의 ResponseCookie를 사용하여 현대적이고 안전한 쿠키를 생성합니다. - * - HttpOnly: JavaScript 접근 불가 (XSS 방지) - * - Secure: HTTPS에서만 전송 (프로덕션 환경) - * - SameSite: CSRF 공격 방지 - * - MaxAge: 14일 (리프레시 토큰 만료 시간과 동일) + * AccessToken/RefreshToken을 HttpOnly 쿠키로 설정합니다. * * @param response HTTP 응답 객체 + * @param accessToken 액세스 토큰 * @param refreshToken 리프레시 토큰 */ - private fun setRefreshTokenCookie(response: HttpServletResponse, refreshToken: String) { - val cookie = ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) // XSS 공격 방지 - .secure(cookieSecure) // 환경별 설정 (개발: false, 프로덕션: true) - .path("/") // 모든 경로에서 쿠키 사용 가능 - .maxAge(Duration.ofDays(14)) // 14일 만료 - .domain(".easyappfactory.com") // 모든 서브도메인 포함 - .sameSite("Lax") //SSO 리다이렉트 시 쿠키 전송을 위해 Lax 권장 // CSRF 공격 방지 (Strict/Lax/None) - .build() - - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()) + private fun setTokenCookies( + response: HttpServletResponse, + accessToken: String, + refreshToken: String, + ) { + 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/request/GoogleSocialLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleSocialLoginRequestDto.kt index ecff497..6bc2c16 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleSocialLoginRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/GoogleSocialLoginRequestDto.kt @@ -13,8 +13,11 @@ data class GoogleSocialLoginRequestDto( @field:NotBlank(message = "codeVerifier는 필수입니다") @field:Schema(description = "PKCE 검증용 코드 검증자", example = "NgAfIySigI...IVxKxbmrpg") - val codeVerifier: String + val codeVerifier: String, + + @field:Schema(description = "인가 요청 시 사용한 redirect_uri. 허용 목록에 있을 때만 사용. 없으면 서버 기본값 사용.") + val redirectUri: String? = null, ) fun GoogleSocialLoginRequestDto.toDomain(): SocialLoginRequest = - SocialLoginRequest(authCode, codeVerifier, null, ProviderType.GOOGLE) \ No newline at end of file + SocialLoginRequest(authCode, codeVerifier, null, ProviderType.GOOGLE, redirectUri) \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLinkRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLinkRequestDto.kt index dde804a..e530b2f 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLinkRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLinkRequestDto.kt @@ -11,5 +11,8 @@ data class KakaoSocialLinkRequestDto( @field:NotBlank(message = "codeVerifier는 필수입니다") @field:Schema(description = "PKCE 검증용 코드 검증자 (카카오는 선택사항이지만 보안을 위해 권장)", example = "NgAfIySigI...IVxKxbmrpg") - val codeVerifier: String + val codeVerifier: String, + + @field:Schema(description = "인가 요청 시 사용한 redirect_uri. 허용 목록에 있을 때만 사용.") + val redirectUri: String? = null, ) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt index 025f381..16f431b 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt @@ -13,8 +13,11 @@ data class KakaoSocialLoginRequestDto( @field:NotBlank(message = "codeVerifier는 필수입니다") @field:Schema(description = "PKCE 검증용 코드 검증자 (카카오는 선택사항이지만 보안을 위해 권장)", example = "NgAfIySigI...IVxKxbmrpg") - val codeVerifier: String + val codeVerifier: String, + + @field:Schema(description = "인가 요청 시 사용한 redirect_uri. 없으면 서버 기본값 사용.") + val redirectUri: String? = null, ) fun KakaoSocialLoginRequestDto.toDomain(): SocialLoginRequest = - SocialLoginRequest(authCode, codeVerifier, null, ProviderType.KAKAO) + SocialLoginRequest(authCode, codeVerifier, null, ProviderType.KAKAO, redirectUri) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLinkRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLinkRequestDto.kt index 9dfeb00..78d2442 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLinkRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLinkRequestDto.kt @@ -16,4 +16,7 @@ data class NaverSocialLinkRequestDto( @field:NotBlank(message = "codeVerifier는 필수입니다") @field:Schema(description = "PKCE 검증용 코드 검증자", example = "NgAfIySigI...IVxKxbmrpg") val codeVerifier: String, + + @field:Schema(description = "인가 요청 시 사용한 redirect_uri. 허용 목록에 있을 때만 사용.") + val redirectUri: String? = null, ) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLoginRequestDto.kt index 57bd861..43e465f 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLoginRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/NaverSocialLoginRequestDto.kt @@ -18,7 +18,10 @@ data class NaverSocialLoginRequestDto( @field:NotBlank(message = "codeVerifier는 필수입니다") @field:Schema(description = "PKCE 검증용 코드 검증자", example = "NgAfIySigI...IVxKxbmrpg") val codeVerifier: String, + + @field:Schema(description = "인가 요청 시 사용한 redirect_uri. 허용 목록에 있을 때만 사용. 없으면 서버 기본값 사용.") + val redirectUri: String? = null, ) fun NaverSocialLoginRequestDto.toDomain(): SocialLoginRequest = - SocialLoginRequest(authCode, codeVerifier, state, ProviderType.NAVER) + SocialLoginRequest(authCode, codeVerifier, state, ProviderType.NAVER, redirectUri) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLinkRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLinkRequestDto.kt index 8673692..4c51379 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLinkRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLinkRequestDto.kt @@ -39,11 +39,15 @@ data class SocialLinkRequestDto( required = true ) val providerType: ProviderType, + + @Schema(description = "인가 요청 시 사용한 redirect_uri. 허용 목록에 있을 때만 사용. 없으면 서버 기본값 사용.") + val redirectUri: String? = null, ) { fun toDomain() = SocialLinkRequest( authCode = authCode, codeVerifier = codeVerifier, state = state, providerType = providerType, + redirectUri = redirectUri, ) } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLoginRequestDto.kt index a7934f9..e9ad725 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLoginRequestDto.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/SocialLoginRequestDto.kt @@ -42,8 +42,10 @@ data class SocialLoginRequestDto( @field:Schema(description = "소셜 로그인 제공자 타입", example = "GOOGLE", allowableValues = ["GOOGLE", "KAKAO", "NAVER"]) val providerType: ProviderType, + @field:Schema(description = "인가 요청 시 사용한 redirect_uri. 허용 목록에 있을 때만 사용.") + val redirectUri: String? = null, ) fun SocialLoginRequestDto.toDomain(): SocialLoginRequest = - SocialLoginRequest(authCode = authCode, codeVerifier = codeVerifier, state = state, providerType = providerType) + SocialLoginRequest(authCode = authCode, codeVerifier = codeVerifier, state = state, providerType = providerType, redirectUri = redirectUri) diff --git a/src/main/kotlin/com/wq/auth/api/controller/email/AuthEmailController.kt b/src/main/kotlin/com/wq/auth/api/controller/email/AuthEmailController.kt index af728c5..d8843a7 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/email/AuthEmailController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/email/AuthEmailController.kt @@ -6,7 +6,7 @@ import com.wq.auth.api.domain.email.AuthEmailService import com.wq.auth.api.domain.email.error.EmailException import com.wq.auth.security.annotation.PublicApi import com.wq.auth.shared.rateLimiter.annotation.RateLimit -import com.wq.auth.web.common.response.* +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 @@ -31,39 +31,39 @@ class AuthEmailController( ApiResponse( responseCode = "200", description = "인증코드 발송 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "올바르지 않은 이메일 형식입니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "해당 도메인에 메일을 보낼 수 없습니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "400", description = "존재하지 않는 도메인입니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "이메일 인증코드 전송에 실패했습니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 3, duration = 10, timeUnit = TimeUnit.MINUTES) @PublicApi - @PostMapping("api/v1/auth/email/request") - fun requestCode(@RequestBody req: EmailRequestDto): BaseResponse { + @PostMapping("/api/v1/auth/email/request") + fun requestCode(@RequestBody req: EmailRequestDto): CommonResponse { return try { authEmailService.sendVerificationCode(req.email) - Responses.success(message = "해당 이메일로 인증코드가 발송되었습니다.", data = null) + CommonResponse.success(message = "해당 이메일로 인증코드가 발송되었습니다.") } catch (e: EmailException) { - Responses.fail(e.emailCode) + CommonResponse.fail(e.emailCode) } } @@ -76,23 +76,23 @@ class AuthEmailController( ApiResponse( responseCode = "200", description = "인증 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "이메일 인증코드가 일치하지 않습니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 10, duration = 5, timeUnit = TimeUnit.MINUTES) - @PostMapping("api/v1/auth/email/verify") - fun verifyCode(@RequestBody req: EmailVerifyRequestDto): BaseResponse { + @PostMapping("/api/v1/auth/email/verify") + fun verifyCode(@RequestBody req: EmailVerifyRequestDto): CommonResponse { return try { authEmailService.verifyCode(req.email, req.verifyCode) - Responses.success(message = "인증되었습니다.", data = null) + CommonResponse.success(message = "인증되었습니다.") } catch (e: EmailException) { - Responses.fail(e.emailCode) + CommonResponse.fail(e.emailCode) } } } \ No newline at end of file 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 57f2da9..90535de 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 @@ -6,9 +6,7 @@ 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.rateLimiter.annotation.RateLimit -import com.wq.auth.web.common.response.FailResponse -import com.wq.auth.web.common.response.Responses -import com.wq.auth.web.common.response.SuccessResponse +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 @@ -34,54 +32,59 @@ class MemberController( ApiResponse( responseCode = "200", description = "조회 성공", - content = [Content(schema = Schema(implementation = SuccessResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", description = "인증 실패 또는 로그인 필요", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "500", description = "회원 정보 조회에 실패했습니다.", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 30, duration = 1, timeUnit = TimeUnit.MINUTES) @GetMapping("/api/v1/auth/members/user-info") @AuthenticatedApi - fun getUserInfo(@AuthenticationPrincipal principalDetail: PrincipalDetails): SuccessResponse { - val (nickname, email, linkedProviders) = memberService.getUserInfo(principalDetail.opaqueId) - val resp = UserInfoResponseDto(nickname, email, linkedProviders) - return Responses.success(message = "회원 정보 조회 성공", data = resp) + fun getUserInfo(@AuthenticationPrincipal principalDetail: PrincipalDetails): CommonResponse { + val result = memberService.getUserInfo(principalDetail.opaqueId) + val resp = UserInfoResponseDto( + userId = result.userId, + nickname = result.nickname, + email = result.email, + linkedProviders = result.providers + ) + return CommonResponse.success(message = "회원 정보 조회 성공", data = resp) } @GetMapping("/api/v1/members") - fun getAll(): SuccessResponse> = - Responses.success("회원 목록 조회 성공", memberService.getAll()) + fun getAll(): CommonResponse> = + CommonResponse.success("회원 목록 조회 성공", memberService.getAll()) @GetMapping("/api/v1/members/{id}") - fun getById(@PathVariable id: Long): SuccessResponse = - Responses.success("회원 조회 성공", memberService.getById(id)) + fun getById(@PathVariable id: Long): CommonResponse = + CommonResponse.success("회원 조회 성공", memberService.getById(id)) @PostMapping("/api/v1/members") - fun create(@RequestBody member: MemberEntity): SuccessResponse = - Responses.success("회원 생성 성공", memberService.create(member)) + fun create(@RequestBody member: MemberEntity): CommonResponse = + CommonResponse.success("회원 생성 성공", memberService.create(member)) @DeleteMapping("/api/v1/members/{id}") - fun delete(@PathVariable id: Long): SuccessResponse { + fun delete(@PathVariable id: Long): CommonResponse { memberService.delete(id) - return Responses.success("회원 삭제 성공") + return CommonResponse.success("회원 삭제 성공") } @PutMapping("/api/v1/members/{id}/nickname") fun updateNickname( @PathVariable id: Long, @RequestBody payload: Map - ): SuccessResponse { + ): CommonResponse { val newNickname = payload["nickname"] ?: throw IllegalArgumentException("닉네임은 필수입니다") - return Responses.success("닉네임 변경 성공", memberService.updateNickname(id, newNickname)) + return CommonResponse.success("닉네임 변경 성공", memberService.updateNickname(id, newNickname)) } } 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 970c4e0..4f4f8f9 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 @@ -3,6 +3,8 @@ package com.wq.auth.api.controller.member.response import com.wq.auth.api.domain.auth.entity.ProviderType data class UserInfoResponseDto( + /** 사용자 식별자 (UUID v7, opaqueId) */ + val userId: String, val nickname: String, val email: String, val linkedProviders: List diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt index ad60146..e3222a6 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt @@ -5,11 +5,11 @@ 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.entity.MemberEntity import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.api.domain.auth.error.AuthException import com.wq.auth.api.domain.auth.error.AuthExceptionCode import com.wq.auth.api.domain.auth.request.EmailLoginLinkRequest import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberStatsService import com.wq.auth.api.domain.member.error.MemberException import com.wq.auth.api.domain.member.error.MemberExceptionCode import com.wq.auth.security.jwt.JwtProvider @@ -20,7 +20,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Instant -import java.time.LocalDateTime @Service class AuthService( @@ -31,8 +30,8 @@ class AuthService( private val jwtProvider: JwtProvider, private val nicknameGenerator: NicknameGenerator, private val memberConnector: MemberConnector, - - ) { + private val memberStatsService: MemberStatsService, +) { private val log = KotlinLogging.logger {} data class TokenResult( @@ -42,51 +41,44 @@ class AuthService( @Transactional fun emailLogin(email: String, deviceId: String?): TokenResult { + val authProviderOfExistingUser = authProviderRepository.findByEmailAndProviderType(email, ProviderType.EMAIL) + + if (authProviderOfExistingUser != null) { + val existingUser = authProviderOfExistingUser.member + val opaqueId = existingUser.opaqueId + val accessToken = jwtProvider.createAccessToken( + opaqueId = existingUser.opaqueId, + extraClaims = mapOf("deviceId" to deviceId) + ) + + val existingRefreshToken = refreshTokenRepository.findActiveByMemberAndDeviceId(existingUser, deviceId) + if (existingRefreshToken != null) { + refreshTokenRepository.softDeleteByMemberAndDeviceId(existingUser, deviceId, Instant.now()) + } + + val refreshToken = jwtProvider.createRefreshToken(opaqueId = existingUser.opaqueId) + val jti = jwtProvider.getJti(refreshToken) + + val refreshTokenEntity = RefreshTokenEntity.of(existingUser, jti, opaqueId, deviceId) + refreshTokenRepository.save(refreshTokenEntity) + + memberStatsService.updateLastLoginAtAsync(existingUser.id) - authProviderRepository.findByEmailAndProviderType(email, ProviderType.EMAIL) - ?.let { authProviderOfExistingUser -> - val existingUser = authProviderOfExistingUser.member - // 이미 가입된 사용자 → 로그인 처리 및 JWT 발급 - val opaqueId = existingUser.opaqueId - val accessToken = - jwtProvider.createAccessToken( - opaqueId = existingUser.opaqueId, - role = Role.MEMBER, - extraClaims = mapOf("deviceId" to deviceId) - ) - - val existingRefreshToken = refreshTokenRepository.findActiveByMemberAndDeviceId(existingUser, deviceId) - - //이전 리프레시토큰 soft delete 처리 - if (existingRefreshToken != null) { - refreshTokenRepository.softDeleteByMemberAndDeviceId(existingUser, deviceId, Instant.now()) - } - - val refreshToken = jwtProvider.createRefreshToken(opaqueId = existingUser.opaqueId) - val jti = jwtProvider.getJti(refreshToken) - - val refreshTokenEntity = RefreshTokenEntity.of(existingUser, jti, opaqueId, deviceId) - refreshTokenRepository.save(refreshTokenEntity) - existingUser.lastLoginAt = LocalDateTime.now() - - return TokenResult(accessToken, refreshToken) - } ?: run { - // 신규 사용자면 회원가입 진행 - return signUp(email, deviceId) + return TokenResult(accessToken, refreshToken) } + return signUp(email, deviceId) } @Transactional fun signUp(email: String, deviceId: String?): TokenResult { - authEmailService.validateEmailFormat(email) var nickname: String do { nickname = nicknameGenerator.generate() - //중복 닉네임인 경우 } while (memberRepository.existsByNickname(nickname)) + val member = MemberEntity.createEmailVerifiedMember(nickname, email) val opaqueId = member.opaqueId @@ -100,7 +92,6 @@ class AuthService( val accessToken = jwtProvider.createAccessToken( opaqueId = member.opaqueId, - role = Role.MEMBER, extraClaims = mapOf("deviceId" to deviceId) ) val refreshToken = jwtProvider.createRefreshToken(opaqueId = member.opaqueId) @@ -112,26 +103,15 @@ class AuthService( return TokenResult(accessToken, refreshToken) } - /** - * 이메일 계정 연동 - * - * @param currentOpaqueId 현재 로그인된 회원의 opaqueId - * @param request 이메일 인증 확인 요청 - */ @Transactional fun processEmailLoginLink(currentOpaqueId: String, request: EmailLoginLinkRequest) { log.info { "이메일 연동 시작: $currentOpaqueId -> ${request.email}" } - // 현재 로그인된 회원 조회 val currentMember = memberRepository.findByOpaqueId(currentOpaqueId) .orElseThrow { MemberException(MemberExceptionCode.MEMBER_NOT_FOUND) } - // 1. 인증 코드 확인 authEmailService.verifyCode(request.email, request.verifyCode) - // 2. 이메일이 다른 계정에 연동되어 있는지 확인 - // 있다면, 해당 계정에 연동되어있던 계정 연동 - // 없다면, 해당 계정에 이메일 provider 추가 memberConnector.linkAccountInternal( currentMember = currentMember, providerType = ProviderType.EMAIL, @@ -148,7 +128,6 @@ class AuthService( log.info { "이메일 연동 완료: $currentOpaqueId -> ${request.email}" } } - @Transactional fun logout(refreshToken: String?) { if (refreshToken.isNullOrBlank()) { @@ -157,59 +136,45 @@ class AuthService( } try { - // 토큰 유효성 검사 jwtProvider.validateOrThrow(refreshToken) - - // 유효한 토큰인 경우 soft delete 처리 val opaqueId = jwtProvider.getOpaqueId(refreshToken) val jti = jwtProvider.getJti(refreshToken) refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) - } catch (e: JwtException) { - // 만료된 토큰이어도 로그아웃 성공으로 처리 log.info { "만료된 refreshToken으로 로그아웃: ${e.message}" } } catch (ex: Exception) { - // DB 삭제 실패 시에만 예외 발생 throw AuthException(AuthExceptionCode.LOGOUT_FAILED, ex) } } @Transactional fun refreshAccessToken(refreshToken: String, deviceId: String?): TokenResult { - //토큰 유효성 검사 jwtProvider.validateOrThrow(refreshToken) val jti = jwtProvider.getJti(refreshToken) val opaqueId = jwtProvider.getOpaqueId(refreshToken) - //토큰 jti+opaqueId로 DB에 있는지 확인 refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti) ?: throw JwtException(JwtExceptionCode.MALFORMED) - //토큰 엔티티 만료 기간 확인 if (jwtProvider.getRefreshTokenExpiredAt(refreshToken).isBefore(Instant.now())) { refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) throw JwtException(JwtExceptionCode.EXPIRED) } - // AccessToken, RefreshToken 재발급 val newAccessToken = jwtProvider.createAccessToken( opaqueId = opaqueId, - role = Role.MEMBER, extraClaims = mapOf("deviceId" to deviceId) ) val newRefreshToken = jwtProvider.createRefreshToken(opaqueId = opaqueId) val newJti = jwtProvider.getJti(newRefreshToken) - // 기존 RefreshToken soft delete 처리 refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) - // 새 refreshToken 저장 val member = memberRepository.findByOpaqueId(opaqueId).get() val newRefreshTokenEntity = RefreshTokenEntity.of(member, newJti, opaqueId, deviceId) refreshTokenRepository.save(newRefreshTokenEntity) return TokenResult(newAccessToken, newRefreshToken) } - } diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLinkProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLinkProvider.kt index 8f4f106..cdd4cf4 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLinkProvider.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLinkProvider.kt @@ -26,6 +26,7 @@ class GoogleLinkProvider( val authCodeRequest = OAuthAuthCodeRequest( authCode = linkRequest.authCode, codeVerifier = linkRequest.codeVerifier, + redirectUri = linkRequest.redirectUri, ) return googleOAuthClient.getUserFromAuthCode(authCodeRequest) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt index 8ed5392..9d4ddcf 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt @@ -18,8 +18,9 @@ class GoogleLoginProvider( override fun getUserInfo(request: SocialLoginRequest): OAuthUser { return googleOAuthClient.getUserFromAuthCode( OAuthAuthCodeRequest( - request.authCode, - request.codeVerifier + authCode = request.authCode, + codeVerifier = request.codeVerifier, + redirectUri = request.redirectUri, ) ) } diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLinkProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLinkProvider.kt index e9d61f6..f20f9e0 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLinkProvider.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLinkProvider.kt @@ -27,6 +27,7 @@ class KakaoLinkProvider( val authCodeRequest = OAuthAuthCodeRequest( authCode = linkRequest.authCode, codeVerifier = linkRequest.codeVerifier, + redirectUri = linkRequest.redirectUri, ) return kakaoOAuthClient.getUserFromAuthCode(authCodeRequest) } diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt index 9f7fcca..a02eaea 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt @@ -18,8 +18,9 @@ class KakaoLoginProvider( override fun getUserInfo(request: SocialLoginRequest): OAuthUser { return kakaoOAuthClient.getUserFromAuthCode( OAuthAuthCodeRequest( - request.authCode, - request.codeVerifier + authCode = request.authCode, + codeVerifier = request.codeVerifier, + redirectUri = request.redirectUri, ) ) } diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLinkProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLinkProvider.kt index 17a6417..c18bca1 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLinkProvider.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLinkProvider.kt @@ -26,7 +26,8 @@ class NaverLinkProvider( val authCodeRequest = OAuthAuthCodeRequest( authCode = linkRequest.authCode, codeVerifier = linkRequest.codeVerifier, - state = linkRequest.state + state = linkRequest.state, + redirectUri = linkRequest.redirectUri, ) return naverOAuthClient.getUserFromAuthCode(authCodeRequest) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLoginProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLoginProvider.kt index 10b951d..0bfd838 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLoginProvider.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/NaverLoginProvider.kt @@ -20,7 +20,8 @@ class NaverLoginProvider( OAuthAuthCodeRequest( authCode = request.authCode, state = request.state!!, // 네이버는 state 사용 - codeVerifier = request.codeVerifier + codeVerifier = request.codeVerifier, + redirectUri = request.redirectUri, ) ) } 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 new file mode 100644 index 0000000..6708123 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt @@ -0,0 +1,93 @@ +package com.wq.auth.api.domain.auth + +import com.wq.auth.api.domain.auth.entity.AuthProviderEntity +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity +import com.wq.auth.api.domain.auth.response.SocialLoginResult +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.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SocialLoginMemberProcessor( + private val authProviderRepository: AuthProviderRepository, + private val memberRepository: MemberRepository, + private val jwtProvider: JwtProvider, + private val refreshTokenRepository: RefreshTokenRepository, + private val memberStatsService: MemberStatsService, +) { + private val log = KotlinLogging.logger {} + + @Transactional + fun processMemberAndIssueTokens(oauthUser: OAuthUser, providerType: ProviderType): SocialLoginResult { + val (member, isNewMember) = findOrCreateMember(oauthUser, providerType) + + createOrUpdateAuthProvider(member, oauthUser, providerType) + + memberStatsService.updateLastLoginAtAsync(member.id) + + val accessToken = jwtProvider.createAccessToken(member.opaqueId) + val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) + + val jti = jwtProvider.getJti(refreshToken) + val opaqueId = jwtProvider.getOpaqueId(refreshToken) + val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) + refreshTokenRepository.save(refreshTokenEntity) + + log.info { "소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } + + return SocialLoginResult( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + + private fun findOrCreateMember( + oauthUser: OAuthUser, + providerType: ProviderType + ): Pair { + return authProviderRepository.findByProviderIdAndProviderType( + oauthUser.providerId, + providerType + )?.let { existingAuthProvider -> + log.info { "기존 회원 발견: ${existingAuthProvider.member.opaqueId}" } + Pair(existingAuthProvider.member, false) + } ?: run { + log.info { "신규 회원 생성: ${oauthUser.email}" } + val newMember = MemberEntity.createSocialMember( + nickname = oauthUser.getNickname(), + isEmailVerified = oauthUser.verifiedEmail, + primaryEmail = oauthUser.email + ) + val savedMember = memberRepository.save(newMember) + log.info { "신규 회원 생성 완료: ${savedMember.opaqueId}" } + Pair(savedMember, true) + } + } + + private fun createOrUpdateAuthProvider( + member: MemberEntity, + oauthUser: OAuthUser, + providerType: ProviderType + ) { + authProviderRepository.findByMemberAndProviderType(member, providerType)?.let { authProvider -> + authProvider.updateProviderInfo(oauthUser.providerId, oauthUser.email) + authProviderRepository.save(authProvider) + log.info { "AuthProvider 업데이트 완료: ${member.opaqueId}" } + } ?: run { + val authProvider = AuthProviderEntity( + member = member, + providerType = providerType, + providerId = oauthUser.providerId, + email = oauthUser.email + ) + authProviderRepository.save(authProvider) + log.info { "AuthProvider 생성 완료: ${member.opaqueId}" } + } + } +} diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginService.kt index 03b6669..67109c8 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginService.kt @@ -1,38 +1,23 @@ package com.wq.auth.api.domain.auth -import com.wq.auth.api.domain.auth.entity.AuthProviderEntity -import com.wq.auth.api.domain.auth.entity.ProviderType -import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity import com.wq.auth.api.domain.auth.request.SocialLoginRequest import com.wq.auth.api.domain.auth.response.SocialLoginResult -import com.wq.auth.api.domain.member.MemberRepository -import com.wq.auth.api.domain.member.entity.MemberEntity -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.security.jwt.JwtProvider import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime /** * 소셜 로그인 서비스 * * 소셜 로그인의 전체 플로우를 관리합니다: - * 1. 소셜 제공자로부터 사용자 정보 조회 - * 2. 기존 회원 확인 또는 신규 회원 생성 - * 3. AuthProvider 엔티티 생성/업데이트 - * 4. JWT 토큰 발급 + * 1. 소셜 제공자로부터 사용자 정보 조회 (트랜잭션 밖에서 수행) + * 2. 기존 회원 확인 또는 신규 회원 생성 및 토큰 발급 (트랜잭션 내에서 수행) */ @Service -@Transactional(readOnly = true) class SocialLoginService( private val loginProviders: MutableList, - private val authProviderRepository: AuthProviderRepository, - private val memberRepository: MemberRepository, - private val jwtProvider: JwtProvider, - private val refreshTokenRepository: RefreshTokenRepository, + private val socialLoginMemberProcessor: SocialLoginMemberProcessor, ) { private val log = KotlinLogging.logger {} @@ -42,104 +27,17 @@ class SocialLoginService( * @param request 소셜 로그인 요청 DTO * @return 소셜 로그인 응답 DTO (JWT 토큰 포함) */ - @Transactional fun processSocialLogin(request: SocialLoginRequest): SocialLoginResult { log.info { "소셜 로그인 처리 시작: ${request.providerType}" } + + // 1. 소셜 제공자로부터 사용자 정보 조회 (외부 네트워크 통신 - 트랜잭션 밖) val oauthUser = loginProviders.find { it.support(request.providerType) } ?.getUserInfo(request) ?: throw SocialLoginException( SocialLoginExceptionCode.UNSUPPORTED_PROVIDER ) - val (member, isNewMember) = findOrCreateMember(oauthUser, oauthUser.providerType) - - createOrUpdateAuthProvider(member, oauthUser, oauthUser.providerType) - - member.lastLoginAt = LocalDateTime.now() - memberRepository.save(member) - - val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) - val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) - - val jti = jwtProvider.getJti(refreshToken) - val opaqueId = jwtProvider.getOpaqueId(refreshToken) - val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) - refreshTokenRepository.save(refreshTokenEntity) - - log.info { "소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } - - return SocialLoginResult( - accessToken = accessToken, - refreshToken = refreshToken - ) - - } - - /** - * 기존 회원을 찾거나 신규 회원을 생성합니다. - * - * @param oauthUser OAuth 사용자 정보 - * @param providerType 소셜 제공자 타입 - * @return Pair<회원 엔티티, 신규 회원 여부> - */ - fun findOrCreateMember( - oauthUser: OAuthUser, - providerType: ProviderType - ): Pair { - - // AuthProvider 테이블에서 기존 회원 확인 - authProviderRepository.findByProviderIdAndProviderType( - oauthUser.providerId, - providerType - )?.let { existingAuthProvider -> - log.info { "기존 회원 발견: ${existingAuthProvider.member.opaqueId}" } - return Pair(existingAuthProvider.member, false) - } ?: run { - // 신규 회원 생성 - log.info { "신규 회원 생성: ${oauthUser.email}" } - val newMember = MemberEntity.createSocialMember( - nickname = oauthUser.getNickname(), - isEmailVerified = oauthUser.verifiedEmail, - primaryEmail = oauthUser.email - ) - - val savedMember = memberRepository.save(newMember) - log.info { "신규 회원 생성 완료: ${savedMember.opaqueId}" } - - return Pair(savedMember, true) - } - - } - - /** - * AuthProvider 엔티티를 생성하거나 업데이트합니다. - * - * @param member 회원 엔티티 - * @param oauthUser OAuth 사용자 정보 - * @param providerType 소셜 제공자 타입 - */ - fun createOrUpdateAuthProvider( - member: MemberEntity, - oauthUser: OAuthUser, - providerType: ProviderType - ) { - authProviderRepository.findByMemberAndProviderType(member, providerType)?.let { authProvider -> - // 기존 AuthProvider 업데이트 - // providerId와 email을 업데이트하는 메서드 호출 (엔티티에 setter 메서드가 있어야 함) - authProvider.updateProviderInfo(oauthUser.providerId, oauthUser.email) - authProviderRepository.save(authProvider) - log.info { "AuthProvider 업데이트 완료: ${member.opaqueId}" } - } ?: run { - // 새로운 AuthProvider 생성 - val authProvider = AuthProviderEntity( - member = member, - providerType = providerType, - providerId = oauthUser.providerId, - email = oauthUser.email - ) - authProviderRepository.save(authProvider) - log.info { "AuthProvider 생성 완료: ${member.opaqueId}" } - } - + // 2. 회원 정보 처리 및 토큰 발급 (DB 트랜잭션 수행) + return socialLoginMemberProcessor.processMemberAndIssueTokens(oauthUser, request.providerType) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/request/OAuthAuthCodeRequest.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/request/OAuthAuthCodeRequest.kt index a055b22..43cdcf9 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/request/OAuthAuthCodeRequest.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/request/OAuthAuthCodeRequest.kt @@ -1,7 +1,13 @@ package com.wq.auth.api.domain.auth.request +/** + * OAuth 인가 코드 교환 요청 + * + * @param redirectUri 토큰 요청 시 사용할 redirect_uri. 없으면 서버 기본값 사용. 있으면 허용 목록에 있을 때만 사용. + */ data class OAuthAuthCodeRequest( val authCode: String, val codeVerifier: String, val state: String? = null, // 네이버만 사용 + val redirectUri: String? = null, ) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLinkRequest.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLinkRequest.kt index d6c7445..37bd553 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLinkRequest.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLinkRequest.kt @@ -4,10 +4,13 @@ import com.wq.auth.api.domain.auth.entity.ProviderType /** * 소셜 계정 연동 요청 도메인 모델 + * + * @param redirectUri 인가 요청 시 사용한 redirect_uri. 없으면 서버 기본값 사용. */ data class SocialLinkRequest( val authCode: String, val codeVerifier: String, val state: String? = null, val providerType: ProviderType, + val redirectUri: String? = null, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLoginRequest.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLoginRequest.kt index 9cfb770..76333fc 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLoginRequest.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/request/SocialLoginRequest.kt @@ -13,10 +13,12 @@ import com.wq.auth.api.domain.auth.entity.ProviderType * @param codeVerifier PKCE 검증용 코드 검증자 * @param state CSRF 방지용 상태 값 (Naver용 - 선택사항) * @param providerType 소셜 로그인 제공자 타입 + * @param redirectUri 인가 요청 시 사용한 redirect_uri. 토큰 교환 시 동일 값 사용. 없으면 서버 기본값 사용. */ data class SocialLoginRequest( val authCode: String, val codeVerifier: String, val state: String?, val providerType: ProviderType, + val redirectUri: String? = null, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index fbc0170..42f13c9 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 @@ -15,11 +15,23 @@ class MemberService( ) { data class UserInfoResult( + val userId: String, val nickname: String, val email: String, val providers: List, ) + /** + * API Gateway introspect용. 회원의 대표 연동 제공자 하나를 반환한다. + * 연동된 provider 목록 중 첫 번째를 사용한다. 회원 없거나 연동 없으면 null. + */ + @Transactional(readOnly = true) + fun getPrimaryProvider(opaqueId: String): ProviderType? { + val member = memberRepository.findByOpaqueId(opaqueId).orElse(null) ?: return null + val providers = authProviderRepository.findByMember(member) + return providers.firstOrNull()?.providerType + } + @Transactional(readOnly = true) fun getUserInfo(opaqueId: String): UserInfoResult { val member = memberRepository.findByOpaqueId(opaqueId) @@ -36,6 +48,7 @@ class MemberService( //TODO //전화번호 로그인 추가시 null처리 필요 return UserInfoResult( + userId = member.opaqueId, nickname = member.nickname, email = email!!, providers = providers diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberStatsService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberStatsService.kt new file mode 100644 index 0000000..9f08c8e --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberStatsService.kt @@ -0,0 +1,24 @@ +package com.wq.auth.api.domain.member + +import com.wq.auth.api.domain.member.entity.MemberEntity +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class MemberStatsService( + private val memberRepository: MemberRepository +) { + /** + * 마지막 로그인 시간 비동기 업데이트 + * 로그인 응답 속도를 개선하기 위해 별도 스레드에서 처리합니다. + */ + @Async + @Transactional + fun updateLastLoginAtAsync(memberId: Long) { + val member = memberRepository.findById(memberId).orElse(null) ?: return + member.lastLoginAt = LocalDateTime.now() + memberRepository.save(member) + } +} diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt index d922af5..25477c9 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 @@ -1,9 +1,9 @@ package com.wq.auth.api.domain.member.entity import com.wq.auth.shared.entity.BaseEntity +import com.github.f4b6a3.uuid.UuidCreator import jakarta.persistence.* import java.time.LocalDateTime -import java.util.* @Entity @Table( @@ -29,10 +29,6 @@ open class MemberEntity protected constructor( @Column(nullable = false, length = 100) var nickname: String, - @Enumerated(EnumType.STRING) - @Column(nullable = false) - val role: Role = Role.MEMBER, - @Column(name = "is_email_verified", nullable = false) var isEmailVerified: Boolean = false, @@ -53,21 +49,19 @@ open class MemberEntity protected constructor( nickname = nickname, isEmailVerified = true, primaryEmail = email, - opaqueId = UUID.randomUUID().toString(), + opaqueId = UuidCreator.getTimeOrdered().toString(), lastLoginAt = LocalDateTime.now(), ) fun create( nickname: String, - role: Role = Role.MEMBER ): MemberEntity { require(nickname.isNotBlank()) { "닉네임은 필수입니다" } require(nickname.length <= 100) { "닉네임은 100자를 초과할 수 없습니다" } return MemberEntity( - opaqueId = UUID.randomUUID().toString(), + opaqueId = UuidCreator.getTimeOrdered().toString(), nickname = nickname.trim(), - role = role ) } @@ -75,15 +69,13 @@ open class MemberEntity protected constructor( nickname: String, isEmailVerified: Boolean = true, primaryEmail: String, - role: Role = Role.MEMBER ): MemberEntity { require(nickname.isNotBlank()) { "닉네임은 필수입니다" } require(nickname.length <= 100) { "닉네임은 100자를 초과할 수 없습니다" } return MemberEntity( - opaqueId = UUID.randomUUID().toString(), + opaqueId = UuidCreator.getTimeOrdered().toString(), nickname = nickname.trim(), - role = role, isEmailVerified = isEmailVerified, primaryEmail = primaryEmail ) @@ -112,12 +104,7 @@ open class MemberEntity protected constructor( this.isDeleted = true } - /** - * 관리자 권한 확인 - */ - fun isAdmin(): Boolean = role == Role.ADMIN - override fun toString(): String { - return "MemberEntity(id=$id, opaqueId='$opaqueId', nickname='$nickname', role=$role)" + return "MemberEntity(id=$id, opaqueId='$opaqueId', nickname='$nickname')" } } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/Role.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/Role.kt deleted file mode 100644 index 6fb44b5..0000000 --- a/src/main/kotlin/com/wq/auth/api/domain/member/entity/Role.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.wq.auth.api.domain.member.entity - -enum class Role { - MEMBER, - ADMIN -} \ 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 fcb41ee..bb487c9 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 @@ -33,6 +33,7 @@ enum class SocialLoginExceptionCode( NAVER_INVALID_STATE(400, "유효하지 않은 네이버 state 값입니다"), // 공통 소셜 로그인 예외 + INVALID_REDIRECT_URI(400, "허용되지 않은 redirect_uri입니다. 등록된 redirect_uri만 사용할 수 있습니다."), UNSUPPORTED_PROVIDER(400, "지원하지 않는 소셜 로그인 제공자입니다"), SOCIAL_LOGIN_PROCESSING_ERROR(500, "소셜 로그인 처리 중 오류가 발생했습니다"), MEMBER_CREATION_FAILED(500, "소셜 로그인 회원 생성에 실패했습니다"), diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/GoogleOAuthClient.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/GoogleOAuthClient.kt index e5f4239..97ab5dc 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/GoogleOAuthClient.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/GoogleOAuthClient.kt @@ -1,6 +1,5 @@ package com.wq.auth.api.external.oauth -import com.fasterxml.jackson.databind.ObjectMapper import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.external.oauth.dto.GoogleUserInfoResponse import com.wq.auth.api.domain.auth.request.OAuthAuthCodeRequest @@ -13,166 +12,128 @@ import org.springframework.http.* import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap -import org.springframework.web.client.RestTemplate -import org.springframework.web.client.HttpClientErrorException -import org.springframework.web.client.HttpServerErrorException +import org.springframework.web.client.RestClient +import tools.jackson.databind.json.JsonMapper -/** - * Google OAuth2 클라이언트 - * - * Google OAuth2 API와 통신하여 인가 코드를 액세스 토큰으로 교환하고, - * 액세스 토큰을 사용하여 사용자 정보를 조회합니다. - */ @Component class GoogleOAuthClient( private val googleOAuthProperties: GoogleOAuthProperties, - private val objectMapper: ObjectMapper + private val jsonMapper: JsonMapper, + private val redirectUriResolver: OAuthRedirectUriResolver, + private val restClient: RestClient, ) : OAuthClient { private val log = KotlinLogging.logger {} - private val restTemplate = RestTemplate() - /** - * 인가 코드를 사용하여 액세스 토큰을 획득합니다. - * - * @param authorizationCode Google로부터 받은 인가 코드 - * @param codeVerifier PKCE 검증용 코드 검증자 - * @return Google 액세스 토큰 - * @throws SocialLoginException 토큰 획득 실패 시 - */ - fun getAccessToken(authorizationCode: String, codeVerifier: String): String { - log.info { "Google 액세스 토큰 요청 시작" } - log.info { "redirectUri: ${googleOAuthProperties.redirectUri}" } - - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - } - + fun getTokenResponse( + authorizationCode: String, + codeVerifier: String, + requestRedirectUri: String? = null, + ): Map { + val redirectUri = redirectUriResolver.resolve(requestRedirectUri, googleOAuthProperties.redirectUri) + log.info { "Google 토큰 요청 시작" } + val body: MultiValueMap = LinkedMultiValueMap().apply { add("client_id", googleOAuthProperties.clientId) add("client_secret", googleOAuthProperties.clientSecret) add("code", authorizationCode) add("grant_type", "authorization_code") add("code_verifier", codeVerifier) - add("redirect_uri", googleOAuthProperties.redirectUri) + add("redirect_uri", redirectUri) } - val request = HttpEntity(body, headers) - try { - val response = restTemplate.postForEntity( - googleOAuthProperties.tokenUri, - request, - String::class.java - ) + val response = restClient.post() + .uri(googleOAuthProperties.tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .toEntity(String::class.java) if (response.statusCode == HttpStatus.OK && response.body != null) { - val tokenResponse = objectMapper.readTree(response.body!!) - val accessToken = tokenResponse.get("access_token")?.asText() + val tokenResponse = jsonMapper.readTree(response.body!!) + val accessToken = tokenResponse.get("access_token")?.asString() + val idToken = tokenResponse.get("id_token")?.asString() if (accessToken != null) { - log.info { "Google 액세스 토큰 획득 성공" } - return accessToken + log.info { "Google 토큰 획득 성공" } + val result = mutableMapOf("access_token" to accessToken) + idToken?.let { result["id_token"] = it } + return result } else { - log.error { "Google 액세스 토큰이 응답에 없습니다: ${response.body}" } + log.error { "Google 액세스 토큰이 응답에 없습니다" } throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_TOKEN_REQUEST_FAILED) } } else { log.error { "Google 토큰 요청 실패: ${response.statusCode}" } throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_TOKEN_REQUEST_FAILED) } - - } catch (e: HttpClientErrorException) { - log.error(e) { "Google 토큰 요청 클라이언트 오류: ${e.statusCode} - ${e.responseBodyAsString}" } - throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_INVALID_AUTHORIZATION_CODE, e) - } catch (e: HttpServerErrorException) { - log.error(e) { "Google 서버 오류: ${e.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_SERVER_ERROR, e) - } catch (e: SocialLoginException) { - throw e // 이미 SocialLoginException인 경우 그대로 전파 } catch (e: Exception) { - log.error(e) { "Google 토큰 요청 중 예상치 못한 오류 발생" } + log.error(e) { "Google 토큰 요청 중 오류 발생" } throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_TOKEN_REQUEST_FAILED, e) } } - /** - * 액세스 토큰을 사용하여 Google 사용자 정보를 조회합니다. - * - * @param accessToken Google 액세스 토큰 - * @return Google 사용자 정보 - * @throws SocialLoginException 사용자 정보 조회 실패 시 - */ - fun getUserInfo(accessToken: String): GoogleUserInfoResponse { - log.info { "Google 사용자 정보 조회 시작" } - - val headers = HttpHeaders().apply { - set("Authorization", "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - } - - val request = HttpEntity(headers) - - try { - val response = restTemplate.exchange( - googleOAuthProperties.userInfoUri, - HttpMethod.GET, - request, - String::class.java - ) - - if (response.statusCode == HttpStatus.OK && response.body != null) { - val userInfo = objectMapper.readValue(response.body!!, GoogleUserInfoResponse::class.java) - log.info { "Google 사용자 정보 조회 성공: ${userInfo.email}" } - return userInfo - } else { - log.error { "Google 사용자 정보 조회 실패: ${response.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_USER_INFO_REQUEST_FAILED) + override fun getUserFromAuthCode(req: OAuthAuthCodeRequest): OAuthUser { + val tokenResponse = getTokenResponse(req.authCode, req.codeVerifier, req.redirectUri) + val accessToken = tokenResponse["access_token"]!! + val idToken = tokenResponse["id_token"] + + if (idToken != null) { + try { + val chunks = idToken.split(".") + if (chunks.size >= 2) { + val payload = String(java.util.Base64.getUrlDecoder().decode(chunks[1])) + val jsonNode = jsonMapper.readTree(payload) + + return OAuthUser( + providerId = jsonNode.get("sub").asString(), + email = jsonNode.get("email")?.asString() ?: "", + verifiedEmail = jsonNode.get("email_verified")?.asBoolean() ?: true, + name = jsonNode.get("name")?.asString() ?: "구글사용자", + givenName = jsonNode.get("given_name")?.asString(), + providerType = ProviderType.GOOGLE + ) + } + } catch (e: Exception) { + log.warn { "id_token 파싱 실패: ${e.message}" } } - - } catch (e: HttpClientErrorException) { - log.error(e) { "Google 사용자 정보 조회 클라이언트 오류: ${e.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_INVALID_ACCESS_TOKEN, e) - } catch (e: HttpServerErrorException) { - log.error(e) { "Google 서버 오류: ${e.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_SERVER_ERROR, e) - } catch (e: SocialLoginException) { - throw e // 이미 SocialLoginException인 경우 그대로 전파 - } catch (e: Exception) { - log.error(e) { "Google 사용자 정보 조회 중 예상치 못한 오류 발생" } - throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_USER_INFO_REQUEST_FAILED, e) } - } - /** - * OAuthClient 인터페이스 구현: 인가 코드를 사용하여 도메인 사용자 정보를 조회합니다. - * - * @param authCode Google로부터 받은 인가 코드 - * @param codeVerifier PKCE 검증용 코드 검증자 - * @return 도메인 사용자 정보 - */ - override fun getUserFromAuthCode(req : OAuthAuthCodeRequest): OAuthUser { - val accessToken = getAccessToken(req.authCode, req.codeVerifier) val googleUserInfo = getUserInfo(accessToken) return OAuthUser( providerId = googleUserInfo.getProviderId(), email = googleUserInfo.email, - verifiedEmail = googleUserInfo.verifiedEmail, + verifiedEmail = googleUserInfo.verifiedEmail ?: true, name = googleUserInfo.name, givenName = googleUserInfo.givenName, providerType = ProviderType.GOOGLE ) } - /** - * 인가 코드를 사용하여 사용자 정보를 직접 조회합니다. (기존 호환성 유지용) - * - * @param authorizationCode Google로부터 받은 인가 코드 - * @param codeVerifier PKCE 검증용 코드 검증자 - * @return Google 사용자 정보 - */ + fun getUserInfo(accessToken: String): GoogleUserInfoResponse { + try { + val response = restClient.get() + .uri(googleOAuthProperties.userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .toEntity(String::class.java) + + if (response.statusCode == HttpStatus.OK && response.body != null) { + return jsonMapper.readValue(response.body!!, GoogleUserInfoResponse::class.java) + } else { + throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_USER_INFO_REQUEST_FAILED) + } + } catch (e: Exception) { + log.error(e) { "Google 사용자 정보 조회 중 오류 발생" } + throw SocialLoginException(SocialLoginExceptionCode.GOOGLE_USER_INFO_REQUEST_FAILED, e) + } + } + fun getUserInfoFromAuthCode(authorizationCode: String, codeVerifier: String): GoogleUserInfoResponse { - val accessToken = getAccessToken(authorizationCode, codeVerifier) + val tokenResponse = getTokenResponse(authorizationCode, codeVerifier, null) + val accessToken = tokenResponse["access_token"]!! return getUserInfo(accessToken) } } diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt index e153230..6877641 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt @@ -1,6 +1,5 @@ package com.wq.auth.api.external.oauth -import com.fasterxml.jackson.databind.ObjectMapper import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.external.oauth.dto.KakaoUserInfoResponse import com.wq.auth.api.domain.auth.request.OAuthAuthCodeRequest @@ -13,151 +12,97 @@ import org.springframework.http.* import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap -import org.springframework.web.client.RestTemplate -import org.springframework.web.client.HttpClientErrorException -import org.springframework.web.client.HttpServerErrorException +import org.springframework.web.client.RestClient +import tools.jackson.databind.json.JsonMapper -/** - * 카카오 OAuth2 클라이언트 - * - * 카카오 OAuth2 API와 통신하여 인가 코드를 액세스 토큰으로 교환하고, - * 액세스 토큰을 사용하여 사용자 정보를 조회합니다. - */ @Component class KakaoOAuthClient( private val kakaoOAuthProperties: KakaoOAuthProperties, - private val objectMapper: ObjectMapper + private val jsonMapper: JsonMapper, + private val redirectUriResolver: OAuthRedirectUriResolver, + private val restClient: RestClient, ) : OAuthClient { private val log = KotlinLogging.logger {} - private val restTemplate = RestTemplate() - /** - * 인가 코드를 사용하여 액세스 토큰을 획득합니다. - * - * @param authorizationCode 카카오로부터 받은 인가 코드 - * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) - * @return 카카오 액세스 토큰 - * @throws SocialLoginException 토큰 획득 실패 시 - */ - fun getAccessToken(authorizationCode: String, codeVerifier: String): String { - log.info { "카카오 액세스 토큰 요청 시작" } - log.info { "redirectUri: ${kakaoOAuthProperties.redirectUri}" } - - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - } - + fun getTokenResponse( + authorizationCode: String, + codeVerifier: String, + requestRedirectUri: String? = null, + ): Map { + val redirectUri = redirectUriResolver.resolve(requestRedirectUri, kakaoOAuthProperties.redirectUri) + log.info { "카카오 토큰 요청 시작" } + val body: MultiValueMap = LinkedMultiValueMap().apply { add("grant_type", "authorization_code") add("client_id", kakaoOAuthProperties.clientId) if (!kakaoOAuthProperties.clientSecret.isNullOrBlank()) { add("client_secret", kakaoOAuthProperties.clientSecret) } - add("redirect_uri", kakaoOAuthProperties.redirectUri) + add("redirect_uri", redirectUri) add("code", authorizationCode) - // 카카오는 PKCE를 지원하지만 선택사항이므로 codeVerifier가 있을 때만 추가 if (codeVerifier.isNotBlank()) { add("code_verifier", codeVerifier) } } - //TODO : fegin 이용 - val request = HttpEntity(body, headers) - try { - val response = restTemplate.postForEntity( - kakaoOAuthProperties.tokenUri, - request, - String::class.java - ) + val response = restClient.post() + .uri(kakaoOAuthProperties.tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .toEntity(String::class.java) if (response.statusCode == HttpStatus.OK && response.body != null) { - val tokenResponse = objectMapper.readTree(response.body!!) - val accessToken = tokenResponse.get("access_token")?.asText() + val tokenResponse = jsonMapper.readTree(response.body!!) + val accessToken = tokenResponse.get("access_token")?.asString() + val idToken = tokenResponse.get("id_token")?.asString() if (accessToken != null) { - log.info { "카카오 액세스 토큰 획득 성공" } - return accessToken + log.info { "카카오 토큰 획득 성공" } + val result = mutableMapOf("access_token" to accessToken) + idToken?.let { result["id_token"] = it } + return result } else { - log.error { "카카오 액세스 토큰이 응답에 없습니다: ${response.body}" } + log.error { "카카오 액세스 토큰이 응답에 없습니다" } throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED) } } else { log.error { "카카오 토큰 요청 실패: ${response.statusCode}" } throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED) } - - } catch (e: HttpClientErrorException) { - log.error(e) { "카카오 토큰 요청 클라이언트 오류: ${e.statusCode} - ${e.responseBodyAsString}" } - throw SocialLoginException(SocialLoginExceptionCode.KAKAO_INVALID_AUTHORIZATION_CODE, e) - } catch (e: HttpServerErrorException) { - log.error(e) { "카카오 서버 오류: ${e.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED, e) - } catch (e: SocialLoginException) { - throw e // 이미 SocialLoginException인 경우 그대로 전파 } catch (e: Exception) { - log.error(e) { "카카오 토큰 요청 중 예상치 못한 오류 발생" } + log.error(e) { "카카오 토큰 요청 중 오류 발생" } throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED, e) } } - /** - * 액세스 토큰을 사용하여 카카오 사용자 정보를 조회합니다. - * - * @param accessToken 카카오 액세스 토큰 - * @return 카카오 사용자 정보 - * @throws SocialLoginException 사용자 정보 조회 실패 시 - */ - fun getUserInfo(accessToken: String): KakaoUserInfoResponse { - log.info { "카카오 사용자 정보 조회 시작" } - - val headers = HttpHeaders().apply { - set("Authorization", "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - } - - val request = HttpEntity(headers) - - try { - val response = restTemplate.exchange( - kakaoOAuthProperties.userInfoUri, - HttpMethod.GET, - request, - String::class.java - ) - - if (response.statusCode == HttpStatus.OK && response.body != null) { - val userInfo = objectMapper.readValue(response.body!!, KakaoUserInfoResponse::class.java) - log.info { "카카오 사용자 정보 조회 성공: ${userInfo.getEmail()}" } - return userInfo - } else { - log.error { "카카오 사용자 정보 조회 실패: ${response.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED) + override fun getUserFromAuthCode(req: OAuthAuthCodeRequest): OAuthUser { + val tokenResponse = getTokenResponse(req.authCode, req.codeVerifier, req.redirectUri) + val accessToken = tokenResponse["access_token"]!! + val idToken = tokenResponse["id_token"] + + if (idToken != null) { + try { + val chunks = idToken.split(".") + if (chunks.size >= 2) { + val payload = String(java.util.Base64.getUrlDecoder().decode(chunks[1])) + val jsonNode = jsonMapper.readTree(payload) + + return OAuthUser( + providerId = jsonNode.get("sub").asString(), + email = jsonNode.get("email")?.asString() ?: "", + verifiedEmail = jsonNode.get("email_needs_agreement")?.asBoolean()?.not() ?: true, + name = jsonNode.get("nickname")?.asString() ?: "카카오사용자", + givenName = jsonNode.get("nickname")?.asString(), + providerType = ProviderType.KAKAO + ) + } + } catch (e: Exception) { + log.warn { "id_token 파싱 실패: ${e.message}" } } - - } catch (e: HttpClientErrorException) { - log.error(e) { "카카오 사용자 정보 조회 클라이언트 오류: ${e.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) - } catch (e: HttpServerErrorException) { - log.error(e) { "카카오 서버 오류: ${e.statusCode}" } - throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) - } catch (e: SocialLoginException) { - throw e // 이미 SocialLoginException인 경우 그대로 전파 - } catch (e: Exception) { - log.error(e) { "카카오 사용자 정보 조회 중 예상치 못한 오류 발생" } - throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) } - } - /** - * OAuthClient 인터페이스 구현: 인가 코드를 사용하여 도메인 사용자 정보를 조회합니다. - * - * @param authCode 카카오로부터 받은 인가 코드 - * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) - * @return 도메인 사용자 정보 - */ - override fun getUserFromAuthCode(req : OAuthAuthCodeRequest): OAuthUser { - val accessToken = getAccessToken(req.authCode, req.codeVerifier) val kakaoUserInfo = getUserInfo(accessToken) return OAuthUser( @@ -170,15 +115,29 @@ class KakaoOAuthClient( ) } - /** - * 인가 코드를 사용하여 사용자 정보를 직접 조회합니다. (기존 호환성 유지용) - * - * @param authorizationCode 카카오로부터 받은 인가 코드 - * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) - * @return 카카오 사용자 정보 - */ + fun getUserInfo(accessToken: String): KakaoUserInfoResponse { + try { + val response = restClient.get() + .uri(kakaoOAuthProperties.userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .toEntity(String::class.java) + + if (response.statusCode == HttpStatus.OK && response.body != null) { + return jsonMapper.readValue(response.body!!, KakaoUserInfoResponse::class.java) + } else { + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED) + } + } catch (e: Exception) { + log.error(e) { "카카오 사용자 정보 조회 중 오류 발생" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) + } + } + fun getUserInfoFromAuthCode(authorizationCode: String, codeVerifier: String): KakaoUserInfoResponse { - val accessToken = getAccessToken(authorizationCode, codeVerifier) + val tokenResponse = getTokenResponse(authorizationCode, codeVerifier, null) + val accessToken = tokenResponse["access_token"]!! return getUserInfo(accessToken) } } 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 53e6ed0..29b4b5c 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 @@ -1,6 +1,5 @@ package com.wq.auth.api.external.oauth -import com.fasterxml.jackson.databind.ObjectMapper import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.external.oauth.dto.NaverUserInfoResponse import com.wq.auth.api.domain.auth.request.OAuthAuthCodeRequest @@ -13,9 +12,10 @@ import org.springframework.http.* import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap -import org.springframework.web.client.RestTemplate import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.HttpServerErrorException +import org.springframework.web.client.RestClient +import tools.jackson.databind.json.JsonMapper /** * Naver OAuth2 클라이언트 @@ -26,10 +26,11 @@ import org.springframework.web.client.HttpServerErrorException @Component class NaverOAuthClient( private val naverOAuthProperties: NaverOAuthProperties, - private val objectMapper: ObjectMapper + private val jsonMapper: JsonMapper, + private val redirectUriResolver: OAuthRedirectUriResolver, + private val restClient: RestClient, ) : OAuthClient { private val log = KotlinLogging.logger {} - private val restTemplate = RestTemplate() /** * 인가 코드를 사용하여 액세스 토큰을 획득합니다. @@ -37,6 +38,7 @@ class NaverOAuthClient( * @param authorizationCode Naver로부터 받은 인가 코드 * @param state CSRF 방지용 상태 값 * @param codeVerifier PKCE 검증용 코드 검증자 + * @param requestRedirectUri 요청에 담긴 redirect_uri (있으면 허용 목록 검증 후 사용) * @return Naver 액세스 토큰 * @throws SocialLoginException 토큰 획득 실패 시 */ @@ -44,13 +46,11 @@ class NaverOAuthClient( authorizationCode: String, state: String, codeVerifier: String, + requestRedirectUri: String? = null, ): String { + val redirectUri = redirectUriResolver.resolve(requestRedirectUri, naverOAuthProperties.redirectUri) log.info { "Naver 액세스 토큰 요청 시작" } - log.info { "redirectUri: ${ naverOAuthProperties.redirectUri}" } - - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - } + log.info { "redirectUri: $redirectUri" } val body: MultiValueMap = LinkedMultiValueMap().apply { add("client_id", naverOAuthProperties.clientId) @@ -59,21 +59,20 @@ class NaverOAuthClient( add("code", authorizationCode) add("grant_type", "authorization_code") add("state", state) - add("redirect_uri", naverOAuthProperties.redirectUri) + add("redirect_uri", redirectUri) } - val request = HttpEntity(body, headers) - try { - val response = restTemplate.postForEntity( - naverOAuthProperties.tokenUri, - request, - String::class.java - ) + val response = restClient.post() + .uri(naverOAuthProperties.tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .toEntity(String::class.java) if (response.statusCode == HttpStatus.OK && response.body != null) { - val tokenResponse = objectMapper.readTree(response.body!!) - val accessToken = tokenResponse.get("access_token")?.asText() + val tokenResponse = jsonMapper.readTree(response.body!!) + val accessToken = tokenResponse.get("access_token")?.asString() if (accessToken != null) { log.info { "Naver 액세스 토큰 획득 성공" } @@ -111,23 +110,16 @@ class NaverOAuthClient( fun getUserInfo(accessToken: String): NaverUserInfoResponse { log.info { "Naver 사용자 정보 조회 시작" } - val headers = HttpHeaders().apply { - set("Authorization", "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - } - - val request = HttpEntity(headers) - try { - val response = restTemplate.exchange( - naverOAuthProperties.userInfoUri, - HttpMethod.GET, - request, - String::class.java - ) + val response = restClient.get() + .uri(naverOAuthProperties.userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .toEntity(String::class.java) if (response.statusCode == HttpStatus.OK && response.body != null) { - val userInfo = objectMapper.readValue(response.body!!, NaverUserInfoResponse::class.java) + val userInfo = jsonMapper.readValue(response.body!!, NaverUserInfoResponse::class.java) log.info { "Naver 사용자 정보 조회 성공: ${userInfo.response.email ?: "이메일 없음"}" } return userInfo } else { @@ -159,8 +151,7 @@ class NaverOAuthClient( */ override fun getUserFromAuthCode(req: OAuthAuthCodeRequest): OAuthUser { log.info { "Naver AuthCode 요청 시작" } - log.info { "redirectUri: ${naverOAuthProperties.redirectUri}" } - val accessToken = getAccessToken(req.authCode, req.state!!, req.codeVerifier) + val accessToken = getAccessToken(req.authCode, req.state!!, req.codeVerifier, req.redirectUri) val naverUserInfo = getUserInfo(accessToken) return OAuthUser( @@ -186,7 +177,7 @@ class NaverOAuthClient( state: String, codeVerifier: String, ): NaverUserInfoResponse { - val accessToken = getAccessToken(authorizationCode, state, codeVerifier) + val accessToken = getAccessToken(authorizationCode, state, codeVerifier, null) return getUserInfo(accessToken) } } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/OAuthRedirectUriResolver.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/OAuthRedirectUriResolver.kt new file mode 100644 index 0000000..92281bf --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/OAuthRedirectUriResolver.kt @@ -0,0 +1,15 @@ +package com.wq.auth.api.external.oauth + +import org.springframework.stereotype.Component + +/** + * 토큰 교환 시 사용할 redirect_uri를 결정합니다. + * + * - 요청 바디에 redirectUri가 있으면 그 값을 사용 + * - null 또는 비어 있으면 서버 기본값(env의 *_REDIRECT_URI) 사용 + */ +@Component +class OAuthRedirectUriResolver { + fun resolve(requestRedirectUri: String?, defaultRedirectUri: String): String = + if (requestRedirectUri.isNullOrBlank()) defaultRedirectUri else requestRedirectUri +} diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/GoogleUserInfoResponse.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/GoogleUserInfoResponse.kt index ddbb1e9..c4583ad 100644 --- a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/GoogleUserInfoResponse.kt +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/GoogleUserInfoResponse.kt @@ -15,7 +15,7 @@ data class GoogleUserInfoResponse( val email: String, @field:JsonProperty("verified_email") - val verifiedEmail: Boolean, + val verifiedEmail: Boolean? = true, @field:JsonProperty("name") val name: String, 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 8f8b2ed..2a329e4 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 @@ -32,7 +32,7 @@ data class NaverUserInfo( val email: String? = null, @field:JsonProperty("verified_email") - val verifiedEmail: Boolean, + val verifiedEmail: Boolean? = false, @field:JsonProperty("gender") val gender: String? = null, diff --git a/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt b/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt index bd702d1..cdd43c8 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt @@ -1,8 +1,7 @@ package com.wq.auth.security -import com.fasterxml.jackson.databind.ObjectMapper import com.wq.auth.security.jwt.error.JwtExceptionCode -import com.wq.auth.web.common.response.Responses +import com.wq.auth.web.common.response.CommonResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import io.github.oshai.kotlinlogging.KotlinLogging @@ -10,6 +9,7 @@ import org.springframework.http.MediaType import org.springframework.security.access.AccessDeniedException import org.springframework.security.web.access.AccessDeniedHandler import org.springframework.stereotype.Component +import tools.jackson.databind.json.JsonMapper import java.nio.charset.StandardCharsets /** @@ -18,7 +18,7 @@ import java.nio.charset.StandardCharsets */ @Component class JwtAccessDeniedHandler( - private val objectMapper: ObjectMapper + private val jsonMapper: JsonMapper ) : AccessDeniedHandler { private val log = KotlinLogging.logger {} @@ -37,8 +37,8 @@ class JwtAccessDeniedHandler( response.characterEncoding = StandardCharsets.UTF_8.name() // 표준 API 응답 형식으로 에러 응답 생성 - val errorResponse = Responses.fail(JwtExceptionCode.FORBIDDEN) - val jsonResponse = objectMapper.writeValueAsString(errorResponse) + val errorResponse = CommonResponse.fail(JwtExceptionCode.FORBIDDEN) + val jsonResponse = jsonMapper.writeValueAsString(errorResponse) response.writer.write(jsonResponse) response.writer.flush() diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt index 7961060..aad9a0e 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt @@ -1,7 +1,6 @@ package com.wq.auth.security -import com.fasterxml.jackson.databind.ObjectMapper -import com.wq.auth.web.common.response.Responses +import com.wq.auth.web.common.response.CommonResponse import com.wq.auth.security.jwt.error.JwtExceptionCode import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -10,6 +9,7 @@ import org.springframework.http.MediaType import org.springframework.security.core.AuthenticationException import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.stereotype.Component +import tools.jackson.databind.json.JsonMapper import java.nio.charset.StandardCharsets /** @@ -18,7 +18,7 @@ import java.nio.charset.StandardCharsets */ @Component class JwtAuthenticationEntryPoint( - private val objectMapper: ObjectMapper + private val jsonMapper: JsonMapper ) : AuthenticationEntryPoint { private val log = KotlinLogging.logger {} @@ -37,8 +37,8 @@ class JwtAuthenticationEntryPoint( response.characterEncoding = StandardCharsets.UTF_8.name() // 표준 API 응답 형식으로 에러 응답 생성 - val errorResponse = Responses.fail(JwtExceptionCode.TOKEN_MISSING) - val jsonResponse = objectMapper.writeValueAsString(errorResponse) + val errorResponse = CommonResponse.fail(JwtExceptionCode.TOKEN_MISSING) + val jsonResponse = jsonMapper.writeValueAsString(errorResponse) response.writer.write(jsonResponse) response.writer.flush() diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt index a4850d4..a2fcde2 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt @@ -1,6 +1,5 @@ package com.wq.auth.security -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.principal.PrincipalDetails @@ -13,12 +12,6 @@ import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import io.github.oshai.kotlinlogging.KotlinLogging -/** - * JWT 토큰 기반 인증 필터 - * - * HTTP 요청 헤더에서 JWT 토큰을 추출하고 검증하여 - * Spring Security 인증 컨텍스트에 사용자 정보를 설정합니다. - */ @Component class JwtAuthenticationFilter( private val jwtProvider: JwtProvider @@ -29,6 +22,31 @@ class JwtAuthenticationFilter( companion object { private const val AUTHORIZATION_HEADER = "Authorization" private const val BEARER_PREFIX = "Bearer " + + /** + * HTTP 요청에서 JWT 토큰을 추출합니다. + * + * 토큰 우선순위: + * 1. `accessToken` HttpOnly 쿠키 (웹 클라이언트 전용) + * - 쿠키가 존재하면 헤더는 완전히 무시됩니다. + * 2. `Authorization: Bearer ` 헤더 (앱 클라이언트 / 쿠키 없을 때만 사용) + * + * @return 토큰 문자열, 아무것도 없으면 null + */ + fun extractToken(request: HttpServletRequest): String? { + val accessTokenCookie = request.cookies?.firstOrNull { it.name == "accessToken" } + + if (accessTokenCookie != null) { + return accessTokenCookie.value + } + + val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) + return if (!authorizationHeader.isNullOrBlank() && authorizationHeader.startsWith(BEARER_PREFIX)) { + authorizationHeader.substring(BEARER_PREFIX.length) + } else { + null + } + } } override fun doFilterInternal( @@ -36,69 +54,27 @@ class JwtAuthenticationFilter( response: HttpServletResponse, filterChain: FilterChain ) { - - val httpReq = request as HttpServletRequest - println("Request URI: ${httpReq.requestURI}") - try { - // Authorization 헤더에서 JWT 토큰 추출 - val token = extractTokenFromRequest(request) - - if (token != null) { - // JWT 토큰 유효성 검증 + val token = extractToken(request) + if (!token.isNullOrBlank()) { jwtProvider.validateOrThrow(token) - - // 토큰에서 사용자 정보 추출 - val principalDetails = extractPrincipalDetails(token) - - // Spring Security 인증 객체 생성 및 설정 + + val principalDetails = PrincipalDetails(jwtProvider.getOpaqueId(token)) + // todo : TokenService로 분리 필요. val authentication = UsernamePasswordAuthenticationToken( - principalDetails, // principal: PrincipalDetails 객체 - null, // credentials: 비밀번호 (JWT에서는 불필요) - principalDetails.authorities // authorities: 사용자 권한 + principalDetails, + null, + principalDetails.authorities ) SecurityContextHolder.getContext().authentication = authentication } } catch (e: JwtException) { - // JWT 예외는 로깅만 하고 필터 체인을 계속 진행 (인증되지 않은 상태) - // 인증이 필요한 엔드포인트 접근 시 JwtAuthenticationEntryPoint에서 401 응답 처리 log.debug(e) { "JWT 인증 실패: ${e.message}" } } catch (e: Exception) { - // 기타 예외는 로깅만 하고 필터 체인을 계속 진행 log.debug(e) { "JWT 필터 처리 중 예외 발생: ${e.message}" } } - - // 다음 필터로 진행 - filterChain.doFilter(request, response) - } - - /** - * HTTP 요청에서 JWT 토큰을 추출합니다. - * - * @param request HTTP 요청 객체 - * @return 추출된 JWT 토큰 문자열, 없으면 null - */ - private fun extractTokenFromRequest(request: HttpServletRequest): String? { - val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) - - return if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) { - authorizationHeader.substring(BEARER_PREFIX.length) - } else { - null - } - } - /** - * JWT 토큰에서 PrincipalDetails 객체를 생성합니다. - * - * @param token JWT 토큰 - * @return PrincipalDetails 객체 - */ - private fun extractPrincipalDetails(token: String): PrincipalDetails { - val opaqueId = jwtProvider.getOpaqueId(token) - val role = jwtProvider.getRole(token) ?: Role.MEMBER - - return PrincipalDetails(opaqueId, role) + filterChain.doFilter(request, response) } } diff --git a/src/main/kotlin/com/wq/auth/security/annotation/AdminApi.kt b/src/main/kotlin/com/wq/auth/security/annotation/AdminApi.kt deleted file mode 100644 index 6ac1454..0000000 --- a/src/main/kotlin/com/wq/auth/security/annotation/AdminApi.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.wq.auth.security.annotation - -import org.springframework.security.access.prepost.PreAuthorize - -/** - * 관리자 권한이 필요한 API에 사용하는 어노테이션 - * - * 이 어노테이션이 적용된 메서드나 클래스는 ROLE_ADMIN 권한을 가진 사용자만 접근할 수 있습니다. - * - * 사용 예시: - * ```kotlin - * @AdminApi - * @GetMapping("/admin/users") - * fun getAllUsers(): List { - * // 관리자만 접근 가능한 로직 - * } - * ``` - */ -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) -@Retention(AnnotationRetention.RUNTIME) -@PreAuthorize("hasRole('ADMIN')") -annotation class AdminApi( - val description: String = "관리자 권한 필요" -) diff --git a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt index 0024949..f14f0aa 100644 --- a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt +++ b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt @@ -1,8 +1,9 @@ package com.wq.auth.security.jwt -import com.wq.auth.api.domain.member.entity.Role import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.jwt.error.JwtExceptionCode +import com.github.f4b6a3.uuid.UuidCreator +import io.jsonwebtoken.Claims import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.MalformedJwtException @@ -25,7 +26,7 @@ class JwtProvider( fun createAccessToken( opaqueId: String, - role: Role + extraClaims: Map = emptyMap() ): String { val now = Instant.now() val exp = Date.from(now.plus(jwtProperties.accessExp)) @@ -34,24 +35,6 @@ class JwtProvider( .subject(opaqueId) .issuedAt(Date.from(now)) .expiration(exp) - .claim("role", role.toString()) - .signWith(key, Jwts.SIG.HS256) - .compact() - } - - fun createAccessToken( - opaqueId: String, - role: Role, - extraClaims: Map - ): String { - val now = Instant.now() - val exp = Date.from(now.plus(jwtProperties.accessExp)) - - return Jwts.builder() - .subject(opaqueId) - .issuedAt(Date.from(now)) - .expiration(exp) - .claim("role", role.toString()) .apply { extraClaims.forEach { (key, value) -> claim(key, value) @@ -63,7 +46,7 @@ class JwtProvider( fun createRefreshToken( opaqueId: String, - jti: String = UUID.randomUUID().toString() + jti: String = UuidCreator.getTimeOrdered().toString() ): String { val now = Instant.now() val exp = Date.from(now.plus(jwtProperties.refreshExp)) @@ -88,20 +71,6 @@ class JwtProvider( .payload .subject - /** - * JWT 토큰에서 role을 추출합니다. - * @param token 대상 JWT 토큰 - * @return 사용자의 역할 (MEMBER, ADMIN 등) - */ - fun getRole(token: String): Role? { - val roleString = Jwts.parser().verifyWith(key) - .build().parseSignedClaims(token) - .payload - .get("role", String::class.java) - - return roleString?.let { Role.valueOf(it) } - } - /** * JWT 토큰에서 jti(ID)를 추출합니다. * @param token 대상 JWT 토큰 @@ -138,6 +107,41 @@ class JwtProvider( .payload .expiration.toInstant() + /** + * 토큰의 남은 유효 시간을 초 단위로 반환합니다. + * + * - 양수: 아직 유효하며 해당 초만큼 남음 + * - 음수(-1): 이미 만료된 토큰 (서명 자체는 유효) + * - 서명 오류, 위조 등 구조적으로 유효하지 않은 토큰은 [JwtException] 을 던집니다. + */ + fun getRemainingTimeSeconds(token: String): Long { + return try { + val expiration = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload.expiration + (expiration.time - System.currentTimeMillis()) / 1000 + } catch (e: ExpiredJwtException) { + -1L + } catch (t: Throwable) { + throw JwtException(mapToCode(t), t) + } + } + + /** + * 만료 여부와 관계없이 토큰의 클레임을 추출합니다. + * + * 만료된 토큰이라도 서명이 유효하다면 클레임을 반환합니다. + * 이는 사일런트 리프레시 시 만료된 AT에서 opaqueId/deviceId를 읽어야 할 때 사용합니다. + * 서명이 위조되거나 형식이 잘못된 경우에는 [JwtException] 을 던집니다. + */ + fun getClaimsEvenIfExpired(token: String): Claims { + return try { + Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload + } catch (e: ExpiredJwtException) { + e.claims + } catch (t: Throwable) { + throw JwtException(mapToCode(t), t) + } + } + /** * 유효성 검사(예외 던짐) – 표준 에러로 변환 * 컨트롤러/서비스에서 이 메서드를 사용하면 GlobalExceptionHandler가 잡아줍니다. diff --git a/src/main/kotlin/com/wq/auth/security/principal/PrincipalDetails.kt b/src/main/kotlin/com/wq/auth/security/principal/PrincipalDetails.kt index ac47715..31d4c5f 100644 --- a/src/main/kotlin/com/wq/auth/security/principal/PrincipalDetails.kt +++ b/src/main/kotlin/com/wq/auth/security/principal/PrincipalDetails.kt @@ -1,46 +1,23 @@ package com.wq.auth.security.principal -import com.wq.auth.api.domain.member.entity.Role import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails -/** - * Spring Security UserDetails 구현체 - * - * JWT 토큰에서 추출한 간소화된 사용자 정보를 Spring Security 컨텍스트에서 사용할 수 있도록 변환합니다. - * - * 간소화된 구조: - * - opaqueId: 사용자 식별을 위한 UUID - * - role: 사용자 역할 (Role enum: MEMBER, ADMIN) - */ data class PrincipalDetails( val opaqueId: String, // 사용자 UUID - val role: Role // 사용자 역할 ) : UserDetails { - /** - * 사용자의 권한 목록을 반환합니다. - * role을 ROLE_ prefix와 함께 GrantedAuthority로 변환합니다. - */ override fun getAuthorities(): Collection { - val roleString = role.name - val roleWithPrefix = if (roleString.startsWith("ROLE_")) roleString else "ROLE_$roleString" - return listOf(SimpleGrantedAuthority(roleWithPrefix)) + return listOf(SimpleGrantedAuthority("ROLE_USER")) } override fun getPassword(): String? = null - /** - * 사용자의 opaqueId 반환. - */ override fun getUsername(): String = opaqueId override fun isAccountNonExpired(): Boolean = true - /** - * 현재 구현에서는 계정 잠금 기능이 없으므로 항상 true를 반환합니다. - */ override fun isAccountNonLocked(): Boolean = true override fun isCredentialsNonExpired(): Boolean = true diff --git a/src/main/kotlin/com/wq/auth/shared/config/CookieFactory.kt b/src/main/kotlin/com/wq/auth/shared/config/CookieFactory.kt new file mode 100644 index 0000000..efdbbb3 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/shared/config/CookieFactory.kt @@ -0,0 +1,45 @@ +package com.wq.auth.shared.config + +import com.wq.auth.security.jwt.JwtProperties +import org.springframework.http.ResponseCookie +import org.springframework.stereotype.Component + +@Component +class CookieFactory( + private val jwtProperties: JwtProperties, + private val cookieProperties: CookieProperties, +) { + + fun createAccessTokenCookie(accessToken: String): ResponseCookie { + return baseCookieBuilder("accessToken", accessToken) + .maxAge(jwtProperties.accessExp.toSeconds()) + .build() + } + + fun createRefreshTokenCookie(refreshToken: String): ResponseCookie { + return baseCookieBuilder("refreshToken", refreshToken) + .maxAge(jwtProperties.refreshExp.toSeconds()) + .build() + } + + fun expireAccessTokenCookie(): ResponseCookie { + return baseCookieBuilder("accessToken", "") + .maxAge(0) + .build() + } + + fun expireRefreshTokenCookie(): ResponseCookie { + return baseCookieBuilder("refreshToken", "") + .maxAge(0) + .build() + } + + private fun baseCookieBuilder(name: String, value: String): ResponseCookie.ResponseCookieBuilder { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(cookieProperties.secure) + .domain(cookieProperties.domain) + .path("/") + .sameSite(cookieProperties.sameSite) + } +} diff --git a/src/main/kotlin/com/wq/auth/shared/config/CookieProperties.kt b/src/main/kotlin/com/wq/auth/shared/config/CookieProperties.kt new file mode 100644 index 0000000..6f3b7d6 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/shared/config/CookieProperties.kt @@ -0,0 +1,10 @@ +package com.wq.auth.shared.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "app.cookie") +data class CookieProperties( + val domain: String, + val secure: Boolean, + val sameSite: String, +) diff --git a/src/main/kotlin/com/wq/auth/shared/config/RestClientConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/RestClientConfig.kt new file mode 100644 index 0000000..2c4a9a3 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/shared/config/RestClientConfig.kt @@ -0,0 +1,47 @@ +package com.wq.auth.shared.config + +import org.apache.hc.client5.http.config.ConnectionConfig +import org.apache.hc.client5.http.config.RequestConfig +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder +import org.apache.hc.core5.util.Timeout +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory +import org.springframework.web.client.RestClient +import java.util.concurrent.TimeUnit + +@Configuration +class RestClientConfig { + + @Bean + fun restClient(): RestClient { + // 1. Connection 설정 (Connect Timeout 설정) + val connectionConfig = ConnectionConfig.custom() + .setConnectTimeout(Timeout.of(3, TimeUnit.SECONDS)) + .build() + + // 2. Connection Manager 설정 (설정된 ConnectionConfig 적용) + val connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(connectionConfig) + .build() + + // 3. Request 설정 (Response/Read Timeout 설정) + val requestConfig = RequestConfig.custom() + .setResponseTimeout(Timeout.of(3, TimeUnit.SECONDS)) + .build() + + // 4. HttpClient 생성 (Manager와 RequestConfig 결합) + val httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .build() + + // 5. Factory 및 RestClient 빌드 + val factory = HttpComponentsClientHttpRequestFactory(httpClient) + + return RestClient.builder() + .requestFactory(factory) + .build() + } +} \ No newline at end of file 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 f008364..5010091 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -12,12 +12,12 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -import org.springframework.web.cors.CorsConfigurationSource /** * Spring Security 설정 클래스 - * + * * JWT 기반 인증을 위한 보안 설정을 구성합니다. + * CORS는 API Gateway에서만 처리하며, auth-BE에서는 비활성화합니다. */ @Configuration @EnableWebSecurity @@ -25,14 +25,13 @@ import org.springframework.web.cors.CorsConfigurationSource class SecurityConfig( private val jwtAuthenticationFilter: JwtAuthenticationFilter, private val jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint, - private val jwtAccessDeniedHandler: JwtAccessDeniedHandler, - private val corsConfigurationSource: CorsConfigurationSource + private val jwtAccessDeniedHandler: JwtAccessDeniedHandler ) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { return http - .cors { it.configurationSource(corsConfigurationSource) } //Security 필터 체인에 CORS 적용 + .cors { it.disable() } // CORS는 Gateway에서만 처리 // CSRF 보호 비활성화 (JWT 사용 시 불필요) .csrf { it.disable() } @@ -54,15 +53,16 @@ class SecurityConfig( // 공개 엔드포인트 (인증 불필요) .requestMatchers( "/api/v1/auth/members/email-login", // 이메일 로그인 - "/api/v1/auth/email/request", // 이메일 일증 코드 요청 - "/api/v1/auth/members/refresh", //액세스 토큰 재발급 + "/api/v1/auth/email/request", // 이메일 인증 코드 요청 + "/api/v1/auth/email/verify", // 이메일 인증 코드 검증 (로그인 전 호출) + "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 "/api/public/**", // 공개 API "/api/v1/auth/*/login", // 소셜 로그인 API - "api/v1/auth/members/logout", //로그아웃 + "/api/v1/auth/members/logout", //로그아웃 "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI "/v3/api-docs/**", // OpenAPI 문서 - "/h2-console/**" // H2 콘솔 (개발용) + "/api/v1/auth/introspect" ).permitAll() // 나머지 모든 요청은 인증 필요 (세부 권한은 @PreAuthorize로 처리) diff --git a/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt index f8f49aa..f10aa6b 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt @@ -1,54 +1,17 @@ package com.wq.auth.shared.config import com.wq.auth.shared.rateLimiter.RateLimiterInterceptor -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer -import org.springframework.web.cors.CorsConfiguration -import org.springframework.web.cors.CorsConfigurationSource -import org.springframework.web.cors.UrlBasedCorsConfigurationSource import org.springframework.web.servlet.config.annotation.InterceptorRegistry @Configuration class WebConfig( - @Value("\${app.cors.allowed-origins:http://localhost:3000,http://localhost:5173, https://www.growgrammers.store,https://auth.easyappfactory.com,https://wedding.easyappfactory.com,https://admin-wedding.easyappfactory.com}") - private val allowedOrigins: String, private val rateLimiterInterceptor: RateLimiterInterceptor ) : WebMvcConfigurer { - override fun addCorsMappings(registry: CorsRegistry) { - val origins = allowedOrigins.split(",").map { it.trim() }.toTypedArray() - - registry.addMapping("/api/**") - .allowedOrigins(*origins) // 환경변수로 설정된 특정 origin들만 허용 - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - .exposedHeaders("Authorization") - .allowedHeaders("*") - .exposedHeaders("Authorization") - .allowCredentials(true) - .maxAge(3600) - } - override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(rateLimiterInterceptor) .addPathPatterns("/api/**") // API 경로에만 적용 } - - @Bean - fun corsConfigurationSource(): CorsConfigurationSource { - val config = CorsConfiguration() - val origins = allowedOrigins.split(",").map { it.trim() } - config.allowedOrigins = origins - config.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - config.allowedHeaders = listOf("*") - config.exposedHeaders = listOf("Authorization") - config.allowCredentials = true - - val source = UrlBasedCorsConfigurationSource() - source.registerCorsConfiguration("/**", config) - return source - } - } diff --git a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt index 4a2e28a..fd69783 100644 --- a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt +++ b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt @@ -1,9 +1,8 @@ package com.wq.auth.shared.rateLimiter -import com.fasterxml.jackson.databind.ObjectMapper import com.wq.auth.shared.error.CommonExceptionCode import com.wq.auth.shared.rateLimiter.annotation.RateLimit -import com.wq.auth.web.common.response.FailResponse +import com.wq.auth.web.common.response.CommonResponse import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -12,13 +11,14 @@ import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor +import tools.jackson.databind.json.JsonMapper import java.time.Duration import java.util.concurrent.TimeUnit @Component class RateLimiterInterceptor( private val rateLimiter: TokenBucketRateLimiter, - private val objectMapper: ObjectMapper + private val jsonMapper: JsonMapper ) : HandlerInterceptor { private val log = KotlinLogging.logger {} @@ -58,13 +58,12 @@ class RateLimiterInterceptor( val limitMessage = "최대 ${rateLimit.limit}회 / ${rateLimit.duration}${rateLimit.timeUnit.name.lowercase()} 제한을 초과했습니다." - val failResponse = FailResponse( - success = false, - message = limitMessage, - error = CommonExceptionCode.RATE_LIMIT_EXCEEDED.toString() + val failResponse = CommonResponse.fail( + CommonExceptionCode.RATE_LIMIT_EXCEEDED.toString(), + limitMessage ) - response.writer.write(objectMapper.writeValueAsString(failResponse)) + response.writer.write(jsonMapper.writeValueAsString(failResponse)) log.info{"Rate limit exceeded: userOpaqueId=$userOpaqueId, endpoint=${request.requestURI}"} false 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 128d918..9f94cac 100644 --- a/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wq/auth/web/common/GlobalExceptionHandler.kt @@ -3,8 +3,7 @@ package com.wq.auth.web.common import com.wq.auth.shared.error.ApiException import com.wq.auth.shared.error.CommonExceptionCode import com.wq.auth.security.jwt.error.JwtExceptionCode -import com.wq.auth.web.common.response.FailResponse -import com.wq.auth.web.common.response.Responses +import com.wq.auth.web.common.response.CommonResponse import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus @@ -21,28 +20,28 @@ class GlobalExceptionHandler { } @ExceptionHandler(ApiException::class) - fun handleApiException(e: ApiException): ResponseEntity { + fun handleApiException(e: ApiException): ResponseEntity> { log.error(e.extractExceptionLocation() + e.message) val status = HttpStatus.valueOf(e.code.status) - val body = Responses.fail(e.code) + val body = CommonResponse.fail(e.code) return ResponseEntity.status(status).body(body) } @ExceptionHandler(AuthorizationDeniedException::class) - fun handleAuthorizationDenied(e: AuthorizationDeniedException): ResponseEntity { + fun handleAuthorizationDenied(e: AuthorizationDeniedException): ResponseEntity> { log.warn("[권한 부족] ${e.message}") val status = HttpStatus.FORBIDDEN - val body = Responses.fail(JwtExceptionCode.FORBIDDEN) + val body = CommonResponse.fail(JwtExceptionCode.FORBIDDEN) return ResponseEntity.status(status).body(body) } // 예상 못 한 예외 처리 @ExceptionHandler(Exception::class) - fun handleUnexpected(e: Exception): ResponseEntity { + fun handleUnexpected(e: Exception): ResponseEntity> { log.error("[예상치 못한 예외 발생] $e") val status = HttpStatus.INTERNAL_SERVER_ERROR - val body = Responses.fail( + val body = CommonResponse.fail( CommonExceptionCode.INTERNAL_SERVER_ERROR ) return ResponseEntity.status(status).body(body) diff --git a/src/main/kotlin/com/wq/auth/web/common/response/BaseResponse.kt b/src/main/kotlin/com/wq/auth/web/common/response/BaseResponse.kt deleted file mode 100644 index 4cf8817..0000000 --- a/src/main/kotlin/com/wq/auth/web/common/response/BaseResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.wq.auth.web.common.response - -sealed interface BaseResponse { - val success: Boolean - val message: String -} \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/web/common/response/CommonResponse.kt b/src/main/kotlin/com/wq/auth/web/common/response/CommonResponse.kt new file mode 100644 index 0000000..bce19f3 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/web/common/response/CommonResponse.kt @@ -0,0 +1,49 @@ +package com.wq.auth.web.common.response + +import com.fasterxml.jackson.annotation.JsonInclude +import com.wq.auth.shared.error.ApiResponseCode +import io.swagger.v3.oas.annotations.media.Schema + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CommonResponse( + @get:Schema(description = "요청 성공 여부", example = "true") + val success: Boolean, + + @get:Schema(description = "응답 코드", example = "SUCCESS") + val code: String, + + @get:Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.") + val message: String, + + @get:Schema(description = "응답 데이터") + val data: T? = null +) { + companion object { + private const val SUCCESS_CODE = "SUCCESS" + private const val DEFAULT_SUCCESS_MESSAGE = "요청이 성공적으로 처리되었습니다." + + fun success( + message: String = DEFAULT_SUCCESS_MESSAGE, + data: T? = null + ): CommonResponse = CommonResponse( + success = true, + code = SUCCESS_CODE, + message = message, + data = data + ) + + fun fail(code: ApiResponseCode): CommonResponse = CommonResponse( + success = false, + code = code.toString(), + message = code.message, + data = null + ) + + fun fail(code: String, message: String): CommonResponse = CommonResponse( + success = false, + code = code, + message = message, + data = null + ) + } +} diff --git a/src/main/kotlin/com/wq/auth/web/common/response/FailResponse.kt b/src/main/kotlin/com/wq/auth/web/common/response/FailResponse.kt deleted file mode 100644 index 0b3637d..0000000 --- a/src/main/kotlin/com/wq/auth/web/common/response/FailResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.wq.auth.web.common.response - -import io.swagger.v3.oas.annotations.media.Schema - -data class FailResponse( - @Schema(description = "요청 성공 여부", example = "false") - override val success: Boolean = false, - - @Schema(description = "에러 메시지") - override val message: String, - - @Schema(description = "에러 코드") - val error: String // enum 에러코드.toString() 값을 사용. -) : BaseResponse diff --git a/src/main/kotlin/com/wq/auth/web/common/response/Responses.kt b/src/main/kotlin/com/wq/auth/web/common/response/Responses.kt deleted file mode 100644 index ccae495..0000000 --- a/src/main/kotlin/com/wq/auth/web/common/response/Responses.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.wq.auth.web.common.response - -import com.wq.auth.shared.error.ApiResponseCode - -object Responses { - fun success( - message: String = "요청에 성공적으로 응답하였습니다.", - data: T? = null - ): SuccessResponse = - SuccessResponse(true, message, data) - - fun fail( - code: ApiResponseCode - ): FailResponse = - FailResponse(false, code.message, code.toString()) -} \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/web/common/response/SuccessResponse.kt b/src/main/kotlin/com/wq/auth/web/common/response/SuccessResponse.kt deleted file mode 100644 index d327868..0000000 --- a/src/main/kotlin/com/wq/auth/web/common/response/SuccessResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.wq.auth.web.common.response - -import io.swagger.v3.oas.annotations.media.Schema - -data class SuccessResponse( - @Schema(description = "요청 성공 여부", example = "true") - override val success: Boolean = true, - - @Schema(description = "응답 메시지", example = "OK") - override val message: String = "OK", - - @Schema(description = "응답 데이터 예시") - val data: T? = null -) : BaseResponse \ No newline at end of file diff --git a/src/main/resources/application-alpha.yml b/src/main/resources/application-alpha.yml new file mode 100644 index 0000000..84bbdad --- /dev/null +++ b/src/main/resources/application-alpha.yml @@ -0,0 +1,25 @@ +# alpha — 알파 서버 (DDL update, 운영에 가까운 쿠키) +spring: + config: + activate: + on-profile: alpha + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: false + use_sql_comments: true + +logging: + level: + org.hibernate.SQL: DEBUG + org.springframework.orm.jpa: DEBUG + +app: + cookie: + domain: ${APP_COOKIE_DOMAIN} + secure: true + same-site: Strict diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-local.yml similarity index 54% rename from src/main/resources/application-dev.yml rename to src/main/resources/application-local.yml index f509524..aa02206 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-local.yml @@ -1,13 +1,8 @@ +# local — 로컬 개발 (DDL update, SQL 디버그, 쿠키 완화) spring: config: activate: - on-profile: dev - - datasource: - url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - driver-class-name: org.h2.Driver - username: sa - password: + on-profile: local jpa: hibernate: @@ -15,15 +10,11 @@ spring: show-sql: true properties: hibernate: - format_sql: true use_sql_comments: true - jdbc: - time_zone: UTC h2: console: - enabled: true - path: /h2-console + enabled: false logging: level: @@ -34,4 +25,4 @@ logging: app: cookie: secure: false - same-site: Lax \ No newline at end of file + same-site: Lax diff --git a/src/main/resources/application-oauth.yml b/src/main/resources/application-oauth.yml index 8d11427..0d8251f 100644 --- a/src/main/resources/application-oauth.yml +++ b/src/main/resources/application-oauth.yml @@ -9,17 +9,15 @@ app: user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo kakao: client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET:} + client-secret: ${KAKAO_CLIENT_SECRET} redirect-uri: ${KAKAO_REDIRECT_URI} auth-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me naver: client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET:} + client-secret: ${NAVER_CLIENT_SECRET} redirect-uri: ${NAVER_REDIRECT_URI} auth-uri: https://nid.naver.com/oauth2/authorize token-uri: https://nid.naver.com/oauth2/token user-info-uri: https://openapi.naver.com/v1/nid/me - cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,https://www.growgrammers.store,https://auth.easyappfactory.com,https://wedding.easyappfactory.com,https://admin-wedding.easyappfactory.com} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bff0060..625a362 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,30 +1,31 @@ +# prod — 운영 (RDS SSL, DDL validate, 쿠키 Strict) spring: config: activate: on-profile: prod datasource: - url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=true&verifyServerCertificate=false&requireSSL=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - driver-class-name: com.mysql.cj.jdbc.Driver - username: ${DB_USER} + url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=require + username: ${DB_USERNAME} password: ${DB_PASSWORD} jpa: hibernate: - ddl-auto: validate # 운영에서는 update X + ddl-auto: validate show-sql: false properties: hibernate: - format_sql: true - jdbc: - time_zone: UTC + format_sql: false + use_sql_comments: false logging: level: - org.hibernate.SQL: warn - org.springframework.orm.jpa: info + org.hibernate.SQL: ERROR + org.hibernate.type.descriptor.sql: ERROR + org.springframework.orm.jpa: WARN app: cookie: + domain: ${APP_COOKIE_DOMAIN} secure: true same-site: Strict diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cfe5f03..f01570d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,30 @@ +server: + port: 9000 + spring: application: name: auth-BE profiles: - active: prod, jwt, oauth # 공통으로 항상 적용되는 프로파일 + group: + local: local, jwt, oauth + alpha: alpha, jwt, oauth + prod: prod, jwt, oauth + active: ${SPRING_PROFILES_ACTIVE:local} + + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:authdb} + driver-class-name: org.postgresql.Driver + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + properties: + hibernate: + format_sql: false + jdbc: + time_zone: UTC mail: host: smtp.gmail.com @@ -29,3 +50,11 @@ springdoc: app: time: default-zone: ${APP_DEFAULT_ZONE:Asia/Seoul} + cookie: + domain: ${APP_COOKIE_DOMAIN:localhost} + +project: + logging: + enabled: true + env: ${SPRING_PROFILES_ACTIVE:local} + internal-secret: ${INTERNAL_LOGGING_SECRET:default-secret} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..33fd440 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + UTC + + + + + + + + + + { + "trace_id": "%mdc{trace_id}" + } + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt index 15065ad..ff7b814 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberServiceTest.kt @@ -50,6 +50,7 @@ class MemberServiceTest : DescribeSpec({ val result = memberService.getUserInfo(opaqueId) // then + result.userId shouldBe opaqueId result.nickname shouldBe nickname result.email shouldBe email