diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d0d296 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# Git +.git +.gitignore +.gitattributes +.github + +# Gradle +.gradle +build/ +!build/libs/*.jar +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +# IDE +.idea +*.iml +*.iws +*.ipr +.vscode +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Local data +postgres_data/ +localstack_data/ + +# Docker +Dockerfile +.dockerignore +docker-compose.yaml + +# Documentation +README.md +*.md + +# Kotlin +.kotlin + +# Test +src/test/ + +# Misc +Makefile \ No newline at end of file diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..4812a96 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,222 @@ +name: Deploy to Staging (K3s) + +on: + push: + branches: + - staging + workflow_dispatch: # 수동 실행 가능 + +jobs: + build-and-deploy: + name: Build, Test and Deploy to K3s + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + cache: 'gradle' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test --no-daemon + + - name: Build with Gradle + run: ./gradlew bootJar --no-daemon -x test + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Registry + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract version + id: version + run: | + VERSION=$(./gradlew properties --no-daemon --console=plain -q | grep "^version:" | awk '{print $2}') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/yapp-dev:${{ steps.version.outputs.version }}-${{ steps.version.outputs.short_sha }} + ${{ secrets.DOCKER_USERNAME }}/yapp-dev:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Discord notification - Deployment started + if: success() + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "🚀 Staging 배포 시작", + "description": "K3s 배포를 시작합니다", + "color": 3447003, + "fields": [ + { + "name": "브랜치", + "value": "'"${{ github.ref_name }}"'", + "inline": true + }, + { + "name": "커밋", + "value": "'"${{ steps.version.outputs.short_sha }}"'", + "inline": true + }, + { + "name": "작성자", + "value": "'"${{ github.actor }}"'", + "inline": true + }, + { + "name": "버전", + "value": "'"${{ steps.version.outputs.version }}-${{ steps.version.outputs.short_sha }}"'", + "inline": false + }, + { + "name": "커밋 메시지", + "value": "'"$(git log -1 --pretty=%B | head -n 1)"'", + "inline": false + } + ], + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + - name: Deploy to K3s (On-premise) with Zero-Downtime + id: deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + password: ${{ secrets.SSH_PASSWORD }} + port: ${{ secrets.SSH_PORT }} + script: | + echo "🚀 Starting Zero-Downtime deployment to K3s..." + + # K3s deployment 디렉토리로 이동 + cd /home/yapp/k3s/staging + + # 현재 실행 중인 이미지 확인 + CURRENT_IMAGE=$(kubectl get deployment yapp-app-staging -n staging -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "none") + echo "📌 Current image: $CURRENT_IMAGE" + + # 이미지 태그 업데이트 + NEW_IMAGE="${{ secrets.DOCKER_USERNAME }}/yapp-dev:${{ steps.version.outputs.version }}-${{ steps.version.outputs.short_sha }}" + echo "📦 New image: $NEW_IMAGE" + sed -i "s|image: .*yapp-dev:.*|image: $NEW_IMAGE|g" deployment.yaml + + # K3s에 배포 적용 (Rolling Update 자동 실행) + echo "🔄 Applying deployment... Rolling update will start automatically" + kubectl apply -f deployment.yaml + + # 배포 상태 확인 (새 파드가 완전히 준비될 때까지 대기) + echo "⏳ Waiting for new pods to be ready..." + if kubectl rollout status deployment/yapp-app-staging -n staging --timeout=10m; then + echo "✅ Rollout completed successfully!" + + # 배포된 파드 정보 확인 + echo "📊 Pod status:" + kubectl get pods -n staging -l app=yapp-app-staging + + # 최종 이미지 확인 + DEPLOYED_IMAGE=$(kubectl get deployment yapp-app-staging -n staging -o jsonpath='{.spec.template.spec.containers[0].image}') + echo "✅ Deployment completed! Deployed image: $DEPLOYED_IMAGE" + else + echo "❌ Rollout failed or timed out!" + kubectl get pods -n staging -l app=yapp-app-staging + exit 1 + fi + + - name: Discord notification - Deployment success + if: success() + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "✅ Staging 배포 성공", + "description": "K3s 배포가 성공적으로 완료되었습니다", + "color": 5763719, + "fields": [ + { + "name": "브랜치", + "value": "'"${{ github.ref_name }}"'", + "inline": true + }, + { + "name": "커밋", + "value": "'"${{ steps.version.outputs.short_sha }}"'", + "inline": true + }, + { + "name": "작성자", + "value": "'"${{ github.actor }}"'", + "inline": true + }, + { + "name": "배포 버전", + "value": "'"${{ steps.version.outputs.version }}-${{ steps.version.outputs.short_sha }}"'", + "inline": false + }, + { + "name": "배포 링크", + "value": "[GitHub Actions](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", + "inline": false + } + ], + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + - name: Discord notification - Deployment failed + if: failure() + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "❌ Staging 배포 실패", + "description": "K3s 배포 중 오류가 발생했습니다", + "color": 15158332, + "fields": [ + { + "name": "브랜치", + "value": "'"${{ github.ref_name }}"'", + "inline": true + }, + { + "name": "커밋", + "value": "'"${{ steps.version.outputs.short_sha }}"'", + "inline": true + }, + { + "name": "작성자", + "value": "'"${{ github.actor }}"'", + "inline": true + }, + { + "name": "실패 로그", + "value": "[GitHub Actions 확인](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", + "inline": false + } + ], + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK_URL }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2b81ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Layer extraction stage +FROM eclipse-temurin:21-jre-alpine AS builder + +WORKDIR /app + +# GitHub Actions에서 빌드된 JAR 파일 복사 +COPY build/libs/*.jar app.jar + +# Spring Boot Layered JAR에서 레이어 추출 +RUN java -Djarmode=layertools -jar app.jar extract + +# Runtime stage +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +# 보안을 위해 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 레이어별로 복사 (변경 빈도가 낮은 순서대로) +# 1. dependencies: 외부 라이브러리 (거의 변경 안 됨) +COPY --from=builder --chown=spring:spring /app/dependencies/ ./ + +# 2. spring-boot-loader: Spring Boot 로더 +COPY --from=builder --chown=spring:spring /app/spring-boot-loader/ ./ + +# 3. snapshot-dependencies: SNAPSHOT 의존성 +COPY --from=builder --chown=spring:spring /app/snapshot-dependencies/ ./ + +# 4. application: 애플리케이션 코드 (자주 변경됨) +COPY --from=builder --chown=spring:spring /app/application/ ./ + +# non-root 사용자로 전환 +USER spring:spring + +# 환경변수 설정 (기본값, 런타임에 오버라이드 가능) +ENV SPRING_PROFILES_ACTIVE=staging +ENV JASYPT_PASSWORD="" + +# 애플리케이션 실행 +# Layered JAR는 org.springframework.boot.loader.launch.JarLauncher를 사용 +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a7b8acc..db874b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { // Spring Boot implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-actuator") // Kotlin implementation("com.fasterxml.jackson.module:jackson-module-kotlin") @@ -137,3 +138,10 @@ tasks.withType { tasks.jar { enabled = false } + +tasks.bootJar { + // Spring Boot Layered JAR 활성화 (Docker 캐싱 최적화) + layered { + enabled = true + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/api/controller/AuthController.kt b/src/main/kotlin/com/yapp2app/auth/api/controller/AuthController.kt index eb9d34c..59624d4 100644 --- a/src/main/kotlin/com/yapp2app/auth/api/controller/AuthController.kt +++ b/src/main/kotlin/com/yapp2app/auth/api/controller/AuthController.kt @@ -1,14 +1,28 @@ package com.yapp2app.auth.api.controller -import com.yapp2app.auth.api.dto.KakaoOIDCLoginRequest -import com.yapp2app.auth.api.dto.TokenResponse +import com.yapp2app.auth.api.converter.AuthCommandConverter +import com.yapp2app.auth.api.converter.AuthResultConverter +import com.yapp2app.auth.api.dto.CreateAuthRequest +import com.yapp2app.auth.api.dto.GetAuthResponse +import com.yapp2app.auth.api.dto.GetKakaoTokenResponse +import com.yapp2app.auth.api.dto.GetTokenResponse +import com.yapp2app.auth.api.dto.LoginRequest +import com.yapp2app.auth.api.dto.RefreshTokenRequest +import com.yapp2app.auth.application.usecase.KakaoRegisterUseCase +import com.yapp2app.auth.application.usecase.LoginUseCase +import com.yapp2app.auth.application.usecase.RefreshTokenUseCase import com.yapp2app.common.api.dto.BaseResponse +import io.swagger.v3.oas.annotations.Hidden +import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController /** @@ -20,15 +34,151 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "AuthController", description = "인증/인가 API") @RequestMapping("/api/auth") @RestController -class AuthController { +class AuthController( + private val kakaoRegisterUseCase: KakaoRegisterUseCase, + private val loginUseCase: LoginUseCase, + private val refreshTokenUseCase: RefreshTokenUseCase, + private val commandConverter: AuthCommandConverter, + private val resultConverter: AuthResultConverter, +) { /** - * OIDC 방식 로그인 + * OIDC 방식 회원가입 */ + @Operation( + summary = "카카오 OIDC 회원가입", + description = """ + ## 카카오 OIDC 회원가입 API + + 앱에서 카카오 SDK로 획득한 idToken을 검증하고 회원가입을 처리합니다. + + ### 테스트용 idToken 발급 방법 (AOS, IOS 는 SDK로 부터 얻은 idToken 바로 넣어주면 됩니다!) + + #### 1단계: Authorization Code 획득 + 아래 URL을 브라우저에서 실행하여 카카오 로그인 후 idToken 얻습니다. + + [local] https://kauth.kakao.com/oauth/authorize?client_id=4db94315d17162e99b36029f6f9775c6&redirect_uri=http://localhost:8080/api/auth/test/kakao/redirect&response_type=code&scope=openid,profile_nickname,profile_image + + [staging] https://kauth.kakao.com/oauth/authorize?client_id=4db94315d17162e99b36029f6f9775c6&redirect_uri=https://dev-yapp.suitestudy.com:4641/api/auth/test/kakao/redirect&response_type=code&scope=openid,profile_nickname,profile_image + + 응답의 `id_token` 필드 값을 이 API의 `idToken`으로 사용하세요. + """, + ) @ApiResponses( ApiResponse(responseCode = "200", description = "카카오 OIDC 엔드포인트가 정상적으로 작동합니다."), ) - @PostMapping("/kakao/oidc") - fun kakaoLoginWithOIDC(@RequestBody request: KakaoOIDCLoginRequest): BaseResponse = - BaseResponse(data = TokenResponse("OK", "OK")) + @PostMapping("/kakao/register") + fun kakaoRegister(@RequestBody @Valid request: CreateAuthRequest): BaseResponse { + val command = commandConverter.toCreateAuthCommand(request) + + val result = kakaoRegisterUseCase.execute(command) + + val response = resultConverter.toCreateAuthResponse(result) + + return BaseResponse(data = response) + } + + @Operation( + summary = "로그인", + description = """ + ## 로그인 API + + 사용자의 OID와 ProviderType을 사용하여 로그인을 수행합니다. + + ### 성공 응답 (200 OK) + - **accessToken**: API 요청에 사용할 액세스 토큰 (유효기간: 설정값에 따름) + - **refreshToken**: 액세스 토큰 갱신에 사용할 리프레시 토큰 (유효기간: 설정값에 따름) + + ### API 호출 시 토큰 사용법 + ``` + Authorization: Bearer {accessToken} + ``` + + ### 토큰 만료 시 처리 방법 + 1. 인가가 필요한 API 호출 시 **401 Unauthorized** 응답을 받은 경우 + 2. 응답의 `code` 필드를 확인: + - **D-997** (토큰 만료): `/api/auth/refresh` API로 토큰 갱신 + - **D-998** (토큰 무효): 재로그인 필요 + - **D-999** (인증 실패): 재로그인 필요 + + ### 토큰 저장 권장사항 + - **accessToken**: 메모리 또는 안전한 저장소 (탈취 위험 최소화) + - **refreshToken**: 안전한 저장소 (Keychain, EncryptedSharedPreferences 등) + """, + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "로그인 성공"), + ApiResponse(responseCode = "400", description = "인증 실패 - 가입되지 않은 사용자"), + ) + @PostMapping("/login") + fun login(@RequestBody @Valid request: LoginRequest): BaseResponse { + val command = commandConverter.toLoginAuthCommand(request) + + val result = loginUseCase.execute(command) + + val response = resultConverter.toLoginAuthResponse(result) + + return BaseResponse(data = response) + } + + @Operation( + summary = "토큰 갱신", + description = """ + ## AccessToken 갱신 API + + RefreshToken을 사용하여 새로운 AccessToken과 RefreshToken을 발급받습니다. + + ### 사용 시나리오 + 1. 보호된 API 호출 시 **401 Unauthorized** 응답을 받음 + 2. 응답의 `resultCode` 필드가 **D-997** (토큰 만료)인 경우 + 3. 저장된 RefreshToken으로 이 API를 호출하여 새로운 토큰 발급 + + ### Refresh Token Rotation (보안 강화) + ⚠️ **중요**: 보안을 위해 Refresh Token Rotation을 적용합니다. + - 새로운 **AccessToken**과 함께 새로운 **RefreshToken**도 함께 발급됩니다. + - 기존 RefreshToken은 반드시 새로운 RefreshToken을 저장해야 합니다. + + [만료된 RefreshToken] eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sIm5hbWUiOiLrjIDtmIQiLCJwcm92aWRlcl90eXBlIjoiS0FLQU8iLCJpYXQiOjE3NjczMzQ2MDAsImV4cCI6MTc2NzMzNTIwMH0.Q1IQrm0QC30GJHA0bQj3TygdoveCiWbzRJ3ig9sDRlwaZqF-HNyshf9Jab8k1C9Ec3RerxJhzgHWEV-Ap7lecw + + """, + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "토큰 갱신 성공 - 새로운 AccessToken과 RefreshToken 발급"), + ApiResponse( + responseCode = "400", + description = "D-998: RefreshToken 만료 (재로그인 필요) 로그인 페이지로 이동", + ), + ) + @PostMapping("/refresh") + fun refreshToken(@RequestBody @Valid request: RefreshTokenRequest): BaseResponse { + val command = commandConverter.toRefreshTokenCommand(request) + + val result = refreshTokenUseCase.execute(command) + + val response = resultConverter.toLoginAuthResponse(result) + + return BaseResponse(data = response) + } + + /** + * ****** Test용이므로 Swagger Hidden 처리 ****** + * 테스트용 카카오 OAuth Redirect 엔드포인트 + * Authorization Code를 받아서 idToken으로 교환 + */ + @Hidden + @GetMapping("/test/kakao/redirect") + fun kakaoTestRedirect(@RequestParam code: String): BaseResponse { + val tokenResponse = kakaoRegisterUseCase.getAccessTokenByCode(code) + return BaseResponse( + data = GetKakaoTokenResponse( + accessToken = tokenResponse.accessToken, + tokenType = tokenResponse.tokenType, + refreshToken = tokenResponse.refreshToken, + expiresIn = tokenResponse.expiresIn, + scope = tokenResponse.scope, + refreshTokenExpiresIn = tokenResponse.refreshTokenExpiresIn, + idToken = tokenResponse.idToken, + ), + ) + } } diff --git a/src/main/kotlin/com/yapp2app/auth/api/controller/TestAuthController.kt b/src/main/kotlin/com/yapp2app/auth/api/controller/TestAuthController.kt deleted file mode 100644 index cdd0931..0000000 --- a/src/main/kotlin/com/yapp2app/auth/api/controller/TestAuthController.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.yapp2app.auth.api.controller - -import com.yapp2app.auth.api.dto.LoginRequest -import com.yapp2app.auth.api.dto.TokenResponse -import com.yapp2app.auth.infra.security.token.AuthTokenProvider -import com.yapp2app.common.api.dto.BaseResponse -import com.yapp2app.common.api.dto.ResultCode -import com.yapp2app.common.exception.BusinessException -import com.yapp2app.user.application.repository.UserRepository -import com.yapp2app.user.domain.enums.ProviderType -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -/** - * fileName : TestAuthController - * author : koo - * date : 2025. 12. 28. 오후 7:58 - * description : - */ -@Deprecated("로컬 토큰 발급을 위한 임시 엔드 포인트") -@RestController -@RequestMapping("/api/auth/test") -class TestAuthController( - private val tokenProvider: AuthTokenProvider, - private val userRepository: UserRepository, - private val passwordEncoder: PasswordEncoder, -) { - - @PostMapping("/login") - fun login(@RequestBody request: LoginRequest): BaseResponse { - val user = userRepository.findByEmailAndProviderType( - request.email, - ProviderType.LOCAL, - ) ?: throw BusinessException(ResultCode.NOT_FOUND_USER) - - if (!passwordEncoder.matches(request.password, user.password)) { - throw BusinessException(ResultCode.SECURITY_ERROR) - } - - // Access Token 생성 - val accessToken = tokenProvider.createToken( - id = user.id.toString(), - roles = user.roles.split(","), - providerType = ProviderType.LOCAL, - ) - - // Refresh Token 생성 - val refreshToken = tokenProvider.createToken( - id = user.id.toString(), - roles = user.roles.split(","), - providerType = ProviderType.LOCAL, - ) - - return BaseResponse( - data = TokenResponse( - accessToken = accessToken, - refreshToken = refreshToken, - ), - ) - } -} diff --git a/src/main/kotlin/com/yapp2app/auth/api/converter/AuthCommandConverter.kt b/src/main/kotlin/com/yapp2app/auth/api/converter/AuthCommandConverter.kt new file mode 100644 index 0000000..5a0f1f3 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/api/converter/AuthCommandConverter.kt @@ -0,0 +1,22 @@ +package com.yapp2app.auth.api.converter + +import com.yapp2app.auth.api.dto.CreateAuthRequest +import com.yapp2app.auth.api.dto.LoginRequest +import com.yapp2app.auth.api.dto.RefreshTokenRequest +import com.yapp2app.auth.application.command.LoginCommand +import com.yapp2app.auth.application.command.RefreshTokenCommand +import com.yapp2app.auth.application.command.RegisterKakaoUserCommand +import org.springframework.stereotype.Component + +@Component +class AuthCommandConverter { + + fun toCreateAuthCommand(request: CreateAuthRequest): RegisterKakaoUserCommand = + RegisterKakaoUserCommand(request.idToken) + + fun toLoginAuthCommand(request: LoginRequest): LoginCommand = + LoginCommand(oid = request.oid, providerType = request.providerType) + + fun toRefreshTokenCommand(request: RefreshTokenRequest): RefreshTokenCommand = + RefreshTokenCommand(request.refreshToken) +} diff --git a/src/main/kotlin/com/yapp2app/auth/api/converter/AuthResultConverter.kt b/src/main/kotlin/com/yapp2app/auth/api/converter/AuthResultConverter.kt new file mode 100644 index 0000000..5113bfe --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/api/converter/AuthResultConverter.kt @@ -0,0 +1,17 @@ +package com.yapp2app.auth.api.converter + +import com.yapp2app.auth.api.dto.GetAuthResponse +import com.yapp2app.auth.api.dto.GetTokenResponse +import com.yapp2app.auth.application.result.GetAuthResult +import com.yapp2app.auth.application.result.GetTokenResult +import org.springframework.stereotype.Component + +@Component +class AuthResultConverter { + + fun toCreateAuthResponse(result: GetAuthResult): GetAuthResponse = + GetAuthResponse(oid = result.oid, providerType = result.providerType) + + fun toLoginAuthResponse(result: GetTokenResult): GetTokenResponse = + GetTokenResponse(accessToken = result.accessToken, result.refreshToken) +} diff --git a/src/main/kotlin/com/yapp2app/auth/api/dto/AuthDto.kt b/src/main/kotlin/com/yapp2app/auth/api/dto/AuthDto.kt deleted file mode 100644 index ea2293f..0000000 --- a/src/main/kotlin/com/yapp2app/auth/api/dto/AuthDto.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.yapp2app.auth.api.dto - -/** - * fileName : AuthDto - * author : darren - * date : 2025. 12. 12. 13:20 - * description : auth 도메인과 관련된 Request/Response DTO - - */ - -// ==================================================================================================== -// Request DTOs -// ==================================================================================================== - -/** - * 카카오 OIDC 로그인 - */ -data class KakaoOIDCLoginRequest(val idToken: String) - -data class LoginRequest(val email: String, val password: String) - -// ==================================================================================================== -// Response DTOs -// ==================================================================================================== - -/** - * JWT 토큰 반환 - */ -data class TokenResponse(val accessToken: String, val refreshToken: String) diff --git a/src/main/kotlin/com/yapp2app/auth/api/dto/AuthRequest.kt b/src/main/kotlin/com/yapp2app/auth/api/dto/AuthRequest.kt new file mode 100644 index 0000000..78d0481 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/api/dto/AuthRequest.kt @@ -0,0 +1,16 @@ +package com.yapp2app.auth.api.dto + +import com.yapp2app.user.domain.enums.ProviderType +import jakarta.validation.constraints.NotBlank + +/** + * fileName : AuthRequest + * author : darren + * date : 2025. 12. 26. 18:05 + * description : 인증/인가 관련 요청 body + */ +data class CreateAuthRequest(@field:NotBlank(message = "ID 토큰은 필수 입니다") val idToken: String) + +data class LoginRequest(val oid: Long, val providerType: ProviderType) + +data class RefreshTokenRequest(@field:NotBlank(message = "Refresh 토큰은 필수입니다") val refreshToken: String) diff --git a/src/main/kotlin/com/yapp2app/auth/api/dto/AuthResponse.kt b/src/main/kotlin/com/yapp2app/auth/api/dto/AuthResponse.kt new file mode 100644 index 0000000..08fcc6d --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/api/dto/AuthResponse.kt @@ -0,0 +1,26 @@ +package com.yapp2app.auth.api.dto + +import com.yapp2app.user.domain.enums.ProviderType + +/** + * fileName : AuthResponse + * author : darren + * date : 2025. 12. 26. 18:05 + * description : Auth aggregate에 대한 응답 + */ +data class GetAuthResponse(val oid: Long, val providerType: ProviderType) + +data class GetTokenResponse(val accessToken: String, val refreshToken: String) + +/** + * REST_API TEST용 DTO + */ +data class GetKakaoTokenResponse( + val accessToken: String, + val tokenType: String, + val refreshToken: String, + val expiresIn: Int, + val scope: String? = null, + val refreshTokenExpiresIn: Int? = null, + val idToken: String? = null, +) diff --git a/src/main/kotlin/com/yapp2app/auth/application/command/AuthCommand.kt b/src/main/kotlin/com/yapp2app/auth/application/command/AuthCommand.kt new file mode 100644 index 0000000..6660ada --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/command/AuthCommand.kt @@ -0,0 +1,15 @@ +package com.yapp2app.auth.application.command + +import com.yapp2app.user.domain.enums.ProviderType + +/** + * fileName : AuthCommand + * author : darren + * date : 2025. 12. 12. 13:18 + * description : 인증/인가 관련 API + */ +data class RegisterKakaoUserCommand(val idToken: String) + +data class LoginCommand(val oid: Long, val providerType: ProviderType) + +data class RefreshTokenCommand(val refreshToken: String) diff --git a/src/main/kotlin/com/yapp2app/auth/application/contract/KakaoClientResponse.kt b/src/main/kotlin/com/yapp2app/auth/application/contract/KakaoClientResponse.kt new file mode 100644 index 0000000..a6a0bd1 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/contract/KakaoClientResponse.kt @@ -0,0 +1,49 @@ +package com.yapp2app.auth.application.contract + +import com.yapp2app.user.domain.enums.ProviderType + +/** + * 카카오 사용자정보 추출 DTO + */ +data class OauthInfoResponse( + val providerType: ProviderType, + val oid: Long, + val email: String?, + val name: String?, + val imageUrl: String?, +) + +data class OIDCDecodePayloadResponse( + /** issuer ex https://kauth.kakao.com */ + val iss: String, + /** client id */ + val aud: String, + /** oauth provider account unique id */ + val sub: Long, + /** biz 앱 신청을 해야 email을 수집가능,, */ + val email: String?, + /** 닉네임 */ + val nickname: String?, + /** 프로필 이미지 */ + val imageUrl: String?, +) + +data class OIDCPublicKeysResponse(val keys: MutableList) + +data class OIDCPublicKeyDto(val kid: String, val alg: String, val use: String, val n: String, val e: String) + +/** + * fileName : AuthResult + * author : darren + * date : 2025. 12. 26. 18:20 + * description : Auth usercase 관련 result idToken을 얻기 위한 테스트 DTO + */ +data class GetKakaoTokenResponse( + val accessToken: String, + val tokenType: String, + val refreshToken: String, + val expiresIn: Int, + val scope: String? = null, + val refreshTokenExpiresIn: Int? = null, + val idToken: String? = null, +) diff --git a/src/main/kotlin/com/yapp2app/auth/application/port/OauthHelperPort.kt b/src/main/kotlin/com/yapp2app/auth/application/port/OauthHelperPort.kt new file mode 100644 index 0000000..d650a7d --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/port/OauthHelperPort.kt @@ -0,0 +1,14 @@ +package com.yapp2app.auth.application.port + +import com.yapp2app.auth.application.contract.OIDCPublicKeysResponse +import com.yapp2app.auth.application.contract.OauthInfoResponse + +/** + * fileName : OauthHelperPort + * author : darren + * date : 2025. 12. 31. 10:21 + * description : OAuth OIDC 검증을 위한 Port + */ +interface OauthHelperPort { + fun getOauthInfoByIdToken(idToken: String, publicKeys: OIDCPublicKeysResponse): OauthInfoResponse +} diff --git a/src/main/kotlin/com/yapp2app/auth/application/port/OidcPort.kt b/src/main/kotlin/com/yapp2app/auth/application/port/OidcPort.kt new file mode 100644 index 0000000..294729c --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/port/OidcPort.kt @@ -0,0 +1,17 @@ +package com.yapp2app.auth.application.port + +import com.yapp2app.auth.application.contract.OIDCPublicKeysResponse + +/** + * fileName : OidcPort + * author : darren + * date : 2025. 12. 31. + * description : OAuth 외부 연동을 위한 Port + */ +interface OidcPort { + /** + * 카카오 OIDC 공개키 조회 + * TODO: Redis 연결 시 1주일간 캐싱 처리 필요 (카카오 측에서 요청 트레픽이 많으면 차단하기 때문) + */ + fun getOIDCPublicKey(): OIDCPublicKeysResponse +} diff --git a/src/main/kotlin/com/yapp2app/auth/application/result/GetAuthResult.kt b/src/main/kotlin/com/yapp2app/auth/application/result/GetAuthResult.kt new file mode 100644 index 0000000..ef34253 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/result/GetAuthResult.kt @@ -0,0 +1,13 @@ +package com.yapp2app.auth.application.result + +import com.yapp2app.user.domain.enums.ProviderType + +/** + * fileName : AuthResult + * author : darren + * date : 2025. 12. 29. 14:23 + * description : + */ +data class GetAuthResult(val oid: Long, val providerType: ProviderType) + +data class GetTokenResult(val accessToken: String, val refreshToken: String) diff --git a/src/main/kotlin/com/yapp2app/auth/application/usecase/KakaoRegisterUseCase.kt b/src/main/kotlin/com/yapp2app/auth/application/usecase/KakaoRegisterUseCase.kt new file mode 100644 index 0000000..dda1b12 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/usecase/KakaoRegisterUseCase.kt @@ -0,0 +1,117 @@ +package com.yapp2app.auth.application.usecase + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.yapp2app.auth.application.command.RegisterKakaoUserCommand +import com.yapp2app.auth.application.contract.GetKakaoTokenResponse +import com.yapp2app.auth.application.contract.OauthInfoResponse +import com.yapp2app.auth.application.port.OauthHelperPort +import com.yapp2app.auth.application.port.OidcPort +import com.yapp2app.auth.application.result.GetAuthResult +import com.yapp2app.auth.infra.security.properties.OauthProperties +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.transaction.TransactionRunner +import com.yapp2app.user.application.port.UserRepositoryPort +import com.yapp2app.user.domain.entity.User +import com.yapp2app.user.domain.enums.RoleType +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient + +/** + * fileName : KaKaoAuthUseCase + * author : darren + * date : 2025. 12. 26. 18:08 + * description : 카카오 auth usecase + */ +@UseCase +class KakaoRegisterUseCase( + private val oauthProperties: OauthProperties, + @Qualifier("kakaoOidcAdapter") private val oidcPort: OidcPort, + @Qualifier("kakaoOauthHelper") private val oauthHelperPort: OauthHelperPort, + private val restClient: RestClient, + private val userRepositoryPort: UserRepositoryPort, + private val transactionRunner: TransactionRunner, +) { + + /** + * 1. 카카오 공개키 조회 + * 2. ID Token 검증 및 Claims 추출 + * 3. oauthInfoResult 값 여부에 따라 회원가입 처리 + */ + fun execute(command: RegisterKakaoUserCommand): GetAuthResult { + // 카카오 공개 키 가져오기 + val oidcPublicKeysResult = oidcPort.getOIDCPublicKey() + + // ID Token 검증 및 Claims 추출 + val oauthInfoResponse: OauthInfoResponse = oauthHelperPort.getOauthInfoByIdToken( + idToken = command.idToken, + publicKeys = oidcPublicKeysResult, + ) + + val user = transactionRunner.run { registerKakaoUserIfEmpty(oauthInfoResponse) } + + return GetAuthResult(oid = user.oid, providerType = user.providerType) + } + + private fun registerKakaoUserIfEmpty(oauthInfoResponse: OauthInfoResponse): User { + val user = userRepositoryPort.findByOid( + oid = oauthInfoResponse.oid, + provider = oauthInfoResponse.providerType, + ) ?: User( + email = oauthInfoResponse.email, + oid = oauthInfoResponse.oid, + name = oauthInfoResponse.name, + roles = RoleType.USER.role, + providerType = oauthInfoResponse.providerType, + imageUrl = oauthInfoResponse.imageUrl, + ) + + return userRepositoryPort.save(user) + } + + /** + * [TEST 용도] + * idToken값을 APP 없이 추출하기 위한 코드 + * 카카오 인가 코드를 사용하여 액세스 토큰을 획득합니다. + * + * @param code 카카오 인증 서버에서 발급받은 인가 코드 + * @return KakaoTokenResponse 카카오 토큰 정보 + * @throws Exception 토큰 획득 실패 시 + */ + fun getAccessTokenByCode(code: String): GetKakaoTokenResponse { + val clientId = oauthProperties.kakao.clientId + val clientSecret = oauthProperties.kakao.clientSecret + + val params = LinkedMultiValueMap() + params.add("grant_type", "authorization_code") + params.add("client_id", clientId) + params.add("code", code) + + // Client Secret이 있으면 추가 + if (clientSecret.isNotBlank()) { + params.add("client_secret", clientSecret) + } + + val response = restClient.post() + .uri("https://kauth.kakao.com/oauth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .body(String::class.java) ?: throw RuntimeException("Failed to get token from Kakao") + + // JSON 응답을 파싱하여 KakaoTokenResponse로 변환 + val objectMapper = jacksonObjectMapper() + val jsonNode = objectMapper.readTree(response) + + return GetKakaoTokenResponse( + accessToken = jsonNode.get("access_token").asText(), + tokenType = jsonNode.get("token_type").asText(), + refreshToken = jsonNode.get("refresh_token").asText(), + expiresIn = jsonNode.get("expires_in").asInt(), + scope = jsonNode.get("scope")?.asText(), + refreshTokenExpiresIn = jsonNode.get("refresh_token_expires_in")?.asInt(), + idToken = jsonNode.get("id_token")?.asText(), + ) + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/application/usecase/LoginUseCase.kt b/src/main/kotlin/com/yapp2app/auth/application/usecase/LoginUseCase.kt new file mode 100644 index 0000000..06ce2f6 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/usecase/LoginUseCase.kt @@ -0,0 +1,45 @@ +package com.yapp2app.auth.application.usecase + +import com.yapp2app.auth.application.command.LoginCommand +import com.yapp2app.auth.application.result.GetTokenResult +import com.yapp2app.auth.infra.security.token.AuthTokenProvider +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.user.application.port.UserRepositoryPort + +/** + * fileName : LoginUseCase + * author : darren + * date : 2025. 12. 30. 18:05 + * description : 스프링 시큐리티를 사용한 로그인 UseCase + */ +@UseCase +class LoginUseCase(private val tokenProvider: AuthTokenProvider, private val userRepositoryPort: UserRepositoryPort) { + + fun execute(loginCommand: LoginCommand): GetTokenResult { + val user = userRepositoryPort.findByOid(loginCommand.oid, loginCommand.providerType) ?: throw BusinessException( + ResultCode.NOT_FOUND_USER, + ) + + // JWT 토큰 생성 + val accessToken = tokenProvider.createAccessToken( + id = user.id.toString(), + roles = user.roles.split(","), + name = user.name, + providerType = user.providerType, + ) + + val refreshToken = tokenProvider.createRefreshToken( + id = user.id.toString(), + roles = user.roles.split(","), + name = user.name, + providerType = user.providerType, + ) + + return GetTokenResult( + accessToken = accessToken, + refreshToken = refreshToken, + ) + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/application/usecase/RefreshTokenUseCase.kt b/src/main/kotlin/com/yapp2app/auth/application/usecase/RefreshTokenUseCase.kt new file mode 100644 index 0000000..502b228 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/application/usecase/RefreshTokenUseCase.kt @@ -0,0 +1,51 @@ +package com.yapp2app.auth.application.usecase + +import com.yapp2app.auth.application.command.RefreshTokenCommand +import com.yapp2app.auth.application.result.GetTokenResult +import com.yapp2app.auth.infra.security.token.AuthTokenProvider +import com.yapp2app.auth.infra.security.token.UserPrincipal +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException + +/** + * fileName : RefreshTokenUseCase + * author : darren + * date : 2026. 1. 2. 18:00 + * description : RefreshToken으로 AccessToken을 갱신하는 UseCase + */ +@UseCase +class RefreshTokenUseCase(private val tokenProvider: AuthTokenProvider) { + + fun execute(command: RefreshTokenCommand): GetTokenResult { + // 1. RefreshToken 유효성 검증 + if (!tokenProvider.validateRefreshToken(command.refreshToken)) { + throw BusinessException(ResultCode.INVALID_TOKEN_ERROR) + } + + // 2. RefreshToken에서 사용자 정보 추출 + val authentication = tokenProvider.getAuthenticationFromRefreshToken(command.refreshToken) + val userPrincipal = authentication.principal as UserPrincipal + + // 3. 새로운 AccessToken 생성 + val newAccessToken = tokenProvider.createAccessToken( + id = userPrincipal.id.toString(), + roles = userPrincipal.roles.toList(), + name = userPrincipal.name, + providerType = userPrincipal.providerType, + ) + + // 4. 새로운 RefreshToken 생성 (Refresh Token Rotation 적용) + val newRefreshToken = tokenProvider.createRefreshToken( + id = userPrincipal.id.toString(), + roles = userPrincipal.roles.toList(), + name = userPrincipal.name, + providerType = userPrincipal.providerType, + ) + + return GetTokenResult( + accessToken = newAccessToken, + refreshToken = newRefreshToken, + ) + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/infra/oauth/AppleOauthHelper.kt b/src/main/kotlin/com/yapp2app/auth/infra/oauth/AppleOauthHelper.kt new file mode 100644 index 0000000..3045574 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/infra/oauth/AppleOauthHelper.kt @@ -0,0 +1,19 @@ +package com.yapp2app.auth.infra.oauth + +import com.yapp2app.auth.application.contract.OIDCPublicKeysResponse +import com.yapp2app.auth.application.contract.OauthInfoResponse +import com.yapp2app.auth.application.port.OauthHelperPort +import org.springframework.stereotype.Component + +/** + * fileName : AppleOauthHelper + * author : darren + * date : 2025. 12. 31. 10:23 + * description : + */ +@Component +class AppleOauthHelper : OauthHelperPort { + override fun getOauthInfoByIdToken(idToken: String, publicKeys: OIDCPublicKeysResponse): OauthInfoResponse { + TODO("Not yet implemented") + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/infra/oauth/AppleOidcAdapter.kt b/src/main/kotlin/com/yapp2app/auth/infra/oauth/AppleOidcAdapter.kt new file mode 100644 index 0000000..04de626 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/infra/oauth/AppleOidcAdapter.kt @@ -0,0 +1,18 @@ +package com.yapp2app.auth.infra.oauth + +import com.yapp2app.auth.application.contract.OIDCPublicKeysResponse +import com.yapp2app.auth.application.port.OidcPort +import org.springframework.stereotype.Component + +/** + * fileName : AppleOidcAdapter + * author : darren + * date : 2025. 12. 31. 10:13 + * description : + */ +@Component +class AppleOidcAdapter : OidcPort { + override fun getOIDCPublicKey(): OIDCPublicKeysResponse { + TODO("Not yet implemented") + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/infra/oauth/KakaoOauthHelper.kt b/src/main/kotlin/com/yapp2app/auth/infra/oauth/KakaoOauthHelper.kt new file mode 100644 index 0000000..d67bbf6 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/infra/oauth/KakaoOauthHelper.kt @@ -0,0 +1,171 @@ +package com.yapp2app.auth.infra.oauth + +import com.fasterxml.jackson.databind.ObjectMapper +import com.yapp2app.auth.application.contract.OIDCDecodePayloadResponse +import com.yapp2app.auth.application.contract.OIDCPublicKeyDto +import com.yapp2app.auth.application.contract.OIDCPublicKeysResponse +import com.yapp2app.auth.application.contract.OauthInfoResponse +import com.yapp2app.auth.application.port.OauthHelperPort +import com.yapp2app.auth.infra.security.properties.OauthProperties +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.user.domain.enums.ProviderType +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Component +import java.math.BigInteger +import java.security.KeyFactory +import java.security.interfaces.RSAPublicKey +import java.security.spec.RSAPublicKeySpec +import java.util.Base64 + +/** + * fileName : KakaoOauthHelper + * author : darren + * date : 2025. 12. 28. 21:48 + * description : 카카오 OIDC 토큰 검증 헬퍼 + * + * 참고: https://devnm.tistory.com/35 + * 카카오 OIDC ID 토큰 검증 절차: + * 1. ID 토큰의 영역 구분자인 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리 페이로드를 Base64 방식으로 디코딩 + * 2. idToken의 kid jwks 검증 + * 3. 페이로드의 iss 값이 https://kauth.kakao.com와 일치하는지 확인 + * 4. 페이로드의 aud 값이 서비스 앱 키와 일치하는지 확인 APP일때는 APP_ID로 체크해야함 application-local만 REST_ID(clientId) + * 5. 페이로드의 exp 값이 현재 UNIX 타임스탬프(Timestamp)보다 큰 값인지 확인(ID 토큰이 만료되지 않았는지 확인) + * 6. 페이로드의 nonce 값이 카카오 로그인 요청 시 전달한 값과 일치하는지 확인 + * 7. 서명 검증 + */ +@Component +class KakaoOauthHelper(private val oauthProperties: OauthProperties, private val objectMapper: ObjectMapper) : + OauthHelperPort { + + companion object { + private const val HEADER_KID = "kid" + private const val CLAIM_EMAIL = "email" + private const val CLAIM_NICKNAME = "nickname" + private const val CLAIM_PROFILEIMAGE = "picture" + } + + /** + * 카카오 ID 토큰을 검증하고 OAuth 정보를 추출합니다. + * + * @param idToken 카카오 ID 토큰 + * @param publicKeys 카카오 공개키 목록 + * @return OAuth 정보 (Provider 타입, OID) + */ + override fun getOauthInfoByIdToken(idToken: String, publicKeys: OIDCPublicKeysResponse): OauthInfoResponse { + // Step 1: 헤더에서 kid 추출 (토큰 분리 및 Base64 디코딩) + val kid = extractKidFromTokenHeader(idToken) + + // Step 2: kid로 jwks 조회 + val publicKey = findPublicKeyByKid(publicKeys.keys, kid) + + // Step 3-7: 토큰 검증 및 Claims 추출 (iss, aud, exp, 서명 검증) + val payload = validateTokenAndExtractPayload( + token = idToken, + publicKey = publicKey, + expectedIssuer = oauthProperties.kakao.issuer, + expectedAudience = oauthProperties.kakao.clientId, + ) + + return OauthInfoResponse( + providerType = ProviderType.KAKAO, + oid = payload.sub, + email = payload.email, + name = payload.nickname, + imageUrl = payload.imageUrl, + ) + } + + /** + * Step 1: ID 토큰 헤더에서 kid 추출 + * - 토큰을 온점(.)으로 분리 + * - 헤더 부분을 Base64 디코딩 + * - JSON 파싱하여 kid 추출 + */ + private fun extractKidFromTokenHeader(token: String): String { + try { + val headerJson = String(Base64.getUrlDecoder().decode(token.split(".")[0])) + val header = objectMapper.readValue(headerJson, Map::class.java) + + return header[HEADER_KID] as? String + ?: throw BusinessException( + ResultCode.INVALID_TOKEN_ERROR, + ) + } catch (e: Exception) { + throw BusinessException(ResultCode.INVALID_TOKEN_ERROR) + } + } + + /** + * Step 2: kid로 공개키 목록에서 해당 공개키 찾기 + */ + private fun findPublicKeyByKid(keys: List, kid: String): OIDCPublicKeyDto = + keys.find { it.kid == kid } + ?: throw BusinessException( + ResultCode.INVALID_TOKEN_ERROR, + ) + + /** + * Step 3-7: 토큰 검증 및 페이로드 추출 + * - Step 3: iss 값 검증 (requireIssuer) + * - Step 4: aud 값 검증 (requireAudience) + * - Step 5: exp 값 검증 (자동, ExpiredJwtException 발생) + * - Step 6: nonce 검증 (선택사항, 현재 미구현) + * - Step 7: 서명 검증 (verifyWith) + */ + private fun validateTokenAndExtractPayload( + token: String, + publicKey: OIDCPublicKeyDto, + expectedIssuer: String, + expectedAudience: String, + ): OIDCDecodePayloadResponse { + val rsaPublicKey = convertToRSAPublicKey(publicKey.n, publicKey.e) + val claims = verifyTokenSignatureAndClaims(token, rsaPublicKey, expectedIssuer, expectedAudience) + + return OIDCDecodePayloadResponse( + iss = claims.issuer, + aud = claims.audience.toString(), + sub = claims.subject.toLong(), + email = claims[CLAIM_EMAIL, String::class.java], + nickname = claims[CLAIM_NICKNAME, String::class.java], + imageUrl = claims[CLAIM_PROFILEIMAGE, String::class.java], + ) + } + + /** + * JWT 서명 검증 및 Claims 검증 + * - 서명 검증 (RSA 공개키 사용) + * - iss, aud, exp 자동 검증 + */ + private fun verifyTokenSignatureAndClaims( + token: String, + publicKey: RSAPublicKey, + expectedIssuer: String, + expectedAudience: String, + ): Claims = try { + Jwts.parser() + .verifyWith(publicKey) // Step 7: 서명 검증 + .requireIssuer(expectedIssuer) // Step 3: iss 검증 + .requireAudience(expectedAudience) // Step 4: aud 검증 + .build() + .parseSignedClaims(token) // Step 5: exp 자동 검증 + .payload + } catch (e: ExpiredJwtException) { + throw BusinessException(ResultCode.EXPIRED_TOKEN_ERROR) + } catch (e: Exception) { + throw BusinessException(ResultCode.INVALID_TOKEN_ERROR) + } + + /** + * JWK의 modulus(n)와 exponent(e)를 RSA 공개키로 변환 + */ + private fun convertToRSAPublicKey(modulus: String, exponent: String): RSAPublicKey { + val keyFactory = KeyFactory.getInstance("RSA") + val n = BigInteger(1, Base64.getUrlDecoder().decode(modulus)) + val e = BigInteger(1, Base64.getUrlDecoder().decode(exponent)) + val keySpec = RSAPublicKeySpec(n, e) + return keyFactory.generatePublic(keySpec) as RSAPublicKey + } +} diff --git a/src/main/kotlin/com/yapp2app/auth/infra/oauth/KakaoOidcAdapter.kt b/src/main/kotlin/com/yapp2app/auth/infra/oauth/KakaoOidcAdapter.kt new file mode 100644 index 0000000..7474ded --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/infra/oauth/KakaoOidcAdapter.kt @@ -0,0 +1,22 @@ +package com.yapp2app.auth.infra.oauth + +import com.yapp2app.auth.application.contract.OIDCPublicKeysResponse +import com.yapp2app.auth.application.port.OidcPort +import com.yapp2app.auth.infra.security.properties.OauthProperties +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +/** + * fileName : KakaoOidcAdapter + * author : darren + * date : 2025. 12. 26. 18:20 + * description : 카카오 OAuth 외부 연동 Adapter + */ +@Component +class KakaoOidcAdapter(private val restClient: RestClient, private val oauthProperties: OauthProperties) : OidcPort { + + override fun getOIDCPublicKey(): OIDCPublicKeysResponse = restClient.get() + .uri(oauthProperties.kakao.jwksUri) + .retrieve() + .body(OIDCPublicKeysResponse::class.java)!! +} diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/config/PasswordConfig.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/config/PasswordConfig.kt deleted file mode 100644 index 5b9048b..0000000 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/config/PasswordConfig.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.yapp2app.auth.infra.security.config - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.DelegatingPasswordEncoder -import org.springframework.security.crypto.password.NoOpPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder - -/** - * fileName : PasswordConfig - * author : koo - * date : 2025. 12. 28. 오후 7:49 - * description : - */ -@Deprecated("password config for local password encrypt") -@Configuration -class PasswordConfig { - - @Bean - fun passwordEncoder(): PasswordEncoder { - val encoders = mapOf( - "bcrypt" to BCryptPasswordEncoder(), - "noop" to NoOpPasswordEncoder.getInstance(), - ) - - return DelegatingPasswordEncoder("noop", encoders) - } -} diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt index 391d992..ee10a27 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/config/SecurityConfig.kt @@ -1,8 +1,6 @@ package com.yapp2app.auth.infra.security.config import com.yapp2app.auth.infra.security.filter.JwtAuthenticationFilter -import com.yapp2app.auth.infra.security.properties.AppProperties -import com.yapp2app.auth.infra.security.token.AuthTokenProvider import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.annotation.Order @@ -17,15 +15,21 @@ import org.springframework.web.cors.CorsConfigurationSource */ @Configuration @EnableWebSecurity -class SecurityConfig( - private val authTokenProvider: AuthTokenProvider, - private val appProperties: AppProperties, - private val corsConfigurationSource: CorsConfigurationSource, - -) { +class SecurityConfig(private val corsConfigurationSource: CorsConfigurationSource) { + /** + * Actuator Health Check 엔드포인트 보안 설정 (Kubernetes Probe용) + */ @Bean @Order(0) + fun actuatorSecurityFilterChain(http: HttpSecurity): SecurityFilterChain = + http.securityMatcher("/actuator/health/**") + .csrf { it.disable() } + .authorizeHttpRequests { it.anyRequest().permitAll() } + .build() + + @Bean + @Order(1) fun documentSecurityFilterChain(http: HttpSecurity): SecurityFilterChain = http.securityMatcher("/swagger-ui/**", "/v3/api-docs/**") .csrf { it.disable() } @@ -35,7 +39,7 @@ class SecurityConfig( .build() @Bean - @Order(1) + @Order(2) fun apiSecurityFilterChain( http: HttpSecurity, jwtAuthenticationFilter: JwtAuthenticationFilter, diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/filter/JwtAuthenticationFilter.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/filter/JwtAuthenticationFilter.kt index b1e6912..5ee5f1f 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/filter/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/filter/JwtAuthenticationFilter.kt @@ -1,9 +1,17 @@ package com.yapp2app.auth.infra.security.filter +import com.nimbusds.jose.shaded.gson.JsonArray +import com.nimbusds.jose.shaded.gson.JsonObject import com.yapp2app.auth.infra.security.token.AuthTokenProvider +import com.yapp2app.common.api.dto.ResultCode +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.security.SignatureException import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter @@ -23,20 +31,26 @@ class JwtAuthenticationFilter(private val tokenProvider: AuthTokenProvider) : On filterChain: FilterChain, ) { try { - // Authorization 헤더에서 토큰 추출 - val token = extractToken(request) + val tokenStr: String? = extractToken(request) + tokenStr?.let { + val authentication: Authentication = tokenProvider.getAuthentication(it) - // 토큰이 있으면 검증 후 인증 정보 설정 - if (token != null) { - val authentication = tokenProvider.getAuthentication(token) SecurityContextHolder.getContext().authentication = authentication } - } catch (e: Exception) { - logger.debug("JWT validation failed: ${e.message}") - return + filterChain.doFilter(request, response) + } catch (ex: SignatureException) { + handleException(response, ResultCode.INVALID_TOKEN_ERROR) + } catch (ex: SecurityException) { + handleException(response, ResultCode.INVALID_TOKEN_ERROR) + } catch (ex: MalformedJwtException) { + handleException(response, ResultCode.INVALID_TOKEN_ERROR) + } catch (ex: ExpiredJwtException) { + handleException(response, ResultCode.EXPIRED_TOKEN_ERROR) + } catch (ex: UnsupportedJwtException) { + handleException(response, ResultCode.EXPIRED_TOKEN_ERROR) + } catch (ex: Exception) { + handleException(response, ResultCode.SECURITY_ERROR) } - - filterChain.doFilter(request, response) } private fun extractToken(request: HttpServletRequest): String? { @@ -47,4 +61,45 @@ class JwtAuthenticationFilter(private val tokenProvider: AuthTokenProvider) : On null } } + + /** + * JWT 검증 실패 시 에러 응답 처리 + * + * @param response HttpServletResponse + * @param resultCode 에러 코드 + * + * ## HTTP Status: 401 Unauthorized + * 인증이 필요하거나 인증에 실패한 경우 반환됩니다. + * + * ## 에러 코드별 클라이언트 대응 방법 + * + * ### D-997 (EXPIRED_TOKEN_ERROR) - 토큰 만료 + * - **원인**: AccessToken의 유효기간이 만료되었습니다. + * - **대응**: RefreshToken을 사용하여 `/api/auth/refresh` API를 호출해 새로운 토큰을 발급받으세요. + * - **재로그인 필요 여부**: 아니오 (RefreshToken이 유효한 경우) + * + * ### D-998 (INVALID_TOKEN_ERROR) - 토큰 무효 + * - **원인**: 토큰의 서명이 올바르지 않거나, 토큰 형식이 잘못되었습니다. + * - **대응**: 재로그인이 필요합니다. `/api/auth/login` API를 호출하세요. + * - **재로그인 필요 여부**: 예 + * + * ### D-999 (SECURITY_ERROR) - 인증 실패 + * - **원인**: 예상하지 못한 인증 오류가 발생했습니다. + * - **대응**: 재로그인이 필요합니다. `/api/auth/login` API를 호출하세요. + * - **재로그인 필요 여부**: 예 + */ + private fun handleException(response: HttpServletResponse, resultCode: ResultCode) { + val jsonObject = JsonObject() + + response.contentType = "application/json;charset=UTF-8" + response.characterEncoding = "utf-8" + response.status = HttpServletResponse.SC_UNAUTHORIZED + + jsonObject.addProperty("resultCode", resultCode.code) + jsonObject.addProperty("message", resultCode.message) + jsonObject.addProperty("success", false) + jsonObject.add("errors", JsonArray()) + + response.writer.print(jsonObject) + } } diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/properties/AppProperties.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/properties/AppProperties.kt index d3d6e0a..59c7726 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/properties/AppProperties.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/properties/AppProperties.kt @@ -3,8 +3,20 @@ package com.yapp2app.auth.infra.security.properties import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "app") -class AppProperties(var auth: Auth = Auth(), var cors: Cors = Cors()) +class AppProperties( + var version: String = "", + var server: Server = Server(), + var auth: Auth = Auth(), + var cors: Cors = Cors(), +) -class Auth(var tokenSecret: String? = null, var tokenExpiry: Long = 0) +class Server(var url: String = "") + +class Auth( + var accessTokenSecret: String? = null, + var accessTokenExpiry: Long = 0, + var refreshTokenSecret: String? = null, + var refreshTokenExpiry: Long = 0, +) class Cors(var allowedOrigins: List = emptyList()) diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/properties/OauthProperties.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/properties/OauthProperties.kt new file mode 100644 index 0000000..5d30e04 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/properties/OauthProperties.kt @@ -0,0 +1,10 @@ +package com.yapp2app.auth.infra.security.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth") +class OauthProperties(var kakao: Kakao = Kakao(), var apple: Apple = Apple()) + +class Kakao(var clientId: String = "", var clientSecret: String = "", var jwksUri: String = "", var issuer: String = "") + +class Apple(var key: String = "") diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt index 8ff6e55..734dee7 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/token/AuthTokenProvider.kt @@ -2,7 +2,6 @@ package com.yapp2app.auth.infra.security.token import com.yapp2app.auth.infra.security.properties.AppProperties import com.yapp2app.user.domain.enums.ProviderType -import com.yapp2app.user.domain.enums.RoleType import io.jsonwebtoken.Claims import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys @@ -29,35 +28,17 @@ class AuthTokenProvider(private val appProperties: AppProperties) { private const val PROVIDER_TYPE_KEY = "provider_type" } - private val secretKey: SecretKey by lazy { - Keys.hmacShaKeyFor(appProperties.auth.tokenSecret?.toByteArray() ?: byteArrayOf()) + private val accessTokenSecretKey: SecretKey by lazy { + Keys.hmacShaKeyFor(appProperties.auth.accessTokenSecret?.toByteArray() ?: byteArrayOf()) } - fun createToken(id: String): String = createToken( - id = id, - roles = listOf(RoleType.USER.role), - name = null, - providerType = null, - ) - - fun createToken(id: String, roles: Collection, providerType: ProviderType): String = createToken( - id = id, - roles = roles, - name = null, - providerType = providerType, - ) - - fun createToken(id: String, name: String, roles: Collection, providerType: ProviderType): String = - createToken( - id = id, - roles = roles, - name = name, - providerType = providerType, - ) + private val refreshTokenSecretKey: SecretKey by lazy { + Keys.hmacShaKeyFor(appProperties.auth.refreshTokenSecret?.toByteArray() ?: byteArrayOf()) + } - private fun createToken(id: String, roles: Collection, name: String?, providerType: ProviderType?): String { + fun createAccessToken(id: String, name: String?, roles: Collection, providerType: ProviderType): String { val now = Instant.now() - val expiryMillis = appProperties.auth.tokenExpiry ?: 0L + val expiryMillis = appProperties.auth.accessTokenExpiry ?: 0L return Jwts.builder() .subject(id) @@ -68,12 +49,29 @@ class AuthTokenProvider(private val appProperties: AppProperties) { } .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(expiryMillis))) - .signWith(secretKey) + .signWith(accessTokenSecretKey) + .compact() + } + + fun createRefreshToken(id: String, name: String?, roles: Collection, providerType: ProviderType): String { + val now = Instant.now() + val expiryMillis = appProperties.auth.refreshTokenExpiry ?: 0L + + return Jwts.builder() + .subject(id) + .claim(AUTHORITIES_KEY, roles) + .apply { + name?.let { claim(NAME_KEY, it) } + providerType?.let { claim(PROVIDER_TYPE_KEY, it) } + } + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusMillis(expiryMillis))) + .signWith(refreshTokenSecretKey) .compact() } fun getAuthentication(token: String): Authentication { - val claims = getTokenClaims(token) + val claims = getAccessTokenClaims(token) @Suppress("UNCHECKED_CAST") val roles = (claims[AUTHORITIES_KEY] as? List<*>) @@ -98,9 +96,48 @@ class AuthTokenProvider(private val appProperties: AppProperties) { return UsernamePasswordAuthenticationToken(principal, token, authorities) } - private fun getTokenClaims(token: String): Claims = Jwts.parser() - .verifyWith(secretKey) + private fun getAccessTokenClaims(token: String): Claims = Jwts.parser() + .verifyWith(accessTokenSecretKey) .build() .parseSignedClaims(token) .payload + + private fun getRefreshTokenClaims(token: String): Claims = Jwts.parser() + .verifyWith(refreshTokenSecretKey) + .build() + .parseSignedClaims(token) + .payload + + fun validateRefreshToken(token: String): Boolean = try { + getRefreshTokenClaims(token) + true + } catch (e: Exception) { + false + } + + fun getAuthenticationFromRefreshToken(token: String): Authentication { + val claims = getRefreshTokenClaims(token) + + @Suppress("UNCHECKED_CAST") + val roles = (claims[AUTHORITIES_KEY] as? List<*>) + ?.filterIsInstance() + ?: emptyList() + + val name = claims[NAME_KEY] as? String ?: "" + val providerTypeStr = claims[PROVIDER_TYPE_KEY] as? String + ?: throw IllegalArgumentException("Provider type not found in token") + + val authorities = roles.map { SimpleGrantedAuthority(it) } + + val principal = UserPrincipal( + id = claims.subject.toLong(), + name = name, + providerType = ProviderType.valueOf(providerTypeStr), + email = "", + roles = roles.toSet(), + password = "NO_PASS", + ) + + return UsernamePasswordAuthenticationToken(principal, token, authorities) + } } diff --git a/src/main/kotlin/com/yapp2app/auth/infra/security/token/UserPrincipal.kt b/src/main/kotlin/com/yapp2app/auth/infra/security/token/UserPrincipal.kt index 4946072..56c7dff 100644 --- a/src/main/kotlin/com/yapp2app/auth/infra/security/token/UserPrincipal.kt +++ b/src/main/kotlin/com/yapp2app/auth/infra/security/token/UserPrincipal.kt @@ -19,11 +19,11 @@ class UserPrincipal( @get:JvmName("id") val id: Long, @get:JvmName("name") - val name: String, + val name: String?, @get:JvmName("providerType") val providerType: ProviderType, @get:JvmName("email") - val email: String, + val email: String?, @get:JvmName("roles") val roles: Set, @get:JvmName("getUserAttributes") @@ -59,7 +59,7 @@ class UserPrincipal( password = "NO_PASS", ) - override fun getName(): String = name + override fun getName(): String? = name override fun getAttributes(): MutableMap = attributes @@ -75,5 +75,5 @@ class UserPrincipal( override fun getPassword(): String = password - override fun getUsername(): String = email + override fun getUsername(): String? = email } diff --git a/src/main/kotlin/com/yapp2app/common/api/config/ObjectMapperConfig.kt b/src/main/kotlin/com/yapp2app/common/api/config/ObjectMapperConfig.kt new file mode 100644 index 0000000..6342a90 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/api/config/ObjectMapperConfig.kt @@ -0,0 +1,23 @@ +package com.yapp2app.common.api.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * fileName : ObjectMapperConfig + * author : darren + * date : 2025. 12. 28. 23:43 + * description : ObjectMapper Bean 등록 + */ +@Configuration +class ObjectMapperConfig { + + @Bean + fun objectMapper(): ObjectMapper { + return ObjectMapper() + .registerKotlinModule() // Kotlin 파라미터 이름 인식을 위한 Kotlin Module 등록 + .findAndRegisterModules() // JavaTime 등 다른 모듈도 자동 등록 + } +} diff --git a/src/main/kotlin/com/yapp2app/common/api/document/SwaggerConfig.kt b/src/main/kotlin/com/yapp2app/common/api/document/SwaggerConfig.kt index b376706..a70cb5f 100644 --- a/src/main/kotlin/com/yapp2app/common/api/document/SwaggerConfig.kt +++ b/src/main/kotlin/com/yapp2app/common/api/document/SwaggerConfig.kt @@ -1,5 +1,6 @@ package com.yapp2app.common.api.document +import com.yapp2app.auth.infra.security.properties.AppProperties import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.Operation @@ -14,16 +15,12 @@ import io.swagger.v3.oas.models.security.SecurityScheme import io.swagger.v3.oas.models.servers.Server import org.springdoc.core.customizers.OperationCustomizer import org.springdoc.core.models.GroupedOpenApi -import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpStatus @Configuration -class SwaggerConfig( - @Value("\${app.server.url}") private val serverUrl: String, - @Value("\${app.version}") private val appVersion: String, -) { +class SwaggerConfig(private val appProperties: AppProperties) { companion object { private const val SECURITY_SCHEME = "JWT" @@ -45,11 +42,11 @@ class SwaggerConfig( // TODO : 도메인 추가 후 수정 .addServersItem( Server() - .url(serverUrl.replace("https://", "http://")) + .url(appProperties.server.url) .description("http server (no ssl)"), ) .components(components) - .info(Info().title("Yapp App Team2 API Document").version(appVersion)) + .info(Info().title("Yapp App Team2 API Document").version(appProperties.version)) } @Bean diff --git a/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt b/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt index 2966161..590bdd2 100644 --- a/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt +++ b/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt @@ -6,7 +6,7 @@ package com.yapp2app.common.api.dto * date : 2025. 12. 12. 13:25 * description : */ -enum class ResultCode(val code: String, var message: String) { +enum class ResultCode(val code: String, val message: String) { SUCCESS("D-0", "OK"), @@ -22,10 +22,4 @@ enum class ResultCode(val code: String, var message: String) { EXPIRED_TOKEN_ERROR("D-997", "토큰이 만료되었습니다."), INVALID_TOKEN_ERROR("D-998", "토큰이 올바르지 않습니다."), SECURITY_ERROR("D-999", "인증에 실패하였습니다."), - ; - - fun addMessage(message: String): ResultCode { - this.message += " => $message" - return this - } } diff --git a/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt b/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt index f6174ca..b0312f0 100644 --- a/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt +++ b/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt @@ -6,6 +6,7 @@ import com.yapp2app.common.api.dto.ResultCode import com.yapp2app.common.exception.BusinessException import com.yapp2app.common.exception.dto.ExceptionMsg import com.yapp2app.common.exception.dto.FieldErrorDetail +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.http.converter.HttpMessageNotReadableException @@ -14,7 +15,6 @@ import org.springframework.validation.ObjectError import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.MissingServletRequestParameterException import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.context.request.WebRequest import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException @@ -28,9 +28,12 @@ import java.util.function.Consumer */ @RestControllerAdvice class ExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) @ExceptionHandler(BusinessException::class) fun businessExceptionHandler(ex: BusinessException): ResponseEntity { + log.error("{} message = {}", ex.resultCode.code, ex.resultCode.message) + val temp = ResponseEntity( ExceptionMsg( resultCode = ex.resultCode.code, @@ -40,6 +43,7 @@ class ExceptionHandler { ), HttpStatus.BAD_REQUEST, ) + return temp } @@ -133,7 +137,6 @@ class ExceptionHandler { ) @ExceptionHandler(MethodArgumentTypeMismatchException::class) - @ResponseStatus(HttpStatus.OK) fun handleTypeMismatchHandler(ex: MethodArgumentTypeMismatchException): ResponseEntity = if (ex.requiredType?.isEnum == true) { val enumValues = ex.requiredType!!.enumConstants?.joinToString(", ") diff --git a/src/main/kotlin/com/yapp2app/common/infra/config/RestClientConfig.kt b/src/main/kotlin/com/yapp2app/common/infra/config/RestClientConfig.kt new file mode 100644 index 0000000..fa55a21 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/infra/config/RestClientConfig.kt @@ -0,0 +1,19 @@ +package com.yapp2app.common.infra.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient + +/** + * fileName : RestClientConfig + * author : darren + * date : 2025. 12. 28. 23:43 + * description : RestClient Bean 등록 + */ +@Configuration +class RestClientConfig { + + @Bean + fun restClient(): RestClient = RestClient.builder() + .build() +} diff --git a/src/main/kotlin/com/yapp2app/user/api/RegisterRequest.kt b/src/main/kotlin/com/yapp2app/user/api/RegisterRequest.kt deleted file mode 100644 index 41d66ab..0000000 --- a/src/main/kotlin/com/yapp2app/user/api/RegisterRequest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.yapp2app.user.api - -/** - * fileName : UserRequest - * author : koo - * date : 2025. 12. 28. 오후 7:45 - * description : 로컬 회원가입을 위한 요청 dto - */ -data class RegisterRequest(val email: String, val name: String, val password: String) diff --git a/src/main/kotlin/com/yapp2app/user/api/UserController.kt b/src/main/kotlin/com/yapp2app/user/api/UserController.kt index e211c56..8ca5e0b 100644 --- a/src/main/kotlin/com/yapp2app/user/api/UserController.kt +++ b/src/main/kotlin/com/yapp2app/user/api/UserController.kt @@ -1,12 +1,12 @@ package com.yapp2app.user.api +import com.yapp2app.auth.infra.security.token.UserPrincipal +import com.yapp2app.common.api.document.RequiresSecurity import com.yapp2app.common.api.dto.BaseResponse -import com.yapp2app.user.application.command.RegisterCommand -import com.yapp2app.user.application.usecase.RegisterUseCase -import com.yapp2app.user.domain.enums.ProviderType -import io.swagger.v3.oas.annotations.Hidden -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody +import com.yapp2app.user.api.response.GetUserInfoResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -18,20 +18,23 @@ import org.springframework.web.bind.annotation.RestController */ @RestController @RequestMapping("/api/users") -class UserController(private val registerUseCase: RegisterUseCase) { +class UserController { - @Hidden - @PostMapping("/register") - fun register(@RequestBody request: RegisterRequest): BaseResponse { - registerUseCase.execute( - RegisterCommand( - request.email, - request.name, - request.password, - ProviderType.LOCAL, - ), - ) + @Operation( + summary = "내 정보 조회", + description = """ + AccessToken 만료 시 HttpStatus 401 - return BaseResponse() - } + """, + ) + @RequiresSecurity // ← Swagger UI에서 JWT 토큰 전송 + @GetMapping("/info") + fun info( + @AuthenticationPrincipal userPrincipal: UserPrincipal, + ): BaseResponse = BaseResponse( + data = GetUserInfoResponse( + name = userPrincipal.name!!, + providerType = userPrincipal.providerType, + ), + ) } diff --git a/src/main/kotlin/com/yapp2app/user/api/response/GetUserInfoResponse.kt b/src/main/kotlin/com/yapp2app/user/api/response/GetUserInfoResponse.kt new file mode 100644 index 0000000..30de5dd --- /dev/null +++ b/src/main/kotlin/com/yapp2app/user/api/response/GetUserInfoResponse.kt @@ -0,0 +1,12 @@ +package com.yapp2app.user.api.response + +import com.yapp2app.user.domain.enums.ProviderType + +/** + * fileName : GetUserInfoResponse + * author : darren + * date : 2025. 12. 31. 14:45 + * description : + */ + +data class GetUserInfoResponse(val name: String, val providerType: ProviderType) diff --git a/src/main/kotlin/com/yapp2app/user/application/command/RegisterCommand.kt b/src/main/kotlin/com/yapp2app/user/application/command/RegisterCommand.kt deleted file mode 100644 index d412d28..0000000 --- a/src/main/kotlin/com/yapp2app/user/application/command/RegisterCommand.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.yapp2app.user.application.command - -import com.yapp2app.user.domain.enums.ProviderType - -/** - * fileName : RegisterCommand - * author : koo - * date : 2025. 12. 28. 오후 7:41 - * description : 로컬 회원가입을 위한 command - */ -data class RegisterCommand(val email: String, val name: String, val password: String, val providerType: ProviderType) diff --git a/src/main/kotlin/com/yapp2app/user/application/port/UserRepositoryPort.kt b/src/main/kotlin/com/yapp2app/user/application/port/UserRepositoryPort.kt new file mode 100644 index 0000000..e1f42cc --- /dev/null +++ b/src/main/kotlin/com/yapp2app/user/application/port/UserRepositoryPort.kt @@ -0,0 +1,16 @@ +package com.yapp2app.user.application.port + +import com.yapp2app.user.domain.entity.User +import com.yapp2app.user.domain.enums.ProviderType + +/** + * fileName : UserRepositoryPort + * author : darren + * date : 2025. 12. 29. 14:07 + * description : User 영속성 관련 포트 (command + query) + */ +interface UserRepositoryPort { + fun save(user: User): User + + fun findByOid(oid: Long, provider: ProviderType): User? +} diff --git a/src/main/kotlin/com/yapp2app/user/application/usecase/RegisterUseCase.kt b/src/main/kotlin/com/yapp2app/user/application/usecase/RegisterUseCase.kt deleted file mode 100644 index 5de030c..0000000 --- a/src/main/kotlin/com/yapp2app/user/application/usecase/RegisterUseCase.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.yapp2app.user.application.usecase - -import com.yapp2app.common.api.dto.ResultCode -import com.yapp2app.common.exception.BusinessException -import com.yapp2app.user.application.command.RegisterCommand -import com.yapp2app.user.application.repository.UserRepository -import com.yapp2app.user.domain.entity.User -import com.yapp2app.user.domain.enums.RoleType -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -/** - * fileName : RegisterUseCase - * author : koo - * date : 2025. 12. 28. 오후 7:40 - * description : 로컬 사용자 회원 가입을 위한 usecase - */ -@Service -class RegisterUseCase(private val userRepository: UserRepository, private val passwordEncoder: PasswordEncoder) { - - @Transactional - fun execute(command: RegisterCommand) { - if (userRepository.existsByEmailAndProviderType(command.email, command.providerType)) { - throw BusinessException(ResultCode.ALREADY_SIGNUP) - } - - val encodedPassword = passwordEncoder.encode(command.password) - - val user = - User( - email = command.email, - name = command.name, - roles = RoleType.USER.role, - password = encodedPassword, - providerType = command.providerType, - ) - - userRepository.save(user) - } -} diff --git a/src/main/kotlin/com/yapp2app/user/domain/entity/User.kt b/src/main/kotlin/com/yapp2app/user/domain/entity/User.kt index 75543c8..e2508b1 100644 --- a/src/main/kotlin/com/yapp2app/user/domain/entity/User.kt +++ b/src/main/kotlin/com/yapp2app/user/domain/entity/User.kt @@ -4,6 +4,7 @@ import com.yapp2app.user.domain.enums.ProviderType import com.yapp2app.user.domain.enums.RoleType import jakarta.persistence.* import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.DynamicUpdate import org.hibernate.annotations.UpdateTimestamp import java.time.LocalDateTime @@ -13,6 +14,7 @@ import java.time.LocalDateTime * date : 2025. 12. 18. 18:45 * description : User Entity */ +@DynamicUpdate @Entity @Table(name = "TB_USERS") class User( @@ -21,18 +23,24 @@ class User( val id: Long? = null, @Column(nullable = false, unique = true) - val email: String, + val email: String?, @Column(nullable = true) var password: String, + @Column(nullable = false) + val oid: Long, + @Column(nullable = false, length = 100) - var name: String, + var name: String?, @Enumerated(EnumType.STRING) @Column(nullable = false, length = 10) val providerType: ProviderType, + @Column(name = "image_url", nullable = true, length = 100) + var imageUrl: String?, + @Column(name = "role", nullable = false, length = 255) var roles: String = RoleType.USER.role, @@ -44,11 +52,20 @@ class User( @Column(nullable = false) var updatedAt: LocalDateTime? = null, ) { - constructor(email: String, name: String, roles: String, providerType: ProviderType) : this( - email = email, + constructor( + email: String?, + name: String?, + oid: Long, + roles: String, + providerType: ProviderType, + imageUrl: String?, + ) : this( + email = email ?: "NO_EMAIL", password = "NO_PASS", + oid = oid, name = name, providerType = providerType, roles = roles, + imageUrl = imageUrl, ) } diff --git a/src/main/kotlin/com/yapp2app/user/infra/persist/UserRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/user/infra/persist/UserRepositoryAdapter.kt new file mode 100644 index 0000000..74f86c8 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/user/infra/persist/UserRepositoryAdapter.kt @@ -0,0 +1,22 @@ +package com.yapp2app.user.infra.persist + +import com.yapp2app.user.application.port.UserRepositoryPort +import com.yapp2app.user.domain.entity.User +import com.yapp2app.user.domain.enums.ProviderType +import com.yapp2app.user.infra.persist.jpa.UserRepository +import org.springframework.stereotype.Repository + +/** + * fileName : UserRepositoryAdapter + * author : darren + * date : 2025. 12. 29. 14:05 + * description : User 영속성에 대한 Adapter (command + query) + */ +@Repository +class UserRepositoryAdapter(private val jpaRepository: UserRepository) : UserRepositoryPort { + + override fun save(user: User): User = jpaRepository.save(user) + + override fun findByOid(oid: Long, providerType: ProviderType): User? = + jpaRepository.findByOidAndProviderType(oid, providerType) +} diff --git a/src/main/kotlin/com/yapp2app/user/application/repository/UserRepository.kt b/src/main/kotlin/com/yapp2app/user/infra/persist/jpa/UserRepository.kt similarity index 62% rename from src/main/kotlin/com/yapp2app/user/application/repository/UserRepository.kt rename to src/main/kotlin/com/yapp2app/user/infra/persist/jpa/UserRepository.kt index 0ee98cb..53caffd 100644 --- a/src/main/kotlin/com/yapp2app/user/application/repository/UserRepository.kt +++ b/src/main/kotlin/com/yapp2app/user/infra/persist/jpa/UserRepository.kt @@ -1,4 +1,4 @@ -package com.yapp2app.user.application.repository +package com.yapp2app.user.infra.persist.jpa import com.yapp2app.user.domain.entity.User import com.yapp2app.user.domain.enums.ProviderType @@ -11,7 +11,5 @@ import org.springframework.data.jpa.repository.JpaRepository * description : User Entity Repository */ interface UserRepository : JpaRepository { - fun findByEmailAndProviderType(email: String, providerType: ProviderType): User? - - fun existsByEmailAndProviderType(email: String, providerType: ProviderType): Boolean + fun findByOidAndProviderType(oid: Long, providerType: ProviderType): User? } diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 74f9fd2..41ac0d4 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -33,9 +33,13 @@ jasypt: password: ${JASYPT_PASSWORD} app: + server: + url: http://localhost:8080 auth: - tokenSecret: ENC(LCqGofuONtTzkgq2ly/BDrRSoZT/cwZILTQZSXJtYf2ui5sDDdINUw+CWzb+GYmnk/yZ2RcSD9dvI1V5wMVjVyM3cwAEmX84CWqvvo602W3CLmfCnFnMIRjV2vvWiDJosuKMb7o7FJT/bPx0kuG2KOYhovDFYdgOMHxLtefhrWE=) - tokenExpiry: 7776000000 #3? + accessTokenSecret: ENC(LCqGofuONtTzkgq2ly/BDrRSoZT/cwZILTQZSXJtYf2ui5sDDdINUw+CWzb+GYmnk/yZ2RcSD9dvI1V5wMVjVyM3cwAEmX84CWqvvo602W3CLmfCnFnMIRjV2vvWiDJosuKMb7o7FJT/bPx0kuG2KOYhovDFYdgOMHxLtefhrWE=) + accessTokenExpiry: 3600000 #1시간 1분(60000) + refreshTokenSecret: ENC(fJ09aRyNLygqwQI3I0HfzIzvuVpsnOMauvW/1/qR5YIvmQS2JtKZiKsvoijbVAl25HE1bDZ29uYPjqFUTMuCdTh8zp+PczJ1FaYao3SgQ/iiodtxmleizF22NLwpwIyvIswND7ANcrS0IVgHTFinViJMFhb6rKgm6z1JK54Mbbg=) + refreshTokenExpiry: 604800000 #7일 cors: allowed-origins: - http://localhost:3000 @@ -45,6 +49,16 @@ app: - http://localhost:63342 - http://127.0.0.1:63342 +oauth: + kakao: + clientId: 4db94315d17162e99b36029f6f9775c6 #local 환경은 REST_KEY, 배포시에는 APP_ID + clientSecret: ENC(PjnJy0hcYdqBkqvSGqCcEp61czlJpJ1n4nYMCkAcFdo7ASIJrbBUqxJay41zVg8kG45KiPhYbxTfJe+X9vQlp5qZfIbZuVtAc85SHLexQIc=) + jwksUri: https://kauth.kakao.com/.well-known/jwks.json + issuer: https://kauth.kakao.com + + apple: + key: test + aws: s3: access-key: test diff --git a/src/main/resources/application-staging.yaml b/src/main/resources/application-staging.yaml index 592a254..dc327c4 100644 --- a/src/main/resources/application-staging.yaml +++ b/src/main/resources/application-staging.yaml @@ -27,6 +27,27 @@ jasypt: password: ${JASYPT_PASSWORD} app: + server: + url: https://dev-yapp.suitestudy.com:4641 auth: - tokenSecret: ENC(LCqGofuONtTzkgq2ly/BDrRSoZT/cwZILTQZSXJtYf2ui5sDDdINUw+CWzb+GYmnk/yZ2RcSD9dvI1V5wMVjVyM3cwAEmX84CWqvvo602W3CLmfCnFnMIRjV2vvWiDJosuKMb7o7FJT/bPx0kuG2KOYhovDFYdgOMHxLtefhrWE=) - tokenExpiry: 7776000000 #3? \ No newline at end of file + accessTokenSecret: ENC(LCqGofuONtTzkgq2ly/BDrRSoZT/cwZILTQZSXJtYf2ui5sDDdINUw+CWzb+GYmnk/yZ2RcSD9dvI1V5wMVjVyM3cwAEmX84CWqvvo602W3CLmfCnFnMIRjV2vvWiDJosuKMb7o7FJT/bPx0kuG2KOYhovDFYdgOMHxLtefhrWE=) + accessTokenExpiry: 3600000 #1시간 + refreshTokenSecret: ENC(fJ09aRyNLygqwQI3I0HfzIzvuVpsnOMauvW/1/qR5YIvmQS2JtKZiKsvoijbVAl25HE1bDZ29uYPjqFUTMuCdTh8zp+PczJ1FaYao3SgQ/iiodtxmleizF22NLwpwIyvIswND7ANcrS0IVgHTFinViJMFhb6rKgm6z1JK54Mbbg=) + refreshTokenExpiry: 604800000 #7일 + +oauth: + kakao: + clientId: 8964db6054f3f0beb0f534673f93eab7 + clientSecret: ENC(PjnJy0hcYdqBkqvSGqCcEp61czlJpJ1n4nYMCkAcFdo7ASIJrbBUqxJay41zVg8kG45KiPhYbxTfJe+X9vQlp5qZfIbZuVtAc85SHLexQIc=) + jwksUri: https://kauth.kakao.com/.well-known/jwks.json + issuer: https://kauth.kakao.com + apple: + key: test + +aws: + s3: + access-key: ENC(fXervUuOTP2A4AwymiZDlo3+l5nZjZ5ObhKMMzGXrNEaqo1MTZaHE+bEnMBsooHmjPiUqg5Jc502FETsVGDB5A==) + secret-key: ENC(/rlccjRKWaheGJ2eDHBAnTMKu6pWkQ6D4LhrKjniCKMMHH8tkoQ7l0VpZEr9UO8GmiMdmjrcK5pGznCZlzVGSf/9mGAcxa9Ezdoql7A/B2c=) + region: ap-northeast-2 + bucket: koosco-commerce-performance-results-ap-northeast-2 + base-url: http://localhost:4566/yapp-local \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4cd1c00..d0e2d8f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,8 +1,3 @@ -app: - version: @version@ - server: - url: ${APP_SERVER_URL:http://localhost:8080} - spring: application: name: Yapp_APP_2_Sever @@ -12,6 +7,29 @@ spring: springdoc: override-with-generic-response: false +# Application Version (from build.gradle.kts) +app: + version: "@version@" + +# Spring Boot Actuator 설정 (Health Check for Kubernetes) +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + probes: + enabled: true + show-details: when-authorized + health: + livenessstate: + enabled: true + readinessstate: + enabled: true + + aws: s3: access-key: ${AWS_S3_ACCESS_KEY:} diff --git a/src/main/resources/db/migration/V1__create_users_table.sql b/src/main/resources/db/migration/V1__create_users_table.sql index a8a90aa..e4b8ff5 100644 --- a/src/main/resources/db/migration/V1__create_users_table.sql +++ b/src/main/resources/db/migration/V1__create_users_table.sql @@ -1,10 +1,12 @@ -- Create users table CREATE TABLE TB_USERS ( id BIGSERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NULL, password VARCHAR(255), - name VARCHAR(100) NOT NULL, + oid BIGINT NOT NULL, + name VARCHAR(100) NULL, provider_type VARCHAR(10) NOT NULL, + image_url VARCHAR(255) NULL, role VARCHAR(255) NOT NULL DEFAULT 'ROLE_USER', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -13,10 +15,12 @@ CREATE TABLE TB_USERS ( -- Add comments for documentation COMMENT ON TABLE TB_USERS IS '사용자 정보 테이블'; COMMENT ON COLUMN TB_USERS.id IS '사용자 고유 ID'; -COMMENT ON COLUMN TB_USERS.email IS '사용자 이메일 (unique)'; +COMMENT ON COLUMN TB_USERS.email IS '사용자 이메일'; COMMENT ON COLUMN TB_USERS.password IS '사용자 비밀번호 (OAuth 사용자는 NO_PASS)'; +COMMENT ON COLUMN TB_USERS.oid IS 'OAuth 제공자의 사용자 고유 ID'; COMMENT ON COLUMN TB_USERS.name IS '사용자 이름'; COMMENT ON COLUMN TB_USERS.provider_type IS 'OAuth 제공자 타입 (APPLE, KAKAO)'; +COMMENT ON COLUMN TB_USERS.image_url IS '프로필 이미지'; COMMENT ON COLUMN TB_USERS.role IS '사용자 역할 (ROLE_USER, ROLE_ADMIN 등)'; COMMENT ON COLUMN TB_USERS.created_at IS '생성일시'; COMMENT ON COLUMN TB_USERS.updated_at IS '수정일시'; \ No newline at end of file diff --git a/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt b/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt index f6d0ed9..a2375c2 100644 --- a/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt +++ b/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt @@ -1,10 +1,10 @@ package com.yapp2app.e2e import com.yapp2app.auth.infra.security.token.AuthTokenProvider -import com.yapp2app.user.application.repository.UserRepository import com.yapp2app.user.domain.entity.User import com.yapp2app.user.domain.enums.ProviderType import com.yapp2app.user.domain.enums.RoleType +import com.yapp2app.user.infra.persist.jpa.UserRepository import org.junit.jupiter.api.AfterEach import org.springframework.beans.factory.annotation.Autowired @@ -44,8 +44,9 @@ abstract class E2ETestBase { ), ) - val token = tokenProvider.createToken( + val token = tokenProvider.createAccessToken( id = user.id.toString(), + name = user.name, roles = listOf(user.roles.split(",")[0]), providerType = user.providerType, ) @@ -63,7 +64,9 @@ abstract class E2ETestBase { email = email, name = name, password = password, + oid = System.currentTimeMillis(), providerType = providerType, roles = roles, + imageUrl = null, ) } diff --git a/src/test/kotlin/com/yapp2app/e2e/auth/AuthE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/auth/AuthE2ETest.kt new file mode 100644 index 0000000..7bb33d9 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/auth/AuthE2ETest.kt @@ -0,0 +1,167 @@ +package com.yapp2app.e2e.auth + +import com.yapp2app.auth.api.dto.LoginRequest +import com.yapp2app.auth.api.dto.RefreshTokenRequest +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.user.domain.entity.User +import com.yapp2app.user.domain.enums.ProviderType +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.notNullValue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : AuthE2ETest + * author : darren + * date : 2026. 01. 04. + * description : 인증/인가 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class AuthE2ETest : E2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + // 테스트 사용자 생성 + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("유효한 사용자 정보로 로그인 요청 시 성공 응답과 토큰을 반환한다") + fun givenValidCredentials_whenLogin_thenReturnsSuccessWithTokens() { + val request = LoginRequest( + oid = testUser.oid, + providerType = testUser.providerType, + ) + + val response = RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/api/auth/login") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.accessToken", notNullValue()) + .body("data.refreshToken", notNullValue()) + .extract() + .response() + + val accessToken = response.jsonPath().getString("data.accessToken") + val refreshToken = response.jsonPath().getString("data.refreshToken") + + println("========================================") + println("🔑 Access Token: $accessToken") + println("🔄 Refresh Token: $refreshToken") + println("========================================") + } + + @Test + @DisplayName("존재하지 않는 사용자로 로그인 요청 시 400 에러를 반환한다") + fun givenNonExistentUser_whenLogin_thenReturnsNotFoundError() { + val request = LoginRequest( + oid = 99999L, + providerType = ProviderType.TEST, + ) + + RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/api/auth/login") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND_USER.code)) + } + + @Test + @DisplayName("유효한 Refresh Token으로 토큰 갱신 요청 시 새로운 토큰을 반환한다") + fun givenValidRefreshToken_whenRefresh_thenReturnsNewTokens() { + // 먼저 로그인하여 토큰 획득 + val loginRequest = LoginRequest( + oid = testUser.oid, + providerType = testUser.providerType, + ) + + val loginResponse = RestAssured.given() + .contentType(ContentType.JSON) + .body(loginRequest) + .`when`() + .post("/api/auth/login") + .then() + .statusCode(HttpStatus.OK.value()) + .extract() + .jsonPath() + + val refreshToken = loginResponse.getString("data.refreshToken") + + // Refresh Token으로 토큰 갱신 + val refreshRequest = RefreshTokenRequest(refreshToken = refreshToken) + + RestAssured.given() + .contentType(ContentType.JSON) + .body(refreshRequest) + .`when`() + .post("/api/auth/refresh") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.accessToken", notNullValue()) + .body("data.refreshToken", notNullValue()) + } + + @Test + @DisplayName("유효하지 않은 Refresh Token으로 토큰 갱신 요청 시 400 에러를 반환한다") + fun givenInvalidRefreshToken_whenRefresh_thenReturnsInvalidTokenError() { + val invalidRefreshToken = "invalid.refresh.token" + val request = RefreshTokenRequest(refreshToken = invalidRefreshToken) + + RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/api/auth/refresh") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_TOKEN_ERROR.code)) + } + + @Test + @DisplayName("빈 Refresh Token으로 토큰 갱신 요청 시 400 에러를 반환한다") + fun givenBlankRefreshToken_whenRefresh_thenReturnsBadRequest() { + val request = RefreshTokenRequest(refreshToken = "") + + RestAssured.given() + .contentType(ContentType.JSON) + .body(request) + .`when`() + .post("/api/auth/refresh") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/user/UserE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/user/UserE2ETest.kt new file mode 100644 index 0000000..1311f5a --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/user/UserE2ETest.kt @@ -0,0 +1,117 @@ +package com.yapp2app.e2e.user + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.CoreMatchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : UserE2ETest + * author : darren + * date : 2026. 01. 04. + * description : 사용자 API E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class UserE2ETest : E2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private val expiredAccessToken: String = + "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiIxIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sIm5hbWUiOiLthYzsiqTtirgg7IKs7Jqp7J6QIiwicHJvdmlkZXJfdHlwZSI6IlRFU1QiLCJpYXQiOjE3Njc1MTQyMjQsImV4cCI6MTc2NzUxNDI4NH0.QJ0T0eoYxMf7PUxQni2AGMMrNEMMFphY1W5vLE66vUyuPES-trmvqs7xbm9mp63v" + private val expiredRefreshToken: String = + "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiIxIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sIm5hbWUiOiLthYzsiqTtirgg7IKs7Jqp7J6QIiwicHJvdmlkZXJfdHlwZSI6IlRFU1QiLCJpYXQiOjE3Njc1MTQyMjQsImV4cCI6MTc2NzUxNDI4NH0.KkvRf2fmXjr51Lk0Q8Xmd_MpKhJUY9m9WGZIqLH3yilMh47iv6Q7PIxmTUGuk1O1" + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + // 테스트 사용자 생성 + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("유효한 토큰으로 사용자 정보 조회 시 성공 응답을 반환한다") + fun givenValidToken_whenGetUserInfo_thenReturnsSuccess() { + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/users/info") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .body("data.name", equalTo(testUser.name)) + .body("data.providerType", equalTo(testUser.providerType.name)) + } + + @Test + @DisplayName("만료된 Access Token으로 사용자 정보 조회 시 401 에러를 반환한다") + fun givenExpiredToken_whenGetUserInfo_thenReturnsUnauthorized() { + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $expiredAccessToken") + .`when`() + .get("/api/users/info") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.EXPIRED_TOKEN_ERROR.code)) + } + + @Test + @DisplayName("토큰 없이 사용자 정보 조회 시 401 에러를 반환한다") + fun givenNoToken_whenGetUserInfo_thenReturnsUnauthorized() { + RestAssured.given() + .contentType(ContentType.JSON) + .`when`() + .get("/api/users/info") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + } + + @Test + @DisplayName("잘못된 형식의 토큰으로 사용자 정보 조회 시 401 에러를 반환한다") + fun givenInvalidToken_whenGetUserInfo_thenReturnsUnauthorized() { + val invalidToken = "invalid.token.format" + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $invalidToken") + .`when`() + .get("/api/users/info") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_TOKEN_ERROR.code)) + } + + @Test + @DisplayName("Bearer 접두사 없이 토큰으로 사용자 정보 조회 시 401 에러를 반환한다") + fun givenTokenWithoutBearerPrefix_whenGetUserInfo_thenReturnsUnauthorized() { + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", accessToken) + .`when`() + .get("/api/users/info") + .then() + .statusCode(HttpStatus.FORBIDDEN.value()) + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6474e85..8d50bda 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -18,5 +18,15 @@ spring: app: auth: - tokenSecret: testSecretTokenMustBeVeryLongTestSecretDoesNotUseJasypt - tokenExpiry: 7776000000 + accessTokenSecret: testSecretTokenMustBeVeryLongTestSecretDoesNotUseJasypt + accessTokenExpiry: 3600000 #1시간 + refreshTokenSecret: testrefreshTokenMustBeVeryLongTestSecretDoesNotUseJasypt + refreshTokenExpiry: 604800000 #7일 + + +aws: + s3: + access-key: test + secret-key: test + region: ap-northeast-2 + bucket: yapp-local \ No newline at end of file