From 515817ea35c06c38b8d42b5dd0061cef227757a9 Mon Sep 17 00:00:00 2001 From: TwoMuchSilver Date: Tue, 3 Feb 2026 16:49:29 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=EC=84=9C=EB=B8=8C=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=86=B5=EC=9D=BC=EC=9C=84=ED=95=B4=20cor?= =?UTF-8?q?s=20=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/wq/auth/api/controller/auth/AuthController.kt | 3 ++- .../com/wq/auth/api/controller/auth/SocialLoginController.kt | 3 ++- src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt | 2 +- src/main/resources/application-oauth.yml | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index d26c42b..d301b4f 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 @@ -93,7 +93,8 @@ class AuthController( .secure(cookieSecure) .path("/") .maxAge(jwtProperties.refreshExp.toSeconds()) - .sameSite(cookieSameSite) + .domain(".easyappfactory.com") // 모든 서브도메인 포함 + .sameSite("Lax") //SSO 리다이렉트 시 쿠키 전송을 위해 Lax 권장 .build() response.addHeader("Set-Cookie", refreshCookie.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 3f3f09f..ab15213 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 @@ -585,7 +585,8 @@ class SocialLoginController( .secure(cookieSecure) // 환경별 설정 (개발: false, 프로덕션: true) .path("/") // 모든 경로에서 쿠키 사용 가능 .maxAge(Duration.ofDays(14)) // 14일 만료 - .sameSite(cookieSameSite) // CSRF 공격 방지 (Strict/Lax/None) + .domain(".easyappfactory.com") // 모든 서브도메인 포함 + .sameSite("Lax") //SSO 리다이렉트 시 쿠키 전송을 위해 Lax 권장 // CSRF 공격 방지 (Strict/Lax/None) .build() response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()) 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 9023418..f8f49aa 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt @@ -13,7 +13,7 @@ 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}") + @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 { diff --git a/src/main/resources/application-oauth.yml b/src/main/resources/application-oauth.yml index a9a2123..8d11427 100644 --- a/src/main/resources/application-oauth.yml +++ b/src/main/resources/application-oauth.yml @@ -22,4 +22,4 @@ app: 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} + 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} From e44efdf7028e764ca8c1e9b0720cefc84cacbecf Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Feb 2026 01:00:48 +0900 Subject: [PATCH 02/18] =?UTF-8?q?[Feature]=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=9B=A8=EC=9D=B4=20=EC=84=9C=EB=B2=84=20=EA=B1=B0=EC=B9=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20docker?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=B0=ED=8F=AC=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 추가 UUID 생성 라이브러리 의존성 * fix: 사용자 역할 관련 로직 제거 및 JwtProvider 리팩토링 - `Role` 사용 및 관련 로직 삭제 - `JwtProvider` 메서드 간소화 및 `UuidCreator`로 jti 생성 로직 변경 - `PrincipalDetails` 구조 단순화 (역할 제거) - `AdminApi` 어노테이션 삭제 * fix: `Role` enum 및 관련 로직 제거 - `Role` 삭제 및 관련 애트리뷰트, 메서드 제거 - `UUID.randomUUID`를 `UuidCreator.getTimeOrdered`로 대체 - `JwtProvider` 호출 시 역할 매개변수 제거 * fix: 소셜 로그인 역할 제거 * feat: Gradle Wrapper 설정 추가 * feat: CommonResponse로 통합 및 불필요 클래스 제거 * feat: Dockerfile 추가 및 애플리케이션 컨테이너화 * feat: EC2 Docker 배포 문서 추가 및 프로젝트 이름 수정 * feat: EC2 배포 GitHub Actions 워크플로 개선 * feat: 프로젝트 이름 변경 및 환경 변수 수정 * feat: GitHub Actions 환경 변수 파일 처리 방식 개선 * feat: EC2 환경 변수 경로 및 배포 스크립트 개선 * feat: CORS 설정 제거 및 Gateway 위임 처리 * feat: redirectUri 지원 및 소셜 로그인 처리 개선 * feat: 배포 워크플로 브랜치 업데이트 --- .dockerignore | 25 ++ .env.sample | 23 ++ .github/workflows/deploy.yml | 105 ++++---- Dockerfile | 24 ++ README.md | 37 ++- build.gradle.kts | 5 +- docker-compose.yml | 28 ++ ...1_\352\260\200\354\235\264\353\223\234.md" | 62 +++++ ...4_\352\260\200\354\235\264\353\223\234.md" | 218 +++++++++++++++ docs/ENV.md | 105 ++++++++ ..._redirect_URI_\354\240\204\353\236\265.md" | 171 ++++++++++++ ...24\354\262\255\354\212\244\355\216\231.md" | 83 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle.kts | 2 +- .../api/controller/TestSecurityController.kt | 35 +-- .../api/controller/auth/AuthController.kt | 97 ++++--- .../controller/auth/SocialLoginController.kt | 91 +++---- .../request/GoogleSocialLoginRequestDto.kt | 7 +- .../auth/request/KakaoSocialLinkRequestDto.kt | 5 +- .../request/KakaoSocialLoginRequestDto.kt | 7 +- .../auth/request/NaverSocialLinkRequestDto.kt | 3 + .../request/NaverSocialLoginRequestDto.kt | 5 +- .../auth/request/SocialLinkRequestDto.kt | 4 + .../auth/request/SocialLoginRequestDto.kt | 4 +- .../controller/email/AuthEmailController.kt | 32 +-- .../api/controller/member/MemberController.kt | 43 +-- .../member/response/UserInfoResponseDto.kt | 2 + .../wq/auth/api/domain/auth/AuthService.kt | 4 - .../api/domain/auth/GoogleLinkProvider.kt | 1 + .../api/domain/auth/GoogleLoginProvider.kt | 5 +- .../auth/api/domain/auth/KakaoLinkProvider.kt | 1 + .../api/domain/auth/KakaoLoginProvider.kt | 5 +- .../auth/api/domain/auth/NaverLinkProvider.kt | 3 +- .../api/domain/auth/NaverLoginProvider.kt | 3 +- .../api/domain/auth/SocialLoginService.kt | 2 +- .../auth/request/OAuthAuthCodeRequest.kt | 6 + .../domain/auth/request/SocialLinkRequest.kt | 3 + .../domain/auth/request/SocialLoginRequest.kt | 2 + .../auth/api/domain/member/MemberService.kt | 13 + .../api/domain/member/entity/MemberEntity.kt | 23 +- .../wq/auth/api/domain/member/entity/Role.kt | 6 - .../oauth/error/SocialLoginExceptionCode.kt | 1 + .../api/external/oauth/GoogleOAuthClient.kt | 27 +- .../api/external/oauth/KakaoOAuthClient.kt | 27 +- .../api/external/oauth/NaverOAuthClient.kt | 15 +- .../oauth/OAuthRedirectUriResolver.kt | 15 ++ .../auth/security/JwtAccessDeniedHandler.kt | 4 +- .../security/JwtAuthenticationEntryPoint.kt | 4 +- .../auth/security/JwtAuthenticationFilter.kt | 29 +- .../wq/auth/security/annotation/AdminApi.kt | 24 -- .../com/wq/auth/security/jwt/JwtProvider.kt | 38 +-- .../security/principal/PrincipalDetails.kt | 25 +- .../wq/auth/shared/config/SecurityConfig.kt | 16 +- .../com/wq/auth/shared/config/WebConfig.kt | 37 --- .../rateLimiter/RateLimiterInterceptor.kt | 9 +- .../auth/web/common/GlobalExceptionHandler.kt | 15 +- .../auth/web/common/response/BaseResponse.kt | 6 - .../web/common/response/CommonResponse.kt | 49 ++++ .../auth/web/common/response/FailResponse.kt | 14 - .../wq/auth/web/common/response/Responses.kt | 16 -- .../web/common/response/SuccessResponse.kt | 14 - src/main/resources/application-oauth.yml | 6 +- src/main/resources/application-prod.yml | 2 +- src/main/resources/application.yml | 3 + .../com/wq/auth/unit/MemberServiceTest.kt | 1 + 68 files changed, 1564 insertions(+), 485 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.sample create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 "docs/API_GATEWAY_\354\227\260\353\217\231_\352\260\200\354\235\264\353\223\234.md" create mode 100644 "docs/CORS_\353\254\270\354\240\234_\352\260\200\354\235\264\353\223\234.md" create mode 100644 docs/ENV.md create mode 100644 "docs/OAuth_redirect_URI_\354\240\204\353\236\265.md" create mode 100644 "docs/\354\206\214\354\205\234\353\241\234\352\267\270\354\235\270_API_\354\232\224\354\262\255\354\212\244\355\216\231.md" create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat delete mode 100644 src/main/kotlin/com/wq/auth/api/domain/member/entity/Role.kt create mode 100644 src/main/kotlin/com/wq/auth/api/external/oauth/OAuthRedirectUriResolver.kt delete mode 100644 src/main/kotlin/com/wq/auth/security/annotation/AdminApi.kt delete mode 100644 src/main/kotlin/com/wq/auth/web/common/response/BaseResponse.kt create mode 100644 src/main/kotlin/com/wq/auth/web/common/response/CommonResponse.kt delete mode 100644 src/main/kotlin/com/wq/auth/web/common/response/FailResponse.kt delete mode 100644 src/main/kotlin/com/wq/auth/web/common/response/Responses.kt delete mode 100644 src/main/kotlin/com/wq/auth/web/common/response/SuccessResponse.kt 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..0b170da --- /dev/null +++ b/.env.sample @@ -0,0 +1,23 @@ +SPRING_PROFILES_ACTIVE=prod,jwt,oauth +DB_HOST= +DB_PORT=3306 +DB_NAME= +DB_USERNAME= +DB_PASSWORD= +JWT_SECRET= +JWT_ACCESS_TOKEN_EXPIRATION=1h +JWT_REFRESH_TOKEN_EXPIRATION=14d +MAIL_USERNAME= +MAIL_PASSWORD= +SWAGGER_PATH=/swagger-ui.html +APP_DEFAULT_ZONE=Asia/Seoul +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://auth.easyappfactory.com +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..a3212f3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,65 +2,66 @@ name: Deploy to EC2 on: push: branches: - - main # main에 push될 때만 실행 + - main + - dev + - feat/internal-server-api + jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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 + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:latest + ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ github.sha }} + deploy: + needs: build-and-push runs-on: ubuntu-latest steps: - - name: Checkout source - uses: actions/checkout@v3 - name: Setup SSH run: | mkdir -p ~/.ssh echo "${{ secrets.EC2_KEY }}" > ~/.ssh/ec2_key.pem chmod 600 ~/.ssh/ec2_key.pem - - name: Deploy to EC2 + + - name: Prepare env file + env: + ENV_FILE_CONTENT: ${{ secrets.AUTH_BE_ENV_FILE }} + run: | + printf '%s\n' "$ENV_FILE_CONTENT" > auth-be.env + + - name: Create env dir on EC2 + run: | + ssh -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} 'mkdir -p ~/env' + + - name: Copy env file to EC2 + run: | + scp -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem auth-be.env ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:~/env/auth-be.env + + - name: Deploy auth-be only (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 }}" + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + cd ~ + docker rm -f auth-be || true + docker-compose pull auth-be + docker-compose up -d --no-deps auth-be + docker logout + ' \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..476a796 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder + +WORKDIR /workspace + +# Gradle wrapper and source +COPY gradle gradle +COPY gradlew build.gradle.kts settings.gradle.kts ./ +COPY src src + +RUN ./gradlew bootJar --no-daemon -x test + +FROM eclipse-temurin:21-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..d0df256 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,7 @@ repositories { extra["spring-security.version"] = "6.5.3" -dependencies { + 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") @@ -74,6 +74,9 @@ dependencies { //rate limiter - token bucket implementation("com.bucket4j:bucket4j-core:8.7.0") + + // UUID v7 생성 (time-ordered UUID) + implementation("com.github.f4b6a3:uuid-creator:6.0.0") } kotlin { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..535b772 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + auth-be: + image: ${DOCKERHUB_USERNAME}/auth-server:latest + container_name: auth-be + restart: unless-stopped + ports: + - "9000:9000" + env_file: + - env/auth-be.env + networks: + - app-network + + api-gateway: + image: ${DOCKERHUB_USERNAME}/api-gateway:latest + container_name: api-gateway + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - env/api-gateway.env + depends_on: + - auth-be + networks: + - app-network + +networks: + app-network: + driver: bridge 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 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + 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..d5dfc8e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "demo" +rootProject.name = "auth-be" 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..f7ce6e3 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,15 +8,13 @@ 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.annotation.AuthenticatedApi import com.wq.auth.security.annotation.PublicApi import com.wq.auth.security.jwt.JwtProperties import com.wq.auth.security.principal.PrincipalDetails 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.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema @@ -37,6 +35,7 @@ import java.util.concurrent.TimeUnit class AuthController( private val authService: AuthService, private val emailService: AuthEmailService, + private val memberService: MemberService, private val jwtProperties: JwtProperties, @Value("\${app.cookie.secure:false}") @@ -56,29 +55,29 @@ 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, @@ -98,14 +97,14 @@ class AuthController( .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) } @@ -142,22 +141,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 +166,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 +181,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 @@ -221,7 +220,7 @@ class AuthController( } //앱 - return Responses.success(message = "로그아웃에 성공했습니다.", data = null) + return CommonResponse.success(message = "로그아웃에 성공했습니다.", data = null) } @Operation( @@ -243,7 +242,7 @@ class AuthController( description = "잘못된 요청, 인증 토큰이 없음, Authorization 헤더는 'Bearer ' 형식이어야 합니다.", content = [Content( mediaType = "application/json", - schema = Schema(implementation = BaseResponse::class) + schema = Schema(implementation = CommonResponse::class) )] ), ApiResponse( @@ -251,7 +250,7 @@ class AuthController( description = "유효하지 않은 토큰, 만료된 토큰, 유효하지 않은 서명, 지원되지 않는 토큰", content = [Content( mediaType = "application/json", - schema = Schema(implementation = BaseResponse::class) + schema = Schema(implementation = CommonResponse::class) )] ), ApiResponse( @@ -259,21 +258,21 @@ 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, @RequestHeader("X-Client-Type") clientType: String, response: HttpServletResponse, @RequestBody req: RefreshAccessTokenRequestDto?, - ): SuccessResponse { - + ): CommonResponse { + val currentRefreshToken : String? if(clientType == "web") { currentRefreshToken = refreshToken @@ -295,16 +294,50 @@ class AuthController( .sameSite(cookieSameSite) .build() response.addHeader("Set-Cookie", refreshCookie.toString()) - - return Responses.success(message = "AccessToken 재발급에 성공했습니다.", data = null) + + 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 = "토큰 인트로스펙트", + description = """ + API Gateway 연동용. Authorization 헤더의 JWT를 검증하고, 성공 시 응답 헤더에 다음을 담아 반환합니다. + - X-User-Id: 사용자 UUID (opaqueId) + - X-Auth-Provider: 대표 연동 제공자 (EMAIL, GOOGLE, KAKAO, NAVER 중 하나, 연동된 경우) + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "인트로스펙트 성공 (X-User-Id, X-Auth-Provider 헤더 포함)", + 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) + @AuthenticatedApi + @GetMapping("/api/v1/auth/introspect") + fun introspect( + response: HttpServletResponse, + @AuthenticationPrincipal principalDetails: PrincipalDetails + ) { + response.setHeader("X-User-Id", principalDetails.opaqueId) + memberService.getPrimaryProvider(principalDetails.opaqueId)?.let { provider -> + response.setHeader("X-Auth-Provider", provider.name) + } + } } 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..62b7841 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 @@ -8,9 +8,7 @@ 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.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 @@ -84,22 +82,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - 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 = "소셜 제공자 API 호출 실패 또는 서버 내부 오류", - content = [Content(schema = Schema(implementation = FailResponse::class))] + content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @@ -108,7 +106,7 @@ class SocialLoginController( fun socialLogin( @Valid @RequestBody request: SocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) // RefreshToken을 HttpOnly 쿠키에 설정 @@ -117,7 +115,7 @@ class SocialLoginController( // Authorization 헤더에 AccessToken 설정 response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") - return Responses.success("소셜 로그인이 완료되었습니다") + return CommonResponse.success("소셜 로그인이 완료되었습니다") } /** @@ -159,22 +157,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "Google 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - 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))] ) ] ) @@ -184,7 +182,7 @@ class SocialLoginController( fun googleLogin( @Valid @RequestBody request: GoogleSocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) @@ -194,7 +192,7 @@ class SocialLoginController( // Authorization 헤더에 AccessToken 설정 response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") - return Responses.success("Google 로그인이 완료되었습니다") + return CommonResponse.success("Google 로그인이 완료되었습니다") } /** @@ -234,22 +232,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "카카오 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - 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))] ) ] ) @@ -259,7 +257,7 @@ class SocialLoginController( fun kakaoLogin( @Valid @RequestBody request: KakaoSocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) @@ -269,7 +267,7 @@ class SocialLoginController( // Authorization 헤더에 AccessToken 설정 response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") - return Responses.success("카카오 로그인이 완료되었습니다") + return CommonResponse.success("카카오 로그인이 완료되었습니다") } /** @@ -311,22 +309,22 @@ class SocialLoginController( ApiResponse( responseCode = "200", description = "Naver 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", - 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 = "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,7 +334,7 @@ class SocialLoginController( fun naverLogin( @Valid @RequestBody request: NaverSocialLoginRequestDto, response: HttpServletResponse - ): SuccessResponse { + ): CommonResponse { val loginResult = socialLoginService.processSocialLogin(request.toDomain()) // RefreshToken을 HttpOnly 쿠키에 설정 @@ -345,7 +343,7 @@ class SocialLoginController( // Authorization 헤더에 AccessToken 설정 response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") - return Responses.success("Naver 로그인이 완료되었습니다") + return CommonResponse.success("Naver 로그인이 완료되었습니다") } /** @@ -381,22 +379,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 +404,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 계정 연동이 완료되었습니다") } /** @@ -455,22 +454,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 +479,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("카카오 계정 연동이 완료되었습니다") } /** @@ -529,22 +529,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,17 +554,18 @@ 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("네이버 계정 연동이 완료되었습니다") } /** 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..315464f 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,7 +5,6 @@ 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 @@ -51,7 +50,6 @@ class AuthService( val accessToken = jwtProvider.createAccessToken( opaqueId = existingUser.opaqueId, - role = Role.MEMBER, extraClaims = mapOf("deviceId" to deviceId) ) @@ -100,7 +98,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) @@ -195,7 +192,6 @@ class AuthService( // AccessToken, RefreshToken 재발급 val newAccessToken = jwtProvider.createAccessToken( opaqueId = opaqueId, - role = Role.MEMBER, extraClaims = mapOf("deviceId" to deviceId) ) val newRefreshToken = jwtProvider.createRefreshToken(opaqueId = opaqueId) 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/SocialLoginService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginService.kt index 03b6669..1e70a1d 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 @@ -58,7 +58,7 @@ class SocialLoginService( member.lastLoginAt = LocalDateTime.now() memberRepository.save(member) - val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) + val accessToken = jwtProvider.createAccessToken(member.opaqueId) val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) val jti = jwtProvider.getJti(refreshToken) 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/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..21d3e9d 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 @@ -26,34 +26,41 @@ import org.springframework.web.client.HttpServerErrorException @Component class GoogleOAuthClient( private val googleOAuthProperties: GoogleOAuthProperties, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val redirectUriResolver: OAuthRedirectUriResolver, ) : OAuthClient { private val log = KotlinLogging.logger {} private val restTemplate = RestTemplate() /** * 인가 코드를 사용하여 액세스 토큰을 획득합니다. - * + * * @param authorizationCode Google로부터 받은 인가 코드 * @param codeVerifier PKCE 검증용 코드 검증자 + * @param requestRedirectUri 요청에 담긴 redirect_uri (있으면 허용 목록 검증 후 사용) * @return Google 액세스 토큰 * @throws SocialLoginException 토큰 획득 실패 시 */ - fun getAccessToken(authorizationCode: String, codeVerifier: String): String { + fun getAccessToken( + authorizationCode: String, + codeVerifier: String, + requestRedirectUri: String? = null, + ): String { + val redirectUri = redirectUriResolver.resolve(requestRedirectUri, googleOAuthProperties.redirectUri) log.info { "Google 액세스 토큰 요청 시작" } - log.info { "redirectUri: ${googleOAuthProperties.redirectUri}" } - + log.info { "redirectUri: $redirectUri" } + val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED } - + 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) @@ -150,8 +157,8 @@ class GoogleOAuthClient( * @param codeVerifier PKCE 검증용 코드 검증자 * @return 도메인 사용자 정보 */ - override fun getUserFromAuthCode(req : OAuthAuthCodeRequest): OAuthUser { - val accessToken = getAccessToken(req.authCode, req.codeVerifier) + override fun getUserFromAuthCode(req: OAuthAuthCodeRequest): OAuthUser { + val accessToken = getAccessToken(req.authCode, req.codeVerifier, req.redirectUri) val googleUserInfo = getUserInfo(accessToken) return OAuthUser( @@ -172,7 +179,7 @@ class GoogleOAuthClient( * @return Google 사용자 정보 */ fun getUserInfoFromAuthCode(authorizationCode: String, codeVerifier: String): GoogleUserInfoResponse { - val accessToken = getAccessToken(authorizationCode, codeVerifier) + val accessToken = getAccessToken(authorizationCode, codeVerifier, null) 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..7ed43f0 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 @@ -26,34 +26,41 @@ import org.springframework.web.client.HttpServerErrorException @Component class KakaoOAuthClient( private val kakaoOAuthProperties: KakaoOAuthProperties, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val redirectUriResolver: OAuthRedirectUriResolver, ) : OAuthClient { private val log = KotlinLogging.logger {} private val restTemplate = RestTemplate() /** * 인가 코드를 사용하여 액세스 토큰을 획득합니다. - * + * * @param authorizationCode 카카오로부터 받은 인가 코드 * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) + * @param requestRedirectUri 요청에 담긴 redirect_uri (있으면 허용 목록 검증 후 사용) * @return 카카오 액세스 토큰 * @throws SocialLoginException 토큰 획득 실패 시 */ - fun getAccessToken(authorizationCode: String, codeVerifier: String): String { + fun getAccessToken( + authorizationCode: String, + codeVerifier: String, + requestRedirectUri: String? = null, + ): String { + val redirectUri = redirectUriResolver.resolve(requestRedirectUri, kakaoOAuthProperties.redirectUri) log.info { "카카오 액세스 토큰 요청 시작" } - log.info { "redirectUri: ${kakaoOAuthProperties.redirectUri}" } - + log.info { "redirectUri: $redirectUri" } + val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED } - + 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()) { @@ -156,8 +163,8 @@ class KakaoOAuthClient( * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) * @return 도메인 사용자 정보 */ - override fun getUserFromAuthCode(req : OAuthAuthCodeRequest): OAuthUser { - val accessToken = getAccessToken(req.authCode, req.codeVerifier) + override fun getUserFromAuthCode(req: OAuthAuthCodeRequest): OAuthUser { + val accessToken = getAccessToken(req.authCode, req.codeVerifier, req.redirectUri) val kakaoUserInfo = getUserInfo(accessToken) return OAuthUser( @@ -178,7 +185,7 @@ class KakaoOAuthClient( * @return 카카오 사용자 정보 */ fun getUserInfoFromAuthCode(authorizationCode: String, codeVerifier: String): KakaoUserInfoResponse { - val accessToken = getAccessToken(authorizationCode, codeVerifier) + val accessToken = getAccessToken(authorizationCode, codeVerifier, null) 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..47f3447 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 @@ -26,7 +26,8 @@ import org.springframework.web.client.HttpServerErrorException @Component class NaverOAuthClient( private val naverOAuthProperties: NaverOAuthProperties, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val redirectUriResolver: OAuthRedirectUriResolver, ) : 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,9 +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}" } + log.info { "redirectUri: $redirectUri" } val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED @@ -59,7 +63,7 @@ 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) @@ -159,8 +163,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 +189,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/security/JwtAccessDeniedHandler.kt b/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt index bd702d1..b61b045 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt @@ -2,7 +2,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 @@ -37,7 +37,7 @@ class JwtAccessDeniedHandler( response.characterEncoding = StandardCharsets.UTF_8.name() // 표준 API 응답 형식으로 에러 응답 생성 - val errorResponse = Responses.fail(JwtExceptionCode.FORBIDDEN) + val errorResponse = CommonResponse.fail(JwtExceptionCode.FORBIDDEN) val jsonResponse = objectMapper.writeValueAsString(errorResponse) response.writer.write(jsonResponse) diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt index 7961060..9fdbab3 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt @@ -1,7 +1,7 @@ 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 @@ -37,7 +37,7 @@ class JwtAuthenticationEntryPoint( response.characterEncoding = StandardCharsets.UTF_8.name() // 표준 API 응답 형식으로 에러 응답 생성 - val errorResponse = Responses.fail(JwtExceptionCode.TOKEN_MISSING) + val errorResponse = CommonResponse.fail(JwtExceptionCode.TOKEN_MISSING) val jsonResponse = objectMapper.writeValueAsString(errorResponse) response.writer.write(jsonResponse) diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt index a4850d4..a65f07c 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 @@ -43,14 +36,14 @@ class JwtAuthenticationFilter( try { // Authorization 헤더에서 JWT 토큰 추출 val token = extractTokenFromRequest(request) - + if (token != null) { // JWT 토큰 유효성 검증 jwtProvider.validateOrThrow(token) - + // 토큰에서 사용자 정보 추출 val principalDetails = extractPrincipalDetails(token) - + // Spring Security 인증 객체 생성 및 설정 // todo : TokenService로 분리 필요. val authentication = UsernamePasswordAuthenticationToken( @@ -61,27 +54,24 @@ class JwtAuthenticationFilter( 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 { @@ -91,14 +81,13 @@ class JwtAuthenticationFilter( /** * 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) + + return PrincipalDetails(opaqueId) } } 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..551b69c 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,8 @@ 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.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.MalformedJwtException @@ -25,7 +25,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 +34,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 +45,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 +70,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 토큰 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/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index f008364..9b36693 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,11 +53,12 @@ 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 문서 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..ac22fcb 100644 --- a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt +++ b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt @@ -3,7 +3,7 @@ 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 @@ -58,10 +58,9 @@ 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)) 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-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..fea8e5c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -6,7 +6,7 @@ spring: 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} + username: ${DB_USERNAME} password: ${DB_PASSWORD} jpa: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cfe5f03..670e32c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,6 +26,9 @@ springdoc: swagger-ui: path: ${SWAGGER_PATH} +server: + port: 9000 + app: time: default-zone: ${APP_DEFAULT_ZONE:Asia/Seoul} 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 From 2ae80c13be2242dac77e3a77109d9e58acfa3225 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 26 Feb 2026 16:05:03 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EC=A1=B4=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `JwtAuthenticationFilter` 수정: HttpOnly 쿠키에서 accessToken 우선 추출하도록 개선 - CookieFactory 추가: AccessToken/RefreshToken 생성을 안전하게 관리 - CookieProperties 추가: 쿠키 설정값 분리 및 관리 용이성 향상 - 기존 ResponseCookie 생성 로직을 CookieFactory로 위임 - SocialLoginController 및 AuthController에서 토큰 처리 방식 리팩토링 - `application-dev.yml` 및 `application-prod.yml`에 쿠키 설정 값 추가 --- .../api/controller/auth/AuthController.kt | 51 ++++---------- .../controller/auth/SocialLoginController.kt | 66 ++++++------------- .../auth/security/JwtAuthenticationFilter.kt | 12 +++- .../wq/auth/shared/config/CookieFactory.kt | 45 +++++++++++++ .../wq/auth/shared/config/CookieProperties.kt | 10 +++ src/main/resources/application-dev.yml | 1 + src/main/resources/application-prod.yml | 1 + 7 files changed, 101 insertions(+), 85 deletions(-) create mode 100644 src/main/kotlin/com/wq/auth/shared/config/CookieFactory.kt create mode 100644 src/main/kotlin/com/wq/auth/shared/config/CookieProperties.kt 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 f7ce6e3..4e36b3a 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 @@ -11,8 +11,8 @@ import com.wq.auth.api.domain.email.AuthEmailService import com.wq.auth.api.domain.member.MemberService 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.principal.PrincipalDetails +import com.wq.auth.shared.config.CookieFactory import com.wq.auth.shared.rateLimiter.annotation.RateLimit import com.wq.auth.web.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -23,9 +23,7 @@ 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.util.concurrent.TimeUnit @@ -36,13 +34,7 @@ class AuthController( private val authService: AuthService, private val emailService: AuthEmailService, private val memberService: MemberService, - 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 cookieFactory: CookieFactory, ) { @Operation( @@ -87,15 +79,10 @@ class AuthController( response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken}") 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()) + val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) + val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) return CommonResponse.success(message = "로그인에 성공했습니다.", data = null) } @@ -209,15 +196,10 @@ 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 CommonResponse.success(message = "로그아웃에 성공했습니다.", data = null) @@ -285,15 +267,10 @@ class AuthController( response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken}") if (clientType == "web") { - - val refreshCookie = ResponseCookie.from("refreshToken", newRefreshToken) - .httpOnly(true) - .secure(cookieSecure) - .path("/") - .maxAge(jwtProperties.refreshExp.toSeconds()) - .sameSite(cookieSameSite) - .build() - response.addHeader("Set-Cookie", refreshCookie.toString()) + val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) + val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = null) } diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt index 62b7841..94b6fb0 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,6 +7,7 @@ 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.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -17,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 /** @@ -40,12 +38,7 @@ 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, ) { /** @@ -109,11 +102,7 @@ class SocialLoginController( ): 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 CommonResponse.success("소셜 로그인이 완료되었습니다") } @@ -186,11 +175,7 @@ class SocialLoginController( 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 CommonResponse.success("Google 로그인이 완료되었습니다") } @@ -261,11 +246,7 @@ class SocialLoginController( 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 CommonResponse.success("카카오 로그인이 완료되었습니다") } @@ -337,11 +318,7 @@ class SocialLoginController( ): 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 CommonResponse.success("Naver 로그인이 완료되었습니다") } @@ -569,27 +546,22 @@ class SocialLoginController( } /** - * RefreshToken을 HttpOnly 쿠키로 설정합니다. - * - * Spring Boot 3.x의 ResponseCookie를 사용하여 현대적이고 안전한 쿠키를 생성합니다. - * - HttpOnly: JavaScript 접근 불가 (XSS 방지) - * - Secure: HTTPS에서만 전송 (프로덕션 환경) - * - SameSite: CSRF 공격 방지 - * - MaxAge: 14일 (리프레시 토큰 만료 시간과 동일) + * AccessToken/RefreshToken을 HttpOnly 쿠키 및 Authorization 헤더로 설정합니다. * * @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()) + response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") } } diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt index a65f07c..4e06139 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt @@ -70,9 +70,19 @@ class JwtAuthenticationFilter( * @return 추출된 JWT 토큰 문자열, 없으면 null */ private fun extractTokenFromRequest(request: HttpServletRequest): String? { + // 1. accessToken HttpOnly 쿠키 우선 사용 (웹 클라이언트) + val cookieToken = request.cookies + ?.firstOrNull { it.name == "accessToken" } + ?.value + + if (!cookieToken.isNullOrBlank()) { + return cookieToken + } + + // 2. Authorization 헤더(Bearer) fallback (앱 및 과도기 웹 클라이언트) val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) - return if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) { + return if (!authorizationHeader.isNullOrBlank() && authorizationHeader.startsWith(BEARER_PREFIX)) { authorizationHeader.substring(BEARER_PREFIX.length) } else { null 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/resources/application-dev.yml b/src/main/resources/application-dev.yml index f509524..65abd70 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -33,5 +33,6 @@ logging: app: cookie: + domain: ${APP_COOKIE_DOMAIN:localhost} secure: false same-site: Lax \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fea8e5c..a43450c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -26,5 +26,6 @@ logging: app: cookie: + domain: ${APP_COOKIE_DOMAIN} secure: true same-site: Strict From e388a92610ab53f2dd41ee8a1f08d81393985099 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 5 Mar 2026 17:38:09 +0900 Subject: [PATCH 04/18] =?UTF-8?q?[Fix]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: JWT 처리 방식 개선 및 인증 로직 확장 - `JwtAuthenticationFilter` 리팩토링: 토큰 추출 방식 통합 및 중복 제거 - `/api/v1/auth/introspect` 엔드포인트 인증 필요 없이 접근 가능하도록 변경 - 만료된 토큰에서도 클레임을 안전히 조회할 수 있도록 내부 로직 확장 - 토큰 관련 예외 처리 및 유효성 검사 코드 간소화 * feat: 토큰 introspect 엔드포인트 확장 및 사일런트 리프레시 지원 - Access Token 만료 시 Refresh Token 기반 자동 재발급 - 새 토큰을 Set-Cookie 헤더로 반환 - 요청 토큰 탐색 우선순위 정의 (HttpOnly 쿠키 > Authorization 헤더) - AT·RT 쿠키 만료를 위한 `clearAuthCookies` 유틸리티 메서드 추가 - `jwtProvider` 사용하여 남은 토큰 유효 시간 및 클레임 처리 로직 개선 - 요청 인증 중 예외 발생 시 쿠키 정리 및 상태 코드 401 반환 처리 * feat: 배포 워크플로 브랜치 목록 수정 * feat: HttpOnly 쿠키 기반 인증 방식 리팩토링 - Authorization 헤더 방식 제거 및 HttpOnly 쿠키 적용 - AccessToken/RefreshToken 반환 방식을 HttpOnly 쿠키로 통일 - 클라이언트별 토큰 처리 순서 및 예외 로직 개선 - 불필요한 응답 헤더(X-Auth-Provider) 제거 - API 문서 수정: 새로운 토큰 처리 방식 반영 - SocialLoginController와 AuthController 코드 간소화 및 가독성 향상 --- .github/workflows/deploy.yml | 1 - .../api/controller/auth/AuthController.kt | 131 +++++++++++++----- .../controller/auth/SocialLoginController.kt | 38 ++--- .../auth/security/JwtAuthenticationFilter.kt | 85 +++++------- .../com/wq/auth/security/jwt/JwtProvider.kt | 36 +++++ .../wq/auth/shared/config/SecurityConfig.kt | 2 +- 6 files changed, 188 insertions(+), 105 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3212f3..f8aa189 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,6 @@ on: branches: - main - dev - - feat/internal-server-api jobs: build-and-push: 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 4e36b3a..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 @@ -9,18 +9,24 @@ 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.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.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.http.HttpHeaders @@ -35,7 +41,9 @@ class AuthController( private val emailService: AuthEmailService, private val memberService: MemberService, private val cookieFactory: CookieFactory, + private val jwtProvider: JwtProvider, ) { + private val log = KotlinLogging.logger {} @Operation( summary = "이메일 로그인", @@ -76,14 +84,12 @@ class AuthController( 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 accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) - val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - return CommonResponse.success(message = "로그인에 성공했습니다.", data = null) } @@ -119,7 +125,7 @@ class AuthController( - 인증 코드 삭제 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -221,7 +227,7 @@ class AuthController( ), ApiResponse( responseCode = "400", - description = "잘못된 요청, 인증 토큰이 없음, Authorization 헤더는 'Bearer ' 형식이어야 합니다.", + description = "잘못된 요청, 인증 토큰이 없음", content = [Content( mediaType = "application/json", schema = Schema(implementation = CommonResponse::class) @@ -249,29 +255,33 @@ class AuthController( @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?, ): CommonResponse { - val currentRefreshToken : String? - if(clientType == "web") { - currentRefreshToken = refreshToken + 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 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") { return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = null) } @@ -284,37 +294,96 @@ class AuthController( } @Operation( - summary = "토큰 인트로스펙트", + summary = "토큰 introspect", description = """ - API Gateway 연동용. Authorization 헤더의 JWT를 검증하고, 성공 시 응답 헤더에 다음을 담아 반환합니다. - - X-User-Id: 사용자 UUID (opaqueId) - - X-Auth-Provider: 대표 연동 제공자 (EMAIL, GOOGLE, KAKAO, NAVER 중 하나, 연동된 경우) + 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, X-Auth-Provider 헤더 포함)", + description = "인트로스펙트 성공 (X-User-Id 헤더 포함). AT가 임박한 경우 Set-Cookie로 새 토큰도 포함됩니다.", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", - description = "인증되지 않은 사용자", + description = "토큰 없음, 빈 쿠키, 리프레시 토큰 만료·오류 등 인증 실패. 쿠키가 존재하던 경우 해당 쿠키는 제거됩니다.", content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 60, duration = 1, timeUnit = TimeUnit.MINUTES) - @AuthenticatedApi + @PublicApi @GetMapping("/api/v1/auth/introspect") fun introspect( + request: HttpServletRequest, response: HttpServletResponse, - @AuthenticationPrincipal principalDetails: PrincipalDetails ) { - response.setHeader("X-User-Id", principalDetails.opaqueId) - memberService.getPrimaryProvider(principalDetails.opaqueId)?.let { provider -> - response.setHeader("X-Auth-Provider", provider.name) + 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 94b6fb0..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 @@ -42,7 +42,7 @@ class SocialLoginController( ) { /** - * 범용 소셜 로그인 처리 + * 소셜 로그인 처리 * * 프론트엔드에서 소셜 제공자로부터 받은 인가 코드를 사용하여 * 사용자 정보를 조회하고 JWT 토큰을 발급합니다. @@ -61,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 @@ -74,7 +74,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + description = "로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -132,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 방지) @@ -145,7 +146,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Google 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + description = "Google 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -208,15 +209,16 @@ 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 쿠키에 리프레시 토큰 설정", + description = "카카오 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -276,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 방지) @@ -289,7 +292,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Naver 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + description = "Naver 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -347,7 +350,7 @@ class SocialLoginController( - 연동 계정이 있는 경우: 두 계정 자동 병합 (기존 회원 정보 유지) **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -422,7 +425,7 @@ class SocialLoginController( - 보안 강화를 위해 사용 권장 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -497,7 +500,7 @@ class SocialLoginController( - 프론트엔드에서 생성한 state 값을 전달해야 함 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -546,7 +549,7 @@ class SocialLoginController( } /** - * AccessToken/RefreshToken을 HttpOnly 쿠키 및 Authorization 헤더로 설정합니다. + * AccessToken/RefreshToken을 HttpOnly 쿠키로 설정합니다. * * @param response HTTP 응답 객체 * @param accessToken 액세스 토큰 @@ -562,6 +565,5 @@ class SocialLoginController( response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") } } diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt index 4e06139..a2fcde2 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt @@ -22,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( @@ -29,27 +54,18 @@ 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) + val principalDetails = PrincipalDetails(jwtProvider.getOpaqueId(token)) - // Spring Security 인증 객체 생성 및 설정 // todo : TokenService로 분리 필요. val authentication = UsernamePasswordAuthenticationToken( - principalDetails, // principal: PrincipalDetails 객체 - null, // credentials: 비밀번호 (JWT에서는 불필요) - principalDetails.authorities // authorities: 사용자 권한 + principalDetails, + null, + principalDetails.authorities ) SecurityContextHolder.getContext().authentication = authentication } @@ -59,45 +75,6 @@ class JwtAuthenticationFilter( log.debug(e) { "JWT 필터 처리 중 예외 발생: ${e.message}" } } - // 다음 필터로 진행 filterChain.doFilter(request, response) } - - /** - * HTTP 요청에서 JWT 토큰을 추출합니다. - * - * @param request HTTP 요청 객체 - * @return 추출된 JWT 토큰 문자열, 없으면 null - */ - private fun extractTokenFromRequest(request: HttpServletRequest): String? { - // 1. accessToken HttpOnly 쿠키 우선 사용 (웹 클라이언트) - val cookieToken = request.cookies - ?.firstOrNull { it.name == "accessToken" } - ?.value - - if (!cookieToken.isNullOrBlank()) { - return cookieToken - } - - // 2. Authorization 헤더(Bearer) fallback (앱 및 과도기 웹 클라이언트) - val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) - - return if (!authorizationHeader.isNullOrBlank() && 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) - - return PrincipalDetails(opaqueId) - } } 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 551b69c..f14f0aa 100644 --- a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt +++ b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt @@ -3,6 +3,7 @@ package com.wq.auth.security.jwt 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 @@ -106,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/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index 9b36693..5010091 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -62,7 +62,7 @@ class SecurityConfig( "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI "/v3/api-docs/**", // OpenAPI 문서 - "/h2-console/**" // H2 콘솔 (개발용) + "/api/v1/auth/introspect" ).permitAll() // 나머지 모든 요청은 인증 필요 (세부 권한은 @PreAuthorize로 처리) From 9b2825c60e1711f6144166e1bd0bda4a1daf6d9c Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 5 Mar 2026 17:50:29 +0900 Subject: [PATCH 05/18] =?UTF-8?q?Revert=20"[Fix]=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#62)"=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e388a92610ab53f2dd41ee8a1f08d81393985099. --- .github/workflows/deploy.yml | 1 + .../api/controller/auth/AuthController.kt | 131 +++++------------- .../controller/auth/SocialLoginController.kt | 38 +++-- .../auth/security/JwtAuthenticationFilter.kt | 85 +++++++----- .../com/wq/auth/security/jwt/JwtProvider.kt | 36 ----- .../wq/auth/shared/config/SecurityConfig.kt | 2 +- 6 files changed, 105 insertions(+), 188 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8aa189..a3212f3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,7 @@ on: branches: - main - dev + - feat/internal-server-api jobs: build-and-push: diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index caf0415..4e36b3a 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 @@ -9,24 +9,18 @@ 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.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.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.http.HttpHeaders @@ -41,9 +35,7 @@ class AuthController( private val emailService: AuthEmailService, private val memberService: MemberService, private val cookieFactory: CookieFactory, - private val jwtProvider: JwtProvider, ) { - private val log = KotlinLogging.logger {} @Operation( summary = "이메일 로그인", @@ -84,12 +76,14 @@ class AuthController( deviceId = req.deviceId, ) - val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) - val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + 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()) + return CommonResponse.success(message = "로그인에 성공했습니다.", data = null) } @@ -125,7 +119,7 @@ class AuthController( - 인증 코드 삭제 **인증 요구사항:** - - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 + - Authorization 헤더에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -227,7 +221,7 @@ class AuthController( ), ApiResponse( responseCode = "400", - description = "잘못된 요청, 인증 토큰이 없음", + description = "잘못된 요청, 인증 토큰이 없음, Authorization 헤더는 'Bearer ' 형식이어야 합니다.", content = [Content( mediaType = "application/json", schema = Schema(implementation = CommonResponse::class) @@ -255,33 +249,29 @@ class AuthController( @PostMapping("/api/v1/auth/members/refresh") @PublicApi fun refreshAccessToken( - request: HttpServletRequest, // 헤더/쿠키에서 이전 AT를 읽기 위함 - @CookieValue(name = "refreshToken", required = false) refreshToken: String?, + @CookieValue(name = "refreshToken", required = false) refreshToken: String, @RequestHeader("X-Client-Type") clientType: String, response: HttpServletResponse, @RequestBody req: RefreshAccessTokenRequestDto?, ): CommonResponse { - val currentRefreshToken : String? = if(clientType == "web") { - refreshToken + val currentRefreshToken : String? + if(clientType == "web") { + currentRefreshToken = refreshToken } else { - req?.refreshToken - } - - if (currentRefreshToken.isNullOrBlank()) { - throw JwtException(JwtExceptionCode.TOKEN_MISSING) + currentRefreshToken = req?.refreshToken } - val (accessToken, newRefreshToken) = authService.refreshAccessToken( - currentRefreshToken, req?.deviceId + currentRefreshToken!!, req?.deviceId, ) - - val accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) - val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + 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()) + return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = null) } @@ -294,96 +284,37 @@ class AuthController( } @Operation( - summary = "토큰 introspect", + summary = "토큰 인트로스펙트", 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) + API Gateway 연동용. Authorization 헤더의 JWT를 검증하고, 성공 시 응답 헤더에 다음을 담아 반환합니다. + - X-User-Id: 사용자 UUID (opaqueId) + - X-Auth-Provider: 대표 연동 제공자 (EMAIL, GOOGLE, KAKAO, NAVER 중 하나, 연동된 경우) """ ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "인트로스펙트 성공 (X-User-Id 헤더 포함). AT가 임박한 경우 Set-Cookie로 새 토큰도 포함됩니다.", + description = "인트로스펙트 성공 (X-User-Id, X-Auth-Provider 헤더 포함)", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", - description = "토큰 없음, 빈 쿠키, 리프레시 토큰 만료·오류 등 인증 실패. 쿠키가 존재하던 경우 해당 쿠키는 제거됩니다.", + description = "인증되지 않은 사용자", content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 60, duration = 1, timeUnit = TimeUnit.MINUTES) - @PublicApi + @AuthenticatedApi @GetMapping("/api/v1/auth/introspect") fun introspect( - request: HttpServletRequest, response: HttpServletResponse, + @AuthenticationPrincipal principalDetails: PrincipalDetails ) { - val token = JwtAuthenticationFilter.extractToken(request) - // 쿠키·헤더 모두 없거나, accessToken 쿠키가 빈 값인 경우 401 - if (token.isNullOrBlank()) { - throw JwtException(JwtExceptionCode.TOKEN_MISSING) + response.setHeader("X-User-Id", principalDetails.opaqueId) + memberService.getPrimaryProvider(principalDetails.opaqueId)?.let { provider -> + response.setHeader("X-Auth-Provider", provider.name) } - - // 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 bb3ac5e..94b6fb0 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 @@ -42,7 +42,7 @@ class SocialLoginController( ) { /** - * 소셜 로그인 처리 + * 범용 소셜 로그인 처리 * * 프론트엔드에서 소셜 제공자로부터 받은 인가 코드를 사용하여 * 사용자 정보를 조회하고 JWT 토큰을 발급합니다. @@ -61,8 +61,8 @@ class SocialLoginController( - 프론트엔드 환경별로 다른 URI 사용 가능 (개발/스테이징/프로덕션) **토큰 반환 방식:** - - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 - - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) + - Access Token: Authorization 헤더에 Bearer 방식으로 반환 + - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) **지원 소셜 제공자:** - GOOGLE: Google OAuth2 @@ -74,7 +74,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + description = "로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -132,9 +132,8 @@ class SocialLoginController( - 프론트엔드 환경별로 다른 URI 사용 가능 **토큰 반환 방식:** - - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 - - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) - - 기존 Authorization 헤더 방식은 보안 및 웹 친화적 설계를 위해 제거되었습니다. + - Access Token: Authorization 헤더에 Bearer 방식으로 반환 + - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) **쿠키 설정:** - HttpOnly: JavaScript 접근 불가 (XSS 방지) @@ -146,7 +145,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Google 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + description = "Google 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -209,16 +208,15 @@ class SocialLoginController( - 보안 강화를 위해 사용 권장 **토큰 반환 방식:** - - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 - - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) - - 기존 Authorization 헤더 방식은 보안 및 웹 친화적 설계를 위해 제거되었습니다. + - Access Token: Authorization 헤더에 Bearer 방식으로 반환 + - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) """ ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "카카오 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + description = "카카오 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -278,9 +276,8 @@ class SocialLoginController( - 프론트엔드 환경별로 다른 URI 사용 가능 **토큰 반환 방식:** - - Access Token: HttpOnly 쿠키(`accessToken`)로 설정 - - Refresh Token: HttpOnly 쿠키(`refreshToken`)로 설정 (XSS 공격 방지) - - 기존 Authorization 헤더 방식은 보안 및 웹 친화적 설계를 위해 제거되었습니다. + - Access Token: Authorization 헤더에 Bearer 방식으로 반환 + - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) **쿠키 설정:** - HttpOnly: JavaScript 접근 불가 (XSS 방지) @@ -292,7 +289,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Naver 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", + description = "Naver 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -350,7 +347,7 @@ class SocialLoginController( - 연동 계정이 있는 경우: 두 계정 자동 병합 (기존 회원 정보 유지) **인증 요구사항:** - - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 + - Authorization 헤더에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -425,7 +422,7 @@ class SocialLoginController( - 보안 강화를 위해 사용 권장 **인증 요구사항:** - - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 + - Authorization 헤더에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -500,7 +497,7 @@ class SocialLoginController( - 프론트엔드에서 생성한 state 값을 전달해야 함 **인증 요구사항:** - - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 + - Authorization 헤더에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -549,7 +546,7 @@ class SocialLoginController( } /** - * AccessToken/RefreshToken을 HttpOnly 쿠키로 설정합니다. + * AccessToken/RefreshToken을 HttpOnly 쿠키 및 Authorization 헤더로 설정합니다. * * @param response HTTP 응답 객체 * @param accessToken 액세스 토큰 @@ -565,5 +562,6 @@ class SocialLoginController( response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") } } diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt index a2fcde2..4e06139 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt @@ -22,31 +22,6 @@ 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( @@ -54,18 +29,27 @@ class JwtAuthenticationFilter( response: HttpServletResponse, filterChain: FilterChain ) { + + val httpReq = request as HttpServletRequest + println("Request URI: ${httpReq.requestURI}") + try { - val token = extractToken(request) - if (!token.isNullOrBlank()) { + // Authorization 헤더에서 JWT 토큰 추출 + val token = extractTokenFromRequest(request) + + if (token != null) { + // JWT 토큰 유효성 검증 jwtProvider.validateOrThrow(token) - val principalDetails = PrincipalDetails(jwtProvider.getOpaqueId(token)) + // 토큰에서 사용자 정보 추출 + val principalDetails = extractPrincipalDetails(token) + // Spring Security 인증 객체 생성 및 설정 // todo : TokenService로 분리 필요. val authentication = UsernamePasswordAuthenticationToken( - principalDetails, - null, - principalDetails.authorities + principalDetails, // principal: PrincipalDetails 객체 + null, // credentials: 비밀번호 (JWT에서는 불필요) + principalDetails.authorities // authorities: 사용자 권한 ) SecurityContextHolder.getContext().authentication = authentication } @@ -75,6 +59,45 @@ class JwtAuthenticationFilter( log.debug(e) { "JWT 필터 처리 중 예외 발생: ${e.message}" } } + // 다음 필터로 진행 filterChain.doFilter(request, response) } + + /** + * HTTP 요청에서 JWT 토큰을 추출합니다. + * + * @param request HTTP 요청 객체 + * @return 추출된 JWT 토큰 문자열, 없으면 null + */ + private fun extractTokenFromRequest(request: HttpServletRequest): String? { + // 1. accessToken HttpOnly 쿠키 우선 사용 (웹 클라이언트) + val cookieToken = request.cookies + ?.firstOrNull { it.name == "accessToken" } + ?.value + + if (!cookieToken.isNullOrBlank()) { + return cookieToken + } + + // 2. Authorization 헤더(Bearer) fallback (앱 및 과도기 웹 클라이언트) + val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) + + return if (!authorizationHeader.isNullOrBlank() && 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) + + return PrincipalDetails(opaqueId) + } } diff --git a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt index f14f0aa..551b69c 100644 --- a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt +++ b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt @@ -3,7 +3,6 @@ package com.wq.auth.security.jwt 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 @@ -107,41 +106,6 @@ 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/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index 5010091..9b36693 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -62,7 +62,7 @@ class SecurityConfig( "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI "/v3/api-docs/**", // OpenAPI 문서 - "/api/v1/auth/introspect" + "/h2-console/**" // H2 콘솔 (개발용) ).permitAll() // 나머지 모든 요청은 인증 필요 (세부 권한은 @PreAuthorize로 처리) From c5f627f00187b99efd5720968236ce290ac5a672 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 9 Mar 2026 19:17:50 +0900 Subject: [PATCH 06/18] =?UTF-8?q?[Fix]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=BF=A0=ED=82=A4=EC=97=90=20=EB=8B=B4=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: JWT 처리 방식 개선 및 인증 로직 확장 - `JwtAuthenticationFilter` 리팩토링: 토큰 추출 방식 통합 및 중복 제거 - `/api/v1/auth/introspect` 엔드포인트 인증 필요 없이 접근 가능하도록 변경 - 만료된 토큰에서도 클레임을 안전히 조회할 수 있도록 내부 로직 확장 - 토큰 관련 예외 처리 및 유효성 검사 코드 간소화 * feat: 토큰 introspect 엔드포인트 확장 및 사일런트 리프레시 지원 - Access Token 만료 시 Refresh Token 기반 자동 재발급 - 새 토큰을 Set-Cookie 헤더로 반환 - 요청 토큰 탐색 우선순위 정의 (HttpOnly 쿠키 > Authorization 헤더) - AT·RT 쿠키 만료를 위한 `clearAuthCookies` 유틸리티 메서드 추가 - `jwtProvider` 사용하여 남은 토큰 유효 시간 및 클레임 처리 로직 개선 - 요청 인증 중 예외 발생 시 쿠키 정리 및 상태 코드 401 반환 처리 * feat: 배포 워크플로 브랜치 목록 수정 * feat: HttpOnly 쿠키 기반 인증 방식 리팩토링 - Authorization 헤더 방식 제거 및 HttpOnly 쿠키 적용 - AccessToken/RefreshToken 반환 방식을 HttpOnly 쿠키로 통일 - 클라이언트별 토큰 처리 순서 및 예외 로직 개선 - 불필요한 응답 헤더(X-Auth-Provider) 제거 - API 문서 수정: 새로운 토큰 처리 방식 반영 - SocialLoginController와 AuthController 코드 간소화 및 가독성 향상 --- .github/workflows/deploy.yml | 1 - .../api/controller/auth/AuthController.kt | 131 +++++++++++++----- .../controller/auth/SocialLoginController.kt | 38 ++--- .../auth/security/JwtAuthenticationFilter.kt | 85 +++++------- .../com/wq/auth/security/jwt/JwtProvider.kt | 36 +++++ .../wq/auth/shared/config/SecurityConfig.kt | 2 +- 6 files changed, 188 insertions(+), 105 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3212f3..f8aa189 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,6 @@ on: branches: - main - dev - - feat/internal-server-api jobs: build-and-push: 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 4e36b3a..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 @@ -9,18 +9,24 @@ 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.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.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.http.HttpHeaders @@ -35,7 +41,9 @@ class AuthController( private val emailService: AuthEmailService, private val memberService: MemberService, private val cookieFactory: CookieFactory, + private val jwtProvider: JwtProvider, ) { + private val log = KotlinLogging.logger {} @Operation( summary = "이메일 로그인", @@ -76,14 +84,12 @@ class AuthController( 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 accessTokenCookie = cookieFactory.createAccessTokenCookie(accessToken) - val refreshTokenCookie = cookieFactory.createRefreshTokenCookie(newRefreshToken) - response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - return CommonResponse.success(message = "로그인에 성공했습니다.", data = null) } @@ -119,7 +125,7 @@ class AuthController( - 인증 코드 삭제 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -221,7 +227,7 @@ class AuthController( ), ApiResponse( responseCode = "400", - description = "잘못된 요청, 인증 토큰이 없음, Authorization 헤더는 'Bearer ' 형식이어야 합니다.", + description = "잘못된 요청, 인증 토큰이 없음", content = [Content( mediaType = "application/json", schema = Schema(implementation = CommonResponse::class) @@ -249,29 +255,33 @@ class AuthController( @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?, ): CommonResponse { - val currentRefreshToken : String? - if(clientType == "web") { - currentRefreshToken = refreshToken + 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 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") { return CommonResponse.success(message = "AccessToken 재발급에 성공했습니다.", data = null) } @@ -284,37 +294,96 @@ class AuthController( } @Operation( - summary = "토큰 인트로스펙트", + summary = "토큰 introspect", description = """ - API Gateway 연동용. Authorization 헤더의 JWT를 검증하고, 성공 시 응답 헤더에 다음을 담아 반환합니다. - - X-User-Id: 사용자 UUID (opaqueId) - - X-Auth-Provider: 대표 연동 제공자 (EMAIL, GOOGLE, KAKAO, NAVER 중 하나, 연동된 경우) + 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, X-Auth-Provider 헤더 포함)", + description = "인트로스펙트 성공 (X-User-Id 헤더 포함). AT가 임박한 경우 Set-Cookie로 새 토큰도 포함됩니다.", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( responseCode = "401", - description = "인증되지 않은 사용자", + description = "토큰 없음, 빈 쿠키, 리프레시 토큰 만료·오류 등 인증 실패. 쿠키가 존재하던 경우 해당 쿠키는 제거됩니다.", content = [Content(schema = Schema(implementation = CommonResponse::class))] ) ] ) @RateLimit(limit = 60, duration = 1, timeUnit = TimeUnit.MINUTES) - @AuthenticatedApi + @PublicApi @GetMapping("/api/v1/auth/introspect") fun introspect( + request: HttpServletRequest, response: HttpServletResponse, - @AuthenticationPrincipal principalDetails: PrincipalDetails ) { - response.setHeader("X-User-Id", principalDetails.opaqueId) - memberService.getPrimaryProvider(principalDetails.opaqueId)?.let { provider -> - response.setHeader("X-Auth-Provider", provider.name) + 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 94b6fb0..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 @@ -42,7 +42,7 @@ class SocialLoginController( ) { /** - * 범용 소셜 로그인 처리 + * 소셜 로그인 처리 * * 프론트엔드에서 소셜 제공자로부터 받은 인가 코드를 사용하여 * 사용자 정보를 조회하고 JWT 토큰을 발급합니다. @@ -61,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 @@ -74,7 +74,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + description = "로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -132,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 방지) @@ -145,7 +146,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Google 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + description = "Google 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -208,15 +209,16 @@ 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 쿠키에 리프레시 토큰 설정", + description = "카카오 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -276,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 방지) @@ -289,7 +292,7 @@ class SocialLoginController( value = [ ApiResponse( responseCode = "200", - description = "Naver 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + description = "Naver 로그인 성공 - HttpOnly 쿠키에 액세스 토큰 및 리프레시 토큰 설정", content = [Content(schema = Schema(implementation = CommonResponse::class))] ), ApiResponse( @@ -347,7 +350,7 @@ class SocialLoginController( - 연동 계정이 있는 경우: 두 계정 자동 병합 (기존 회원 정보 유지) **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -422,7 +425,7 @@ class SocialLoginController( - 보안 강화를 위해 사용 권장 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -497,7 +500,7 @@ class SocialLoginController( - 프론트엔드에서 생성한 state 값을 전달해야 함 **인증 요구사항:** - - Authorization 헤더에 유효한 JWT 토큰 필요 + - `accessToken` HttpOnly 쿠키에 유효한 JWT 토큰 필요 - 토큰은 재발급되지 않으며 기존 토큰 그대로 사용 """ ) @@ -546,7 +549,7 @@ class SocialLoginController( } /** - * AccessToken/RefreshToken을 HttpOnly 쿠키 및 Authorization 헤더로 설정합니다. + * AccessToken/RefreshToken을 HttpOnly 쿠키로 설정합니다. * * @param response HTTP 응답 객체 * @param accessToken 액세스 토큰 @@ -562,6 +565,5 @@ class SocialLoginController( response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) - response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") } } diff --git a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt index 4e06139..a2fcde2 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationFilter.kt @@ -22,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( @@ -29,27 +54,18 @@ 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) + val principalDetails = PrincipalDetails(jwtProvider.getOpaqueId(token)) - // Spring Security 인증 객체 생성 및 설정 // todo : TokenService로 분리 필요. val authentication = UsernamePasswordAuthenticationToken( - principalDetails, // principal: PrincipalDetails 객체 - null, // credentials: 비밀번호 (JWT에서는 불필요) - principalDetails.authorities // authorities: 사용자 권한 + principalDetails, + null, + principalDetails.authorities ) SecurityContextHolder.getContext().authentication = authentication } @@ -59,45 +75,6 @@ class JwtAuthenticationFilter( log.debug(e) { "JWT 필터 처리 중 예외 발생: ${e.message}" } } - // 다음 필터로 진행 filterChain.doFilter(request, response) } - - /** - * HTTP 요청에서 JWT 토큰을 추출합니다. - * - * @param request HTTP 요청 객체 - * @return 추출된 JWT 토큰 문자열, 없으면 null - */ - private fun extractTokenFromRequest(request: HttpServletRequest): String? { - // 1. accessToken HttpOnly 쿠키 우선 사용 (웹 클라이언트) - val cookieToken = request.cookies - ?.firstOrNull { it.name == "accessToken" } - ?.value - - if (!cookieToken.isNullOrBlank()) { - return cookieToken - } - - // 2. Authorization 헤더(Bearer) fallback (앱 및 과도기 웹 클라이언트) - val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) - - return if (!authorizationHeader.isNullOrBlank() && 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) - - return PrincipalDetails(opaqueId) - } } 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 551b69c..f14f0aa 100644 --- a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt +++ b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt @@ -3,6 +3,7 @@ package com.wq.auth.security.jwt 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 @@ -106,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/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index 9b36693..5010091 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -62,7 +62,7 @@ class SecurityConfig( "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI "/v3/api-docs/**", // OpenAPI 문서 - "/h2-console/**" // H2 콘솔 (개발용) + "/api/v1/auth/introspect" ).permitAll() // 나머지 모든 요청은 인증 필요 (세부 권한은 @PreAuthorize로 처리) From 564b2ce24eedffea46a2c7157bf20521bdfcdf25 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 11:58:44 +0900 Subject: [PATCH 07/18] =?UTF-8?q?[Deploy]=20=EC=95=8C=ED=8C=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=9D=B4=EC=A0=84=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 소셜 로그인 및 토큰 처리 로직 리팩토링 - OAuthClient 인터페이스 구현 정리 및 카카오/구글 OAuth 로직 통합 - id_token 파싱 추가로 사용자의 프로필 정보를 효율적으로 처리 - 사용자 로그인 응답 속도를 개선하기 위해 비동기적 로그인 시간 업데이트 도입 - RefreshToken 발행 및 관련 로직 간소화 - RestTemplate 구성 추가로 HTTP 통신 설정 통합 - 코드 가독성 및 유지보수성을 고려한 주요 메서드 리팩토링 * feat: 프로파일별 설정 분리 및 데이터베이스 설정 변경 * feat: PostgreSQL 전환 및 배포 프로세스 개선 - 프로젝트 데이터베이스를 MySQL에서 PostgreSQL로 전환 - Docker Compose 설정 삭제 및 환경 변수 샘플 파일 업데이트 - Gradle에 PostgreSQL 드라이버 및 HTTP 클라이언트 의존성 추가 - EC2 배포 워크플로 수정: 브랜치별 환경 변수 처리 및 Docker Run 방식 적용 - 기존 CORS 설정 초기화 및 Gateway를 통한 위임 구조 반영 * feat: 배포 워크플로 브랜치 리스트에 alpha 추가 * feat: 환경별 프로파일 설정 및 배포 워크플로 개선 - 운영, 알파, 로컬 환경별 Spring 프로파일 설정 추가 - SPRING_PROFILES_ACTIVE 기본값을 'local'로 변경 - GitHub Actions 배포 워크플로 환경 변수 처리 방식 간소화 - Docker Hub 및 EC2 관련 Secrets 활용으로 보안 강화 - PEM 키 처리 로직 개선 및 유효성 검증 추가 * feat: RestClient 구성 추가 및 기존 RestTemplate 제거 - Spring WebClient 기반 RestClientConfig 추가 - Kakao, Google, Naver OAuth 클라이언트에서 RestTemplate 제거 후 RestClient로 전환 - Spring Boot, Kotlin, HttpClient 등 주요 의존성 버전 업그레이드 - Gradle 설정 업데이트: Spring Boot 4 및 Kotlin 2.3 대응 * feat: Java 언어 버전 다운그레이드 * feat: JSON 데이터 처리 의존성 추가 - Spring Boot JSON Starter 의존성 추가 - JSON 처리 기능 개선 및 확장 준비 완료 * feat: `ObjectMapper`를 `JsonMapper`로 전환 --- .env.sample | 12 +- .github/workflows/deploy.yml | 77 +++++--- build.gradle.kts | 81 ++++---- docker-compose.yml | 28 --- .../kotlin/com/wq/auth/AuthApplication.kt | 2 + .../wq/auth/api/domain/auth/AuthService.kt | 87 +++------ .../domain/auth/SocialLoginMemberProcessor.kt | 93 +++++++++ .../api/domain/auth/SocialLoginService.kt | 118 +----------- .../api/domain/member/MemberStatsService.kt | 24 +++ .../api/external/oauth/GoogleOAuthClient.kt | 180 +++++++---------- .../api/external/oauth/KakaoOAuthClient.kt | 182 +++++++----------- .../api/external/oauth/NaverOAuthClient.kt | 50 ++--- .../auth/security/JwtAccessDeniedHandler.kt | 6 +- .../security/JwtAuthenticationEntryPoint.kt | 6 +- .../wq/auth/shared/config/RestClientConfig.kt | 47 +++++ .../rateLimiter/RateLimiterInterceptor.kt | 6 +- src/main/resources/application-alpha.yml | 21 ++ ...lication-dev.yml => application-local.yml} | 18 +- src/main/resources/application-prod.yml | 11 +- src/main/resources/application.yml | 28 ++- 20 files changed, 513 insertions(+), 564 deletions(-) delete mode 100644 docker-compose.yml create mode 100644 src/main/kotlin/com/wq/auth/api/domain/auth/SocialLoginMemberProcessor.kt create mode 100644 src/main/kotlin/com/wq/auth/api/domain/member/MemberStatsService.kt create mode 100644 src/main/kotlin/com/wq/auth/shared/config/RestClientConfig.kt create mode 100644 src/main/resources/application-alpha.yml rename src/main/resources/{application-dev.yml => application-local.yml} (51%) diff --git a/.env.sample b/.env.sample index 0b170da..8d85e02 100644 --- a/.env.sample +++ b/.env.sample @@ -1,17 +1,17 @@ -SPRING_PROFILES_ACTIVE=prod,jwt,oauth +SPRING_PROFILES_ACTIVE= DB_HOST= -DB_PORT=3306 +DB_PORT= DB_NAME= DB_USERNAME= DB_PASSWORD= JWT_SECRET= -JWT_ACCESS_TOKEN_EXPIRATION=1h -JWT_REFRESH_TOKEN_EXPIRATION=14d +JWT_ACCESS_TOKEN_EXPIRATION= +JWT_REFRESH_TOKEN_EXPIRATION= MAIL_USERNAME= MAIL_PASSWORD= -SWAGGER_PATH=/swagger-ui.html +SWAGGER_PATH= APP_DEFAULT_ZONE=Asia/Seoul -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://auth.easyappfactory.com +CORS_ALLOWED_ORIGINS= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8aa189..1609304 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,17 +1,31 @@ -name: Deploy to EC2 +name: Deploy (auth-BE) + on: push: branches: - main - dev + - deploy/alpha jobs: - build-and-push: + deploy: runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }} + steps: - name: Checkout uses: actions/checkout@v4 + - name: Set image tag and Spring profile + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "IMAGE_TAG=latest" >> $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: @@ -24,43 +38,52 @@ jobs: context: . push: true tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:latest + ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ env.IMAGE_TAG }} ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ github.sha }} - deploy: - needs: build-and-push - runs-on: ubuntu-latest - steps: - 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: Prepare env file - env: - ENV_FILE_CONTENT: ${{ secrets.AUTH_BE_ENV_FILE }} - run: | - printf '%s\n' "$ENV_FILE_CONTENT" > auth-be.env - - - name: Create env dir on EC2 + - 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' - - - name: Copy env file to EC2 - run: | scp -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem auth-be.env ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:~/env/auth-be.env - - name: Deploy auth-be only (EC2) + - name: Deploy to EC2 run: | - ssh -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} ' + 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 }}" - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin - cd ~ + echo '${{ secrets.DOCKERHUB_TOKEN }}' | docker login -u '${{ secrets.DOCKERHUB_USERNAME }}' --password-stdin + + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ env.IMAGE_TAG }} + docker rm -f auth-be || true - docker-compose pull auth-be - docker-compose up -d --no-deps auth-be + + docker run -d \ + --name auth-be \ + --restart unless-stopped \ + --network app-network \ + --env-file ~/env/auth-be.env \ + -e SPRING_PROFILES_ACTIVE=${{ env.SPRING_PROFILE }} \ + ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ env.IMAGE_TAG }} + docker logout - ' \ No newline at end of file + " diff --git a/build.gradle.kts b/build.gradle.kts index d0df256..e312b04 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.20" + kotlin("plugin.spring") version "2.3.20" + kotlin("plugin.jpa") version "2.3.20" + + 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" @@ -25,58 +26,57 @@ repositories { mavenCentral() } -extra["spring-security.version"] = "6.5.3" - dependencies { +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") + + // 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.0") - // 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") + + // Rate Limiter: Artifact 이름이 변경되었습니다 + implementation("com.bucket4j:bucket4j_jdk17-core:8.17.0") - // Kotest 버전 변수로 관리 - val kotestVersion = "5.9.1" + // 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") - - // UUID v7 생성 (time-ordered UUID) - implementation("com.github.f4b6a3:uuid-creator:6.0.0") + // Mockito-Kotlin + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") } kotlin { @@ -91,9 +91,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/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 535b772..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - auth-be: - image: ${DOCKERHUB_USERNAME}/auth-server:latest - container_name: auth-be - restart: unless-stopped - ports: - - "9000:9000" - env_file: - - env/auth-be.env - networks: - - app-network - - api-gateway: - image: ${DOCKERHUB_USERNAME}/api-gateway:latest - container_name: api-gateway - restart: unless-stopped - ports: - - "8080:8080" - env_file: - - env/api-gateway.env - depends_on: - - auth-be - networks: - - app-network - -networks: - app-network: - driver: bridge 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/domain/auth/AuthService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt index 315464f..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 @@ -9,6 +9,7 @@ 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 @@ -19,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( @@ -30,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( @@ -41,50 +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, - 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 @@ -109,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, @@ -145,7 +128,6 @@ class AuthService( log.info { "이메일 연동 완료: $currentOpaqueId -> ${request.email}" } } - @Transactional fun logout(refreshToken: String?) { if (refreshToken.isNullOrBlank()) { @@ -154,42 +136,32 @@ 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, extraClaims = mapOf("deviceId" to deviceId) @@ -197,15 +169,12 @@ class AuthService( 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/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 1e70a1d..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) - 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/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/external/oauth/GoogleOAuthClient.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/GoogleOAuthClient.kt index 21d3e9d..91b4469 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,46 +12,25 @@ 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 검증용 코드 검증자 - * @param requestRedirectUri 요청에 담긴 redirect_uri (있으면 허용 목록 검증 후 사용) - * @return Google 액세스 토큰 - * @throws SocialLoginException 토큰 획득 실패 시 - */ - fun getAccessToken( + fun getTokenResponse( authorizationCode: String, codeVerifier: String, requestRedirectUri: String? = null, - ): String { + ): Map { val redirectUri = redirectUriResolver.resolve(requestRedirectUri, googleOAuthProperties.redirectUri) - log.info { "Google 액세스 토큰 요청 시작" } - log.info { "redirectUri: $redirectUri" } - - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - } + log.info { "Google 토큰 요청 시작" } val body: MultiValueMap = LinkedMultiValueMap().apply { add("client_id", googleOAuthProperties.clientId) @@ -63,102 +41,64 @@ class GoogleOAuthClient( 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, req.redirectUri) val googleUserInfo = getUserInfo(accessToken) return OAuthUser( @@ -171,15 +111,29 @@ class GoogleOAuthClient( ) } - /** - * 인가 코드를 사용하여 사용자 정보를 직접 조회합니다. (기존 호환성 유지용) - * - * @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, null) + 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 7ed43f0..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,46 +12,25 @@ 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 검증용 코드 검증자 (카카오는 선택사항) - * @param requestRedirectUri 요청에 담긴 redirect_uri (있으면 허용 목록 검증 후 사용) - * @return 카카오 액세스 토큰 - * @throws SocialLoginException 토큰 획득 실패 시 - */ - fun getAccessToken( + fun getTokenResponse( authorizationCode: String, codeVerifier: String, requestRedirectUri: String? = null, - ): String { + ): Map { val redirectUri = redirectUriResolver.resolve(requestRedirectUri, kakaoOAuthProperties.redirectUri) - log.info { "카카오 액세스 토큰 요청 시작" } - log.info { "redirectUri: $redirectUri" } - - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - } + log.info { "카카오 토큰 요청 시작" } val body: MultiValueMap = LinkedMultiValueMap().apply { add("grant_type", "authorization_code") @@ -62,109 +40,69 @@ class KakaoOAuthClient( } 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, req.redirectUri) val kakaoUserInfo = getUserInfo(accessToken) return OAuthUser( @@ -177,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, null) + 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 47f3447..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,11 +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() /** * 인가 코드를 사용하여 액세스 토큰을 획득합니다. @@ -52,10 +52,6 @@ class NaverOAuthClient( log.info { "Naver 액세스 토큰 요청 시작" } log.info { "redirectUri: $redirectUri" } - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - } - val body: MultiValueMap = LinkedMultiValueMap().apply { add("client_id", naverOAuthProperties.clientId) add("client_secret", naverOAuthProperties.clientSecret) @@ -66,18 +62,17 @@ class NaverOAuthClient( 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 액세스 토큰 획득 성공" } @@ -115,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 { diff --git a/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt b/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt index b61b045..cdd43c8 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAccessDeniedHandler.kt @@ -1,6 +1,5 @@ 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.CommonResponse import jakarta.servlet.http.HttpServletRequest @@ -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 {} @@ -38,7 +38,7 @@ class JwtAccessDeniedHandler( // 표준 API 응답 형식으로 에러 응답 생성 val errorResponse = CommonResponse.fail(JwtExceptionCode.FORBIDDEN) - val jsonResponse = objectMapper.writeValueAsString(errorResponse) + 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 9fdbab3..aad9a0e 100644 --- a/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt +++ b/src/main/kotlin/com/wq/auth/security/JwtAuthenticationEntryPoint.kt @@ -1,6 +1,5 @@ package com.wq.auth.security -import com.fasterxml.jackson.databind.ObjectMapper import com.wq.auth.web.common.response.CommonResponse import com.wq.auth.security.jwt.error.JwtExceptionCode import jakarta.servlet.http.HttpServletRequest @@ -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 {} @@ -38,7 +38,7 @@ class JwtAuthenticationEntryPoint( // 표준 API 응답 형식으로 에러 응답 생성 val errorResponse = CommonResponse.fail(JwtExceptionCode.TOKEN_MISSING) - val jsonResponse = objectMapper.writeValueAsString(errorResponse) + val jsonResponse = jsonMapper.writeValueAsString(errorResponse) response.writer.write(jsonResponse) response.writer.flush() 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/rateLimiter/RateLimiterInterceptor.kt b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt index ac22fcb..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,6 +1,5 @@ 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.CommonResponse @@ -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 {} @@ -63,7 +63,7 @@ class RateLimiterInterceptor( 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/resources/application-alpha.yml b/src/main/resources/application-alpha.yml new file mode 100644 index 0000000..e141a9f --- /dev/null +++ b/src/main/resources/application-alpha.yml @@ -0,0 +1,21 @@ +# alpha — 알파 서버 (DDL update, 운영에 가까운 쿠키) +spring: + config: + activate: + on-profile: alpha + + jpa: + hibernate: + ddl-auto: update + show-sql: true + +logging: + level: + org.hibernate.SQL: info + org.springframework.orm.jpa: info + +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 51% rename from src/main/resources/application-dev.yml rename to src/main/resources/application-local.yml index 65abd70..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: @@ -33,6 +24,5 @@ logging: app: cookie: - domain: ${APP_COOKIE_DOMAIN:localhost} secure: false - same-site: Lax \ No newline at end of file + same-site: Lax diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a43450c..769cf08 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,23 +1,18 @@ +# 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 + 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 logging: level: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 670e32c..11f742f 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: true + jdbc: + time_zone: UTC mail: host: smtp.gmail.com @@ -26,9 +47,8 @@ springdoc: swagger-ui: path: ${SWAGGER_PATH} -server: - port: 9000 - app: time: default-zone: ${APP_DEFAULT_ZONE:Asia/Seoul} + cookie: + domain: ${APP_COOKIE_DOMAIN:localhost} From 3531c9ab236bc6d55095a7f3df0a10ef85adceda Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 14:16:50 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20EC2=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EC2 배포 기본 경로 설정(PROJECT_ROOT) 추가 및 환경변수 경로 명시 --- .github/workflows/deploy.yml | 37 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1609304..ed8cca1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,16 +1,24 @@ name: Deploy (auth-BE) +# --------------------------------------------------------------------------- +# EC2 원격 경로 (아래 deploy job 의 env.PROJECT_ROOT 만 수정) +# PROJECT_ROOT … 배포 기준 루트. 예: ~, ~/myapp (끝 슬래시 없음) +# 환경변수 파일은 항상 {PROJECT_ROOT}/env/ 아래 (이름 고정: env) +# 배포는 EC2의 PROJECT_ROOT 에서 docker-compose (서비스명: auth-be, compose.yml 과 일치해야 함) +# --------------------------------------------------------------------------- + on: push: branches: - main - dev - - deploy/alpha jobs: deploy: runs-on: ubuntu-latest environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }} + env: + PROJECT_ROOT: '~/easyappfactory-demo' steps: - name: Checkout @@ -64,26 +72,19 @@ jobs: - 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' - scp -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem auth-be.env ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:~/env/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 }} " + ssh -o StrictHostKeyChecking=no -i ~/.ssh/ec2_key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} ' set -e - echo '${{ secrets.DOCKERHUB_TOKEN }}' | docker login -u '${{ secrets.DOCKERHUB_USERNAME }}' --password-stdin - - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ env.IMAGE_TAG }} - + export PATH="/usr/bin:/usr/local/bin:$PATH" + export SPRING_PROFILES_ACTIVE="${{ env.SPRING_PROFILE }}" + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + cd ${{ env.PROJECT_ROOT }} docker rm -f auth-be || true - - docker run -d \ - --name auth-be \ - --restart unless-stopped \ - --network app-network \ - --env-file ~/env/auth-be.env \ - -e SPRING_PROFILES_ACTIVE=${{ env.SPRING_PROFILE }} \ - ${{ secrets.DOCKERHUB_USERNAME }}/auth-server:${{ env.IMAGE_TAG }} - + docker-compose pull auth-be + docker-compose up -d --no-deps auth-be docker logout - " + ' From 8dcd6fc8337170034fc08186dd09c8f699ae0a37 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 14:36:46 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20=ED=99=98=EA=B2=BD=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed8cca1..bae2d5b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -80,6 +80,8 @@ jobs: 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 }} From c68603537dc8f12c77256f302e2cac20b599bab4 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 15:13:08 +0900 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20EC2=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EC2 배포 경로를 명확히 설정하기 위해 PROJECT_ROOT 값을 절대 경로로 변경 --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bae2d5b..0c5c2eb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }} env: - PROJECT_ROOT: '~/easyappfactory-demo' + PROJECT_ROOT: '/home/ubuntu/easyappfactory-demo' steps: - name: Checkout From 4d9afce86edede8559f8763716df5ac4c16aea5f Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 15:37:52 +0900 Subject: [PATCH 11/18] =?UTF-8?q?feat:=20GitHub=20Actions=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IMAGE_TAG 값을 latest에서 prod로 수정 - 운영 환경(Spring 프로파일)과 이미지 태그 설정 일치하도록 개선 --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c5c2eb..1feb4d8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,7 +27,7 @@ jobs: - name: Set image tag and Spring profile run: | if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - echo "IMAGE_TAG=latest" >> $GITHUB_ENV + echo "IMAGE_TAG=prod" >> $GITHUB_ENV echo "SPRING_PROFILE=prod" >> $GITHUB_ENV else echo "IMAGE_TAG=alpha" >> $GITHUB_ENV From 1af7c121e7eddeac56be3904086fd1cb21b515d3 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 16:54:00 +0900 Subject: [PATCH 12/18] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20docker-compose=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=EB=A5=BC=20docker=20compose=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose 명령어를 최신 표준인 docker compose로 교체 - 배포 스크립트의 가독성 및 유지보수성 향상 --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1feb4d8..8232892 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -86,7 +86,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd ${{ env.PROJECT_ROOT }} docker rm -f auth-be || true - docker-compose pull auth-be - docker-compose up -d --no-deps auth-be + docker compose pull auth-be + docker compose up -d --no-deps auth-be docker logout ' From 408ac84548167b9b32360518c98c27fdf8ac7428 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 17:06:36 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20docker=20compose=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker stop 및 rm 명령어를 docker compose stop과 rm으로 변경 - `--force-recreate` 옵션 추가로 컨테이너 재생성 로직 개선 --- .github/workflows/deploy.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8232892..cb3c60e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ name: Deploy (auth-BE) # EC2 원격 경로 (아래 deploy job 의 env.PROJECT_ROOT 만 수정) # PROJECT_ROOT … 배포 기준 루트. 예: ~, ~/myapp (끝 슬래시 없음) # 환경변수 파일은 항상 {PROJECT_ROOT}/env/ 아래 (이름 고정: env) -# 배포는 EC2의 PROJECT_ROOT 에서 docker-compose (서비스명: auth-be, compose.yml 과 일치해야 함) +# 배포는 EC2의 PROJECT_ROOT 에서 docker compose (서비스명: auth-be, V2 기준) # --------------------------------------------------------------------------- on: @@ -85,8 +85,9 @@ jobs: export SPRING_PROFILES_ACTIVE="${{ env.SPRING_PROFILE }}" echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd ${{ env.PROJECT_ROOT }} - docker rm -f auth-be || true + docker compose stop auth-be || true + docker compose rm -f auth-be || true docker compose pull auth-be - docker compose up -d --no-deps auth-be + docker compose up -d --no-deps --force-recreate auth-be docker logout ' From 1deeb9621e9047e7187f950a0ff3185e906358c5 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 17:17:54 +0900 Subject: [PATCH 14/18] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9D=91=EB=8B=B5=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=95=84=EB=93=9C=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GoogleUserInfoResponse의 `verifiedEmail` 필드 기본값을 `true`로 설정 - NaverUserInfoResponse의 `verifiedEmail` 필드 기본값을 `false`로 설정 --- .../wq/auth/api/external/oauth/dto/GoogleUserInfoResponse.kt | 2 +- .../com/wq/auth/api/external/oauth/dto/NaverUserInfoResponse.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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, From 34c6487f12c6c72add41ce97e348a4b6efe89369 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Tue, 24 Mar 2026 17:26:42 +0900 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20Google=20OAuth=20`verifiedEmail`?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GoogleUserInfoResponse의 `verifiedEmail` 값이 null일 경우 기본값을 `true`로 설정하여 예외 방지 --- .../kotlin/com/wq/auth/api/external/oauth/GoogleOAuthClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 91b4469..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 @@ -104,7 +104,7 @@ class GoogleOAuthClient( return OAuthUser( providerId = googleUserInfo.getProviderId(), email = googleUserInfo.email, - verifiedEmail = googleUserInfo.verifiedEmail, + verifiedEmail = googleUserInfo.verifiedEmail ?: true, name = googleUserInfo.name, givenName = googleUserInfo.givenName, providerType = ProviderType.GOOGLE From 72a198e5fdd5448922377b4fc815fa2052a7d7cc Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 30 Mar 2026 08:42:38 +0900 Subject: [PATCH 16/18] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Java 버전을 25로 업그레이드하고 관련 Gradle 설정 업데이트 - Gradle 및 Kotlin 플러그인 버전 업그레이드 - 로깅 설정 추가: Logback 구성(`logback-spring.xml`) 및 Spring 프로퍼티 적용 --- .github/workflows/deploy.yml | 4 +++ Dockerfile | 16 ++++++++-- build.gradle.kts | 18 +++++------- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 17 +++++++++++ src/main/resources/application.yml | 6 ++++ src/main/resources/logback-spring.xml | 37 ++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cb3c60e..054cbdc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -45,6 +45,10 @@ jobs: 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 }} diff --git a/Dockerfile b/Dockerfile index 476a796..9985eba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,10 @@ -FROM eclipse-temurin:21-jdk-alpine AS builder +# ================================ +# Stage 1: Build +# ================================ +FROM eclipse-temurin:25-jdk-alpine AS builder + +# GITHUB_ACTOR는 빌드 시점에 일반 ARG로 넘겨받습니다. +ARG GITHUB_ACTOR=easyappfactory WORKDIR /workspace @@ -7,9 +13,13 @@ COPY gradle gradle COPY gradlew build.gradle.kts settings.gradle.kts ./ COPY src src -RUN ./gradlew bootJar --no-daemon -x test +# 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:21-jre-alpine +FROM eclipse-temurin:25-jre-alpine RUN adduser -D -h /app appuser diff --git a/build.gradle.kts b/build.gradle.kts index e312b04..96711b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ plugins { - kotlin("jvm") version "2.3.20" - kotlin("plugin.spring") version "2.3.20" - kotlin("plugin.jpa") version "2.3.20" + 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" @@ -12,7 +12,7 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } @@ -22,11 +22,6 @@ configurations { } } -repositories { - mavenCentral() -} - - dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") @@ -34,6 +29,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-mail") 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") @@ -46,7 +44,7 @@ dependencies { 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.0") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2") // OAuth & Google API implementation("com.google.api-client:google-api-client:2.7.0") diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c8..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index d5dfc8e..b1a06e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,18 @@ 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/resources/application.yml b/src/main/resources/application.yml index 11f742f..29a34de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,3 +52,9 @@ app: 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..96ea060 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + UTC + + + + + + + + + + { + "trace_id": "%mdc{trace_id}" + } + + + + + + + + + + + + From 8e2f3f0748fbf9ceeadf9e4a9fc3d4aac2a02581 Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Mon, 30 Mar 2026 09:53:23 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B9=85=20=EB=B0=8F=20?= =?UTF-8?q?Hibernate=20=EC=84=A4=EC=A0=95=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hibernate `format_sql` 설정 값 false로 변경 (모든 환경) - `org.hibernate.SQL` 로그 레벨을 warn에서 error로 변경 (prod 환경) - Logback 설정 개선: 'service', 'env' 필드 자동 포함 처리 - local, alpha 환경에서 Hibernate SQL 로깅 추가 (JSON 형식) --- src/main/resources/application-prod.yml | 5 ++++- src/main/resources/application.yml | 2 +- src/main/resources/logback-spring.xml | 13 ++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 769cf08..5e40c8e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -13,10 +13,13 @@ spring: hibernate: ddl-auto: validate show-sql: false + properties: + hibernate: + format_sql: false logging: level: - org.hibernate.SQL: warn + org.hibernate.SQL: error org.springframework.orm.jpa: info app: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29a34de..f01570d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,7 +22,7 @@ spring: database-platform: org.hibernate.dialect.PostgreSQLDialect properties: hibernate: - format_sql: true + format_sql: false jdbc: time_zone: UTC diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 96ea060..33fd440 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -2,6 +2,7 @@ + @@ -17,7 +18,7 @@ - + { @@ -34,4 +35,14 @@ + + + + + + + + + + From 48e3c5486305421aa5742cee1af66a1b2f59481b Mon Sep 17 00:00:00 2001 From: imeasy99 Date: Thu, 2 Apr 2026 11:15:33 +0900 Subject: [PATCH 18/18] =?UTF-8?q?feat:=20=ED=99=98=EA=B2=BD=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `application-prod.yml` 및 `application-alpha.yml` Hibernate 및 로깅 설정 조정 - Hibernate `format_sql` 및 `use_sql_comments` 설정 추가 - 로그 레벨 수정: prod 환경에서 `WARN` → `ERROR`, alpha 환경에서 `INFO` → `DEBUG` - EC2 배포 워크플로 기본 경로(PROJECT_ROOT) 절대 경로에서 홈 디렉토리로 수정 --- .github/workflows/deploy.yml | 2 +- src/main/resources/application-alpha.yml | 10 +++++++--- src/main/resources/application-prod.yml | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 054cbdc..61150a1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }} env: - PROJECT_ROOT: '/home/ubuntu/easyappfactory-demo' + PROJECT_ROOT: '~/apps' steps: - name: Checkout diff --git a/src/main/resources/application-alpha.yml b/src/main/resources/application-alpha.yml index e141a9f..84bbdad 100644 --- a/src/main/resources/application-alpha.yml +++ b/src/main/resources/application-alpha.yml @@ -7,12 +7,16 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false + properties: + hibernate: + format_sql: false + use_sql_comments: true logging: level: - org.hibernate.SQL: info - org.springframework.orm.jpa: info + org.hibernate.SQL: DEBUG + org.springframework.orm.jpa: DEBUG app: cookie: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5e40c8e..625a362 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -16,11 +16,13 @@ spring: properties: hibernate: format_sql: false + use_sql_comments: false logging: level: - org.hibernate.SQL: error - org.springframework.orm.jpa: info + org.hibernate.SQL: ERROR + org.hibernate.type.descriptor.sql: ERROR + org.springframework.orm.jpa: WARN app: cookie: