From 416e900c543c6a578fed7f9716af858a62eea807 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 00:52:27 +0900 Subject: [PATCH 01/27] =?UTF-8?q?chore:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B3=A8=EA=B2=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 28 +++++++++++++++ docker-compose.yml | 18 ++++++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 34 +++++++++++++++++++ .../nexters/gamss/GamssApplicationTests.kt | 3 ++ .../gamss/support/TestcontainersConfig.kt | 17 ++++++++++ src/test/resources/application.yml | 22 ++++++++++++ 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/test/kotlin/com/nexters/gamss/support/TestcontainersConfig.kt create mode 100644 src/test/resources/application.yml diff --git a/build.gradle.kts b/build.gradle.kts index a188e89..a7623d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") version "2.3.21" kotlin("plugin.spring") version "2.3.21" + kotlin("plugin.jpa") version "2.3.21" id("org.springframework.boot") version "4.1.0" id("io.spring.dependency-management") version "1.1.7" id("org.jlleitschuh.gradle.ktlint") version "12.1.2" @@ -21,10 +22,37 @@ repositories { } dependencies { + // Web / JSON implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("tools.jackson.module:jackson-module-kotlin") + + // Persistence + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("com.mysql:mysql-connector-j") + + // Security · Validation + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // JWT — 자체 토큰 발급/파싱(jjwt), 소셜 토큰 서명 검증(nimbus) + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + implementation("com.nimbusds:nimbus-jose-jwt:10.0.2") + + // API Docs (Swagger) + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") + + // Test testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4")) + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:mysql") + testImplementation("io.mockk:mockk:1.13.13") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4aaba46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +# 로컬 개발용 MySQL. 실행: docker compose up -d +services: + mysql: + image: mysql:8.0 + container_name: gamss-mysql + ports: + - "3306:3306" + environment: + MYSQL_DATABASE: gamss + MYSQL_USER: gamss + MYSQL_PASSWORD: gamss + MYSQL_ROOT_PASSWORD: root + volumes: + - gamss-mysql-data:/var/lib/mysql + restart: unless-stopped + +volumes: + gamss-mysql-data: diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 9ee4547..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=gamss diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..43b0906 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,34 @@ +spring: + application: + name: gamss + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3306/gamss} + username: ${DB_USERNAME:gamss} + password: ${DB_PASSWORD:gamss} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + open-in-view: false + properties: + hibernate: + format_sql: true + +jwt: + secret: ${JWT_SECRET:local-development-secret-change-me-to-a-long-random-value-0123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:PT1H} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:P14D} + +oauth: + google: + issuer: https://accounts.google.com + jwks-uri: https://www.googleapis.com/oauth2/v3/certs + client-ids: ${GOOGLE_CLIENT_IDS:} + apple: + issuer: https://appleid.apple.com + jwks-uri: https://appleid.apple.com/auth/keys + client-ids: ${APPLE_CLIENT_IDS:} + +springdoc: + swagger-ui: + path: /swagger-ui.html diff --git a/src/test/kotlin/com/nexters/gamss/GamssApplicationTests.kt b/src/test/kotlin/com/nexters/gamss/GamssApplicationTests.kt index c49ba17..9bd0d1b 100644 --- a/src/test/kotlin/com/nexters/gamss/GamssApplicationTests.kt +++ b/src/test/kotlin/com/nexters/gamss/GamssApplicationTests.kt @@ -1,8 +1,11 @@ package com.nexters.gamss +import com.nexters.gamss.support.TestcontainersConfig import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +@Import(TestcontainersConfig::class) @SpringBootTest class GamssApplicationTests { @Test diff --git a/src/test/kotlin/com/nexters/gamss/support/TestcontainersConfig.kt b/src/test/kotlin/com/nexters/gamss/support/TestcontainersConfig.kt new file mode 100644 index 0000000..d2b4852 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/support/TestcontainersConfig.kt @@ -0,0 +1,17 @@ +package com.nexters.gamss.support + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.context.annotation.Bean +import org.testcontainers.containers.MySQLContainer + +/** + * 통합/리포지토리 테스트용 MySQL Testcontainer. + * `@ServiceConnection` 으로 datasource 설정이 자동 주입된다. + */ +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfig { + @Bean + @ServiceConnection + fun mysqlContainer(): MySQLContainer<*> = MySQLContainer("mysql:8.0") +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..32f038a --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,22 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: false + +jwt: + secret: test-secret-value-sufficiently-long-for-hs256-signing-0123456789abcdef + access-token-validity: PT1H + refresh-token-validity: P14D + +oauth: + google: + issuer: https://accounts.google.com + jwks-uri: https://www.googleapis.com/oauth2/v3/certs + client-ids: test-google-client-id + apple: + issuer: https://appleid.apple.com + jwks-uri: https://appleid.apple.com/auth/keys + client-ids: test-apple-client-id From 30661e10eddbfc7b1bb031a2c37786389299f72e Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 00:54:45 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/BusinessException.kt | 9 +++ .../gamss/global/exception/ErrorCode.kt | 29 ++++++++++ .../exception/GlobalExceptionHandler.kt | 43 +++++++++++++++ .../gamss/global/response/ApiResponse.kt | 23 ++++++++ .../exception/GlobalExceptionHandlerTest.kt | 55 +++++++++++++++++++ .../gamss/global/response/ApiResponseTest.kt | 35 ++++++++++++ 6 files changed, 194 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/global/exception/BusinessException.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt create mode 100644 src/test/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandlerTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/global/response/ApiResponseTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/global/exception/BusinessException.kt b/src/main/kotlin/com/nexters/gamss/global/exception/BusinessException.kt new file mode 100644 index 0000000..94b155d --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/exception/BusinessException.kt @@ -0,0 +1,9 @@ +package com.nexters.gamss.global.exception + +/** + * 도메인 비즈니스 예외. 에러 코드로 HTTP 상태·메시지를 표현한다. + */ +open class BusinessException( + val errorCode: ErrorCode, + message: String? = null, +) : RuntimeException(message ?: errorCode.message) diff --git a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt new file mode 100644 index 0000000..b70ee66 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt @@ -0,0 +1,29 @@ +package com.nexters.gamss.global.exception + +import org.springframework.http.HttpStatus + +/** + * 도메인 예외의 에러 코드. 새 에러는 여기에 상수만 추가한다(OCP). + */ +enum class ErrorCode( + val status: HttpStatus, + val message: String, +) { + // 공통 + INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다."), + + // 인증 · 소셜 로그인 + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 제공자입니다."), + INVALID_SOCIAL_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 소셜 토큰입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰을 찾을 수 없습니다."), + + // 회원 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), + ; + + val code: String get() = name +} diff --git a/src/main/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..a400179 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandler.kt @@ -0,0 +1,43 @@ +package com.nexters.gamss.global.exception + +import com.nexters.gamss.global.response.ApiResponse +import com.nexters.gamss.global.response.ErrorResponse +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(BusinessException::class) + fun handleBusiness(e: BusinessException): ResponseEntity> { + val errorCode = e.errorCode + return ResponseEntity + .status(errorCode.status) + .body(ApiResponse.error(ErrorResponse(errorCode.code, e.message ?: errorCode.message))) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity> { + val errorCode = ErrorCode.INVALID_INPUT + val message = + e.bindingResult.fieldErrors + .firstOrNull() + ?.defaultMessage ?: errorCode.message + return ResponseEntity + .status(errorCode.status) + .body(ApiResponse.error(ErrorResponse(errorCode.code, message))) + } + + @ExceptionHandler(Exception::class) + fun handleUnexpected(e: Exception): ResponseEntity> { + log.error("처리되지 않은 예외", e) + val errorCode = ErrorCode.INTERNAL_ERROR + return ResponseEntity + .status(errorCode.status) + .body(ApiResponse.error(ErrorResponse(errorCode.code, errorCode.message))) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt b/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt new file mode 100644 index 0000000..f2b5660 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt @@ -0,0 +1,23 @@ +package com.nexters.gamss.global.response + +/** + * 모든 API의 공통 응답 포맷. + */ +data class ApiResponse( + val success: Boolean, + val data: T? = null, + val error: ErrorResponse? = null, +) { + companion object { + fun success(data: T): ApiResponse = ApiResponse(success = true, data = data) + + fun success(): ApiResponse = ApiResponse(success = true) + + fun error(error: ErrorResponse): ApiResponse = ApiResponse(success = false, error = error) + } +} + +data class ErrorResponse( + val code: String, + val message: String, +) diff --git a/src/test/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandlerTest.kt b/src/test/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandlerTest.kt new file mode 100644 index 0000000..8ef5f9f --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/exception/GlobalExceptionHandlerTest.kt @@ -0,0 +1,55 @@ +package com.nexters.gamss.global.exception + +import io.mockk.every +import io.mockk.mockk +import org.springframework.http.HttpStatus +import org.springframework.validation.BindingResult +import org.springframework.web.bind.MethodArgumentNotValidException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class GlobalExceptionHandlerTest { + private val handler = GlobalExceptionHandler() + + @Test + fun `비즈니스 예외를 에러 코드에 맞는 응답으로 변환한다`() { + val response = handler.handleBusiness(BusinessException(ErrorCode.MEMBER_NOT_FOUND)) + + assertEquals(HttpStatus.NOT_FOUND, response.statusCode) + val body = response.body!! + assertFalse(body.success) + assertNull(body.data) + assertEquals("MEMBER_NOT_FOUND", body.error!!.code) + assertEquals(ErrorCode.MEMBER_NOT_FOUND.message, body.error!!.message) + } + + @Test + fun `비즈니스 예외의 커스텀 메시지를 그대로 전달한다`() { + val response = handler.handleBusiness(BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN, "서명 검증 실패")) + + assertEquals("서명 검증 실패", response.body!!.error!!.message) + } + + @Test + fun `검증 실패는 400 INVALID_INPUT 으로 변환한다`() { + val exception = mockk() + val bindingResult = mockk() + every { exception.bindingResult } returns bindingResult + every { bindingResult.fieldErrors } returns emptyList() + + val response = handler.handleValidation(exception) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertEquals("INVALID_INPUT", response.body!!.error!!.code) + } + + @Test + fun `예상치 못한 예외는 500 INTERNAL_ERROR 로 변환한다`() { + val response = handler.handleUnexpected(RuntimeException("boom")) + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) + assertEquals("INTERNAL_ERROR", response.body!!.error!!.code) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/global/response/ApiResponseTest.kt b/src/test/kotlin/com/nexters/gamss/global/response/ApiResponseTest.kt new file mode 100644 index 0000000..db82cf2 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/response/ApiResponseTest.kt @@ -0,0 +1,35 @@ +package com.nexters.gamss.global.response + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ApiResponseTest { + @Test + fun `데이터를 담은 성공 응답을 생성한다`() { + val response = ApiResponse.success("hello") + + assertTrue(response.success) + assertEquals("hello", response.data) + assertNull(response.error) + } + + @Test + fun `데이터 없는 성공 응답을 생성한다`() { + val response = ApiResponse.success() + + assertTrue(response.success) + assertNull(response.error) + } + + @Test + fun `에러 응답을 생성한다`() { + val response = ApiResponse.error(ErrorResponse("INVALID_TOKEN", "유효하지 않은 토큰입니다.")) + + assertFalse(response.success) + assertNull(response.data) + assertEquals("INVALID_TOKEN", response.error!!.code) + } +} From 9832595d4444eff01d407ef793bea74bd2c4bcd6 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 00:59:09 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20Member=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/nexters/gamss/member/domain/Member.kt | 43 +++++++++++++++++++ .../gamss/member/domain/OAuthProvider.kt | 19 ++++++++ .../member/repository/MemberRepository.kt | 12 ++++++ .../nexters/gamss/member/domain/MemberTest.kt | 32 ++++++++++++++ .../gamss/member/domain/OAuthProviderTest.kt | 22 ++++++++++ .../member/repository/MemberRepositoryTest.kt | 32 ++++++++++++++ .../nexters/gamss/support/RepositoryTest.kt | 13 ++++++ 7 files changed, 173 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/member/domain/Member.kt create mode 100644 src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt create mode 100644 src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt create mode 100644 src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/support/RepositoryTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt new file mode 100644 index 0000000..d6f8e40 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt @@ -0,0 +1,43 @@ +package com.nexters.gamss.member.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant + +/** + * 소셜 로그인으로 식별되는 회원. (provider, providerId)가 유일 식별자. + */ +@Entity +@Table( + name = "members", + uniqueConstraints = [ + UniqueConstraint(name = "uk_member_provider", columnNames = ["provider", "provider_id"]), + ], +) +class Member( + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 20) + val provider: OAuthProvider, + @Column(name = "provider_id", nullable = false) + val providerId: String, + @Column(name = "email") + var email: String? = null, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L + + @Column(name = "created_at", nullable = false, updatable = false) + val createdAt: Instant = Instant.now() + + fun updateEmail(email: String?) { + this.email = email + } +} diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt b/src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt new file mode 100644 index 0000000..54132c2 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt @@ -0,0 +1,19 @@ +package com.nexters.gamss.member.domain + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode + +/** + * 소셜 로그인 제공자. 새 제공자는 여기에 상수만 추가한다. + */ +enum class OAuthProvider { + GOOGLE, + APPLE, + ; + + companion object { + fun from(value: String): OAuthProvider = + entries.firstOrNull { it.name.equals(value, ignoreCase = true) } + ?: throw BusinessException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt b/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt new file mode 100644 index 0000000..b0c54d8 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt @@ -0,0 +1,12 @@ +package com.nexters.gamss.member.repository + +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.OAuthProvider +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberRepository : JpaRepository { + fun findByProviderAndProviderId( + provider: OAuthProvider, + providerId: String, + ): Member? +} diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt new file mode 100644 index 0000000..d77dc75 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt @@ -0,0 +1,32 @@ +package com.nexters.gamss.member.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MemberTest { + @Test + fun `회원을 생성한다`() { + val member = Member(OAuthProvider.GOOGLE, "google-sub-1", "user@example.com") + + assertEquals(OAuthProvider.GOOGLE, member.provider) + assertEquals("google-sub-1", member.providerId) + assertEquals("user@example.com", member.email) + } + + @Test + fun `이메일 없이도 회원을 생성할 수 있다`() { + val member = Member(OAuthProvider.APPLE, "apple-sub-1") + + assertNull(member.email) + } + + @Test + fun `이메일을 수정한다`() { + val member = Member(OAuthProvider.APPLE, "apple-sub-2") + + member.updateEmail("changed@example.com") + + assertEquals("changed@example.com", member.email) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt new file mode 100644 index 0000000..2f666b4 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt @@ -0,0 +1,22 @@ +package com.nexters.gamss.member.domain + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class OAuthProviderTest { + @Test + fun `대소문자 무관하게 문자열로 provider를 찾는다`() { + assertEquals(OAuthProvider.GOOGLE, OAuthProvider.from("google")) + assertEquals(OAuthProvider.APPLE, OAuthProvider.from("APPLE")) + } + + @Test + fun `지원하지 않는 provider면 예외를 던진다`() { + val exception = assertFailsWith { OAuthProvider.from("kakao") } + + assertEquals(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, exception.errorCode) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt new file mode 100644 index 0000000..2893f0a --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt @@ -0,0 +1,32 @@ +package com.nexters.gamss.member.repository + +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.OAuthProvider +import com.nexters.gamss.support.RepositoryTest +import org.springframework.beans.factory.annotation.Autowired +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MemberRepositoryTest : RepositoryTest() { + @Autowired + lateinit var memberRepository: MemberRepository + + @Test + fun `provider와 providerId로 회원을 조회한다`() { + memberRepository.save(Member(OAuthProvider.GOOGLE, "sub-100", "a@example.com")) + + val found = memberRepository.findByProviderAndProviderId(OAuthProvider.GOOGLE, "sub-100") + + assertNotNull(found) + assertEquals("a@example.com", found.email) + } + + @Test + fun `없는 회원이면 null을 반환한다`() { + val found = memberRepository.findByProviderAndProviderId(OAuthProvider.APPLE, "not-exist") + + assertNull(found) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/support/RepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/support/RepositoryTest.kt new file mode 100644 index 0000000..b49b330 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/support/RepositoryTest.kt @@ -0,0 +1,13 @@ +package com.nexters.gamss.support + +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.transaction.annotation.Transactional + +/** + * 리포지토리 테스트 베이스. Testcontainers MySQL 을 사용하고, 각 테스트는 트랜잭션 롤백된다. + */ +@SpringBootTest +@Import(TestcontainersConfig::class) +@Transactional +abstract class RepositoryTest From 10c437a06972e2e7a279acbe091d1ec244b75c8f Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:01:12 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20JWT=20=EB=B0=9C=EA=B8=89=C2=B7?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/nexters/gamss/GamssApplication.kt | 2 + .../gamss/global/security/JwtProperties.kt | 11 ++++ .../gamss/global/security/JwtProvider.kt | 62 +++++++++++++++++++ .../gamss/global/security/JwtProviderTest.kt | 60 ++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/JwtProperties.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt create mode 100644 src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/GamssApplication.kt b/src/main/kotlin/com/nexters/gamss/GamssApplication.kt index dc45ad2..d41cbe6 100644 --- a/src/main/kotlin/com/nexters/gamss/GamssApplication.kt +++ b/src/main/kotlin/com/nexters/gamss/GamssApplication.kt @@ -1,8 +1,10 @@ package com.nexters.gamss import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +@ConfigurationPropertiesScan @SpringBootApplication class GamssApplication diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtProperties.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtProperties.kt new file mode 100644 index 0000000..30cf331 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtProperties.kt @@ -0,0 +1,11 @@ +package com.nexters.gamss.global.security + +import org.springframework.boot.context.properties.ConfigurationProperties +import java.time.Duration + +@ConfigurationProperties(prefix = "jwt") +data class JwtProperties( + val secret: String, + val accessTokenValidity: Duration, + val refreshTokenValidity: Duration, +) diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt new file mode 100644 index 0000000..63a66c0 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt @@ -0,0 +1,62 @@ +package com.nexters.gamss.global.security + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import java.util.Date + +/** + * 서비스 자체 JWT(access/refresh) 발급·파싱. 서명은 HMAC-SHA256. + */ +@Component +class JwtProvider( + properties: JwtProperties, +) { + private val key = Keys.hmacShaKeyFor(properties.secret.toByteArray(StandardCharsets.UTF_8)) + private val accessTokenValidity = properties.accessTokenValidity + private val refreshTokenValidity = properties.refreshTokenValidity + + fun issueAccessToken(memberId: Long): String = build(memberId, accessTokenValidity) + + fun issueRefreshToken(memberId: Long): String = build(memberId, refreshTokenValidity) + + fun parseMemberId(token: String): Long = parse(token).subject.toLong() + + private fun build( + memberId: Long, + validity: Duration, + ): String { + val now = Instant.now() + return Jwts + .builder() + .subject(memberId.toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(validity))) + .signWith(key) + .compact() + } + + private fun parse(token: String): Claims = + try { + Jwts + .parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .payload + } catch (e: ExpiredJwtException) { + throw BusinessException(ErrorCode.EXPIRED_TOKEN) + } catch (e: JwtException) { + throw BusinessException(ErrorCode.INVALID_TOKEN) + } catch (e: IllegalArgumentException) { + throw BusinessException(ErrorCode.INVALID_TOKEN) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt new file mode 100644 index 0000000..43d1b8a --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt @@ -0,0 +1,60 @@ +package com.nexters.gamss.global.security + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class JwtProviderTest { + private val properties = + JwtProperties( + secret = "test-secret-value-sufficiently-long-for-hs256-signing-0123456789abcdef", + accessTokenValidity = Duration.ofHours(1), + refreshTokenValidity = Duration.ofDays(14), + ) + private val jwtProvider = JwtProvider(properties) + + @Test + fun `액세스 토큰을 발급하고 memberId를 파싱한다`() { + val token = jwtProvider.issueAccessToken(42L) + + assertEquals(42L, jwtProvider.parseMemberId(token)) + } + + @Test + fun `리프레시 토큰도 memberId를 파싱한다`() { + val token = jwtProvider.issueRefreshToken(7L) + + assertEquals(7L, jwtProvider.parseMemberId(token)) + } + + @Test + fun `서명이 다른 토큰은 INVALID_TOKEN 예외를 던진다`() { + val other = JwtProvider(properties.copy(secret = "another-secret-value-also-long-enough-for-hs256-abcdefghijklmnop")) + val forged = other.issueAccessToken(1L) + + val exception = assertFailsWith { jwtProvider.parseMemberId(forged) } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } + + @Test + fun `만료된 토큰은 EXPIRED_TOKEN 예외를 던진다`() { + val shortLived = JwtProvider(properties.copy(accessTokenValidity = Duration.ofMillis(1))) + val token = shortLived.issueAccessToken(1L) + Thread.sleep(50) + + val exception = assertFailsWith { shortLived.parseMemberId(token) } + + assertEquals(ErrorCode.EXPIRED_TOKEN, exception.errorCode) + } + + @Test + fun `형식이 잘못된 토큰은 INVALID_TOKEN 예외를 던진다`() { + val exception = assertFailsWith { jwtProvider.parseMemberId("not-a-valid-jwt") } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } +} From 49f025b7628763c1ca55ff95656b0a8b13671350 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:06:26 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20OAuthClient=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20Google/Apple=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamss/auth/oauth/AppleOAuthClient.kt | 14 +++ .../gamss/auth/oauth/GoogleOAuthClient.kt | 14 +++ .../nexters/gamss/auth/oauth/JwkSources.kt | 13 +++ .../nexters/gamss/auth/oauth/OAuthClient.kt | 12 +++ .../gamss/auth/oauth/OAuthProperties.kt | 15 +++ .../nexters/gamss/auth/oauth/OAuthUserInfo.kt | 9 ++ .../gamss/auth/oauth/OidcTokenVerifier.kt | 56 ++++++++++ .../gamss/auth/oauth/OAuthClientTest.kt | 50 +++++++++ .../gamss/auth/oauth/OidcTokenVerifierTest.kt | 102 ++++++++++++++++++ 9 files changed, 285 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/JwkSources.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProperties.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthUserInfo.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt new file mode 100644 index 0000000..5250b02 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt @@ -0,0 +1,14 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.member.domain.OAuthProvider +import org.springframework.stereotype.Component + +@Component +class AppleOAuthClient( + properties: OAuthProperties, +) : OAuthClient { + override val provider = OAuthProvider.APPLE + private val verifier = OidcTokenVerifier(properties.apple, JwkSources.remote(properties.apple.jwksUri)) + + override fun verify(idToken: String): OAuthUserInfo = verifier.verify(idToken) +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt new file mode 100644 index 0000000..e4bd8e8 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt @@ -0,0 +1,14 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.member.domain.OAuthProvider +import org.springframework.stereotype.Component + +@Component +class GoogleOAuthClient( + properties: OAuthProperties, +) : OAuthClient { + override val provider = OAuthProvider.GOOGLE + private val verifier = OidcTokenVerifier(properties.google, JwkSources.remote(properties.google.jwksUri)) + + override fun verify(idToken: String): OAuthUserInfo = verifier.verify(idToken) +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/JwkSources.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/JwkSources.kt new file mode 100644 index 0000000..0538627 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/JwkSources.kt @@ -0,0 +1,13 @@ +package com.nexters.gamss.auth.oauth + +import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.jwk.source.JWKSourceBuilder +import com.nimbusds.jose.proc.SecurityContext +import java.net.URI + +/** + * 원격 JWKS 엔드포인트로부터 공개키를 가져오는 JWKSource 생성기(캐싱 포함). + */ +object JwkSources { + fun remote(uri: String): JWKSource = JWKSourceBuilder.create(URI(uri).toURL()).build() +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt new file mode 100644 index 0000000..fe054ef --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt @@ -0,0 +1,12 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.member.domain.OAuthProvider + +/** + * 소셜 제공자별 토큰 검증 전략. 새 제공자는 이 인터페이스 구현체만 추가하면 된다(OCP). + */ +interface OAuthClient { + val provider: OAuthProvider + + fun verify(idToken: String): OAuthUserInfo +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProperties.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProperties.kt new file mode 100644 index 0000000..d5461c8 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProperties.kt @@ -0,0 +1,15 @@ +package com.nexters.gamss.auth.oauth + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth") +data class OAuthProperties( + val google: Provider, + val apple: Provider, +) { + data class Provider( + val issuer: String, + val jwksUri: String, + val clientIds: List = emptyList(), + ) +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthUserInfo.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthUserInfo.kt new file mode 100644 index 0000000..4462505 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthUserInfo.kt @@ -0,0 +1,9 @@ +package com.nexters.gamss.auth.oauth + +/** + * 소셜 토큰 검증으로 추출한 사용자 식별 정보. + */ +data class OAuthUserInfo( + val providerId: String, + val email: String?, +) diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt new file mode 100644 index 0000000..4b1e1af --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt @@ -0,0 +1,56 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nimbusds.jose.JOSEException +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.proc.BadJOSEException +import com.nimbusds.jose.proc.JWSVerificationKeySelector +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier +import com.nimbusds.jwt.proc.DefaultJWTProcessor +import java.text.ParseException + +/** + * OIDC id_token(RS256)의 서명·발급자·대상(aud)·만료를 검증하고 사용자 정보를 추출한다. + * JWKS 소스를 주입받아 테스트에서 로컬 키로 대체할 수 있다. + */ +class OidcTokenVerifier( + private val provider: OAuthProperties.Provider, + jwkSource: JWKSource, +) { + private val processor = + DefaultJWTProcessor().apply { + jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource) + jwtClaimsSetVerifier = + DefaultJWTClaimsVerifier( + JWTClaimsSet.Builder().issuer(provider.issuer).build(), + setOf("sub", "exp"), + ) + } + + fun verify(idToken: String): OAuthUserInfo { + val claims = + try { + processor.process(idToken, null) + } catch (e: ParseException) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } catch (e: BadJOSEException) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } catch (e: JOSEException) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } + verifyAudience(claims.audience) + val subject = claims.subject ?: throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + return OAuthUserInfo(providerId = subject, email = claims.getStringClaim("email")) + } + + private fun verifyAudience(audience: List?) { + if (provider.clientIds.isEmpty()) return + if (audience.isNullOrEmpty() || audience.none { it in provider.clientIds }) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt new file mode 100644 index 0000000..dec5aa8 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt @@ -0,0 +1,50 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.member.domain.OAuthProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class OAuthClientTest { + private val properties = + OAuthProperties( + google = + OAuthProperties.Provider( + issuer = "https://accounts.google.com", + jwksUri = "https://www.googleapis.com/oauth2/v3/certs", + clientIds = listOf("google-client"), + ), + apple = + OAuthProperties.Provider( + issuer = "https://appleid.apple.com", + jwksUri = "https://appleid.apple.com/auth/keys", + clientIds = listOf("apple-client"), + ), + ) + + @Test + fun `Google 클라이언트는 GOOGLE provider를 가진다`() { + assertEquals(OAuthProvider.GOOGLE, GoogleOAuthClient(properties).provider) + } + + @Test + fun `Apple 클라이언트는 APPLE provider를 가진다`() { + assertEquals(OAuthProvider.APPLE, AppleOAuthClient(properties).provider) + } + + @Test + fun `Google 클라이언트는 잘못된 토큰에 INVALID_SOCIAL_TOKEN`() { + val exception = assertFailsWith { GoogleOAuthClient(properties).verify("garbage") } + + assertEquals(ErrorCode.INVALID_SOCIAL_TOKEN, exception.errorCode) + } + + @Test + fun `Apple 클라이언트는 잘못된 토큰에 INVALID_SOCIAL_TOKEN`() { + val exception = assertFailsWith { AppleOAuthClient(properties).verify("garbage") } + + assertEquals(ErrorCode.INVALID_SOCIAL_TOKEN, exception.errorCode) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt new file mode 100644 index 0000000..e5fbffe --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt @@ -0,0 +1,102 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import java.time.Instant +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class OidcTokenVerifierTest { + private val issuer = "https://accounts.google.com" + private val clientId = "gamss-client-id" + private val rsaKey: RSAKey = RSAKeyGenerator(2048).keyID("test-key").generate() + private val jwkSource = ImmutableJWKSet(JWKSet(rsaKey)) + private val provider = + OAuthProperties.Provider( + issuer = issuer, + jwksUri = "https://unused", + clientIds = listOf(clientId), + ) + private val verifier = OidcTokenVerifier(provider, jwkSource) + + private fun signedToken( + subject: String = "user-sub-1", + iss: String = issuer, + aud: String = clientId, + email: String? = "user@example.com", + expiresAt: Date = Date.from(Instant.now().plusSeconds(300)), + signingKey: RSAKey = rsaKey, + ): String { + val builder = + JWTClaimsSet + .Builder() + .subject(subject) + .issuer(iss) + .audience(aud) + .expirationTime(expiresAt) + .issueTime(Date()) + if (email != null) builder.claim("email", email) + val jwt = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.keyID).build(), builder.build()) + jwt.sign(RSASSASigner(signingKey)) + return jwt.serialize() + } + + @Test + fun `유효한 토큰이면 사용자 정보를 반환한다`() { + val info = verifier.verify(signedToken()) + + assertEquals("user-sub-1", info.providerId) + assertEquals("user@example.com", info.email) + } + + @Test + fun `이메일 클레임이 없어도 검증에 성공한다`() { + val info = verifier.verify(signedToken(email = null)) + + assertNull(info.email) + } + + @Test + fun `발급자가 다르면 INVALID_SOCIAL_TOKEN`() { + assertSocialTokenInvalid(signedToken(iss = "https://evil.example.com")) + } + + @Test + fun `대상(aud)이 허용 목록에 없으면 INVALID_SOCIAL_TOKEN`() { + assertSocialTokenInvalid(signedToken(aud = "other-client")) + } + + @Test + fun `만료된 토큰이면 INVALID_SOCIAL_TOKEN`() { + assertSocialTokenInvalid(signedToken(expiresAt = Date.from(Instant.now().minusSeconds(60)))) + } + + @Test + fun `등록되지 않은 키로 서명하면 INVALID_SOCIAL_TOKEN`() { + val anotherKey = RSAKeyGenerator(2048).keyID("another-key").generate() + assertSocialTokenInvalid(signedToken(signingKey = anotherKey)) + } + + @Test + fun `형식이 잘못된 토큰이면 INVALID_SOCIAL_TOKEN`() { + assertSocialTokenInvalid("not-a-jwt") + } + + private fun assertSocialTokenInvalid(token: String) { + val exception = assertFailsWith { verifier.verify(token) } + assertEquals(ErrorCode.INVALID_SOCIAL_TOKEN, exception.errorCode) + } +} From bab7917857a3169f90dea6daa9252d3e67bd4f99 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:07:31 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=C2=B7=EA=B0=80=EC=9E=85=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamss/member/service/MemberService.kt | 36 ++++++++++ .../gamss/member/service/MemberServiceTest.kt | 70 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt create mode 100644 src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt new file mode 100644 index 0000000..ad47c5a --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt @@ -0,0 +1,36 @@ +package com.nexters.gamss.member.service + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.OAuthProvider +import com.nexters.gamss.member.repository.MemberRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class MemberService( + private val memberRepository: MemberRepository, +) { + @Transactional + fun findOrCreate( + provider: OAuthProvider, + providerId: String, + email: String?, + ): Member { + val existing = memberRepository.findByProviderAndProviderId(provider, providerId) + if (existing != null) { + if (email != null && existing.email != email) { + existing.updateEmail(email) + } + return existing + } + return memberRepository.save(Member(provider, providerId, email)) + } + + @Transactional(readOnly = true) + fun getById(id: Long): Member = + memberRepository + .findById(id) + .orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } +} diff --git a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt new file mode 100644 index 0000000..cf50fef --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt @@ -0,0 +1,70 @@ +package com.nexters.gamss.member.service + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.OAuthProvider +import com.nexters.gamss.member.repository.MemberRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.Optional +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class MemberServiceTest { + private val memberRepository = mockk() + private val memberService = MemberService(memberRepository) + + @Test + fun `기존 회원이면 저장하지 않고 반환한다`() { + val member = Member(OAuthProvider.GOOGLE, "sub-1", "a@example.com") + every { memberRepository.findByProviderAndProviderId(OAuthProvider.GOOGLE, "sub-1") } returns member + + val result = memberService.findOrCreate(OAuthProvider.GOOGLE, "sub-1", "a@example.com") + + assertSame(member, result) + verify(exactly = 0) { memberRepository.save(any()) } + } + + @Test + fun `신규 회원이면 저장한다`() { + every { memberRepository.findByProviderAndProviderId(OAuthProvider.APPLE, "sub-2") } returns null + every { memberRepository.save(any()) } answers { firstArg() } + + val result = memberService.findOrCreate(OAuthProvider.APPLE, "sub-2", "b@example.com") + + assertEquals("sub-2", result.providerId) + assertEquals(OAuthProvider.APPLE, result.provider) + verify(exactly = 1) { memberRepository.save(any()) } + } + + @Test + fun `기존 회원의 이메일이 바뀌면 갱신한다`() { + val member = Member(OAuthProvider.GOOGLE, "sub-3", "old@example.com") + every { memberRepository.findByProviderAndProviderId(OAuthProvider.GOOGLE, "sub-3") } returns member + + memberService.findOrCreate(OAuthProvider.GOOGLE, "sub-3", "new@example.com") + + assertEquals("new@example.com", member.email) + } + + @Test + fun `getById로 회원을 조회한다`() { + val member = Member(OAuthProvider.GOOGLE, "sub-4", "c@example.com") + every { memberRepository.findById(10L) } returns Optional.of(member) + + assertSame(member, memberService.getById(10L)) + } + + @Test + fun `없는 회원을 조회하면 MEMBER_NOT_FOUND`() { + every { memberRepository.findById(99L) } returns Optional.empty() + + val exception = assertFailsWith { memberService.getById(99L) } + + assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) + } +} From 0f273ee60f8db3152bd0b31da4953ed415e96e43 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:41:32 +0900 Subject: [PATCH 07/27] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EC=88=9C=EC=88=98=EC=84=B1=20=EB=B0=8F=20provider=20enum=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member에서 인증 책임을 제거해 순수 도메인으로 두고, 소셜 provider를 member.domain enum에서 auth.oauth로 옮긴다. 변경 가능한 인프라 요소 (provider 목록, 토큰 발급 방식)를 domain 밖으로 분리해 provider 추가 시 domain 코드가 바뀌지 않도록 한다. - OAuthProvider enum을 auth.oauth로 이동, 이름 기반 파싱 - OAuthClient/Resolver가 문자열 대신 enum을 사용 - JwtProvider를 JwtIssuer 인터페이스와 JjwtIssuer 구현으로 분리 - 허용 audience 검증을 AllowedAudiences 일급 컬렉션으로 추출 --- .../gamss/auth/oauth/AllowedAudiences.kt | 15 ++++++++ .../gamss/auth/oauth/AppleOAuthClient.kt | 1 - .../gamss/auth/oauth/GoogleOAuthClient.kt | 1 - .../nexters/gamss/auth/oauth/OAuthClient.kt | 4 +- .../gamss/auth/oauth/OAuthClientResolver.kt | 20 ++++++++++ .../domain => auth/oauth}/OAuthProvider.kt | 5 ++- .../gamss/auth/oauth/OidcTokenVerifier.kt | 14 +++---- .../{JwtProvider.kt => JjwtIssuer.kt} | 12 +++--- .../gamss/global/security/JwtIssuer.kt | 12 ++++++ .../com/nexters/gamss/member/domain/Member.kt | 17 +-------- .../member/repository/MemberRepository.kt | 8 +--- .../gamss/member/service/MemberService.kt | 16 +------- .../gamss/auth/oauth/AllowedAudiencesTest.kt | 37 +++++++++++++++++++ .../auth/oauth/OAuthClientResolverTest.kt | 34 +++++++++++++++++ .../gamss/auth/oauth/OAuthClientTest.kt | 1 - .../oauth}/OAuthProviderTest.kt | 9 +++-- .../{JwtProviderTest.kt => JjwtIssuerTest.kt} | 20 +++++----- .../nexters/gamss/member/domain/MemberTest.kt | 16 +++----- .../member/repository/MemberRepositoryTest.kt | 17 ++------- .../gamss/member/service/MemberServiceTest.kt | 32 ++-------------- 20 files changed, 166 insertions(+), 125 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolver.kt rename src/main/kotlin/com/nexters/gamss/{member/domain => auth/oauth}/OAuthProvider.kt (59%) rename src/main/kotlin/com/nexters/gamss/global/security/{JwtProvider.kt => JjwtIssuer.kt} (82%) create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt rename src/test/kotlin/com/nexters/gamss/{member/domain => auth/oauth}/OAuthProviderTest.kt (61%) rename src/test/kotlin/com/nexters/gamss/global/security/{JwtProviderTest.kt => JjwtIssuerTest.kt} (71%) diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt new file mode 100644 index 0000000..2a99f1c --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt @@ -0,0 +1,15 @@ +package com.nexters.gamss.auth.oauth + +/** + * 허용된 대상(aud) 목록 일급 컬렉션. 토큰의 audience 가 허용 목록에 포함되는지 판단한다. + */ +class AllowedAudiences( + private val values: List, +) { + fun accepts(audiences: List?): Boolean { + if (values.isEmpty()) { + return true + } + return !audiences.isNullOrEmpty() && audiences.any { it in values } + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt index 5250b02..9091c86 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt @@ -1,6 +1,5 @@ package com.nexters.gamss.auth.oauth -import com.nexters.gamss.member.domain.OAuthProvider import org.springframework.stereotype.Component @Component diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt index e4bd8e8..9f2356a 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt @@ -1,6 +1,5 @@ package com.nexters.gamss.auth.oauth -import com.nexters.gamss.member.domain.OAuthProvider import org.springframework.stereotype.Component @Component diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt index fe054ef..09978d5 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt @@ -1,9 +1,7 @@ package com.nexters.gamss.auth.oauth -import com.nexters.gamss.member.domain.OAuthProvider - /** - * 소셜 제공자별 토큰 검증 전략. 새 제공자는 이 인터페이스 구현체만 추가하면 된다(OCP). + * 소셜 제공자별 토큰 검증 전략. 새 제공자는 이 인터페이스 구현체(@Component)만 추가하면 된다(OCP). */ interface OAuthClient { val provider: OAuthProvider diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolver.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolver.kt new file mode 100644 index 0000000..1866995 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolver.kt @@ -0,0 +1,20 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import org.springframework.stereotype.Component + +/** + * 등록된 OAuthClient 들을 provider 로 매핑한다(일급 컬렉션). + * 새 제공자 구현체(@Component)를 추가하면 자동으로 등록된다(OCP). + */ +@Component +class OAuthClientResolver( + clients: List, +) { + private val clientsByProvider: Map = clients.associateBy { it.provider } + + fun resolve(provider: OAuthProvider): OAuthClient = + clientsByProvider[provider] + ?: throw BusinessException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER) +} diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProvider.kt similarity index 59% rename from src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt rename to src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProvider.kt index 54132c2..659b982 100644 --- a/src/main/kotlin/com/nexters/gamss/member/domain/OAuthProvider.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProvider.kt @@ -1,10 +1,11 @@ -package com.nexters.gamss.member.domain +package com.nexters.gamss.auth.oauth import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode /** - * 소셜 로그인 제공자. 새 제공자는 여기에 상수만 추가한다. + * 지원하는 소셜 제공자. 변경 가능한 목록이므로 인프라(oauth) 계층에 둔다. + * 새 제공자 추가 = 상수 + OAuthClient 구현체 추가. (domain 은 provider 를 String 으로만 저장하므로 바뀌지 않는다) */ enum class OAuthProvider { GOOGLE, diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt index 4b1e1af..312bda6 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt @@ -18,9 +18,10 @@ import java.text.ParseException * JWKS 소스를 주입받아 테스트에서 로컬 키로 대체할 수 있다. */ class OidcTokenVerifier( - private val provider: OAuthProperties.Provider, + provider: OAuthProperties.Provider, jwkSource: JWKSource, ) { + private val allowedAudiences = AllowedAudiences(provider.clientIds) private val processor = DefaultJWTProcessor().apply { jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource) @@ -42,15 +43,10 @@ class OidcTokenVerifier( } catch (e: JOSEException) { throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) } - verifyAudience(claims.audience) - val subject = claims.subject ?: throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) - return OAuthUserInfo(providerId = subject, email = claims.getStringClaim("email")) - } - - private fun verifyAudience(audience: List?) { - if (provider.clientIds.isEmpty()) return - if (audience.isNullOrEmpty() || audience.none { it in provider.clientIds }) { + if (!allowedAudiences.accepts(claims.audience)) { throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) } + val subject = claims.subject ?: throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + return OAuthUserInfo(providerId = subject, email = claims.getStringClaim("email")) } } diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt b/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt similarity index 82% rename from src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt rename to src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt index 63a66c0..1cfba06 100644 --- a/src/main/kotlin/com/nexters/gamss/global/security/JwtProvider.kt +++ b/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt @@ -14,21 +14,21 @@ import java.time.Instant import java.util.Date /** - * 서비스 자체 JWT(access/refresh) 발급·파싱. 서명은 HMAC-SHA256. + * jjwt 기반 JwtIssuer 구현. 서명은 HMAC-SHA256. */ @Component -class JwtProvider( +class JjwtIssuer( properties: JwtProperties, -) { +) : JwtIssuer { private val key = Keys.hmacShaKeyFor(properties.secret.toByteArray(StandardCharsets.UTF_8)) private val accessTokenValidity = properties.accessTokenValidity private val refreshTokenValidity = properties.refreshTokenValidity - fun issueAccessToken(memberId: Long): String = build(memberId, accessTokenValidity) + override fun issueAccessToken(memberId: Long): String = build(memberId, accessTokenValidity) - fun issueRefreshToken(memberId: Long): String = build(memberId, refreshTokenValidity) + override fun issueRefreshToken(memberId: Long): String = build(memberId, refreshTokenValidity) - fun parseMemberId(token: String): Long = parse(token).subject.toLong() + override fun parseMemberId(token: String): Long = parse(token).subject.toLong() private fun build( memberId: Long, diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt new file mode 100644 index 0000000..752393e --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt @@ -0,0 +1,12 @@ +package com.nexters.gamss.global.security + +/** + * 서비스 자체 토큰(access/refresh) 발급·파싱 추상화. 구현(JWT 등)은 교체 가능하다. + */ +interface JwtIssuer { + fun issueAccessToken(memberId: Long): String + + fun issueRefreshToken(memberId: Long): String + + fun parseMemberId(token: String): Long +} diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt index d6f8e40..0d17eed 100644 --- a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt @@ -2,31 +2,18 @@ package com.nexters.gamss.member.domain import jakarta.persistence.Column import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint import java.time.Instant /** - * 소셜 로그인으로 식별되는 회원. (provider, providerId)가 유일 식별자. + * 서비스 사용자. 인증 방식(소셜 등)에 의존하지 않는 순수 유저 도메인. */ @Entity -@Table( - name = "members", - uniqueConstraints = [ - UniqueConstraint(name = "uk_member_provider", columnNames = ["provider", "provider_id"]), - ], -) +@Table(name = "members") class Member( - @Enumerated(EnumType.STRING) - @Column(name = "provider", nullable = false, length = 20) - val provider: OAuthProvider, - @Column(name = "provider_id", nullable = false) - val providerId: String, @Column(name = "email") var email: String? = null, ) { diff --git a/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt b/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt index b0c54d8..03b5480 100644 --- a/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt +++ b/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt @@ -1,12 +1,6 @@ package com.nexters.gamss.member.repository import com.nexters.gamss.member.domain.Member -import com.nexters.gamss.member.domain.OAuthProvider import org.springframework.data.jpa.repository.JpaRepository -interface MemberRepository : JpaRepository { - fun findByProviderAndProviderId( - provider: OAuthProvider, - providerId: String, - ): Member? -} +interface MemberRepository : JpaRepository diff --git a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt index ad47c5a..3b072a2 100644 --- a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt +++ b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt @@ -3,7 +3,6 @@ package com.nexters.gamss.member.service import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.member.domain.Member -import com.nexters.gamss.member.domain.OAuthProvider import com.nexters.gamss.member.repository.MemberRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,20 +12,7 @@ class MemberService( private val memberRepository: MemberRepository, ) { @Transactional - fun findOrCreate( - provider: OAuthProvider, - providerId: String, - email: String?, - ): Member { - val existing = memberRepository.findByProviderAndProviderId(provider, providerId) - if (existing != null) { - if (email != null && existing.email != email) { - existing.updateEmail(email) - } - return existing - } - return memberRepository.save(Member(provider, providerId, email)) - } + fun create(email: String?): Member = memberRepository.save(Member(email)) @Transactional(readOnly = true) fun getById(id: Long): Member = diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt new file mode 100644 index 0000000..781df9c --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt @@ -0,0 +1,37 @@ +package com.nexters.gamss.auth.oauth + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AllowedAudiencesTest { + @Test + fun `허용 목록이 비어 있으면 모든 audience를 허용한다`() { + val audiences = AllowedAudiences(emptyList()) + + assertTrue(audiences.accepts(listOf("anything"))) + assertTrue(audiences.accepts(null)) + } + + @Test + fun `교집합이 있으면 허용한다`() { + val audiences = AllowedAudiences(listOf("a", "b")) + + assertTrue(audiences.accepts(listOf("b"))) + } + + @Test + fun `교집합이 없으면 거부한다`() { + val audiences = AllowedAudiences(listOf("a", "b")) + + assertFalse(audiences.accepts(listOf("z"))) + } + + @Test + fun `허용 목록이 있는데 audience가 비어 있으면 거부한다`() { + val audiences = AllowedAudiences(listOf("a")) + + assertFalse(audiences.accepts(null)) + assertFalse(audiences.accepts(emptyList())) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt new file mode 100644 index 0000000..84c1f50 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt @@ -0,0 +1,34 @@ +package com.nexters.gamss.auth.oauth + +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class OAuthClientResolverTest { + private fun fakeClient(target: OAuthProvider) = + object : OAuthClient { + override val provider = target + + override fun verify(idToken: String) = OAuthUserInfo("id", null) + } + + @Test + fun `provider에 맞는 클라이언트를 반환한다`() { + val googleClient = fakeClient(OAuthProvider.GOOGLE) + val resolver = OAuthClientResolver(listOf(googleClient, fakeClient(OAuthProvider.APPLE))) + + assertSame(googleClient, resolver.resolve(OAuthProvider.GOOGLE)) + } + + @Test + fun `등록되지 않은 provider면 UNSUPPORTED_OAUTH_PROVIDER`() { + val resolver = OAuthClientResolver(listOf(fakeClient(OAuthProvider.GOOGLE))) + + val exception = assertFailsWith { resolver.resolve(OAuthProvider.APPLE) } + + assertEquals(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, exception.errorCode) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt index dec5aa8..6a4223b 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt @@ -2,7 +2,6 @@ package com.nexters.gamss.auth.oauth import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode -import com.nexters.gamss.member.domain.OAuthProvider import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthProviderTest.kt similarity index 61% rename from src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt rename to src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthProviderTest.kt index 2f666b4..cd8cc15 100644 --- a/src/test/kotlin/com/nexters/gamss/member/domain/OAuthProviderTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthProviderTest.kt @@ -1,4 +1,4 @@ -package com.nexters.gamss.member.domain +package com.nexters.gamss.auth.oauth import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode @@ -8,13 +8,14 @@ import kotlin.test.assertFailsWith class OAuthProviderTest { @Test - fun `대소문자 무관하게 문자열로 provider를 찾는다`() { + fun `대소문자 무관하게 provider를 파싱한다`() { assertEquals(OAuthProvider.GOOGLE, OAuthProvider.from("google")) - assertEquals(OAuthProvider.APPLE, OAuthProvider.from("APPLE")) + assertEquals(OAuthProvider.GOOGLE, OAuthProvider.from("GOOGLE")) + assertEquals(OAuthProvider.APPLE, OAuthProvider.from("Apple")) } @Test - fun `지원하지 않는 provider면 예외를 던진다`() { + fun `지원하지 않는 provider면 UNSUPPORTED_OAUTH_PROVIDER`() { val exception = assertFailsWith { OAuthProvider.from("kakao") } assertEquals(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, exception.errorCode) diff --git a/src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt similarity index 71% rename from src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt rename to src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt index 43d1b8a..065bcc3 100644 --- a/src/test/kotlin/com/nexters/gamss/global/security/JwtProviderTest.kt +++ b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt @@ -7,42 +7,42 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -class JwtProviderTest { +class JjwtIssuerTest { private val properties = JwtProperties( secret = "test-secret-value-sufficiently-long-for-hs256-signing-0123456789abcdef", accessTokenValidity = Duration.ofHours(1), refreshTokenValidity = Duration.ofDays(14), ) - private val jwtProvider = JwtProvider(properties) + private val jwtIssuer = JjwtIssuer(properties) @Test fun `액세스 토큰을 발급하고 memberId를 파싱한다`() { - val token = jwtProvider.issueAccessToken(42L) + val token = jwtIssuer.issueAccessToken(42L) - assertEquals(42L, jwtProvider.parseMemberId(token)) + assertEquals(42L, jwtIssuer.parseMemberId(token)) } @Test fun `리프레시 토큰도 memberId를 파싱한다`() { - val token = jwtProvider.issueRefreshToken(7L) + val token = jwtIssuer.issueRefreshToken(7L) - assertEquals(7L, jwtProvider.parseMemberId(token)) + assertEquals(7L, jwtIssuer.parseMemberId(token)) } @Test fun `서명이 다른 토큰은 INVALID_TOKEN 예외를 던진다`() { - val other = JwtProvider(properties.copy(secret = "another-secret-value-also-long-enough-for-hs256-abcdefghijklmnop")) + val other = JjwtIssuer(properties.copy(secret = "another-secret-value-also-long-enough-for-hs256-abcdefghijklmnop")) val forged = other.issueAccessToken(1L) - val exception = assertFailsWith { jwtProvider.parseMemberId(forged) } + val exception = assertFailsWith { jwtIssuer.parseMemberId(forged) } assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } @Test fun `만료된 토큰은 EXPIRED_TOKEN 예외를 던진다`() { - val shortLived = JwtProvider(properties.copy(accessTokenValidity = Duration.ofMillis(1))) + val shortLived = JjwtIssuer(properties.copy(accessTokenValidity = Duration.ofMillis(1))) val token = shortLived.issueAccessToken(1L) Thread.sleep(50) @@ -53,7 +53,7 @@ class JwtProviderTest { @Test fun `형식이 잘못된 토큰은 INVALID_TOKEN 예외를 던진다`() { - val exception = assertFailsWith { jwtProvider.parseMemberId("not-a-valid-jwt") } + val exception = assertFailsWith { jwtIssuer.parseMemberId("not-a-valid-jwt") } assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt index d77dc75..ce3f270 100644 --- a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt @@ -6,27 +6,23 @@ import kotlin.test.assertNull class MemberTest { @Test - fun `회원을 생성한다`() { - val member = Member(OAuthProvider.GOOGLE, "google-sub-1", "user@example.com") + fun `이메일로 회원을 생성한다`() { + val member = Member("user@example.com") - assertEquals(OAuthProvider.GOOGLE, member.provider) - assertEquals("google-sub-1", member.providerId) assertEquals("user@example.com", member.email) } @Test fun `이메일 없이도 회원을 생성할 수 있다`() { - val member = Member(OAuthProvider.APPLE, "apple-sub-1") - - assertNull(member.email) + assertNull(Member().email) } @Test fun `이메일을 수정한다`() { - val member = Member(OAuthProvider.APPLE, "apple-sub-2") + val member = Member("old@example.com") - member.updateEmail("changed@example.com") + member.updateEmail("new@example.com") - assertEquals("changed@example.com", member.email) + assertEquals("new@example.com", member.email) } } diff --git a/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt index 2893f0a..a4e9b86 100644 --- a/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt @@ -1,32 +1,23 @@ package com.nexters.gamss.member.repository import com.nexters.gamss.member.domain.Member -import com.nexters.gamss.member.domain.OAuthProvider import com.nexters.gamss.support.RepositoryTest import org.springframework.beans.factory.annotation.Autowired import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull class MemberRepositoryTest : RepositoryTest() { @Autowired lateinit var memberRepository: MemberRepository @Test - fun `provider와 providerId로 회원을 조회한다`() { - memberRepository.save(Member(OAuthProvider.GOOGLE, "sub-100", "a@example.com")) + fun `회원을 저장하고 조회한다`() { + val saved = memberRepository.save(Member("user@example.com")) - val found = memberRepository.findByProviderAndProviderId(OAuthProvider.GOOGLE, "sub-100") + val found = memberRepository.findById(saved.id).orElse(null) assertNotNull(found) - assertEquals("a@example.com", found.email) - } - - @Test - fun `없는 회원이면 null을 반환한다`() { - val found = memberRepository.findByProviderAndProviderId(OAuthProvider.APPLE, "not-exist") - - assertNull(found) + assertEquals("user@example.com", found.email) } } diff --git a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt index cf50fef..6128e0d 100644 --- a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt @@ -3,7 +3,6 @@ package com.nexters.gamss.member.service import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.member.domain.Member -import com.nexters.gamss.member.domain.OAuthProvider import com.nexters.gamss.member.repository.MemberRepository import io.mockk.every import io.mockk.mockk @@ -19,41 +18,18 @@ class MemberServiceTest { private val memberService = MemberService(memberRepository) @Test - fun `기존 회원이면 저장하지 않고 반환한다`() { - val member = Member(OAuthProvider.GOOGLE, "sub-1", "a@example.com") - every { memberRepository.findByProviderAndProviderId(OAuthProvider.GOOGLE, "sub-1") } returns member - - val result = memberService.findOrCreate(OAuthProvider.GOOGLE, "sub-1", "a@example.com") - - assertSame(member, result) - verify(exactly = 0) { memberRepository.save(any()) } - } - - @Test - fun `신규 회원이면 저장한다`() { - every { memberRepository.findByProviderAndProviderId(OAuthProvider.APPLE, "sub-2") } returns null + fun `회원을 생성한다`() { every { memberRepository.save(any()) } answers { firstArg() } - val result = memberService.findOrCreate(OAuthProvider.APPLE, "sub-2", "b@example.com") + val member = memberService.create("a@example.com") - assertEquals("sub-2", result.providerId) - assertEquals(OAuthProvider.APPLE, result.provider) + assertEquals("a@example.com", member.email) verify(exactly = 1) { memberRepository.save(any()) } } - @Test - fun `기존 회원의 이메일이 바뀌면 갱신한다`() { - val member = Member(OAuthProvider.GOOGLE, "sub-3", "old@example.com") - every { memberRepository.findByProviderAndProviderId(OAuthProvider.GOOGLE, "sub-3") } returns member - - memberService.findOrCreate(OAuthProvider.GOOGLE, "sub-3", "new@example.com") - - assertEquals("new@example.com", member.email) - } - @Test fun `getById로 회원을 조회한다`() { - val member = Member(OAuthProvider.GOOGLE, "sub-4", "c@example.com") + val member = Member("b@example.com") every { memberRepository.findById(10L) } returns Optional.of(member) assertSame(member, memberService.getById(10L)) From b652c91743e241b248e8167f2e4e73692ca48516 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:41:48 +0900 Subject: [PATCH 08/27] =?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=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소셜 id_token 검증 결과로 회원을 조회·가입하고 서비스 JWT(access·refresh)를 발급한다. refresh는 회원별로 저장·회전하며 재발급 시 검증한다. - POST /api/auth/login/{provider}: 소셜 로그인 - POST /api/auth/reissue: refresh 토큰으로 재발급 - SocialAccount로 소셜 계정과 회원을 분리해 연결 - RefreshToken 엔티티가 회전·일치 검증 규칙 보유 --- .../gamss/auth/controller/AuthController.kt | 37 ++++++ .../gamss/auth/controller/dto/AuthDtos.kt | 23 ++++ .../nexters/gamss/auth/domain/RefreshToken.kt | 36 ++++++ .../gamss/auth/domain/SocialAccount.kt | 33 ++++++ .../auth/repository/RefreshTokenRepository.kt | 8 ++ .../repository/SocialAccountRepository.kt | 11 ++ .../nexters/gamss/auth/service/AuthService.kt | 57 +++++++++ .../auth/service/SocialAccountService.kt | 35 ++++++ .../nexters/gamss/auth/service/TokenResult.kt | 6 + .../auth/controller/AuthControllerTest.kt | 78 ++++++++++++ .../gamss/auth/domain/RefreshTokenTest.kt | 25 ++++ .../repository/SocialAccountRepositoryTest.kt | 29 +++++ .../gamss/auth/service/AuthServiceTest.kt | 111 ++++++++++++++++++ .../auth/service/SocialAccountServiceTest.kt | 48 ++++++++ 14 files changed, 537 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/domain/RefreshToken.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/domain/SocialAccount.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/repository/RefreshTokenRepository.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepository.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/TokenResult.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt new file mode 100644 index 0000000..ae27798 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt @@ -0,0 +1,37 @@ +package com.nexters.gamss.auth.controller + +import com.nexters.gamss.auth.controller.dto.LoginRequest +import com.nexters.gamss.auth.controller.dto.ReissueRequest +import com.nexters.gamss.auth.controller.dto.TokenResponse +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.service.AuthService +import com.nexters.gamss.global.response.ApiResponse +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PathVariable +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 + +@RestController +@RequestMapping("/api/auth") +class AuthController( + private val authService: AuthService, +) { + @PostMapping("/login/{provider}") + fun login( + @PathVariable provider: String, + @Valid @RequestBody request: LoginRequest, + ): ApiResponse { + val result = authService.login(OAuthProvider.from(provider), request.idToken) + return ApiResponse.success(TokenResponse.from(result)) + } + + @PostMapping("/reissue") + fun reissue( + @Valid @RequestBody request: ReissueRequest, + ): ApiResponse { + val result = authService.reissue(request.refreshToken) + return ApiResponse.success(TokenResponse.from(result)) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt new file mode 100644 index 0000000..8623560 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt @@ -0,0 +1,23 @@ +package com.nexters.gamss.auth.controller.dto + +import com.nexters.gamss.auth.service.TokenResult +import jakarta.validation.constraints.NotBlank + +data class LoginRequest( + @field:NotBlank(message = "idToken은 필수입니다.") + val idToken: String, +) + +data class ReissueRequest( + @field:NotBlank(message = "refreshToken은 필수입니다.") + val refreshToken: String, +) + +data class TokenResponse( + val accessToken: String, + val refreshToken: String, +) { + companion object { + fun from(result: TokenResult): TokenResponse = TokenResponse(result.accessToken, result.refreshToken) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/domain/RefreshToken.kt b/src/main/kotlin/com/nexters/gamss/auth/domain/RefreshToken.kt new file mode 100644 index 0000000..89b5a03 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/domain/RefreshToken.kt @@ -0,0 +1,36 @@ +package com.nexters.gamss.auth.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +/** + * 회원별 현재 유효한 리프레시 토큰. 재발급 시 회전(rotate)한다. + */ +@Entity +@Table( + name = "refresh_tokens", + uniqueConstraints = [ + UniqueConstraint(name = "uk_refresh_member", columnNames = ["member_id"]), + ], +) +class RefreshToken( + @Column(name = "member_id", nullable = false) + val memberId: Long, + @Column(name = "token", nullable = false, length = 512) + var token: String, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L + + fun rotate(token: String) { + this.token = token + } + + fun matches(token: String): Boolean = this.token == token +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/domain/SocialAccount.kt b/src/main/kotlin/com/nexters/gamss/auth/domain/SocialAccount.kt new file mode 100644 index 0000000..53ea787 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/domain/SocialAccount.kt @@ -0,0 +1,33 @@ +package com.nexters.gamss.auth.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +/** + * 회원의 소셜 로그인 계정. (provider, providerId)로 유일하며, 회원은 memberId(ID 참조)로 연결한다. + * provider 는 문자열로 저장한다 — 제공자가 추가돼도 이 도메인 코드는 바뀌지 않는다. + */ +@Entity +@Table( + name = "social_accounts", + uniqueConstraints = [ + UniqueConstraint(name = "uk_social_provider", columnNames = ["provider", "provider_id"]), + ], +) +class SocialAccount( + @Column(name = "member_id", nullable = false) + val memberId: Long, + @Column(name = "provider", nullable = false, length = 30) + val provider: String, + @Column(name = "provider_id", nullable = false) + val providerId: String, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/repository/RefreshTokenRepository.kt b/src/main/kotlin/com/nexters/gamss/auth/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..839e845 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/repository/RefreshTokenRepository.kt @@ -0,0 +1,8 @@ +package com.nexters.gamss.auth.repository + +import com.nexters.gamss.auth.domain.RefreshToken +import org.springframework.data.jpa.repository.JpaRepository + +interface RefreshTokenRepository : JpaRepository { + fun findByMemberId(memberId: Long): RefreshToken? +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepository.kt b/src/main/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepository.kt new file mode 100644 index 0000000..1a5e662 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepository.kt @@ -0,0 +1,11 @@ +package com.nexters.gamss.auth.repository + +import com.nexters.gamss.auth.domain.SocialAccount +import org.springframework.data.jpa.repository.JpaRepository + +interface SocialAccountRepository : JpaRepository { + fun findByProviderAndProviderId( + provider: String, + providerId: String, + ): SocialAccount? +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt new file mode 100644 index 0000000..866cb36 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -0,0 +1,57 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.domain.RefreshToken +import com.nexters.gamss.auth.oauth.OAuthClientResolver +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.repository.RefreshTokenRepository +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.global.security.JwtIssuer +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 소셜 로그인·토큰 재발급 오케스트레이션. + * 소셜 검증(OAuthClient), 소셜↔회원 연결(SocialAccountService), 토큰(JwtIssuer)은 각 컴포넌트에 위임한다. + */ +@Service +class AuthService( + private val oAuthClientResolver: OAuthClientResolver, + private val socialAccountService: SocialAccountService, + private val jwtIssuer: JwtIssuer, + private val refreshTokenRepository: RefreshTokenRepository, +) { + @Transactional + fun login( + provider: OAuthProvider, + idToken: String, + ): TokenResult { + val userInfo = oAuthClientResolver.resolve(provider).verify(idToken) + val member = socialAccountService.resolveMember(provider, userInfo.providerId, userInfo.email) + return issueTokens(member.id) + } + + @Transactional + fun reissue(refreshToken: String): TokenResult { + val memberId = jwtIssuer.parseMemberId(refreshToken) + val stored = + refreshTokenRepository.findByMemberId(memberId) + ?: throw BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND) + if (!stored.matches(refreshToken)) { + throw BusinessException(ErrorCode.INVALID_TOKEN) + } + return issueTokens(memberId) + } + + private fun issueTokens(memberId: Long): TokenResult { + val accessToken = jwtIssuer.issueAccessToken(memberId) + val refreshToken = jwtIssuer.issueRefreshToken(memberId) + val stored = refreshTokenRepository.findByMemberId(memberId) + if (stored != null) { + stored.rotate(refreshToken) + } else { + refreshTokenRepository.save(RefreshToken(memberId, refreshToken)) + } + return TokenResult(accessToken, refreshToken) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt new file mode 100644 index 0000000..de4bc34 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt @@ -0,0 +1,35 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.domain.SocialAccount +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.repository.SocialAccountRepository +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.service.MemberService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 소셜 계정과 회원을 연결한다. 소셜 계정이 없으면 회원을 새로 만들어 연결한다. + * provider 는 domain(SocialAccount)에 문자열로 저장한다. + */ +@Service +class SocialAccountService( + private val socialAccountRepository: SocialAccountRepository, + private val memberService: MemberService, +) { + @Transactional + fun resolveMember( + provider: OAuthProvider, + providerId: String, + email: String?, + ): Member { + val storedProvider = provider.name + val socialAccount = socialAccountRepository.findByProviderAndProviderId(storedProvider, providerId) + if (socialAccount != null) { + return memberService.getById(socialAccount.memberId) + } + val member = memberService.create(email) + socialAccountRepository.save(SocialAccount(member.id, storedProvider, providerId)) + return member + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/TokenResult.kt b/src/main/kotlin/com/nexters/gamss/auth/service/TokenResult.kt new file mode 100644 index 0000000..49ca690 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/TokenResult.kt @@ -0,0 +1,6 @@ +package com.nexters.gamss.auth.service + +data class TokenResult( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt b/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt new file mode 100644 index 0000000..90261e9 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt @@ -0,0 +1,78 @@ +package com.nexters.gamss.auth.controller + +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.service.AuthService +import com.nexters.gamss.auth.service.TokenResult +import com.nexters.gamss.global.exception.GlobalExceptionHandler +import io.mockk.every +import io.mockk.mockk +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean +import kotlin.test.Test + +class AuthControllerTest { + private val authService = mockk() + private val mockMvc = + MockMvcBuilders + .standaloneSetup(AuthController(authService)) + .setControllerAdvice(GlobalExceptionHandler()) + .setValidator(LocalValidatorFactoryBean().apply { afterPropertiesSet() }) + .build() + + @Test + fun `로그인에 성공하면 토큰을 반환한다`() { + every { authService.login(OAuthProvider.GOOGLE, "idtok") } returns TokenResult("access", "refresh") + + mockMvc + .post("/api/auth/login/google") { + contentType = MediaType.APPLICATION_JSON + content = """{"idToken":"idtok"}""" + }.andExpect { + status { isOk() } + jsonPath("$.success") { value(true) } + jsonPath("$.data.accessToken") { value("access") } + jsonPath("$.data.refreshToken") { value("refresh") } + } + } + + @Test + fun `idToken이 비어 있으면 400과 INVALID_INPUT을 반환한다`() { + mockMvc + .post("/api/auth/login/google") { + contentType = MediaType.APPLICATION_JSON + content = """{"idToken":""}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.error.code") { value("INVALID_INPUT") } + } + } + + @Test + fun `지원하지 않는 provider면 400과 UNSUPPORTED_OAUTH_PROVIDER를 반환한다`() { + mockMvc + .post("/api/auth/login/kakao") { + contentType = MediaType.APPLICATION_JSON + content = """{"idToken":"x"}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.error.code") { value("UNSUPPORTED_OAUTH_PROVIDER") } + } + } + + @Test + fun `재발급에 성공하면 새 토큰을 반환한다`() { + every { authService.reissue("r") } returns TokenResult("na", "nr") + + mockMvc + .post("/api/auth/reissue") { + contentType = MediaType.APPLICATION_JSON + content = """{"refreshToken":"r"}""" + }.andExpect { + status { isOk() } + jsonPath("$.data.accessToken") { value("na") } + jsonPath("$.data.refreshToken") { value("nr") } + } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt b/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt new file mode 100644 index 0000000..24bba99 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt @@ -0,0 +1,25 @@ +package com.nexters.gamss.auth.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RefreshTokenTest { + @Test + fun `회전하면 토큰이 교체된다`() { + val refreshToken = RefreshToken(memberId = 1L, token = "old") + + refreshToken.rotate("new") + + assertEquals("new", refreshToken.token) + } + + @Test + fun `같은 토큰이면 일치한다`() { + val refreshToken = RefreshToken(memberId = 1L, token = "abc") + + assertTrue(refreshToken.matches("abc")) + assertFalse(refreshToken.matches("xyz")) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt new file mode 100644 index 0000000..3bcec99 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt @@ -0,0 +1,29 @@ +package com.nexters.gamss.auth.repository + +import com.nexters.gamss.auth.domain.SocialAccount +import com.nexters.gamss.support.RepositoryTest +import org.springframework.beans.factory.annotation.Autowired +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SocialAccountRepositoryTest : RepositoryTest() { + @Autowired + private lateinit var socialAccountRepository: SocialAccountRepository + + @Test + fun `provider와 providerId로 소셜 계정을 조회한다`() { + socialAccountRepository.save(SocialAccount(memberId = 1L, provider = "GOOGLE", providerId = "sub-1")) + + val found = socialAccountRepository.findByProviderAndProviderId("GOOGLE", "sub-1") + + assertNotNull(found) + assertEquals(1L, found.memberId) + } + + @Test + fun `일치하는 소셜 계정이 없으면 null을 반환한다`() { + assertNull(socialAccountRepository.findByProviderAndProviderId("APPLE", "none")) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt new file mode 100644 index 0000000..191775b --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt @@ -0,0 +1,111 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.domain.RefreshToken +import com.nexters.gamss.auth.oauth.OAuthClient +import com.nexters.gamss.auth.oauth.OAuthClientResolver +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.oauth.OAuthUserInfo +import com.nexters.gamss.auth.repository.RefreshTokenRepository +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.member.domain.Member +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AuthServiceTest { + private val oAuthClientResolver = mockk() + private val socialAccountService = mockk() + private val jwtIssuer = mockk() + private val refreshTokenRepository = mockk() + private val authService = + AuthService(oAuthClientResolver, socialAccountService, jwtIssuer, refreshTokenRepository) + + @Test + fun `로그인 시 신규 회원이면 리프레시 토큰을 저장한다`() { + val client = mockk() + every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client + every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") + val member = mockk { every { id } returns 100L } + every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member + every { jwtIssuer.issueAccessToken(100L) } returns "access" + every { jwtIssuer.issueRefreshToken(100L) } returns "refresh" + every { refreshTokenRepository.findByMemberId(100L) } returns null + every { refreshTokenRepository.save(any()) } answers { firstArg() } + + val result = authService.login(OAuthProvider.GOOGLE, "idtok") + + assertEquals("access", result.accessToken) + assertEquals("refresh", result.refreshToken) + verify { refreshTokenRepository.save(match { it.memberId == 100L && it.token == "refresh" }) } + } + + @Test + fun `로그인 시 기존 리프레시 토큰이 있으면 회전한다`() { + val client = mockk() + every { oAuthClientResolver.resolve(OAuthProvider.APPLE) } returns client + every { client.verify("t") } returns OAuthUserInfo("sub", "e@e.com") + val member = mockk { every { id } returns 1L } + every { socialAccountService.resolveMember(OAuthProvider.APPLE, "sub", "e@e.com") } returns member + every { jwtIssuer.issueAccessToken(1L) } returns "a" + every { jwtIssuer.issueRefreshToken(1L) } returns "r" + val stored = mockk(relaxed = true) + every { refreshTokenRepository.findByMemberId(1L) } returns stored + + authService.login(OAuthProvider.APPLE, "t") + + verify { stored.rotate("r") } + verify(exactly = 0) { refreshTokenRepository.save(any()) } + } + + @Test + fun `재발급이 정상이면 새 토큰을 발급하고 회전한다`() { + every { jwtIssuer.parseMemberId("refresh") } returns 100L + val stored = mockk(relaxed = true) + every { stored.matches("refresh") } returns true + every { refreshTokenRepository.findByMemberId(100L) } returns stored + every { jwtIssuer.issueAccessToken(100L) } returns "na" + every { jwtIssuer.issueRefreshToken(100L) } returns "nr" + + val result = authService.reissue("refresh") + + assertEquals("na", result.accessToken) + assertEquals("nr", result.refreshToken) + verify { stored.rotate("nr") } + } + + @Test + fun `재발급 시 저장된 토큰이 없으면 REFRESH_TOKEN_NOT_FOUND`() { + every { jwtIssuer.parseMemberId("r") } returns 1L + every { refreshTokenRepository.findByMemberId(1L) } returns null + + val exception = assertFailsWith { authService.reissue("r") } + + assertEquals(ErrorCode.REFRESH_TOKEN_NOT_FOUND, exception.errorCode) + } + + @Test + fun `재발급 시 토큰이 일치하지 않으면 INVALID_TOKEN`() { + every { jwtIssuer.parseMemberId("r") } returns 1L + val stored = mockk() + every { stored.matches("r") } returns false + every { refreshTokenRepository.findByMemberId(1L) } returns stored + + val exception = assertFailsWith { authService.reissue("r") } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } + + @Test + fun `재발급 시 토큰 파싱 예외를 전파한다`() { + every { jwtIssuer.parseMemberId("bad") } throws BusinessException(ErrorCode.EXPIRED_TOKEN) + + val exception = assertFailsWith { authService.reissue("bad") } + + assertEquals(ErrorCode.EXPIRED_TOKEN, exception.errorCode) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt new file mode 100644 index 0000000..7924761 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt @@ -0,0 +1,48 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.domain.SocialAccount +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.repository.SocialAccountRepository +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.service.MemberService +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertSame + +class SocialAccountServiceTest { + private val socialAccountRepository = mockk() + private val memberService = mockk() + private val socialAccountService = SocialAccountService(socialAccountRepository, memberService) + + @Test + fun `기존 소셜 계정이면 연결된 회원을 반환한다`() { + val socialAccount = SocialAccount(memberId = 5L, provider = "GOOGLE", providerId = "sub-1") + every { socialAccountRepository.findByProviderAndProviderId("GOOGLE", "sub-1") } returns socialAccount + val member = mockk() + every { memberService.getById(5L) } returns member + + val result = socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") + + assertSame(member, result) + verify(exactly = 0) { memberService.create(any()) } + } + + @Test + fun `소셜 계정이 없으면 회원을 생성하고 연결한다`() { + every { socialAccountRepository.findByProviderAndProviderId("APPLE", "sub-2") } returns null + val member = mockk { every { id } returns 9L } + every { memberService.create("b@a.com") } returns member + every { socialAccountRepository.save(any()) } answers { firstArg() } + + val result = socialAccountService.resolveMember(OAuthProvider.APPLE, "sub-2", "b@a.com") + + assertSame(member, result) + verify { + socialAccountRepository.save( + match { it.memberId == 9L && it.provider == "APPLE" && it.providerId == "sub-2" }, + ) + } + } +} From f76d54bfe1fb10c4b11d112428c1510260035ed7 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:44:54 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat:=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20JWT=20=EC=9D=B8=EC=A6=9D=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무상태(STATELESS) 보안 필터체인을 구성하고 Bearer 토큰을 검증하는 JWT 인증 필터를 추가한다. 인증 실패 시 공통 응답 포맷으로 401을 반환한다. - /api/auth, /swagger, /v3/api-docs 외 경로는 인증 필요 - JwtAuthenticationFilter가 토큰을 파싱해 AuthPrincipal 주입 - 미인증 요청은 JwtAuthenticationEntryPoint가 401 JSON 응답 - 보호 엔드포인트 GET /api/members/me 추가 --- .../gamss/global/security/AuthPrincipal.kt | 8 ++ .../security/JwtAuthenticationEntryPoint.kt | 35 ++++++++ .../security/JwtAuthenticationFilter.kt | 47 +++++++++++ .../gamss/global/security/SecurityConfig.kt | 45 +++++++++++ .../member/controller/MemberController.kt | 24 ++++++ .../member/controller/dto/MemberResponse.kt | 12 +++ .../security/SecurityIntegrationTest.kt | 80 +++++++++++++++++++ 7 files changed, 251 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/AuthPrincipal.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationEntryPoint.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/SecurityConfig.kt create mode 100644 src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt create mode 100644 src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt create mode 100644 src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/global/security/AuthPrincipal.kt b/src/main/kotlin/com/nexters/gamss/global/security/AuthPrincipal.kt new file mode 100644 index 0000000..8e5f609 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/AuthPrincipal.kt @@ -0,0 +1,8 @@ +package com.nexters.gamss.global.security + +/** + * 인증된 요청 주체. 컨트롤러에서 @AuthenticationPrincipal 로 회원 식별자를 받는다. + */ +data class AuthPrincipal( + val memberId: Long, +) diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationEntryPoint.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationEntryPoint.kt new file mode 100644 index 0000000..d3efa8f --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationEntryPoint.kt @@ -0,0 +1,35 @@ +package com.nexters.gamss.global.security + +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.global.response.ApiResponse +import com.nexters.gamss.global.response.ErrorResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +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.ObjectMapper + +/** + * 인증되지 않은 요청에 공통 응답 포맷으로 401을 반환한다. + */ +@Component +class JwtAuthenticationEntryPoint( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException, + ) { + val body = + ApiResponse.error( + ErrorResponse(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message), + ) + response.status = ErrorCode.UNAUTHORIZED.status.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write(objectMapper.writeValueAsString(body)) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..aa2604d --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,47 @@ +package com.nexters.gamss.global.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +/** + * Authorization: Bearer 토큰을 파싱해 SecurityContext에 인증 정보를 채운다. + * 토큰이 없거나 유효하지 않으면 인증하지 않고 통과시켜 EntryPoint가 401을 응답하게 한다. + */ +@Component +class JwtAuthenticationFilter( + private val jwtIssuer: JwtIssuer, +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + resolveToken(request)?.let { token -> + runCatching { jwtIssuer.parseMemberId(token) } + .onSuccess { memberId -> + val authentication = + UsernamePasswordAuthenticationToken(AuthPrincipal(memberId), null, emptyList()) + SecurityContextHolder.getContext().authentication = authentication + } + } + filterChain.doFilter(request, response) + } + + private fun resolveToken(request: HttpServletRequest): String? { + val header = request.getHeader(HttpHeaders.AUTHORIZATION) ?: return null + if (!header.startsWith(BEARER_PREFIX)) { + return null + } + return header.substring(BEARER_PREFIX.length) + } + + companion object { + private const val BEARER_PREFIX = "Bearer " + } +} diff --git a/src/main/kotlin/com/nexters/gamss/global/security/SecurityConfig.kt b/src/main/kotlin/com/nexters/gamss/global/security/SecurityConfig.kt new file mode 100644 index 0000000..3f9f9ff --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/SecurityConfig.kt @@ -0,0 +1,45 @@ +package com.nexters.gamss.global.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +/** + * 무상태(JWT) 보안 설정. 인증 없이 접근 가능한 경로 외에는 모두 인증을 요구한다. + */ +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, + private val jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .httpBasic { it.disable() } + .formLogin { it.disable() } + .logout { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { + it.requestMatchers(*PUBLIC_PATHS).permitAll() + it.anyRequest().authenticated() + }.exceptionHandling { it.authenticationEntryPoint(jwtAuthenticationEntryPoint) } + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + return http.build() + } + + companion object { + private val PUBLIC_PATHS = + arrayOf( + "/api/auth/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + ) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt new file mode 100644 index 0000000..d5f3e17 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt @@ -0,0 +1,24 @@ +package com.nexters.gamss.member.controller + +import com.nexters.gamss.global.response.ApiResponse +import com.nexters.gamss.global.security.AuthPrincipal +import com.nexters.gamss.member.controller.dto.MemberResponse +import com.nexters.gamss.member.service.MemberService +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 + +@RestController +@RequestMapping("/api/members") +class MemberController( + private val memberService: MemberService, +) { + @GetMapping("/me") + fun me( + @AuthenticationPrincipal principal: AuthPrincipal, + ): ApiResponse { + val member = memberService.getById(principal.memberId) + return ApiResponse.success(MemberResponse.from(member)) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt new file mode 100644 index 0000000..771dd39 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt @@ -0,0 +1,12 @@ +package com.nexters.gamss.member.controller.dto + +import com.nexters.gamss.member.domain.Member + +data class MemberResponse( + val id: Long, + val email: String?, +) { + companion object { + fun from(member: Member): MemberResponse = MemberResponse(id = member.id, email = member.email) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt new file mode 100644 index 0000000..c84a81e --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt @@ -0,0 +1,80 @@ +package com.nexters.gamss.global.security + +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.repository.MemberRepository +import com.nexters.gamss.support.TestcontainersConfig +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.HttpHeaders +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest +@Import(TestcontainersConfig::class) +@Transactional +class SecurityIntegrationTest { + @Autowired + private lateinit var context: WebApplicationContext + + @Autowired + private lateinit var memberRepository: MemberRepository + + @Autowired + private lateinit var jwtIssuer: JwtIssuer + + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + mockMvc = + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `유효한 토큰이면 보호된 자원에 접근할 수 있다`() { + val member = memberRepository.save(Member("me@a.com")) + val token = jwtIssuer.issueAccessToken(member.id) + + mockMvc + .get("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + }.andExpect { + status { isOk() } + jsonPath("$.data.id") { value(member.id) } + jsonPath("$.data.email") { value("me@a.com") } + } + } + + @Test + fun `토큰이 없으면 401과 UNAUTHORIZED를 반환한다`() { + mockMvc + .get("/api/members/me") + .andExpect { + status { isUnauthorized() } + jsonPath("$.success") { value(false) } + jsonPath("$.error.code") { value("UNAUTHORIZED") } + } + } + + @Test + fun `잘못된 토큰이면 401을 반환한다`() { + mockMvc + .get("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer invalid-token") + }.andExpect { + status { isUnauthorized() } + jsonPath("$.error.code") { value("UNAUTHORIZED") } + } + } +} From 0d811235fc2da19141929279706dd7de60b68131 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:46:08 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat:=20Swagger(springdoc)=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 문서에 JWT Bearer 보안 스키마를 등록해 Swagger UI에서 토큰을 넣고 보호 API를 호출할 수 있게 한다. - /swagger-ui.html, /v3/api-docs 노출 - bearerAuth 보안 스키마 등록 --- .../gamss/global/config/SwaggerConfig.kt | 37 ++++++++++++++++++ .../gamss/global/config/SwaggerDocsTest.kt | 38 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt create mode 100644 src/test/kotlin/com/nexters/gamss/global/config/SwaggerDocsTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt b/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..06dce3e --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt @@ -0,0 +1,37 @@ +package com.nexters.gamss.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * OpenAPI(Swagger) 문서 설정. JWT Bearer 인증 스키마를 등록해 Swagger UI에서 토큰을 넣을 수 있게 한다. + */ +@Configuration +class SwaggerConfig { + @Bean + fun openAPI(): OpenAPI { + val securitySchemeName = "bearerAuth" + return OpenAPI() + .info( + Info() + .title("GAMSS API") + .description("감정 쓰레기통 서비스 API 문서") + .version("v0.1"), + ).addSecurityItem(SecurityRequirement().addList(securitySchemeName)) + .components( + Components().addSecuritySchemes( + securitySchemeName, + SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"), + ), + ) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/global/config/SwaggerDocsTest.kt b/src/test/kotlin/com/nexters/gamss/global/config/SwaggerDocsTest.kt new file mode 100644 index 0000000..a758edf --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/config/SwaggerDocsTest.kt @@ -0,0 +1,38 @@ +package com.nexters.gamss.global.config + +import com.nexters.gamss.support.TestcontainersConfig +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest +@Import(TestcontainersConfig::class) +class SwaggerDocsTest { + @Autowired + private lateinit var context: WebApplicationContext + + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).build() + } + + @Test + fun `OpenAPI 문서에 인증 API와 JWT 보안 스키마가 노출된다`() { + mockMvc + .get("/v3/api-docs") + .andExpect { + status { isOk() } + jsonPath("$.info.title") { value("GAMSS API") } + jsonPath("$.paths['/api/auth/login/{provider}'].post") { exists() } + jsonPath("$.components.securitySchemes.bearerAuth.scheme") { value("bearer") } + } + } +} From db28a5da7456ddd39d06cb3536df61719a72ef4c Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 01:57:03 +0900 Subject: [PATCH 11/27] =?UTF-8?q?chore:=20Flyway=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=ED=99=98=EA=B2=BD=EB=B3=84=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB 스키마를 Flyway 마이그레이션으로 관리하고, ddl-auto를 validate로 바꿔 엔티티와 스키마 정합성을 검증한다. 설정을 local·dev·prod 프로필로 분리한다. - spring-boot-flyway 모듈 추가(Boot 4 자동설정 제공), flyway-mysql - V1__init.sql: members·social_accounts·refresh_tokens 초기 스키마 - application.yml 공통 + application-{local,dev,prod}.yml 분리 - 테스트는 Testcontainers에서 Flyway 실행 후 스키마 검증 --- build.gradle.kts | 5 +++++ src/main/resources/application-dev.yml | 14 ++++++++++++ src/main/resources/application-local.yml | 13 +++++++++++ src/main/resources/application-prod.yml | 6 +++++ src/main/resources/application.yml | 15 ++++++------- src/main/resources/db/migration/V1__init.sql | 23 ++++++++++++++++++++ src/test/resources/application.yml | 4 +++- 7 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/db/migration/V1__init.sql diff --git a/build.gradle.kts b/build.gradle.kts index a7623d4..3d1a7d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,11 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("com.mysql:mysql-connector-j") + // DB Migration (Flyway) — spring-boot-flyway 모듈이 Boot 4 자동설정을 제공 + implementation("org.springframework.boot:spring-boot-flyway") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-mysql") + // Security · Validation implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..5383dc1 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,14 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + properties: + hibernate: + format_sql: true + +logging: + level: + org.hibernate.SQL: debug diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..fa041d0 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/gamss + username: gamss + password: gamss + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + properties: + hibernate: + format_sql: true + +jwt: + secret: local-development-secret-change-me-to-a-long-random-value-0123456789 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..77ab4d7 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 43b0906..4b7890d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,21 +1,20 @@ spring: application: name: gamss - datasource: - url: ${DB_URL:jdbc:mysql://localhost:3306/gamss} - username: ${DB_USERNAME:gamss} - password: ${DB_PASSWORD:gamss} - driver-class-name: com.mysql.cj.jdbc.Driver + profiles: + default: local jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} + ddl-auto: validate open-in-view: false properties: hibernate: - format_sql: true + format_sql: false + flyway: + enabled: true jwt: - secret: ${JWT_SECRET:local-development-secret-change-me-to-a-long-random-value-0123456789} + secret: ${JWT_SECRET} access-token-validity: ${JWT_ACCESS_VALIDITY:PT1H} refresh-token-validity: ${JWT_REFRESH_VALIDITY:P14D} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..cb04e2a --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,23 @@ +CREATE TABLE members ( + id BIGINT NOT NULL AUTO_INCREMENT, + email VARCHAR(255) NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB; + +CREATE TABLE social_accounts ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + provider VARCHAR(30) NOT NULL, + provider_id VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_social_provider UNIQUE (provider, provider_id) +) ENGINE = InnoDB; + +CREATE TABLE refresh_tokens ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + token VARCHAR(512) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_refresh_member UNIQUE (member_id) +) ENGINE = InnoDB; diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 32f038a..2fce933 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,7 +1,9 @@ spring: + flyway: + enabled: true jpa: hibernate: - ddl-auto: create-drop + ddl-auto: validate properties: hibernate: format_sql: false From 446278bfc880749cfbc87188b6fab86f83ffb33a Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 02:05:53 +0900 Subject: [PATCH 12/27] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20de?= =?UTF-8?q?v=C2=B7prod=20=EC=84=A4=EC=A0=95=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아직 배포 서버가 없어 dev·prod 프로필은 잠자는 스캐폴딩일 뿐이라 제거한다. 로컬 실행(local)과 테스트는 환경변수 없이 동작하며, 실제 배포 셋업 시 인프라 값에 맞춰 프로필을 다시 추가한다. --- src/main/resources/application-dev.yml | 14 -------------- src/main/resources/application-prod.yml | 6 ------ 2 files changed, 20 deletions(-) delete mode 100644 src/main/resources/application-dev.yml delete mode 100644 src/main/resources/application-prod.yml diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml deleted file mode 100644 index 5383dc1..0000000 --- a/src/main/resources/application-dev.yml +++ /dev/null @@ -1,14 +0,0 @@ -spring: - datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - jpa: - properties: - hibernate: - format_sql: true - -logging: - level: - org.hibernate.SQL: debug diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml deleted file mode 100644 index 77ab4d7..0000000 --- a/src/main/resources/application-prod.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver From 06293667df1dfdb4892cf2a25efbb0be5faa921e Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 02:14:14 +0900 Subject: [PATCH 13/27] =?UTF-8?q?refactor:=20DTO=EB=A5=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 파일에 여러 DTO를 모으지 않고 클래스마다 개별 파일로 분리한다. - AuthDtos를 LoginRequest·ReissueRequest·TokenResponse로 분리 - 응답 래퍼 ErrorResponse를 ApiResponse에서 분리 --- .../nexters/gamss/auth/controller/dto/LoginRequest.kt | 8 ++++++++ .../gamss/auth/controller/dto/ReissueRequest.kt | 8 ++++++++ .../controller/dto/{AuthDtos.kt => TokenResponse.kt} | 11 ----------- .../com/nexters/gamss/global/response/ApiResponse.kt | 5 ----- .../nexters/gamss/global/response/ErrorResponse.kt | 6 ++++++ 5 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt rename src/main/kotlin/com/nexters/gamss/auth/controller/dto/{AuthDtos.kt => TokenResponse.kt} (53%) create mode 100644 src/main/kotlin/com/nexters/gamss/global/response/ErrorResponse.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt new file mode 100644 index 0000000..bcc3724 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt @@ -0,0 +1,8 @@ +package com.nexters.gamss.auth.controller.dto + +import jakarta.validation.constraints.NotBlank + +data class LoginRequest( + @field:NotBlank(message = "idToken은 필수입니다.") + val idToken: String, +) diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt new file mode 100644 index 0000000..eea888b --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt @@ -0,0 +1,8 @@ +package com.nexters.gamss.auth.controller.dto + +import jakarta.validation.constraints.NotBlank + +data class ReissueRequest( + @field:NotBlank(message = "refreshToken은 필수입니다.") + val refreshToken: String, +) diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt similarity index 53% rename from src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt rename to src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt index 8623560..d0b8900 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/AuthDtos.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt @@ -1,17 +1,6 @@ package com.nexters.gamss.auth.controller.dto import com.nexters.gamss.auth.service.TokenResult -import jakarta.validation.constraints.NotBlank - -data class LoginRequest( - @field:NotBlank(message = "idToken은 필수입니다.") - val idToken: String, -) - -data class ReissueRequest( - @field:NotBlank(message = "refreshToken은 필수입니다.") - val refreshToken: String, -) data class TokenResponse( val accessToken: String, diff --git a/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt b/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt index f2b5660..ace2118 100644 --- a/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt +++ b/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt @@ -16,8 +16,3 @@ data class ApiResponse( fun error(error: ErrorResponse): ApiResponse = ApiResponse(success = false, error = error) } } - -data class ErrorResponse( - val code: String, - val message: String, -) diff --git a/src/main/kotlin/com/nexters/gamss/global/response/ErrorResponse.kt b/src/main/kotlin/com/nexters/gamss/global/response/ErrorResponse.kt new file mode 100644 index 0000000..b7052a9 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/response/ErrorResponse.kt @@ -0,0 +1,6 @@ +package com.nexters.gamss.global.response + +data class ErrorResponse( + val code: String, + val message: String, +) From e89471c6606fc5e57471dc5efc0cbd758fc2efb0 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 02:14:47 +0900 Subject: [PATCH 14/27] =?UTF-8?q?refactor:=20else=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20early=20return=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issueTokens의 if-else 분기를 persistRefreshToken 함수로 추출하고 guard clause로 평탄화한다. --- .../com/nexters/gamss/auth/service/AuthService.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt index 866cb36..22248af 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -46,12 +46,19 @@ class AuthService( private fun issueTokens(memberId: Long): TokenResult { val accessToken = jwtIssuer.issueAccessToken(memberId) val refreshToken = jwtIssuer.issueRefreshToken(memberId) + persistRefreshToken(memberId, refreshToken) + return TokenResult(accessToken, refreshToken) + } + + private fun persistRefreshToken( + memberId: Long, + refreshToken: String, + ) { val stored = refreshTokenRepository.findByMemberId(memberId) - if (stored != null) { - stored.rotate(refreshToken) - } else { + if (stored == null) { refreshTokenRepository.save(RefreshToken(memberId, refreshToken)) + return } - return TokenResult(accessToken, refreshToken) + stored.rotate(refreshToken) } } From c9405996018667a7be8e3e8532f9adcc0ad89dbc Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 02:14:57 +0900 Subject: [PATCH 15/27] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EB=B0=8F=20OAuth=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9A=A9=EC=9D=B4?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부트스트랩 진입점을 커버리지에서 제외하고, 미커버 분기·게터를 검증하는 테스트를 추가한다. OAuth 클라이언트는 검증기를 주입받도록 바꿔 위임 동작을 단위 테스트로 검증한다. - jacoco에서 GamssApplication 제외 - Google·AppleOAuthClient에 OidcTokenVerifier 주입 생성자 추가 - OidcTokenVerifier의 subject 존재 검증을 guard로 일원화 - 엔티티 게터·빈 토큰·비Bearer 헤더·OAuthProperties 기본값 테스트 추가 --- build.gradle.kts | 6 ++++ .../gamss/auth/oauth/AppleOAuthClient.kt | 9 ++++-- .../gamss/auth/oauth/GoogleOAuthClient.kt | 9 ++++-- .../gamss/auth/oauth/OidcTokenVerifier.kt | 2 +- .../gamss/auth/domain/RefreshTokenTest.kt | 5 ++++ .../gamss/auth/oauth/OAuthClientTest.kt | 21 ++++++++++++++ .../gamss/auth/oauth/OAuthPropertiesTest.kt | 28 +++++++++++++++++++ .../gamss/auth/oauth/OidcTokenVerifierTest.kt | 9 ++++-- .../repository/SocialAccountRepositoryTest.kt | 3 +- .../gamss/global/security/JjwtIssuerTest.kt | 7 +++++ .../security/SecurityIntegrationTest.kt | 11 ++++++++ .../nexters/gamss/member/domain/MemberTest.kt | 9 ++++++ 12 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthPropertiesTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3d1a7d9..1e509f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,4 +87,10 @@ tasks.jacocoTestReport { xml.required.set(true) html.required.set(true) } + // 부트스트랩 진입점(main)은 단위 테스트 대상이 아니므로 커버리지에서 제외한다. + classDirectories.setFrom( + classDirectories.files.map { + fileTree(it) { exclude("**/GamssApplication*") } + }, + ) } diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt index 9091c86..04135fe 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt @@ -1,13 +1,18 @@ package com.nexters.gamss.auth.oauth +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component @Component class AppleOAuthClient( - properties: OAuthProperties, + private val verifier: OidcTokenVerifier, ) : OAuthClient { + @Autowired + constructor(properties: OAuthProperties) : this( + OidcTokenVerifier(properties.apple, JwkSources.remote(properties.apple.jwksUri)), + ) + override val provider = OAuthProvider.APPLE - private val verifier = OidcTokenVerifier(properties.apple, JwkSources.remote(properties.apple.jwksUri)) override fun verify(idToken: String): OAuthUserInfo = verifier.verify(idToken) } diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt index 9f2356a..6299873 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt @@ -1,13 +1,18 @@ package com.nexters.gamss.auth.oauth +import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component @Component class GoogleOAuthClient( - properties: OAuthProperties, + private val verifier: OidcTokenVerifier, ) : OAuthClient { + @Autowired + constructor(properties: OAuthProperties) : this( + OidcTokenVerifier(properties.google, JwkSources.remote(properties.google.jwksUri)), + ) + override val provider = OAuthProvider.GOOGLE - private val verifier = OidcTokenVerifier(properties.google, JwkSources.remote(properties.google.jwksUri)) override fun verify(idToken: String): OAuthUserInfo = verifier.verify(idToken) } diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt index 312bda6..bb37a1d 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt @@ -28,7 +28,7 @@ class OidcTokenVerifier( jwtClaimsSetVerifier = DefaultJWTClaimsVerifier( JWTClaimsSet.Builder().issuer(provider.issuer).build(), - setOf("sub", "exp"), + setOf("exp"), ) } diff --git a/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt b/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt index 24bba99..fa46a44 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt @@ -22,4 +22,9 @@ class RefreshTokenTest { assertTrue(refreshToken.matches("abc")) assertFalse(refreshToken.matches("xyz")) } + + @Test + fun `식별자 기본값은 0이다`() { + assertEquals(0L, RefreshToken(memberId = 1L, token = "abc").id) + } } diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt index 6a4223b..948ee5c 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt @@ -2,9 +2,12 @@ package com.nexters.gamss.auth.oauth import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode +import io.mockk.every +import io.mockk.mockk import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertSame class OAuthClientTest { private val properties = @@ -46,4 +49,22 @@ class OAuthClientTest { assertEquals(ErrorCode.INVALID_SOCIAL_TOKEN, exception.errorCode) } + + @Test + fun `Google 클라이언트는 검증기의 결과를 그대로 반환한다`() { + val verifier = mockk() + val userInfo = OAuthUserInfo("google-sub", "g@example.com") + every { verifier.verify("token") } returns userInfo + + assertSame(userInfo, GoogleOAuthClient(verifier).verify("token")) + } + + @Test + fun `Apple 클라이언트는 검증기의 결과를 그대로 반환한다`() { + val verifier = mockk() + val userInfo = OAuthUserInfo("apple-sub", null) + every { verifier.verify("token") } returns userInfo + + assertSame(userInfo, AppleOAuthClient(verifier).verify("token")) + } } diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthPropertiesTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthPropertiesTest.kt new file mode 100644 index 0000000..ee0922d --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthPropertiesTest.kt @@ -0,0 +1,28 @@ +package com.nexters.gamss.auth.oauth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OAuthPropertiesTest { + @Test + fun `clientIds를 지정하지 않으면 빈 목록이 기본값이다`() { + val provider = + OAuthProperties.Provider( + issuer = "https://accounts.google.com", + jwksUri = "https://www.googleapis.com/oauth2/v3/certs", + ) + + assertTrue(provider.clientIds.isEmpty()) + } + + @Test + fun `google과 apple 설정을 보관한다`() { + val google = OAuthProperties.Provider(issuer = "g-iss", jwksUri = "g-jwks", clientIds = listOf("g")) + val apple = OAuthProperties.Provider(issuer = "a-iss", jwksUri = "a-jwks", clientIds = listOf("a")) + val properties = OAuthProperties(google = google, apple = apple) + + assertEquals(google, properties.google) + assertEquals(apple, properties.apple) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt index e5fbffe..dc80d5e 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt @@ -33,7 +33,7 @@ class OidcTokenVerifierTest { private val verifier = OidcTokenVerifier(provider, jwkSource) private fun signedToken( - subject: String = "user-sub-1", + subject: String? = "user-sub-1", iss: String = issuer, aud: String = clientId, email: String? = "user@example.com", @@ -43,11 +43,11 @@ class OidcTokenVerifierTest { val builder = JWTClaimsSet .Builder() - .subject(subject) .issuer(iss) .audience(aud) .expirationTime(expiresAt) .issueTime(Date()) + if (subject != null) builder.subject(subject) if (email != null) builder.claim("email", email) val jwt = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.keyID).build(), builder.build()) jwt.sign(RSASSASigner(signingKey)) @@ -95,6 +95,11 @@ class OidcTokenVerifierTest { assertSocialTokenInvalid("not-a-jwt") } + @Test + fun `subject(sub)가 없으면 INVALID_SOCIAL_TOKEN`() { + assertSocialTokenInvalid(signedToken(subject = null)) + } + private fun assertSocialTokenInvalid(token: String) { val exception = assertFailsWith { verifier.verify(token) } assertEquals(ErrorCode.INVALID_SOCIAL_TOKEN, exception.errorCode) diff --git a/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt index 3bcec99..6b014a4 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt @@ -14,12 +14,13 @@ class SocialAccountRepositoryTest : RepositoryTest() { @Test fun `provider와 providerId로 소셜 계정을 조회한다`() { - socialAccountRepository.save(SocialAccount(memberId = 1L, provider = "GOOGLE", providerId = "sub-1")) + val saved = socialAccountRepository.save(SocialAccount(memberId = 1L, provider = "GOOGLE", providerId = "sub-1")) val found = socialAccountRepository.findByProviderAndProviderId("GOOGLE", "sub-1") assertNotNull(found) assertEquals(1L, found.memberId) + assertEquals(saved.id, found.id) } @Test diff --git a/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt index 065bcc3..cbb3342 100644 --- a/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt +++ b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt @@ -57,4 +57,11 @@ class JjwtIssuerTest { assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } + + @Test + fun `빈 토큰은 INVALID_TOKEN 예외를 던진다`() { + val exception = assertFailsWith { jwtIssuer.parseMemberId("") } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } } diff --git a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt index c84a81e..390eda1 100644 --- a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt @@ -77,4 +77,15 @@ class SecurityIntegrationTest { jsonPath("$.error.code") { value("UNAUTHORIZED") } } } + + @Test + fun `Bearer 형식이 아닌 헤더는 인증되지 않아 401을 반환한다`() { + mockMvc + .get("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, "Basic dXNlcjpwYXNz") + }.andExpect { + status { isUnauthorized() } + jsonPath("$.error.code") { value("UNAUTHORIZED") } + } + } } diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt index ce3f270..be8a2de 100644 --- a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt @@ -2,6 +2,7 @@ package com.nexters.gamss.member.domain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull class MemberTest { @@ -12,6 +13,14 @@ class MemberTest { assertEquals("user@example.com", member.email) } + @Test + fun `생성 시 생성 시각이 기록되고 식별자 기본값은 0이다`() { + val member = Member("user@example.com") + + assertNotNull(member.createdAt) + assertEquals(0L, member.id) + } + @Test fun `이메일 없이도 회원을 생성할 수 있다`() { assertNull(Member().email) From a78770c96291ba55310dd15a674759449ac96ce7 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 02:29:21 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 닉네임을 값 객체(Nickname)로 분리해 공백 불가·최대 20자 불변식을 스스로 보장하게 하고, 회원이 닉네임을 수정할 수 있게 한다. - Nickname 값 객체(@Embeddable, 검증 포함) 추가 - Member에 nickname 필드와 updateNickname 추가 - PATCH /api/members/me/nickname 엔드포인트 - V1 스키마에 nickname 컬럼 추가(배포 전이라 마이그레이션 통합) --- .../member/controller/MemberController.kt | 14 +++ .../member/controller/dto/MemberResponse.kt | 3 +- .../controller/dto/UpdateNicknameRequest.kt | 11 ++ .../com/nexters/gamss/member/domain/Member.kt | 7 ++ .../nexters/gamss/member/domain/Nickname.kt | 22 ++++ .../gamss/member/service/MemberService.kt | 11 ++ src/main/resources/db/migration/V1__init.sql | 1 + .../MemberControllerIntegrationTest.kt | 105 ++++++++++++++++++ .../nexters/gamss/member/domain/MemberTest.kt | 9 ++ .../gamss/member/domain/NicknameTest.kt | 34 ++++++ .../gamss/member/service/MemberServiceTest.kt | 12 ++ 11 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt create mode 100644 src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt create mode 100644 src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt index d5f3e17..eee6332 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt @@ -3,9 +3,14 @@ package com.nexters.gamss.member.controller import com.nexters.gamss.global.response.ApiResponse import com.nexters.gamss.global.security.AuthPrincipal import com.nexters.gamss.member.controller.dto.MemberResponse +import com.nexters.gamss.member.controller.dto.UpdateNicknameRequest +import com.nexters.gamss.member.domain.Nickname import com.nexters.gamss.member.service.MemberService +import jakarta.validation.Valid import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -21,4 +26,13 @@ class MemberController( val member = memberService.getById(principal.memberId) return ApiResponse.success(MemberResponse.from(member)) } + + @PatchMapping("/me/nickname") + fun updateNickname( + @AuthenticationPrincipal principal: AuthPrincipal, + @Valid @RequestBody request: UpdateNicknameRequest, + ): ApiResponse { + val member = memberService.updateNickname(principal.memberId, Nickname(request.nickname)) + return ApiResponse.success(MemberResponse.from(member)) + } } diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt index 771dd39..7fb7450 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt @@ -5,8 +5,9 @@ import com.nexters.gamss.member.domain.Member data class MemberResponse( val id: Long, val email: String?, + val nickname: String?, ) { companion object { - fun from(member: Member): MemberResponse = MemberResponse(id = member.id, email = member.email) + fun from(member: Member): MemberResponse = MemberResponse(id = member.id, email = member.email, nickname = member.nickname?.value) } } diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt new file mode 100644 index 0000000..ecccf18 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt @@ -0,0 +1,11 @@ +package com.nexters.gamss.member.controller.dto + +import com.nexters.gamss.member.domain.Nickname +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class UpdateNicknameRequest( + @field:NotBlank(message = "nickname은 필수입니다.") + @field:Size(max = Nickname.MAX_LENGTH, message = "닉네임은 ${Nickname.MAX_LENGTH}자 이하여야 합니다.") + val nickname: String, +) diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt index 0d17eed..a4d9e64 100644 --- a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt @@ -1,6 +1,7 @@ package com.nexters.gamss.member.domain import jakarta.persistence.Column +import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType @@ -16,6 +17,8 @@ import java.time.Instant class Member( @Column(name = "email") var email: String? = null, + @Embedded + var nickname: Nickname? = null, ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -27,4 +30,8 @@ class Member( fun updateEmail(email: String?) { this.email = email } + + fun updateNickname(nickname: Nickname) { + this.nickname = nickname + } } diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt new file mode 100644 index 0000000..aaeaad6 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt @@ -0,0 +1,22 @@ +package com.nexters.gamss.member.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +/** + * 회원 닉네임 값 객체. 공백 불가·최대 길이 같은 불변식을 스스로 보장한다. + */ +@Embeddable +data class Nickname( + @Column(name = "nickname", length = MAX_LENGTH) + val value: String, +) { + init { + require(value.isNotBlank()) { "닉네임은 공백일 수 없습니다." } + require(value.length <= MAX_LENGTH) { "닉네임은 ${MAX_LENGTH}자 이하여야 합니다." } + } + + companion object { + const val MAX_LENGTH = 20 + } +} diff --git a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt index 3b072a2..43c41bf 100644 --- a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt +++ b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt @@ -3,6 +3,7 @@ package com.nexters.gamss.member.service import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.Nickname import com.nexters.gamss.member.repository.MemberRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -19,4 +20,14 @@ class MemberService( memberRepository .findById(id) .orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) } + + @Transactional + fun updateNickname( + id: Long, + nickname: Nickname, + ): Member { + val member = getById(id) + member.updateNickname(nickname) + return member + } } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index cb04e2a..0b7f1b3 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,6 +1,7 @@ CREATE TABLE members ( id BIGINT NOT NULL AUTO_INCREMENT, email VARCHAR(255) NULL, + nickname VARCHAR(20) NULL, created_at DATETIME(6) NOT NULL, PRIMARY KEY (id) ) ENGINE = InnoDB; diff --git a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt new file mode 100644 index 0000000..fa3f5f5 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt @@ -0,0 +1,105 @@ +package com.nexters.gamss.member.controller + +import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.Nickname +import com.nexters.gamss.member.repository.MemberRepository +import com.nexters.gamss.support.TestcontainersConfig +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest +@Import(TestcontainersConfig::class) +@Transactional +class MemberControllerIntegrationTest { + @Autowired + private lateinit var context: WebApplicationContext + + @Autowired + private lateinit var memberRepository: MemberRepository + + @Autowired + private lateinit var jwtIssuer: JwtIssuer + + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + mockMvc = + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + private fun bearerFor(member: Member): String = "Bearer ${jwtIssuer.issueAccessToken(member.id)}" + + @Test + fun `닉네임을 수정하면 변경된 닉네임을 반환한다`() { + val member = memberRepository.save(Member("me@a.com")) + + mockMvc + .patch("/api/members/me/nickname") { + header(HttpHeaders.AUTHORIZATION, bearerFor(member)) + contentType = MediaType.APPLICATION_JSON + content = """{"nickname":"바다"}""" + }.andExpect { + status { isOk() } + jsonPath("$.data.nickname") { value("바다") } + } + } + + @Test + fun `닉네임이 공백이면 400을 반환한다`() { + val member = memberRepository.save(Member("me@a.com")) + + mockMvc + .patch("/api/members/me/nickname") { + header(HttpHeaders.AUTHORIZATION, bearerFor(member)) + contentType = MediaType.APPLICATION_JSON + content = """{"nickname":" "}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.error.code") { value("INVALID_INPUT") } + } + } + + @Test + fun `닉네임이 최대 길이를 넘으면 400을 반환한다`() { + val member = memberRepository.save(Member("me@a.com")) + val tooLong = "가".repeat(Nickname.MAX_LENGTH + 1) + + mockMvc + .patch("/api/members/me/nickname") { + header(HttpHeaders.AUTHORIZATION, bearerFor(member)) + contentType = MediaType.APPLICATION_JSON + content = """{"nickname":"$tooLong"}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.error.code") { value("INVALID_INPUT") } + } + } + + @Test + fun `인증 없이 닉네임을 수정하면 401을 반환한다`() { + mockMvc + .patch("/api/members/me/nickname") { + contentType = MediaType.APPLICATION_JSON + content = """{"nickname":"바다"}""" + }.andExpect { + status { isUnauthorized() } + } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt index be8a2de..fe3893c 100644 --- a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt @@ -34,4 +34,13 @@ class MemberTest { assertEquals("new@example.com", member.email) } + + @Test + fun `닉네임을 수정한다`() { + val member = Member("user@example.com") + + member.updateNickname(Nickname("바다")) + + assertEquals(Nickname("바다"), member.nickname) + } } diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt new file mode 100644 index 0000000..0be9c9b --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt @@ -0,0 +1,34 @@ +package com.nexters.gamss.member.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class NicknameTest { + @Test + fun `유효한 값으로 닉네임을 생성한다`() { + assertEquals("바다", Nickname("바다").value) + } + + @Test + fun `최대 길이까지 허용한다`() { + val value = "가".repeat(Nickname.MAX_LENGTH) + + assertEquals(value, Nickname(value).value) + } + + @Test + fun `공백이면 생성할 수 없다`() { + assertFailsWith { Nickname(" ") } + } + + @Test + fun `최대 길이를 넘으면 생성할 수 없다`() { + assertFailsWith { Nickname("가".repeat(Nickname.MAX_LENGTH + 1)) } + } + + @Test + fun `값이 같으면 동등하다`() { + assertEquals(Nickname("바다"), Nickname("바다")) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt index 6128e0d..b9cbc07 100644 --- a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt @@ -3,6 +3,7 @@ package com.nexters.gamss.member.service import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.Nickname import com.nexters.gamss.member.repository.MemberRepository import io.mockk.every import io.mockk.mockk @@ -43,4 +44,15 @@ class MemberServiceTest { assertEquals(ErrorCode.MEMBER_NOT_FOUND, exception.errorCode) } + + @Test + fun `닉네임을 수정한다`() { + val member = Member("b@example.com") + every { memberRepository.findById(10L) } returns Optional.of(member) + + val updated = memberService.updateNickname(10L, Nickname("바다")) + + assertSame(member, updated) + assertEquals(Nickname("바다"), updated.nickname) + } } From 9e3c870420e33e90a32ae6bedddfda2c01cf65f6 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 02:47:04 +0900 Subject: [PATCH 17/27] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=EA=B0=81=20=EA=B8=B0=EB=A1=9D=20=EB=B0=8F?= =?UTF-8?q?=20=EC=86=8C=ED=94=84=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JPA Auditing으로 updatedAt을 자동 관리하고, 회원 탈퇴를 소프트 삭제로 구현한다. 탈퇴 회원은 로그인·재발급이 차단된다. - JPA Auditing 적용(createdAt·updatedAt @CreatedDate/@LastModifiedDate) - MemberStatus(ACTIVE/WITHDRAWN)와 deletedAt 추가, Member.withdraw() - DELETE /api/members/me 탈퇴 엔드포인트(소프트 삭제) - 탈퇴 회원 로그인·재발급 시 WITHDRAWN_MEMBER, 중복 탈퇴 시 ALREADY_WITHDRAWN - V1 스키마에 status·updated_at·deleted_at 컬럼 추가 --- .../nexters/gamss/auth/service/AuthService.kt | 8 ++++ .../gamss/global/config/JpaAuditingConfig.kt | 11 +++++ .../gamss/global/exception/ErrorCode.kt | 2 + .../member/controller/MemberController.kt | 9 ++++ .../com/nexters/gamss/member/domain/Member.kt | 33 +++++++++++++- .../gamss/member/domain/MemberStatus.kt | 9 ++++ .../gamss/member/service/MemberService.kt | 9 ++++ src/main/resources/db/migration/V1__init.sql | 3 ++ .../gamss/auth/service/AuthServiceTest.kt | 43 ++++++++++++++++-- .../MemberControllerIntegrationTest.kt | 44 +++++++++++++++++++ .../nexters/gamss/member/domain/MemberTest.kt | 22 ++++++++++ .../member/repository/MemberRepositoryTest.kt | 15 +++++++ .../gamss/member/service/MemberServiceTest.kt | 20 +++++++++ 13 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/global/config/JpaAuditingConfig.kt create mode 100644 src/main/kotlin/com/nexters/gamss/member/domain/MemberStatus.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt index 22248af..2dcbc30 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -7,6 +7,7 @@ import com.nexters.gamss.auth.repository.RefreshTokenRepository import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.member.service.MemberService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional class AuthService( private val oAuthClientResolver: OAuthClientResolver, private val socialAccountService: SocialAccountService, + private val memberService: MemberService, private val jwtIssuer: JwtIssuer, private val refreshTokenRepository: RefreshTokenRepository, ) { @@ -28,6 +30,9 @@ class AuthService( ): TokenResult { val userInfo = oAuthClientResolver.resolve(provider).verify(idToken) val member = socialAccountService.resolveMember(provider, userInfo.providerId, userInfo.email) + if (member.isWithdrawn()) { + throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) + } return issueTokens(member.id) } @@ -40,6 +45,9 @@ class AuthService( if (!stored.matches(refreshToken)) { throw BusinessException(ErrorCode.INVALID_TOKEN) } + if (memberService.getById(memberId).isWithdrawn()) { + throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) + } return issueTokens(memberId) } diff --git a/src/main/kotlin/com/nexters/gamss/global/config/JpaAuditingConfig.kt b/src/main/kotlin/com/nexters/gamss/global/config/JpaAuditingConfig.kt new file mode 100644 index 0000000..2737e32 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/config/JpaAuditingConfig.kt @@ -0,0 +1,11 @@ +package com.nexters.gamss.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +/** + * JPA Auditing 활성화. @CreatedDate·@LastModifiedDate 가 자동으로 채워진다. + */ +@Configuration +@EnableJpaAuditing +class JpaAuditingConfig diff --git a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt index b70ee66..5db0f32 100644 --- a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt +++ b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt @@ -23,6 +23,8 @@ enum class ErrorCode( // 회원 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), + ALREADY_WITHDRAWN(HttpStatus.CONFLICT, "이미 탈퇴한 회원입니다."), + WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, "탈퇴한 회원입니다."), ; val code: String get() = name diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt index eee6332..fc5d1de 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt @@ -8,6 +8,7 @@ import com.nexters.gamss.member.domain.Nickname import com.nexters.gamss.member.service.MemberService import jakarta.validation.Valid import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.RequestBody @@ -35,4 +36,12 @@ class MemberController( val member = memberService.updateNickname(principal.memberId, Nickname(request.nickname)) return ApiResponse.success(MemberResponse.from(member)) } + + @DeleteMapping("/me") + fun withdraw( + @AuthenticationPrincipal principal: AuthPrincipal, + ): ApiResponse { + memberService.withdraw(principal.memberId) + return ApiResponse.success() + } } diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt index a4d9e64..126ed1a 100644 --- a/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt @@ -3,17 +3,25 @@ package com.nexters.gamss.member.domain import jakarta.persistence.Column import jakarta.persistence.Embedded import jakarta.persistence.Entity +import jakarta.persistence.EntityListeners +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Instant /** * 서비스 사용자. 인증 방식(소셜 등)에 의존하지 않는 순수 유저 도메인. + * 탈퇴는 소프트 삭제(WITHDRAWN 전이 + deletedAt 기록)로 처리한다. */ @Entity @Table(name = "members") +@EntityListeners(AuditingEntityListener::class) class Member( @Column(name = "email") var email: String? = null, @@ -24,8 +32,24 @@ class Member( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + var status: MemberStatus = MemberStatus.ACTIVE + protected set + + @Column(name = "deleted_at") + var deletedAt: Instant? = null + protected set + + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) - val createdAt: Instant = Instant.now() + var createdAt: Instant = Instant.now() + protected set + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.now() + protected set fun updateEmail(email: String?) { this.email = email @@ -34,4 +58,11 @@ class Member( fun updateNickname(nickname: Nickname) { this.nickname = nickname } + + fun withdraw() { + status = MemberStatus.WITHDRAWN + deletedAt = Instant.now() + } + + fun isWithdrawn(): Boolean = status == MemberStatus.WITHDRAWN } diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/MemberStatus.kt b/src/main/kotlin/com/nexters/gamss/member/domain/MemberStatus.kt new file mode 100644 index 0000000..1909ef8 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/domain/MemberStatus.kt @@ -0,0 +1,9 @@ +package com.nexters.gamss.member.domain + +/** + * 회원 상태. 탈퇴는 소프트 삭제로 WITHDRAWN 전이한다. + */ +enum class MemberStatus { + ACTIVE, + WITHDRAWN, +} diff --git a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt index 43c41bf..40e581d 100644 --- a/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt +++ b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt @@ -30,4 +30,13 @@ class MemberService( member.updateNickname(nickname) return member } + + @Transactional + fun withdraw(id: Long) { + val member = getById(id) + if (member.isWithdrawn()) { + throw BusinessException(ErrorCode.ALREADY_WITHDRAWN) + } + member.withdraw() + } } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 0b7f1b3..60af352 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -2,7 +2,10 @@ CREATE TABLE members ( id BIGINT NOT NULL AUTO_INCREMENT, email VARCHAR(255) NULL, nickname VARCHAR(20) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, PRIMARY KEY (id) ) ENGINE = InnoDB; diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt index 191775b..1fbb46f 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt @@ -10,6 +10,7 @@ import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.global.security.JwtIssuer import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.service.MemberService import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -20,17 +21,22 @@ import kotlin.test.assertFailsWith class AuthServiceTest { private val oAuthClientResolver = mockk() private val socialAccountService = mockk() + private val memberService = mockk() private val jwtIssuer = mockk() private val refreshTokenRepository = mockk() private val authService = - AuthService(oAuthClientResolver, socialAccountService, jwtIssuer, refreshTokenRepository) + AuthService(oAuthClientResolver, socialAccountService, memberService, jwtIssuer, refreshTokenRepository) @Test fun `로그인 시 신규 회원이면 리프레시 토큰을 저장한다`() { val client = mockk() every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") - val member = mockk { every { id } returns 100L } + val member = + mockk { + every { id } returns 100L + every { isWithdrawn() } returns false + } every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member every { jwtIssuer.issueAccessToken(100L) } returns "access" every { jwtIssuer.issueRefreshToken(100L) } returns "refresh" @@ -49,7 +55,11 @@ class AuthServiceTest { val client = mockk() every { oAuthClientResolver.resolve(OAuthProvider.APPLE) } returns client every { client.verify("t") } returns OAuthUserInfo("sub", "e@e.com") - val member = mockk { every { id } returns 1L } + val member = + mockk { + every { id } returns 1L + every { isWithdrawn() } returns false + } every { socialAccountService.resolveMember(OAuthProvider.APPLE, "sub", "e@e.com") } returns member every { jwtIssuer.issueAccessToken(1L) } returns "a" every { jwtIssuer.issueRefreshToken(1L) } returns "r" @@ -62,12 +72,26 @@ class AuthServiceTest { verify(exactly = 0) { refreshTokenRepository.save(any()) } } + @Test + fun `탈퇴한 회원이 로그인하면 WITHDRAWN_MEMBER`() { + val client = mockk() + every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client + every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") + val member = mockk { every { isWithdrawn() } returns true } + every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member + + val exception = assertFailsWith { authService.login(OAuthProvider.GOOGLE, "idtok") } + + assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) + } + @Test fun `재발급이 정상이면 새 토큰을 발급하고 회전한다`() { every { jwtIssuer.parseMemberId("refresh") } returns 100L val stored = mockk(relaxed = true) every { stored.matches("refresh") } returns true every { refreshTokenRepository.findByMemberId(100L) } returns stored + every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns false } every { jwtIssuer.issueAccessToken(100L) } returns "na" every { jwtIssuer.issueRefreshToken(100L) } returns "nr" @@ -78,6 +102,19 @@ class AuthServiceTest { verify { stored.rotate("nr") } } + @Test + fun `탈퇴한 회원이 재발급하면 WITHDRAWN_MEMBER`() { + every { jwtIssuer.parseMemberId("refresh") } returns 100L + val stored = mockk() + every { stored.matches("refresh") } returns true + every { refreshTokenRepository.findByMemberId(100L) } returns stored + every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns true } + + val exception = assertFailsWith { authService.reissue("refresh") } + + assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) + } + @Test fun `재발급 시 저장된 토큰이 없으면 REFRESH_TOKEN_NOT_FOUND`() { every { jwtIssuer.parseMemberId("r") } returns 1L diff --git a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt index fa3f5f5..5078f08 100644 --- a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt @@ -14,11 +14,13 @@ import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.patch import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext +import kotlin.test.assertTrue @SpringBootTest @Import(TestcontainersConfig::class) @@ -102,4 +104,46 @@ class MemberControllerIntegrationTest { status { isUnauthorized() } } } + + @Test + fun `회원을 탈퇴하면 소프트 삭제된다`() { + val member = memberRepository.save(Member("me@a.com")) + + mockMvc + .delete("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, bearerFor(member)) + }.andExpect { + status { isOk() } + jsonPath("$.success") { value(true) } + } + + assertTrue(memberRepository.findById(member.id).get().isWithdrawn()) + } + + @Test + fun `이미 탈퇴한 회원이 다시 탈퇴하면 409를 반환한다`() { + val member = memberRepository.save(Member("me@a.com")) + val bearer = bearerFor(member) + + mockMvc.delete("/api/members/me") { header(HttpHeaders.AUTHORIZATION, bearer) }.andExpect { + status { isOk() } + } + + mockMvc + .delete("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, bearer) + }.andExpect { + status { isConflict() } + jsonPath("$.error.code") { value("ALREADY_WITHDRAWN") } + } + } + + @Test + fun `인증 없이 탈퇴하면 401을 반환한다`() { + mockMvc + .delete("/api/members/me") + .andExpect { + status { isUnauthorized() } + } + } } diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt index fe3893c..7a95733 100644 --- a/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt @@ -2,8 +2,10 @@ package com.nexters.gamss.member.domain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class MemberTest { @Test @@ -43,4 +45,24 @@ class MemberTest { assertEquals(Nickname("바다"), member.nickname) } + + @Test + fun `생성 직후에는 활성 상태이며 탈퇴하지 않았다`() { + val member = Member("user@example.com") + + assertEquals(MemberStatus.ACTIVE, member.status) + assertFalse(member.isWithdrawn()) + assertNull(member.deletedAt) + } + + @Test + fun `탈퇴하면 상태가 WITHDRAWN이 되고 삭제 시각이 기록된다`() { + val member = Member("user@example.com") + + member.withdraw() + + assertEquals(MemberStatus.WITHDRAWN, member.status) + assertTrue(member.isWithdrawn()) + assertNotNull(member.deletedAt) + } } diff --git a/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt index a4e9b86..47e5110 100644 --- a/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt @@ -1,11 +1,13 @@ package com.nexters.gamss.member.repository import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.domain.Nickname import com.nexters.gamss.support.RepositoryTest import org.springframework.beans.factory.annotation.Autowired import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class MemberRepositoryTest : RepositoryTest() { @Autowired @@ -20,4 +22,17 @@ class MemberRepositoryTest : RepositoryTest() { assertNotNull(found) assertEquals("user@example.com", found.email) } + + @Test + fun `수정하면 updatedAt이 갱신된다`() { + val saved = memberRepository.saveAndFlush(Member("user@example.com")) + val firstUpdatedAt = saved.updatedAt + Thread.sleep(10) + + saved.updateNickname(Nickname("바다")) + memberRepository.saveAndFlush(saved) + + assertTrue(saved.updatedAt.isAfter(firstUpdatedAt)) + assertNotNull(saved.createdAt) + } } diff --git a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt index b9cbc07..3804445 100644 --- a/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt @@ -55,4 +55,24 @@ class MemberServiceTest { assertSame(member, updated) assertEquals(Nickname("바다"), updated.nickname) } + + @Test + fun `회원을 탈퇴 처리한다`() { + val member = Member("b@example.com") + every { memberRepository.findById(10L) } returns Optional.of(member) + + memberService.withdraw(10L) + + assertEquals(true, member.isWithdrawn()) + } + + @Test + fun `이미 탈퇴한 회원을 다시 탈퇴하면 ALREADY_WITHDRAWN`() { + val member = Member("b@example.com").apply { withdraw() } + every { memberRepository.findById(10L) } returns Optional.of(member) + + val exception = assertFailsWith { memberService.withdraw(10L) } + + assertEquals(ErrorCode.ALREADY_WITHDRAWN, exception.errorCode) + } } From 8b25a3b57f49df90c4d936add0c56386f9d3f1f6 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 03:09:10 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=A0=95=EC=B1=85=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /me 응답에 가입일과 상태를 추가하고, 닉네임 값 객체에 공백 정리· 최소 길이·금칙어 규칙을 더한다. - MemberResponse에 status·createdAt 추가 - Nickname: 앞뒤 공백 trim, 최소 2자, 금칙어 필터(INVALID_NICKNAME) - 닉네임 길이·금칙어 규칙을 Nickname 값 객체로 일원화(DTO는 구조 검증만) --- .../gamss/global/exception/ErrorCode.kt | 1 + .../member/controller/dto/MemberResponse.kt | 12 +++++- .../controller/dto/UpdateNicknameRequest.kt | 3 -- .../nexters/gamss/member/domain/Nickname.kt | 41 ++++++++++++++++--- .../security/SecurityIntegrationTest.kt | 2 + .../MemberControllerIntegrationTest.kt | 17 +++++++- .../gamss/member/domain/NicknameTest.kt | 33 +++++++++++++-- 7 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt index 5db0f32..ef30830 100644 --- a/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt +++ b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt @@ -25,6 +25,7 @@ enum class ErrorCode( MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."), ALREADY_WITHDRAWN(HttpStatus.CONFLICT, "이미 탈퇴한 회원입니다."), WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, "탈퇴한 회원입니다."), + INVALID_NICKNAME(HttpStatus.BAD_REQUEST, "사용할 수 없는 닉네임입니다."), ; val code: String get() = name diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt index 7fb7450..63f123e 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt @@ -1,13 +1,23 @@ package com.nexters.gamss.member.controller.dto import com.nexters.gamss.member.domain.Member +import java.time.Instant data class MemberResponse( val id: Long, val email: String?, val nickname: String?, + val status: String, + val createdAt: Instant, ) { companion object { - fun from(member: Member): MemberResponse = MemberResponse(id = member.id, email = member.email, nickname = member.nickname?.value) + fun from(member: Member): MemberResponse = + MemberResponse( + id = member.id, + email = member.email, + nickname = member.nickname?.value, + status = member.status.name, + createdAt = member.createdAt, + ) } } diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt index ecccf18..cbe4671 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt @@ -1,11 +1,8 @@ package com.nexters.gamss.member.controller.dto -import com.nexters.gamss.member.domain.Nickname import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Size data class UpdateNicknameRequest( @field:NotBlank(message = "nickname은 필수입니다.") - @field:Size(max = Nickname.MAX_LENGTH, message = "닉네임은 ${Nickname.MAX_LENGTH}자 이하여야 합니다.") val nickname: String, ) diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt index aaeaad6..d240a1f 100644 --- a/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt @@ -1,22 +1,51 @@ package com.nexters.gamss.member.domain +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode import jakarta.persistence.Column import jakarta.persistence.Embeddable /** - * 회원 닉네임 값 객체. 공백 불가·최대 길이 같은 불변식을 스스로 보장한다. + * 회원 닉네임 값 객체. 앞뒤 공백을 정리하고 길이·금칙어 불변식을 스스로 보장한다. */ @Embeddable -data class Nickname( - @Column(name = "nickname", length = MAX_LENGTH) - val value: String, +class Nickname( + value: String, ) { + @Column(name = "nickname", length = MAX_LENGTH) + val value: String = value.trim() + init { - require(value.isNotBlank()) { "닉네임은 공백일 수 없습니다." } - require(value.length <= MAX_LENGTH) { "닉네임은 ${MAX_LENGTH}자 이하여야 합니다." } + if (this.value.length !in MIN_LENGTH..MAX_LENGTH) { + throw BusinessException( + ErrorCode.INVALID_NICKNAME, + "닉네임은 ${MIN_LENGTH}자 이상 ${MAX_LENGTH}자 이하여야 합니다.", + ) + } + if (BANNED_WORDS.any { this.value.contains(it, ignoreCase = true) }) { + throw BusinessException(ErrorCode.INVALID_NICKNAME, "닉네임에 사용할 수 없는 표현이 포함되어 있습니다.") + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is Nickname) { + return false + } + return value == other.value } + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = value + companion object { + const val MIN_LENGTH = 2 const val MAX_LENGTH = 20 + + // 금칙어 시작 목록. 필요 시 확장한다. + private val BANNED_WORDS = setOf("시발", "씨발", "새끼", "병신", "지랄", "좆", "썅") } } diff --git a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt index 390eda1..9be39d5 100644 --- a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt @@ -53,6 +53,8 @@ class SecurityIntegrationTest { status { isOk() } jsonPath("$.data.id") { value(member.id) } jsonPath("$.data.email") { value("me@a.com") } + jsonPath("$.data.status") { value("ACTIVE") } + jsonPath("$.data.createdAt") { exists() } } } diff --git a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt index 5078f08..e2e8d2c 100644 --- a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt @@ -90,7 +90,22 @@ class MemberControllerIntegrationTest { content = """{"nickname":"$tooLong"}""" }.andExpect { status { isBadRequest() } - jsonPath("$.error.code") { value("INVALID_INPUT") } + jsonPath("$.error.code") { value("INVALID_NICKNAME") } + } + } + + @Test + fun `금칙어가 포함된 닉네임은 400을 반환한다`() { + val member = memberRepository.save(Member("me@a.com")) + + mockMvc + .patch("/api/members/me/nickname") { + header(HttpHeaders.AUTHORIZATION, bearerFor(member)) + contentType = MediaType.APPLICATION_JSON + content = """{"nickname":"시발이"}""" + }.andExpect { + status { isBadRequest() } + jsonPath("$.error.code") { value("INVALID_NICKNAME") } } } diff --git a/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt index 0be9c9b..023e367 100644 --- a/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt @@ -1,5 +1,7 @@ package com.nexters.gamss.member.domain +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -10,6 +12,11 @@ class NicknameTest { assertEquals("바다", Nickname("바다").value) } + @Test + fun `앞뒤 공백을 제거한다`() { + assertEquals("바다", Nickname(" 바다 ").value) + } + @Test fun `최대 길이까지 허용한다`() { val value = "가".repeat(Nickname.MAX_LENGTH) @@ -18,13 +25,31 @@ class NicknameTest { } @Test - fun `공백이면 생성할 수 없다`() { - assertFailsWith { Nickname(" ") } + fun `최소 길이 미만이면 INVALID_NICKNAME`() { + val exception = assertFailsWith { Nickname("가") } + + assertEquals(ErrorCode.INVALID_NICKNAME, exception.errorCode) } @Test - fun `최대 길이를 넘으면 생성할 수 없다`() { - assertFailsWith { Nickname("가".repeat(Nickname.MAX_LENGTH + 1)) } + fun `공백만 있으면 INVALID_NICKNAME`() { + val exception = assertFailsWith { Nickname(" ") } + + assertEquals(ErrorCode.INVALID_NICKNAME, exception.errorCode) + } + + @Test + fun `최대 길이를 넘으면 INVALID_NICKNAME`() { + val exception = assertFailsWith { Nickname("가".repeat(Nickname.MAX_LENGTH + 1)) } + + assertEquals(ErrorCode.INVALID_NICKNAME, exception.errorCode) + } + + @Test + fun `금칙어가 포함되면 INVALID_NICKNAME`() { + val exception = assertFailsWith { Nickname("시발이") } + + assertEquals(ErrorCode.INVALID_NICKNAME, exception.errorCode) } @Test From f8acd46db7499e77cf640653a766bc645210e252 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 03:12:59 +0900 Subject: [PATCH 19/27] =?UTF-8?q?refactor:=20Nickname=20init=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init 블록이 커지지 않도록 검증을 validate로 위임하고 길이·금칙어 검사를 private 함수로 추출한다. --- .../nexters/gamss/member/domain/Nickname.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt index d240a1f..db3de5d 100644 --- a/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt @@ -16,15 +16,29 @@ class Nickname( val value: String = value.trim() init { - if (this.value.length !in MIN_LENGTH..MAX_LENGTH) { - throw BusinessException( - ErrorCode.INVALID_NICKNAME, - "닉네임은 ${MIN_LENGTH}자 이상 ${MAX_LENGTH}자 이하여야 합니다.", - ) + validate(this.value) + } + + private fun validate(value: String) { + validateLength(value) + validateBannedWord(value) + } + + private fun validateLength(value: String) { + if (value.length in MIN_LENGTH..MAX_LENGTH) { + return } - if (BANNED_WORDS.any { this.value.contains(it, ignoreCase = true) }) { - throw BusinessException(ErrorCode.INVALID_NICKNAME, "닉네임에 사용할 수 없는 표현이 포함되어 있습니다.") + throw BusinessException( + ErrorCode.INVALID_NICKNAME, + "닉네임은 ${MIN_LENGTH}자 이상 ${MAX_LENGTH}자 이하여야 합니다.", + ) + } + + private fun validateBannedWord(value: String) { + if (BANNED_WORDS.none { value.contains(it, ignoreCase = true) }) { + return } + throw BusinessException(ErrorCode.INVALID_NICKNAME, "닉네임에 사용할 수 없는 표현이 포함되어 있습니다.") } override fun equals(other: Any?): Boolean { From 9b9f5ded23f4910249d96b37a899ede447ea592b Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 03:21:59 +0900 Subject: [PATCH 20/27] =?UTF-8?q?refactor:=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EA=B8=B4?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관용 컨벤션(공개 API → private → Any 오버라이드 → companion)에 맞춰 private 헬퍼 위치를 정리하고, 긴 함수를 분리한다. - OidcTokenVerifier.verify의 파싱 로직을 parseClaims로 분리 - 테스트의 private 헬퍼를 클래스 하단으로 이동 --- .../gamss/auth/oauth/OidcTokenVerifier.kt | 22 +++++----- .../auth/oauth/OAuthClientResolverTest.kt | 14 +++--- .../gamss/auth/oauth/OidcTokenVerifierTest.kt | 44 +++++++++---------- .../MemberControllerIntegrationTest.kt | 4 +- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt index bb37a1d..cb9a694 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt @@ -33,20 +33,22 @@ class OidcTokenVerifier( } fun verify(idToken: String): OAuthUserInfo { - val claims = - try { - processor.process(idToken, null) - } catch (e: ParseException) { - throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) - } catch (e: BadJOSEException) { - throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) - } catch (e: JOSEException) { - throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) - } + val claims = parseClaims(idToken) if (!allowedAudiences.accepts(claims.audience)) { throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) } val subject = claims.subject ?: throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) return OAuthUserInfo(providerId = subject, email = claims.getStringClaim("email")) } + + private fun parseClaims(idToken: String): JWTClaimsSet = + try { + processor.process(idToken, null) + } catch (e: ParseException) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } catch (e: BadJOSEException) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } catch (e: JOSEException) { + throw BusinessException(ErrorCode.INVALID_SOCIAL_TOKEN) + } } diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt index 84c1f50..5c299c3 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientResolverTest.kt @@ -8,13 +8,6 @@ import kotlin.test.assertFailsWith import kotlin.test.assertSame class OAuthClientResolverTest { - private fun fakeClient(target: OAuthProvider) = - object : OAuthClient { - override val provider = target - - override fun verify(idToken: String) = OAuthUserInfo("id", null) - } - @Test fun `provider에 맞는 클라이언트를 반환한다`() { val googleClient = fakeClient(OAuthProvider.GOOGLE) @@ -31,4 +24,11 @@ class OAuthClientResolverTest { assertEquals(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, exception.errorCode) } + + private fun fakeClient(target: OAuthProvider) = + object : OAuthClient { + override val provider = target + + override fun verify(idToken: String) = OAuthUserInfo("id", null) + } } diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt index dc80d5e..59e18e5 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt @@ -32,28 +32,6 @@ class OidcTokenVerifierTest { ) private val verifier = OidcTokenVerifier(provider, jwkSource) - private fun signedToken( - subject: String? = "user-sub-1", - iss: String = issuer, - aud: String = clientId, - email: String? = "user@example.com", - expiresAt: Date = Date.from(Instant.now().plusSeconds(300)), - signingKey: RSAKey = rsaKey, - ): String { - val builder = - JWTClaimsSet - .Builder() - .issuer(iss) - .audience(aud) - .expirationTime(expiresAt) - .issueTime(Date()) - if (subject != null) builder.subject(subject) - if (email != null) builder.claim("email", email) - val jwt = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.keyID).build(), builder.build()) - jwt.sign(RSASSASigner(signingKey)) - return jwt.serialize() - } - @Test fun `유효한 토큰이면 사용자 정보를 반환한다`() { val info = verifier.verify(signedToken()) @@ -104,4 +82,26 @@ class OidcTokenVerifierTest { val exception = assertFailsWith { verifier.verify(token) } assertEquals(ErrorCode.INVALID_SOCIAL_TOKEN, exception.errorCode) } + + private fun signedToken( + subject: String? = "user-sub-1", + iss: String = issuer, + aud: String = clientId, + email: String? = "user@example.com", + expiresAt: Date = Date.from(Instant.now().plusSeconds(300)), + signingKey: RSAKey = rsaKey, + ): String { + val builder = + JWTClaimsSet + .Builder() + .issuer(iss) + .audience(aud) + .expirationTime(expiresAt) + .issueTime(Date()) + if (subject != null) builder.subject(subject) + if (email != null) builder.claim("email", email) + val jwt = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.keyID).build(), builder.build()) + jwt.sign(RSASSASigner(signingKey)) + return jwt.serialize() + } } diff --git a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt index e2e8d2c..9f2046f 100644 --- a/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt @@ -46,8 +46,6 @@ class MemberControllerIntegrationTest { .build() } - private fun bearerFor(member: Member): String = "Bearer ${jwtIssuer.issueAccessToken(member.id)}" - @Test fun `닉네임을 수정하면 변경된 닉네임을 반환한다`() { val member = memberRepository.save(Member("me@a.com")) @@ -161,4 +159,6 @@ class MemberControllerIntegrationTest { status { isUnauthorized() } } } + + private fun bearerFor(member: Member): String = "Bearer ${jwtIssuer.issueAccessToken(member.id)}" } From aaded75c95f16b4f63873a923121347cc94df45d Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 03:48:25 +0900 Subject: [PATCH 21/27] =?UTF-8?q?chore:=20=EB=A1=9C=EC=BB=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20docker-compose=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=8F=99=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spring-boot-docker-compose(developmentOnly)를 추가해 앱 실행 시 MySQL 컨테이너를 자동으로 띄우고 종료 시 함께 정리한다. 배포 산출물에는 포함되지 않는다. --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 1e509f1..2e18db5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("com.mysql:mysql-connector-j") + // 로컬 실행 시 docker-compose(MySQL) 자동 기동·종료 (배포 산출물에는 미포함) + developmentOnly("org.springframework.boot:spring-boot-docker-compose") + // DB Migration (Flyway) — spring-boot-flyway 모듈이 Boot 4 자동설정을 제공 implementation("org.springframework.boot:spring-boot-flyway") implementation("org.flywaydb:flyway-core") From c155de59cd1d23cb885b62da7e14a996ef0fb8aa Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Wed, 15 Jul 2026 03:53:21 +0900 Subject: [PATCH 22/27] =?UTF-8?q?docs:=20Swagger=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=84=A4=EB=AA=85=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 API에 한글 태그·요약·설명과 요청/응답 필드 설명·예시를 추가해 Swagger UI 가독성을 높인다. - 컨트롤러에 @Tag(인증·회원)와 @Operation 요약·설명 추가 - provider 파라미터, 요청/응답 DTO에 @Schema 설명·예시 추가 - OpenAPI 문서 설명 문구 조정 --- .../gamss/auth/controller/AuthController.kt | 17 ++++++++++++++ .../gamss/auth/controller/dto/LoginRequest.kt | 2 ++ .../auth/controller/dto/ReissueRequest.kt | 2 ++ .../auth/controller/dto/TokenResponse.kt | 3 +++ .../gamss/global/config/SwaggerConfig.kt | 2 +- .../member/controller/MemberController.kt | 22 ++++++++++++++++--- .../member/controller/dto/MemberResponse.kt | 6 +++++ .../controller/dto/UpdateNicknameRequest.kt | 2 ++ 8 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt index ae27798..06eb613 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt @@ -6,6 +6,9 @@ import com.nexters.gamss.auth.controller.dto.TokenResponse import com.nexters.gamss.auth.oauth.OAuthProvider import com.nexters.gamss.auth.service.AuthService import com.nexters.gamss.global.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -13,13 +16,21 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Tag(name = "인증", description = "소셜 로그인 및 토큰 재발급 API") @RestController @RequestMapping("/api/auth") class AuthController( private val authService: AuthService, ) { + @Operation( + summary = "소셜 로그인", + description = + "앱이 소셜 SDK로 받은 id_token을 검증해 회원을 조회·가입하고 " + + "서비스 토큰(accessToken·refreshToken)을 발급합니다. 최초 로그인 시 회원이 자동 생성됩니다.", + ) @PostMapping("/login/{provider}") fun login( + @Parameter(description = "소셜 제공자 (google 또는 apple)", example = "google") @PathVariable provider: String, @Valid @RequestBody request: LoginRequest, ): ApiResponse { @@ -27,6 +38,12 @@ class AuthController( return ApiResponse.success(TokenResponse.from(result)) } + @Operation( + summary = "토큰 재발급", + description = + "refreshToken으로 accessToken·refreshToken을 재발급합니다. " + + "refreshToken은 회전(rotate)되어 이전 토큰은 무효화됩니다.", + ) @PostMapping("/reissue") fun reissue( @Valid @RequestBody request: ReissueRequest, diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt index bcc3724..3ba0fa1 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt @@ -1,8 +1,10 @@ package com.nexters.gamss.auth.controller.dto +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank data class LoginRequest( @field:NotBlank(message = "idToken은 필수입니다.") + @field:Schema(description = "소셜 SDK로 발급받은 idToken(JWT)", example = "eyJhbGciOiJSUzI1NiIsImtpZCI6...") val idToken: String, ) diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt index eea888b..be50c59 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt @@ -1,8 +1,10 @@ package com.nexters.gamss.auth.controller.dto +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank data class ReissueRequest( @field:NotBlank(message = "refreshToken은 필수입니다.") + @field:Schema(description = "로그인 시 발급받은 refreshToken", example = "eyJhbGciOiJIUzI1NiJ9...") val refreshToken: String, ) diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt index d0b8900..c7f1ffa 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt @@ -1,9 +1,12 @@ package com.nexters.gamss.auth.controller.dto import com.nexters.gamss.auth.service.TokenResult +import io.swagger.v3.oas.annotations.media.Schema data class TokenResponse( + @field:Schema(description = "액세스 토큰 (Authorization 헤더에 Bearer로 사용)") val accessToken: String, + @field:Schema(description = "리프레시 토큰 (재발급에 사용)") val refreshToken: String, ) { companion object { diff --git a/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt b/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt index 06dce3e..e9551a8 100644 --- a/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt @@ -20,7 +20,7 @@ class SwaggerConfig { .info( Info() .title("GAMSS API") - .description("감정 쓰레기통 서비스 API 문서") + .description("GAMSS 서비스 API 문서") .version("v0.1"), ).addSecurityItem(SecurityRequirement().addList(securitySchemeName)) .components( diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt index fc5d1de..281eda5 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt @@ -6,6 +6,9 @@ import com.nexters.gamss.member.controller.dto.MemberResponse import com.nexters.gamss.member.controller.dto.UpdateNicknameRequest import com.nexters.gamss.member.domain.Nickname import com.nexters.gamss.member.service.MemberService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping @@ -15,31 +18,44 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Tag(name = "회원", description = "내 프로필 조회·수정·탈퇴 API (모두 로그인 필요)") @RestController @RequestMapping("/api/members") class MemberController( private val memberService: MemberService, ) { + @Operation( + summary = "내 정보 조회", + description = "로그인한 회원의 정보(이메일·닉네임·가입일·상태)를 반환합니다.", + ) @GetMapping("/me") fun me( - @AuthenticationPrincipal principal: AuthPrincipal, + @Parameter(hidden = true) @AuthenticationPrincipal principal: AuthPrincipal, ): ApiResponse { val member = memberService.getById(principal.memberId) return ApiResponse.success(MemberResponse.from(member)) } + @Operation( + summary = "닉네임 수정", + description = "닉네임을 변경합니다. 앞뒤 공백은 제거되며 2~20자·금칙어 규칙을 따릅니다.", + ) @PatchMapping("/me/nickname") fun updateNickname( - @AuthenticationPrincipal principal: AuthPrincipal, + @Parameter(hidden = true) @AuthenticationPrincipal principal: AuthPrincipal, @Valid @RequestBody request: UpdateNicknameRequest, ): ApiResponse { val member = memberService.updateNickname(principal.memberId, Nickname(request.nickname)) return ApiResponse.success(MemberResponse.from(member)) } + @Operation( + summary = "회원 탈퇴", + description = "소프트 삭제로 탈퇴 처리합니다. 탈퇴 후에는 로그인·토큰 재발급이 차단됩니다.", + ) @DeleteMapping("/me") fun withdraw( - @AuthenticationPrincipal principal: AuthPrincipal, + @Parameter(hidden = true) @AuthenticationPrincipal principal: AuthPrincipal, ): ApiResponse { memberService.withdraw(principal.memberId) return ApiResponse.success() diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt index 63f123e..3dd1833 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt @@ -1,13 +1,19 @@ package com.nexters.gamss.member.controller.dto import com.nexters.gamss.member.domain.Member +import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant data class MemberResponse( + @field:Schema(description = "회원 ID", example = "1") val id: Long, + @field:Schema(description = "이메일 (소셜 제공자가 제공하지 않으면 null)", example = "user@example.com") val email: String?, + @field:Schema(description = "닉네임 (설정 전이면 null)", example = "바다") val nickname: String?, + @field:Schema(description = "회원 상태", example = "ACTIVE", allowableValues = ["ACTIVE", "WITHDRAWN"]) val status: String, + @field:Schema(description = "가입 일시") val createdAt: Instant, ) { companion object { diff --git a/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt index cbe4671..06b432a 100644 --- a/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt @@ -1,8 +1,10 @@ package com.nexters.gamss.member.controller.dto +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank data class UpdateNicknameRequest( @field:NotBlank(message = "nickname은 필수입니다.") + @field:Schema(description = "새 닉네임 (앞뒤 공백 제거 후 2~20자, 금칙어 불가)", example = "바다") val nickname: String, ) From 443105430094a80d3c33431640859d6fd09f88f5 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Fri, 17 Jul 2026 21:54:31 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix:=20refresh=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=EC=A6=9D=EC=9D=B4=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit access·refresh 를 같은 build() 로 만들면서 유효기간만 다르게 줬기 때문에 발급된 두 토큰이 만료 시각 외에는 구별되지 않았다. JwtAuthenticationFilter 는 parseMemberId 가 성공하면 인증을 채우므로, refresh 토큰을 Bearer 로 보내도 보호된 API 가 호출됐다. 탈퇴 처리에도 영향이 있었다. login·reissue 에는 탈퇴 검사가 있지만 필터는 DB 를 보지 않아, 탈퇴 회원이 refresh 토큰을 Bearer 로 쓰면 그대로 통과했다. 의도한 잔존 기간은 access 만료까지의 1시간이었으나 실제로는 refresh 만료인 14일이었다. 토큰에 종류 클레임을 싣고, parseMemberId 를 parseAccessToken·parseRefreshToken 으로 나눠 발급과 대칭이 되게 한다. 파싱이 하나면 호출부가 종류를 확인할 방법 자체가 없어 같은 실수가 반복된다. reissue 에 access 토큰을 쓰는 반대 방향 구멍도 함께 막힌다. 기존 JjwtIssuerTest 의 '리프레시 토큰도 memberId를 파싱한다' 는 이 버그를 정상 동작으로 못박고 있어 교체한다. 커버리지가 98%였음에도 발견되지 않은 이유다. --- .../nexters/gamss/auth/service/AuthService.kt | 2 +- .../gamss/global/security/JjwtIssuer.kt | 26 ++++++++++++-- .../security/JwtAuthenticationFilter.kt | 4 ++- .../gamss/global/security/JwtIssuer.kt | 9 ++++- .../gamss/global/security/TokenType.kt | 15 ++++++++ .../gamss/auth/service/AuthServiceTest.kt | 10 +++--- .../gamss/global/security/JjwtIssuerTest.kt | 32 +++++++++++++---- .../security/SecurityIntegrationTest.kt | 35 +++++++++++++++++++ 8 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/TokenType.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt index 2dcbc30..05d02ce 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -38,7 +38,7 @@ class AuthService( @Transactional fun reissue(refreshToken: String): TokenResult { - val memberId = jwtIssuer.parseMemberId(refreshToken) + val memberId = jwtIssuer.parseRefreshToken(refreshToken) val stored = refreshTokenRepository.findByMemberId(memberId) ?: throw BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt b/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt index 1cfba06..6dcc16b 100644 --- a/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt +++ b/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt @@ -24,26 +24,42 @@ class JjwtIssuer( private val accessTokenValidity = properties.accessTokenValidity private val refreshTokenValidity = properties.refreshTokenValidity - override fun issueAccessToken(memberId: Long): String = build(memberId, accessTokenValidity) + override fun issueAccessToken(memberId: Long): String = build(memberId, TokenType.ACCESS, accessTokenValidity) - override fun issueRefreshToken(memberId: Long): String = build(memberId, refreshTokenValidity) + override fun issueRefreshToken(memberId: Long): String = build(memberId, TokenType.REFRESH, refreshTokenValidity) - override fun parseMemberId(token: String): Long = parse(token).subject.toLong() + override fun parseAccessToken(token: String): Long = parseAs(token, TokenType.ACCESS) + + override fun parseRefreshToken(token: String): Long = parseAs(token, TokenType.REFRESH) private fun build( memberId: Long, + type: TokenType, validity: Duration, ): String { val now = Instant.now() return Jwts .builder() .subject(memberId.toString()) + .claim(TYPE_CLAIM, type.name) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(validity))) .signWith(key) .compact() } + private fun parseAs( + token: String, + expected: TokenType, + ): Long { + val claims = parse(token) + // 종류 클레임이 없는 토큰(구버전)도 여기서 거부된다. + if (TokenType.from(claims[TYPE_CLAIM] as? String) != expected) { + throw BusinessException(ErrorCode.INVALID_TOKEN) + } + return claims.subject.toLong() + } + private fun parse(token: String): Claims = try { Jwts @@ -59,4 +75,8 @@ class JjwtIssuer( } catch (e: IllegalArgumentException) { throw BusinessException(ErrorCode.INVALID_TOKEN) } + + companion object { + private const val TYPE_CLAIM = "type" + } } diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt index aa2604d..80ed373 100644 --- a/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt @@ -12,6 +12,8 @@ import org.springframework.web.filter.OncePerRequestFilter /** * Authorization: Bearer 토큰을 파싱해 SecurityContext에 인증 정보를 채운다. * 토큰이 없거나 유효하지 않으면 인증하지 않고 통과시켜 EntryPoint가 401을 응답하게 한다. + * + * access 토큰만 받는다. refresh 토큰은 재발급(reissue)에만 쓰인다. */ @Component class JwtAuthenticationFilter( @@ -23,7 +25,7 @@ class JwtAuthenticationFilter( filterChain: FilterChain, ) { resolveToken(request)?.let { token -> - runCatching { jwtIssuer.parseMemberId(token) } + runCatching { jwtIssuer.parseAccessToken(token) } .onSuccess { memberId -> val authentication = UsernamePasswordAuthenticationToken(AuthPrincipal(memberId), null, emptyList()) diff --git a/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt index 752393e..b53e593 100644 --- a/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt @@ -2,11 +2,18 @@ package com.nexters.gamss.global.security /** * 서비스 자체 토큰(access/refresh) 발급·파싱 추상화. 구현(JWT 등)은 교체 가능하다. + * + * 발급과 파싱을 종류별로 대칭되게 둔다. 파싱을 하나로 합치면 호출부가 토큰 종류를 + * 확인할 방법이 없어져, refresh 토큰으로 인증이 통과하는 것을 막지 못한다. */ interface JwtIssuer { fun issueAccessToken(memberId: Long): String fun issueRefreshToken(memberId: Long): String - fun parseMemberId(token: String): Long + /** access 토큰이 아니면 INVALID_TOKEN 으로 거부한다. */ + fun parseAccessToken(token: String): Long + + /** refresh 토큰이 아니면 INVALID_TOKEN 으로 거부한다. */ + fun parseRefreshToken(token: String): Long } diff --git a/src/main/kotlin/com/nexters/gamss/global/security/TokenType.kt b/src/main/kotlin/com/nexters/gamss/global/security/TokenType.kt new file mode 100644 index 0000000..f79cbd6 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/TokenType.kt @@ -0,0 +1,15 @@ +package com.nexters.gamss.global.security + +/** + * 서비스 토큰의 종류. 토큰에 클레임으로 실어 access/refresh 를 구분한다. + * 구분이 없으면 refresh 토큰으로도 인증이 통과한다. + */ +enum class TokenType { + ACCESS, + REFRESH, + ; + + companion object { + fun from(value: String?): TokenType? = entries.find { it.name == value } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt index 1fbb46f..c053778 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt @@ -87,7 +87,7 @@ class AuthServiceTest { @Test fun `재발급이 정상이면 새 토큰을 발급하고 회전한다`() { - every { jwtIssuer.parseMemberId("refresh") } returns 100L + every { jwtIssuer.parseRefreshToken("refresh") } returns 100L val stored = mockk(relaxed = true) every { stored.matches("refresh") } returns true every { refreshTokenRepository.findByMemberId(100L) } returns stored @@ -104,7 +104,7 @@ class AuthServiceTest { @Test fun `탈퇴한 회원이 재발급하면 WITHDRAWN_MEMBER`() { - every { jwtIssuer.parseMemberId("refresh") } returns 100L + every { jwtIssuer.parseRefreshToken("refresh") } returns 100L val stored = mockk() every { stored.matches("refresh") } returns true every { refreshTokenRepository.findByMemberId(100L) } returns stored @@ -117,7 +117,7 @@ class AuthServiceTest { @Test fun `재발급 시 저장된 토큰이 없으면 REFRESH_TOKEN_NOT_FOUND`() { - every { jwtIssuer.parseMemberId("r") } returns 1L + every { jwtIssuer.parseRefreshToken("r") } returns 1L every { refreshTokenRepository.findByMemberId(1L) } returns null val exception = assertFailsWith { authService.reissue("r") } @@ -127,7 +127,7 @@ class AuthServiceTest { @Test fun `재발급 시 토큰이 일치하지 않으면 INVALID_TOKEN`() { - every { jwtIssuer.parseMemberId("r") } returns 1L + every { jwtIssuer.parseRefreshToken("r") } returns 1L val stored = mockk() every { stored.matches("r") } returns false every { refreshTokenRepository.findByMemberId(1L) } returns stored @@ -139,7 +139,7 @@ class AuthServiceTest { @Test fun `재발급 시 토큰 파싱 예외를 전파한다`() { - every { jwtIssuer.parseMemberId("bad") } throws BusinessException(ErrorCode.EXPIRED_TOKEN) + every { jwtIssuer.parseRefreshToken("bad") } throws BusinessException(ErrorCode.EXPIRED_TOKEN) val exception = assertFailsWith { authService.reissue("bad") } diff --git a/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt index cbb3342..a5049e2 100644 --- a/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt +++ b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt @@ -20,14 +20,32 @@ class JjwtIssuerTest { fun `액세스 토큰을 발급하고 memberId를 파싱한다`() { val token = jwtIssuer.issueAccessToken(42L) - assertEquals(42L, jwtIssuer.parseMemberId(token)) + assertEquals(42L, jwtIssuer.parseAccessToken(token)) } @Test - fun `리프레시 토큰도 memberId를 파싱한다`() { + fun `리프레시 토큰을 발급하고 memberId를 파싱한다`() { val token = jwtIssuer.issueRefreshToken(7L) - assertEquals(7L, jwtIssuer.parseMemberId(token)) + assertEquals(7L, jwtIssuer.parseRefreshToken(token)) + } + + @Test + fun `리프레시 토큰을 액세스 토큰으로 파싱하면 INVALID_TOKEN 예외를 던진다`() { + val refreshToken = jwtIssuer.issueRefreshToken(1L) + + val exception = assertFailsWith { jwtIssuer.parseAccessToken(refreshToken) } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } + + @Test + fun `액세스 토큰을 리프레시 토큰으로 파싱하면 INVALID_TOKEN 예외를 던진다`() { + val accessToken = jwtIssuer.issueAccessToken(1L) + + val exception = assertFailsWith { jwtIssuer.parseRefreshToken(accessToken) } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } @Test @@ -35,7 +53,7 @@ class JjwtIssuerTest { val other = JjwtIssuer(properties.copy(secret = "another-secret-value-also-long-enough-for-hs256-abcdefghijklmnop")) val forged = other.issueAccessToken(1L) - val exception = assertFailsWith { jwtIssuer.parseMemberId(forged) } + val exception = assertFailsWith { jwtIssuer.parseAccessToken(forged) } assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } @@ -46,21 +64,21 @@ class JjwtIssuerTest { val token = shortLived.issueAccessToken(1L) Thread.sleep(50) - val exception = assertFailsWith { shortLived.parseMemberId(token) } + val exception = assertFailsWith { shortLived.parseAccessToken(token) } assertEquals(ErrorCode.EXPIRED_TOKEN, exception.errorCode) } @Test fun `형식이 잘못된 토큰은 INVALID_TOKEN 예외를 던진다`() { - val exception = assertFailsWith { jwtIssuer.parseMemberId("not-a-valid-jwt") } + val exception = assertFailsWith { jwtIssuer.parseAccessToken("not-a-valid-jwt") } assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } @Test fun `빈 토큰은 INVALID_TOKEN 예외를 던진다`() { - val exception = assertFailsWith { jwtIssuer.parseMemberId("") } + val exception = assertFailsWith { jwtIssuer.parseAccessToken("") } assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) } diff --git a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt index 9be39d5..5bd4f32 100644 --- a/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import import org.springframework.http.HttpHeaders import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders @@ -58,6 +59,40 @@ class SecurityIntegrationTest { } } + @Test + fun `리프레시 토큰으로는 보호된 자원에 접근할 수 없다`() { + val member = memberRepository.save(Member("me@a.com")) + val refreshToken = jwtIssuer.issueRefreshToken(member.id) + + mockMvc + .get("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer $refreshToken") + }.andExpect { + status { isUnauthorized() } + jsonPath("$.error.code") { value("UNAUTHORIZED") } + } + } + + @Test + fun `탈퇴한 회원의 리프레시 토큰으로도 조회할 수 없다`() { + val member = memberRepository.save(Member("me@a.com")) + val refreshToken = jwtIssuer.issueRefreshToken(member.id) + + mockMvc + .delete("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer ${jwtIssuer.issueAccessToken(member.id)}") + }.andExpect { status { isOk() } } + + // refresh 유효기간(14일)이 access(1시간)보다 길어, 여기서 통과하면 + // 탈퇴 후 잔존 기간이 의도한 1시간에서 14일로 벌어진다. + mockMvc + .get("/api/members/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer $refreshToken") + }.andExpect { + status { isUnauthorized() } + } + } + @Test fun `토큰이 없으면 401과 UNAUTHORIZED를 반환한다`() { mockMvc From 617b25b2cf2346a3e37fd35eda02fabffc87ba61 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Sat, 18 Jul 2026 01:26:47 +0900 Subject: [PATCH 24/27] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EC=B6=A9=EB=8F=8C=EB=A1=9C=20500=EC=9D=B4?= =?UTF-8?q?=20=EB=82=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 처음 로그인하는 소셜 계정으로 요청이 동시에 오면 두 요청 모두 계정이 없다고 보고 회원을 생성해, 뒤늦은 하나가 (provider, providerId) 유니크 제약에 걸려 500을 반환했다. 사용자가 로그인 버튼을 연속으로 눌러 요청이 겹치면 재현된다. login 트랜잭션 안에서는 회복할 수 없다. 제약 위반으로 트랜잭션이 rollback-only 가 되어 REQUIRES_NEW로 재조회를 분리해도 바깥이 커밋에 실패한다. 그래서 트랜잭션 경계 바깥의 AuthFacade 에서 한 번만 재시도한다. 재시도 때는 앞선 요청이 커밋한 소셜 계정이 조회돼 기존 회원으로 로그인된다. login 을 별도 빈(AuthService)에 두는 것은 자기 호출이 트랜잭션 프록시를 거치지 않아 새 트랜잭션으로 시작되지 않기 때문이다. 소셜 계정과 리프레시 토큰을 한 트랜잭션으로 커밋하므로, 재시도 시 리프레시 토큰의 유니크 충돌 (uk_refresh_member)도 함께 해소된다 — 이미 커밋된 토큰을 회전하게 된다. 8개 스레드가 같은 계정으로 동시 로그인하는 통합 테스트로 검증했다. 재시도를 제거하면 이 테스트가 실패하는 것을 확인했다. --- .../gamss/auth/controller/AuthController.kt | 8 +- .../nexters/gamss/auth/service/AuthFacade.kt | 37 +++++++ .../nexters/gamss/auth/service/AuthService.kt | 2 + .../auth/controller/AuthControllerTest.kt | 10 +- .../gamss/auth/service/AuthFacadeTest.kt | 54 ++++++++++ .../service/ConcurrentLoginIntegrationTest.kt | 98 +++++++++++++++++++ 6 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt index 06eb613..d0c4479 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt @@ -4,7 +4,7 @@ import com.nexters.gamss.auth.controller.dto.LoginRequest import com.nexters.gamss.auth.controller.dto.ReissueRequest import com.nexters.gamss.auth.controller.dto.TokenResponse import com.nexters.gamss.auth.oauth.OAuthProvider -import com.nexters.gamss.auth.service.AuthService +import com.nexters.gamss.auth.service.AuthFacade import com.nexters.gamss.global.response.ApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/auth") class AuthController( - private val authService: AuthService, + private val authFacade: AuthFacade, ) { @Operation( summary = "소셜 로그인", @@ -34,7 +34,7 @@ class AuthController( @PathVariable provider: String, @Valid @RequestBody request: LoginRequest, ): ApiResponse { - val result = authService.login(OAuthProvider.from(provider), request.idToken) + val result = authFacade.login(OAuthProvider.from(provider), request.idToken) return ApiResponse.success(TokenResponse.from(result)) } @@ -48,7 +48,7 @@ class AuthController( fun reissue( @Valid @RequestBody request: ReissueRequest, ): ApiResponse { - val result = authService.reissue(request.refreshToken) + val result = authFacade.reissue(request.refreshToken) return ApiResponse.success(TokenResponse.from(result)) } } diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt new file mode 100644 index 0000000..658fdeb --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt @@ -0,0 +1,37 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.oauth.OAuthProvider +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service + +/** + * 소셜 로그인 진입점. 동시 최초 로그인 경합을 트랜잭션 경계 바깥에서 재시도한다. + * + * 최초 로그인 요청이 동시에 들어오면 두 요청 모두 소셜 계정을 없다고 보고 회원을 생성해, + * 뒤늦은 하나가 (provider, providerId) 유니크 제약에 걸린다. 이때 login 트랜잭션은 + * rollback-only가 되어 같은 트랜잭션 안에서는 회복할 수 없다 — + * REQUIRES_NEW로 재조회를 분리해도 바깥 트랜잭션이 이미 오염돼 커밋이 실패한다. + * + * 그래서 트랜잭션 경계 바깥인 이 빈에서 한 번만 재시도한다. login 은 소셜 계정과 + * 리프레시 토큰을 한 트랜잭션에서 커밋하므로, 재시도 시점에는 앞선 요청이 커밋한 둘 다 + * 조회되어(소셜 계정 → 기존 회원, 리프레시 토큰 → 회전) 유니크 충돌 없이 성공한다. + * + * AuthService 를 별도 빈으로 두는 이유도 이것이다. 같은 빈 안에서 재시도하면 + * 자기 호출이라 트랜잭션 프록시를 거치지 않아 매 시도가 새 트랜잭션으로 시작되지 않는다. + */ +@Service +class AuthFacade( + private val authService: AuthService, +) { + fun login( + provider: OAuthProvider, + idToken: String, + ): TokenResult = + try { + authService.login(provider, idToken) + } catch (e: DataIntegrityViolationException) { + authService.login(provider, idToken) + } + + fun reissue(refreshToken: String): TokenResult = authService.reissue(refreshToken) +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt index 05d02ce..ec05e7e 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -23,6 +23,8 @@ class AuthService( private val jwtIssuer: JwtIssuer, private val refreshTokenRepository: RefreshTokenRepository, ) { + // 동시 최초 로그인 시 유니크 충돌 재시도는 AuthFacade 가 트랜잭션 경계 바깥에서 담당한다. + // 여기서 소셜 계정·리프레시 토큰을 한 트랜잭션으로 커밋해야 그 재시도가 성립한다. @Transactional fun login( provider: OAuthProvider, diff --git a/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt b/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt index 90261e9..36bbee9 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt @@ -1,7 +1,7 @@ package com.nexters.gamss.auth.controller import com.nexters.gamss.auth.oauth.OAuthProvider -import com.nexters.gamss.auth.service.AuthService +import com.nexters.gamss.auth.service.AuthFacade import com.nexters.gamss.auth.service.TokenResult import com.nexters.gamss.global.exception.GlobalExceptionHandler import io.mockk.every @@ -13,17 +13,17 @@ import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean import kotlin.test.Test class AuthControllerTest { - private val authService = mockk() + private val authFacade = mockk() private val mockMvc = MockMvcBuilders - .standaloneSetup(AuthController(authService)) + .standaloneSetup(AuthController(authFacade)) .setControllerAdvice(GlobalExceptionHandler()) .setValidator(LocalValidatorFactoryBean().apply { afterPropertiesSet() }) .build() @Test fun `로그인에 성공하면 토큰을 반환한다`() { - every { authService.login(OAuthProvider.GOOGLE, "idtok") } returns TokenResult("access", "refresh") + every { authFacade.login(OAuthProvider.GOOGLE, "idtok") } returns TokenResult("access", "refresh") mockMvc .post("/api/auth/login/google") { @@ -63,7 +63,7 @@ class AuthControllerTest { @Test fun `재발급에 성공하면 새 토큰을 반환한다`() { - every { authService.reissue("r") } returns TokenResult("na", "nr") + every { authFacade.reissue("r") } returns TokenResult("na", "nr") mockMvc .post("/api/auth/reissue") { diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt new file mode 100644 index 0000000..38b372f --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt @@ -0,0 +1,54 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.oauth.OAuthProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.dao.DataIntegrityViolationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AuthFacadeTest { + private val authService = mockk() + private val authFacade = AuthFacade(authService) + + @Test + fun `로그인이 정상이면 그대로 반환하고 재시도하지 않는다`() { + every { authService.login(OAuthProvider.GOOGLE, "t") } returns TokenResult("a", "r") + + val result = authFacade.login(OAuthProvider.GOOGLE, "t") + + assertEquals("a", result.accessToken) + verify(exactly = 1) { authService.login(OAuthProvider.GOOGLE, "t") } + } + + @Test + fun `유니크 충돌이 나면 한 번 재시도해 성공한다`() { + every { authService.login(OAuthProvider.GOOGLE, "t") } throws + DataIntegrityViolationException("duplicate") andThen TokenResult("a", "r") + + val result = authFacade.login(OAuthProvider.GOOGLE, "t") + + assertEquals("a", result.accessToken) + verify(exactly = 2) { authService.login(OAuthProvider.GOOGLE, "t") } + } + + @Test + fun `재시도도 충돌하면 예외를 전파한다`() { + every { authService.login(OAuthProvider.GOOGLE, "t") } throws DataIntegrityViolationException("duplicate") + + assertFailsWith { authFacade.login(OAuthProvider.GOOGLE, "t") } + verify(exactly = 2) { authService.login(OAuthProvider.GOOGLE, "t") } + } + + @Test + fun `재발급은 재시도 없이 그대로 위임한다`() { + every { authService.reissue("r") } returns TokenResult("na", "nr") + + val result = authFacade.reissue("r") + + assertEquals("na", result.accessToken) + verify(exactly = 1) { authService.reissue("r") } + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt new file mode 100644 index 0000000..d7c1518 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt @@ -0,0 +1,98 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.oauth.OAuthClient +import com.nexters.gamss.auth.oauth.OAuthClientResolver +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.oauth.OAuthUserInfo +import com.nexters.gamss.auth.repository.RefreshTokenRepository +import com.nexters.gamss.auth.repository.SocialAccountRepository +import com.nexters.gamss.member.repository.MemberRepository +import com.nexters.gamss.support.TestcontainersConfig +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary +import java.util.Collections +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * 동시 최초 로그인 경합 검증. 여러 스레드가 같은 소셜 계정으로 동시에 처음 로그인해도 + * 모두 성공하고, 회원·소셜 계정·리프레시 토큰이 각각 하나만 남아야 한다. + * + * 재시도가 없으면 뒤늦은 요청이 (provider, providerId) 유니크 제약 위반으로 실패한다. + */ +@SpringBootTest +@Import(TestcontainersConfig::class, ConcurrentLoginIntegrationTest.StubOAuthConfig::class) +class ConcurrentLoginIntegrationTest { + @Autowired + private lateinit var authFacade: AuthFacade + + @Autowired + private lateinit var memberRepository: MemberRepository + + @Autowired + private lateinit var socialAccountRepository: SocialAccountRepository + + @Autowired + private lateinit var refreshTokenRepository: RefreshTokenRepository + + @TestConfiguration(proxyBeanMethods = false) + class StubOAuthConfig { + // 어떤 idToken 이든 같은 소셜 신원을 반환해, 동시 요청이 같은 계정으로 경합하게 한다. + @Bean + @Primary + fun stubOAuthClientResolver(): OAuthClientResolver = + OAuthClientResolver( + listOf( + object : OAuthClient { + override val provider = OAuthProvider.GOOGLE + + override fun verify(idToken: String) = OAuthUserInfo("concurrent-sub", "u@a.com") + }, + ), + ) + } + + @AfterEach + fun cleanUp() { + // 이 테스트는 실제 커밋이 필요해 @Transactional 롤백을 쓸 수 없으므로 직접 정리한다. + refreshTokenRepository.deleteAll() + socialAccountRepository.deleteAll() + memberRepository.deleteAll() + } + + @Test + fun `첫 로그인이 동시에 여러 개 와도 모두 성공하고 회원은 하나만 생성된다`() { + val threadCount = 8 + val startLine = CyclicBarrier(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + val errors = Collections.synchronizedList(mutableListOf()) + + val futures = + (1..threadCount).map { + executor.submit { + try { + startLine.await() // 모든 스레드를 동시에 출발시켜 경합을 유도한다 + authFacade.login(OAuthProvider.GOOGLE, "idtok") + } catch (t: Throwable) { + errors.add(t) + } + } + } + futures.forEach { it.get(30, TimeUnit.SECONDS) } + executor.shutdown() + + assertTrue(errors.isEmpty(), "동시 로그인 중 예외 발생: $errors") + assertEquals(1, memberRepository.count(), "회원은 하나만 생성돼야 한다") + assertEquals(1, socialAccountRepository.count(), "소셜 계정은 하나만 생성돼야 한다") + assertEquals(1, refreshTokenRepository.count(), "리프레시 토큰은 하나만 남아야 한다") + } +} From ce30db2909977cc6db888f23450be06134cbaa9d Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Sat, 18 Jul 2026 01:48:17 +0900 Subject: [PATCH 25/27] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=EC=98=A4?= =?UTF-8?q?=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=85=98=C2=B7?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=C2=B7=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시 로그인 재시도를 AuthFacade 에서 인라인으로 처리하던 것을, 책임별로 나눈다. 컨트롤러는 AuthService 하나만 호출한다. - AuthService: 흐름 조율만. 재시도는 ConflictRetry 에, 트랜잭션 작업은 LoginService 에 위임한다. 영속성 예외 타입에 더는 의존하지 않는다. - LoginService: 로그인·재발급의 트랜잭션 작업 단위(소셜 검증·회원 확보·토큰 발급을 한 트랜잭션으로). 기존 AuthService 로직을 그대로 옮겼다. - ConflictRetry: 재시도 정책만 담당. 무엇을 재시도할지는 호출부가 넘긴다. - SocialAccountService: 유니크 제약 위반(DataIntegrityViolationException)을 ConcurrentRegistrationException 으로 번역해, 재시도 판단이 영속성 기술에 의존하지 않게 한다. 소셜 계정과 리프레시 토큰을 한 트랜잭션으로 커밋하므로 충돌 지점이 소셜 계정 하나뿐이라 1회 재시도로 충분하다. 8스레드 동시 로그인 통합 테스트로 검증했고, 재시도를 제거하면 실패하는 것도 확인했다. --- .../gamss/auth/controller/AuthController.kt | 8 +- .../nexters/gamss/auth/service/AuthFacade.kt | 37 ----- .../nexters/gamss/auth/service/AuthService.kt | 68 ++------ .../ConcurrentRegistrationException.kt | 15 ++ .../gamss/auth/service/ConflictRetry.kt | 33 ++++ .../gamss/auth/service/LoginService.kt | 75 +++++++++ .../auth/service/SocialAccountService.kt | 9 +- .../auth/controller/AuthControllerTest.kt | 10 +- .../gamss/auth/service/AuthFacadeTest.kt | 54 ------- .../gamss/auth/service/AuthServiceTest.kt | 136 +++------------- .../service/ConcurrentLoginIntegrationTest.kt | 4 +- .../gamss/auth/service/ConflictRetryTest.kt | 69 ++++++++ .../gamss/auth/service/LoginServiceTest.kt | 148 ++++++++++++++++++ .../auth/service/SocialAccountServiceTest.kt | 13 ++ 14 files changed, 402 insertions(+), 277 deletions(-) delete mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/ConcurrentRegistrationException.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/ConflictRetry.kt create mode 100644 src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt delete mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/ConflictRetryTest.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt index d0c4479..06eb613 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt @@ -4,7 +4,7 @@ import com.nexters.gamss.auth.controller.dto.LoginRequest import com.nexters.gamss.auth.controller.dto.ReissueRequest import com.nexters.gamss.auth.controller.dto.TokenResponse import com.nexters.gamss.auth.oauth.OAuthProvider -import com.nexters.gamss.auth.service.AuthFacade +import com.nexters.gamss.auth.service.AuthService import com.nexters.gamss.global.response.ApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter @@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/auth") class AuthController( - private val authFacade: AuthFacade, + private val authService: AuthService, ) { @Operation( summary = "소셜 로그인", @@ -34,7 +34,7 @@ class AuthController( @PathVariable provider: String, @Valid @RequestBody request: LoginRequest, ): ApiResponse { - val result = authFacade.login(OAuthProvider.from(provider), request.idToken) + val result = authService.login(OAuthProvider.from(provider), request.idToken) return ApiResponse.success(TokenResponse.from(result)) } @@ -48,7 +48,7 @@ class AuthController( fun reissue( @Valid @RequestBody request: ReissueRequest, ): ApiResponse { - val result = authFacade.reissue(request.refreshToken) + val result = authService.reissue(request.refreshToken) return ApiResponse.success(TokenResponse.from(result)) } } diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt deleted file mode 100644 index 658fdeb..0000000 --- a/src/main/kotlin/com/nexters/gamss/auth/service/AuthFacade.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.nexters.gamss.auth.service - -import com.nexters.gamss.auth.oauth.OAuthProvider -import org.springframework.dao.DataIntegrityViolationException -import org.springframework.stereotype.Service - -/** - * 소셜 로그인 진입점. 동시 최초 로그인 경합을 트랜잭션 경계 바깥에서 재시도한다. - * - * 최초 로그인 요청이 동시에 들어오면 두 요청 모두 소셜 계정을 없다고 보고 회원을 생성해, - * 뒤늦은 하나가 (provider, providerId) 유니크 제약에 걸린다. 이때 login 트랜잭션은 - * rollback-only가 되어 같은 트랜잭션 안에서는 회복할 수 없다 — - * REQUIRES_NEW로 재조회를 분리해도 바깥 트랜잭션이 이미 오염돼 커밋이 실패한다. - * - * 그래서 트랜잭션 경계 바깥인 이 빈에서 한 번만 재시도한다. login 은 소셜 계정과 - * 리프레시 토큰을 한 트랜잭션에서 커밋하므로, 재시도 시점에는 앞선 요청이 커밋한 둘 다 - * 조회되어(소셜 계정 → 기존 회원, 리프레시 토큰 → 회전) 유니크 충돌 없이 성공한다. - * - * AuthService 를 별도 빈으로 두는 이유도 이것이다. 같은 빈 안에서 재시도하면 - * 자기 호출이라 트랜잭션 프록시를 거치지 않아 매 시도가 새 트랜잭션으로 시작되지 않는다. - */ -@Service -class AuthFacade( - private val authService: AuthService, -) { - fun login( - provider: OAuthProvider, - idToken: String, - ): TokenResult = - try { - authService.login(provider, idToken) - } catch (e: DataIntegrityViolationException) { - authService.login(provider, idToken) - } - - fun reissue(refreshToken: String): TokenResult = authService.reissue(refreshToken) -} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt index ec05e7e..47d3767 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -1,74 +1,24 @@ package com.nexters.gamss.auth.service -import com.nexters.gamss.auth.domain.RefreshToken -import com.nexters.gamss.auth.oauth.OAuthClientResolver import com.nexters.gamss.auth.oauth.OAuthProvider -import com.nexters.gamss.auth.repository.RefreshTokenRepository -import com.nexters.gamss.global.exception.BusinessException -import com.nexters.gamss.global.exception.ErrorCode -import com.nexters.gamss.global.security.JwtIssuer -import com.nexters.gamss.member.service.MemberService import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional /** - * 소셜 로그인·토큰 재발급 오케스트레이션. - * 소셜 검증(OAuthClient), 소셜↔회원 연결(SocialAccountService), 토큰(JwtIssuer)은 각 컴포넌트에 위임한다. + * 인증 진입점. 로그인·재발급 흐름을 조율한다. + * + * 트랜잭션 작업은 LoginService 에, 동시 가입 경합의 재시도는 ConflictRetry 에 위임한다. + * 재시도를 별도 객체로 두는 것은 트랜잭션 경계 바깥에서 실행돼야 하기 때문이자(같은 빈 내부 + * 호출은 트랜잭션 프록시를 거치지 않는다), 재시도 정책과 흐름 조율을 분리하기 위함이다. */ @Service class AuthService( - private val oAuthClientResolver: OAuthClientResolver, - private val socialAccountService: SocialAccountService, - private val memberService: MemberService, - private val jwtIssuer: JwtIssuer, - private val refreshTokenRepository: RefreshTokenRepository, + private val loginService: LoginService, + private val conflictRetry: ConflictRetry, ) { - // 동시 최초 로그인 시 유니크 충돌 재시도는 AuthFacade 가 트랜잭션 경계 바깥에서 담당한다. - // 여기서 소셜 계정·리프레시 토큰을 한 트랜잭션으로 커밋해야 그 재시도가 성립한다. - @Transactional fun login( provider: OAuthProvider, idToken: String, - ): TokenResult { - val userInfo = oAuthClientResolver.resolve(provider).verify(idToken) - val member = socialAccountService.resolveMember(provider, userInfo.providerId, userInfo.email) - if (member.isWithdrawn()) { - throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) - } - return issueTokens(member.id) - } + ): TokenResult = conflictRetry.execute { loginService.login(provider, idToken) } - @Transactional - fun reissue(refreshToken: String): TokenResult { - val memberId = jwtIssuer.parseRefreshToken(refreshToken) - val stored = - refreshTokenRepository.findByMemberId(memberId) - ?: throw BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND) - if (!stored.matches(refreshToken)) { - throw BusinessException(ErrorCode.INVALID_TOKEN) - } - if (memberService.getById(memberId).isWithdrawn()) { - throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) - } - return issueTokens(memberId) - } - - private fun issueTokens(memberId: Long): TokenResult { - val accessToken = jwtIssuer.issueAccessToken(memberId) - val refreshToken = jwtIssuer.issueRefreshToken(memberId) - persistRefreshToken(memberId, refreshToken) - return TokenResult(accessToken, refreshToken) - } - - private fun persistRefreshToken( - memberId: Long, - refreshToken: String, - ) { - val stored = refreshTokenRepository.findByMemberId(memberId) - if (stored == null) { - refreshTokenRepository.save(RefreshToken(memberId, refreshToken)) - return - } - stored.rotate(refreshToken) - } + fun reissue(refreshToken: String): TokenResult = loginService.reissue(refreshToken) } diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/ConcurrentRegistrationException.kt b/src/main/kotlin/com/nexters/gamss/auth/service/ConcurrentRegistrationException.kt new file mode 100644 index 0000000..993d646 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/ConcurrentRegistrationException.kt @@ -0,0 +1,15 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.oauth.OAuthProvider + +/** + * 동시 최초 로그인으로 같은 소셜 계정이 중복 가입되려다 유니크 제약에 걸렸음을 나타내는 도메인 예외. + * + * 영속성 계층의 예외(DataIntegrityViolationException)를 이 타입으로 번역해, 재시도를 판단하는 + * 상위 계층이 특정 영속성 기술에 의존하지 않게 한다. 재시도하면 앞선 요청이 커밋한 소셜 계정이 + * 조회되어 해소되는, 회복 가능한 경합이다. + */ +class ConcurrentRegistrationException( + provider: OAuthProvider, + providerId: String, +) : RuntimeException("동시 가입 경합: provider=$provider, providerId=$providerId") diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/ConflictRetry.kt b/src/main/kotlin/com/nexters/gamss/auth/service/ConflictRetry.kt new file mode 100644 index 0000000..4cb8cff --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/ConflictRetry.kt @@ -0,0 +1,33 @@ +package com.nexters.gamss.auth.service + +import org.springframework.stereotype.Component + +/** + * 동시 가입 경합을 트랜잭션 경계 바깥에서 재시도하는 정책 객체. + * + * 트랜잭션 안에서는 회복할 수 없다 — 유니크 제약 위반으로 트랜잭션이 rollback-only가 되기 때문이다. + * 그래서 트랜잭션이 끝난(롤백된) 뒤 이 객체가 작업 전체를 새로 실행한다. 재시도 시점에는 앞선 + * 요청이 커밋을 마친 상태라, 다음 시도가 그 결과를 조회해 성공한다. + * + * 재시도 정책만 담당한다(SRP). 무엇을 재시도할지는 호출부가 람다로 넘긴다. + */ +@Component +class ConflictRetry { + fun execute(operation: () -> T): T { + var attempt = 1 + while (true) { + try { + return operation() + } catch (e: ConcurrentRegistrationException) { + if (attempt >= MAX_ATTEMPTS) { + throw e + } + attempt++ + } + } + } + + companion object { + private const val MAX_ATTEMPTS = 3 + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt new file mode 100644 index 0000000..817f1b1 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt @@ -0,0 +1,75 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.domain.RefreshToken +import com.nexters.gamss.auth.oauth.OAuthClientResolver +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.repository.RefreshTokenRepository +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.member.service.MemberService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 로그인·재발급의 트랜잭션 작업 단위. 소셜 검증·회원 확보·토큰 발급을 한 트랜잭션으로 묶는다. + * + * 동시 최초 로그인 경합의 재시도는 이 트랜잭션 바깥(AuthService + ConflictRetry)에서 담당한다. + * 소셜 계정과 리프레시 토큰을 한 트랜잭션으로 커밋하므로, 경합에 진 요청은 소셜 계정 생성에서 + * 걸려 통째로 롤백된다 — 토큰 발급까지 가지 않아 충돌 지점이 소셜 계정 하나로 한정된다. + */ +@Service +class LoginService( + private val oAuthClientResolver: OAuthClientResolver, + private val socialAccountService: SocialAccountService, + private val memberService: MemberService, + private val jwtIssuer: JwtIssuer, + private val refreshTokenRepository: RefreshTokenRepository, +) { + @Transactional + fun login( + provider: OAuthProvider, + idToken: String, + ): TokenResult { + val userInfo = oAuthClientResolver.resolve(provider).verify(idToken) + val member = socialAccountService.resolveMember(provider, userInfo.providerId, userInfo.email) + if (member.isWithdrawn()) { + throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) + } + return issueTokens(member.id) + } + + @Transactional + fun reissue(refreshToken: String): TokenResult { + val memberId = jwtIssuer.parseRefreshToken(refreshToken) + val stored = + refreshTokenRepository.findByMemberId(memberId) + ?: throw BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND) + if (!stored.matches(refreshToken)) { + throw BusinessException(ErrorCode.INVALID_TOKEN) + } + if (memberService.getById(memberId).isWithdrawn()) { + throw BusinessException(ErrorCode.WITHDRAWN_MEMBER) + } + return issueTokens(memberId) + } + + private fun issueTokens(memberId: Long): TokenResult { + val accessToken = jwtIssuer.issueAccessToken(memberId) + val refreshToken = jwtIssuer.issueRefreshToken(memberId) + persistRefreshToken(memberId, refreshToken) + return TokenResult(accessToken, refreshToken) + } + + private fun persistRefreshToken( + memberId: Long, + refreshToken: String, + ) { + val stored = refreshTokenRepository.findByMemberId(memberId) + if (stored == null) { + refreshTokenRepository.save(RefreshToken(memberId, refreshToken)) + return + } + stored.rotate(refreshToken) + } +} diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt index de4bc34..0e41f73 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt @@ -5,6 +5,7 @@ import com.nexters.gamss.auth.oauth.OAuthProvider import com.nexters.gamss.auth.repository.SocialAccountRepository import com.nexters.gamss.member.domain.Member import com.nexters.gamss.member.service.MemberService +import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -29,7 +30,13 @@ class SocialAccountService( return memberService.getById(socialAccount.memberId) } val member = memberService.create(email) - socialAccountRepository.save(SocialAccount(member.id, storedProvider, providerId)) + // 동시 최초 로그인 시 (provider, providerId) 유니크 제약에 걸릴 수 있다. + // 영속성 예외를 도메인 예외로 번역해, 재시도 판단이 특정 영속성 기술에 의존하지 않게 한다. + try { + socialAccountRepository.save(SocialAccount(member.id, storedProvider, providerId)) + } catch (e: DataIntegrityViolationException) { + throw ConcurrentRegistrationException(provider, providerId) + } return member } } diff --git a/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt b/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt index 36bbee9..90261e9 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/controller/AuthControllerTest.kt @@ -1,7 +1,7 @@ package com.nexters.gamss.auth.controller import com.nexters.gamss.auth.oauth.OAuthProvider -import com.nexters.gamss.auth.service.AuthFacade +import com.nexters.gamss.auth.service.AuthService import com.nexters.gamss.auth.service.TokenResult import com.nexters.gamss.global.exception.GlobalExceptionHandler import io.mockk.every @@ -13,17 +13,17 @@ import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean import kotlin.test.Test class AuthControllerTest { - private val authFacade = mockk() + private val authService = mockk() private val mockMvc = MockMvcBuilders - .standaloneSetup(AuthController(authFacade)) + .standaloneSetup(AuthController(authService)) .setControllerAdvice(GlobalExceptionHandler()) .setValidator(LocalValidatorFactoryBean().apply { afterPropertiesSet() }) .build() @Test fun `로그인에 성공하면 토큰을 반환한다`() { - every { authFacade.login(OAuthProvider.GOOGLE, "idtok") } returns TokenResult("access", "refresh") + every { authService.login(OAuthProvider.GOOGLE, "idtok") } returns TokenResult("access", "refresh") mockMvc .post("/api/auth/login/google") { @@ -63,7 +63,7 @@ class AuthControllerTest { @Test fun `재발급에 성공하면 새 토큰을 반환한다`() { - every { authFacade.reissue("r") } returns TokenResult("na", "nr") + every { authService.reissue("r") } returns TokenResult("na", "nr") mockMvc .post("/api/auth/reissue") { diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt deleted file mode 100644 index 38b372f..0000000 --- a/src/test/kotlin/com/nexters/gamss/auth/service/AuthFacadeTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.nexters.gamss.auth.service - -import com.nexters.gamss.auth.oauth.OAuthProvider -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.dao.DataIntegrityViolationException -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class AuthFacadeTest { - private val authService = mockk() - private val authFacade = AuthFacade(authService) - - @Test - fun `로그인이 정상이면 그대로 반환하고 재시도하지 않는다`() { - every { authService.login(OAuthProvider.GOOGLE, "t") } returns TokenResult("a", "r") - - val result = authFacade.login(OAuthProvider.GOOGLE, "t") - - assertEquals("a", result.accessToken) - verify(exactly = 1) { authService.login(OAuthProvider.GOOGLE, "t") } - } - - @Test - fun `유니크 충돌이 나면 한 번 재시도해 성공한다`() { - every { authService.login(OAuthProvider.GOOGLE, "t") } throws - DataIntegrityViolationException("duplicate") andThen TokenResult("a", "r") - - val result = authFacade.login(OAuthProvider.GOOGLE, "t") - - assertEquals("a", result.accessToken) - verify(exactly = 2) { authService.login(OAuthProvider.GOOGLE, "t") } - } - - @Test - fun `재시도도 충돌하면 예외를 전파한다`() { - every { authService.login(OAuthProvider.GOOGLE, "t") } throws DataIntegrityViolationException("duplicate") - - assertFailsWith { authFacade.login(OAuthProvider.GOOGLE, "t") } - verify(exactly = 2) { authService.login(OAuthProvider.GOOGLE, "t") } - } - - @Test - fun `재발급은 재시도 없이 그대로 위임한다`() { - every { authService.reissue("r") } returns TokenResult("na", "nr") - - val result = authFacade.reissue("r") - - assertEquals("na", result.accessToken) - verify(exactly = 1) { authService.reissue("r") } - } -} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt index c053778..e8cfe5b 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.kt @@ -1,16 +1,6 @@ package com.nexters.gamss.auth.service -import com.nexters.gamss.auth.domain.RefreshToken -import com.nexters.gamss.auth.oauth.OAuthClient -import com.nexters.gamss.auth.oauth.OAuthClientResolver import com.nexters.gamss.auth.oauth.OAuthProvider -import com.nexters.gamss.auth.oauth.OAuthUserInfo -import com.nexters.gamss.auth.repository.RefreshTokenRepository -import com.nexters.gamss.global.exception.BusinessException -import com.nexters.gamss.global.exception.ErrorCode -import com.nexters.gamss.global.security.JwtIssuer -import com.nexters.gamss.member.domain.Member -import com.nexters.gamss.member.service.MemberService import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -19,130 +9,46 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith class AuthServiceTest { - private val oAuthClientResolver = mockk() - private val socialAccountService = mockk() - private val memberService = mockk() - private val jwtIssuer = mockk() - private val refreshTokenRepository = mockk() - private val authService = - AuthService(oAuthClientResolver, socialAccountService, memberService, jwtIssuer, refreshTokenRepository) + private val loginService = mockk() - @Test - fun `로그인 시 신규 회원이면 리프레시 토큰을 저장한다`() { - val client = mockk() - every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client - every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") - val member = - mockk { - every { id } returns 100L - every { isWithdrawn() } returns false - } - every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member - every { jwtIssuer.issueAccessToken(100L) } returns "access" - every { jwtIssuer.issueRefreshToken(100L) } returns "refresh" - every { refreshTokenRepository.findByMemberId(100L) } returns null - every { refreshTokenRepository.save(any()) } answers { firstArg() } - - val result = authService.login(OAuthProvider.GOOGLE, "idtok") - - assertEquals("access", result.accessToken) - assertEquals("refresh", result.refreshToken) - verify { refreshTokenRepository.save(match { it.memberId == 100L && it.token == "refresh" }) } - } + // 실제 재시도 정책을 그대로 사용해 위임이 올바른지 검증한다. + private val authService = AuthService(loginService, ConflictRetry()) @Test - fun `로그인 시 기존 리프레시 토큰이 있으면 회전한다`() { - val client = mockk() - every { oAuthClientResolver.resolve(OAuthProvider.APPLE) } returns client - every { client.verify("t") } returns OAuthUserInfo("sub", "e@e.com") - val member = - mockk { - every { id } returns 1L - every { isWithdrawn() } returns false - } - every { socialAccountService.resolveMember(OAuthProvider.APPLE, "sub", "e@e.com") } returns member - every { jwtIssuer.issueAccessToken(1L) } returns "a" - every { jwtIssuer.issueRefreshToken(1L) } returns "r" - val stored = mockk(relaxed = true) - every { refreshTokenRepository.findByMemberId(1L) } returns stored + fun `로그인은 LoginService에 위임하고 정상이면 재시도하지 않는다`() { + every { loginService.login(OAuthProvider.GOOGLE, "t") } returns TokenResult("a", "r") - authService.login(OAuthProvider.APPLE, "t") + val result = authService.login(OAuthProvider.GOOGLE, "t") - verify { stored.rotate("r") } - verify(exactly = 0) { refreshTokenRepository.save(any()) } + assertEquals("a", result.accessToken) + verify(exactly = 1) { loginService.login(OAuthProvider.GOOGLE, "t") } } @Test - fun `탈퇴한 회원이 로그인하면 WITHDRAWN_MEMBER`() { - val client = mockk() - every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client - every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") - val member = mockk { every { isWithdrawn() } returns true } - every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member + fun `동시 가입 경합이 나면 재시도해 성공한다`() { + every { loginService.login(OAuthProvider.GOOGLE, "t") } throws + ConcurrentRegistrationException(OAuthProvider.GOOGLE, "sub") andThen TokenResult("a", "r") - val exception = assertFailsWith { authService.login(OAuthProvider.GOOGLE, "idtok") } + val result = authService.login(OAuthProvider.GOOGLE, "t") - assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) + assertEquals("a", result.accessToken) + verify(exactly = 2) { loginService.login(OAuthProvider.GOOGLE, "t") } } @Test - fun `재발급이 정상이면 새 토큰을 발급하고 회전한다`() { - every { jwtIssuer.parseRefreshToken("refresh") } returns 100L - val stored = mockk(relaxed = true) - every { stored.matches("refresh") } returns true - every { refreshTokenRepository.findByMemberId(100L) } returns stored - every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns false } - every { jwtIssuer.issueAccessToken(100L) } returns "na" - every { jwtIssuer.issueRefreshToken(100L) } returns "nr" + fun `재발급은 재시도 없이 LoginService에 위임한다`() { + every { loginService.reissue("r") } returns TokenResult("na", "nr") - val result = authService.reissue("refresh") + val result = authService.reissue("r") assertEquals("na", result.accessToken) - assertEquals("nr", result.refreshToken) - verify { stored.rotate("nr") } - } - - @Test - fun `탈퇴한 회원이 재발급하면 WITHDRAWN_MEMBER`() { - every { jwtIssuer.parseRefreshToken("refresh") } returns 100L - val stored = mockk() - every { stored.matches("refresh") } returns true - every { refreshTokenRepository.findByMemberId(100L) } returns stored - every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns true } - - val exception = assertFailsWith { authService.reissue("refresh") } - - assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) - } - - @Test - fun `재발급 시 저장된 토큰이 없으면 REFRESH_TOKEN_NOT_FOUND`() { - every { jwtIssuer.parseRefreshToken("r") } returns 1L - every { refreshTokenRepository.findByMemberId(1L) } returns null - - val exception = assertFailsWith { authService.reissue("r") } - - assertEquals(ErrorCode.REFRESH_TOKEN_NOT_FOUND, exception.errorCode) - } - - @Test - fun `재발급 시 토큰이 일치하지 않으면 INVALID_TOKEN`() { - every { jwtIssuer.parseRefreshToken("r") } returns 1L - val stored = mockk() - every { stored.matches("r") } returns false - every { refreshTokenRepository.findByMemberId(1L) } returns stored - - val exception = assertFailsWith { authService.reissue("r") } - - assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + verify(exactly = 1) { loginService.reissue("r") } } @Test - fun `재발급 시 토큰 파싱 예외를 전파한다`() { - every { jwtIssuer.parseRefreshToken("bad") } throws BusinessException(ErrorCode.EXPIRED_TOKEN) - - val exception = assertFailsWith { authService.reissue("bad") } + fun `재발급 중 예외는 그대로 전파한다`() { + every { loginService.reissue("bad") } throws IllegalStateException("boom") - assertEquals(ErrorCode.EXPIRED_TOKEN, exception.errorCode) + assertFailsWith { authService.reissue("bad") } } } diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt index d7c1518..f06ea2e 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/service/ConcurrentLoginIntegrationTest.kt @@ -33,7 +33,7 @@ import kotlin.test.assertTrue @Import(TestcontainersConfig::class, ConcurrentLoginIntegrationTest.StubOAuthConfig::class) class ConcurrentLoginIntegrationTest { @Autowired - private lateinit var authFacade: AuthFacade + private lateinit var authService: AuthService @Autowired private lateinit var memberRepository: MemberRepository @@ -81,7 +81,7 @@ class ConcurrentLoginIntegrationTest { executor.submit { try { startLine.await() // 모든 스레드를 동시에 출발시켜 경합을 유도한다 - authFacade.login(OAuthProvider.GOOGLE, "idtok") + authService.login(OAuthProvider.GOOGLE, "idtok") } catch (t: Throwable) { errors.add(t) } diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/ConflictRetryTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/ConflictRetryTest.kt new file mode 100644 index 0000000..3c54831 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/ConflictRetryTest.kt @@ -0,0 +1,69 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.oauth.OAuthProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ConflictRetryTest { + private val conflictRetry = ConflictRetry() + + private fun conflict() = ConcurrentRegistrationException(OAuthProvider.GOOGLE, "sub") + + @Test + fun `성공하면 그대로 반환하고 한 번만 실행한다`() { + var calls = 0 + + val result = + conflictRetry.execute { + calls++ + "ok" + } + + assertEquals("ok", result) + assertEquals(1, calls) + } + + @Test + fun `경합 예외가 나면 다시 실행해 성공한다`() { + var calls = 0 + + val result = + conflictRetry.execute { + calls++ + if (calls < 2) throw conflict() + "ok" + } + + assertEquals("ok", result) + assertEquals(2, calls) + } + + @Test + fun `최대 시도를 넘겨도 경합이 계속되면 예외를 전파한다`() { + var calls = 0 + + assertFailsWith { + conflictRetry.execute { + calls++ + throw conflict() + } + } + + assertEquals(3, calls) + } + + @Test + fun `경합이 아닌 예외는 재시도하지 않고 전파한다`() { + var calls = 0 + + assertFailsWith { + conflictRetry.execute { + calls++ + throw IllegalStateException("boom") + } + } + + assertEquals(1, calls) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt new file mode 100644 index 0000000..8f1822c --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt @@ -0,0 +1,148 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.domain.RefreshToken +import com.nexters.gamss.auth.oauth.OAuthClient +import com.nexters.gamss.auth.oauth.OAuthClientResolver +import com.nexters.gamss.auth.oauth.OAuthProvider +import com.nexters.gamss.auth.oauth.OAuthUserInfo +import com.nexters.gamss.auth.repository.RefreshTokenRepository +import com.nexters.gamss.global.exception.BusinessException +import com.nexters.gamss.global.exception.ErrorCode +import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.member.domain.Member +import com.nexters.gamss.member.service.MemberService +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LoginServiceTest { + private val oAuthClientResolver = mockk() + private val socialAccountService = mockk() + private val memberService = mockk() + private val jwtIssuer = mockk() + private val refreshTokenRepository = mockk() + private val loginService = + LoginService(oAuthClientResolver, socialAccountService, memberService, jwtIssuer, refreshTokenRepository) + + @Test + fun `로그인 시 신규 회원이면 리프레시 토큰을 저장한다`() { + val client = mockk() + every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client + every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") + val member = + mockk { + every { id } returns 100L + every { isWithdrawn() } returns false + } + every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member + every { jwtIssuer.issueAccessToken(100L) } returns "access" + every { jwtIssuer.issueRefreshToken(100L) } returns "refresh" + every { refreshTokenRepository.findByMemberId(100L) } returns null + every { refreshTokenRepository.save(any()) } answers { firstArg() } + + val result = loginService.login(OAuthProvider.GOOGLE, "idtok") + + assertEquals("access", result.accessToken) + assertEquals("refresh", result.refreshToken) + verify { refreshTokenRepository.save(match { it.memberId == 100L && it.token == "refresh" }) } + } + + @Test + fun `로그인 시 기존 리프레시 토큰이 있으면 회전한다`() { + val client = mockk() + every { oAuthClientResolver.resolve(OAuthProvider.APPLE) } returns client + every { client.verify("t") } returns OAuthUserInfo("sub", "e@e.com") + val member = + mockk { + every { id } returns 1L + every { isWithdrawn() } returns false + } + every { socialAccountService.resolveMember(OAuthProvider.APPLE, "sub", "e@e.com") } returns member + every { jwtIssuer.issueAccessToken(1L) } returns "a" + every { jwtIssuer.issueRefreshToken(1L) } returns "r" + val stored = mockk(relaxed = true) + every { refreshTokenRepository.findByMemberId(1L) } returns stored + + loginService.login(OAuthProvider.APPLE, "t") + + verify { stored.rotate("r") } + verify(exactly = 0) { refreshTokenRepository.save(any()) } + } + + @Test + fun `탈퇴한 회원이 로그인하면 WITHDRAWN_MEMBER`() { + val client = mockk() + every { oAuthClientResolver.resolve(OAuthProvider.GOOGLE) } returns client + every { client.verify("idtok") } returns OAuthUserInfo("sub-1", "a@a.com") + val member = mockk { every { isWithdrawn() } returns true } + every { socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-1", "a@a.com") } returns member + + val exception = assertFailsWith { loginService.login(OAuthProvider.GOOGLE, "idtok") } + + assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) + } + + @Test + fun `재발급이 정상이면 새 토큰을 발급하고 회전한다`() { + every { jwtIssuer.parseRefreshToken("refresh") } returns 100L + val stored = mockk(relaxed = true) + every { stored.matches("refresh") } returns true + every { refreshTokenRepository.findByMemberId(100L) } returns stored + every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns false } + every { jwtIssuer.issueAccessToken(100L) } returns "na" + every { jwtIssuer.issueRefreshToken(100L) } returns "nr" + + val result = loginService.reissue("refresh") + + assertEquals("na", result.accessToken) + assertEquals("nr", result.refreshToken) + verify { stored.rotate("nr") } + } + + @Test + fun `탈퇴한 회원이 재발급하면 WITHDRAWN_MEMBER`() { + every { jwtIssuer.parseRefreshToken("refresh") } returns 100L + val stored = mockk() + every { stored.matches("refresh") } returns true + every { refreshTokenRepository.findByMemberId(100L) } returns stored + every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns true } + + val exception = assertFailsWith { loginService.reissue("refresh") } + + assertEquals(ErrorCode.WITHDRAWN_MEMBER, exception.errorCode) + } + + @Test + fun `재발급 시 저장된 토큰이 없으면 REFRESH_TOKEN_NOT_FOUND`() { + every { jwtIssuer.parseRefreshToken("r") } returns 1L + every { refreshTokenRepository.findByMemberId(1L) } returns null + + val exception = assertFailsWith { loginService.reissue("r") } + + assertEquals(ErrorCode.REFRESH_TOKEN_NOT_FOUND, exception.errorCode) + } + + @Test + fun `재발급 시 토큰이 일치하지 않으면 INVALID_TOKEN`() { + every { jwtIssuer.parseRefreshToken("r") } returns 1L + val stored = mockk() + every { stored.matches("r") } returns false + every { refreshTokenRepository.findByMemberId(1L) } returns stored + + val exception = assertFailsWith { loginService.reissue("r") } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } + + @Test + fun `재발급 시 토큰 파싱 예외를 전파한다`() { + every { jwtIssuer.parseRefreshToken("bad") } throws BusinessException(ErrorCode.EXPIRED_TOKEN) + + val exception = assertFailsWith { loginService.reissue("bad") } + + assertEquals(ErrorCode.EXPIRED_TOKEN, exception.errorCode) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt index 7924761..a76183c 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt @@ -8,7 +8,9 @@ import com.nexters.gamss.member.service.MemberService import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.springframework.dao.DataIntegrityViolationException import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.test.assertSame class SocialAccountServiceTest { @@ -45,4 +47,15 @@ class SocialAccountServiceTest { ) } } + + @Test + fun `소셜 계정 저장이 유니크 제약에 걸리면 도메인 예외로 번역한다`() { + every { socialAccountRepository.findByProviderAndProviderId("GOOGLE", "sub-3") } returns null + every { memberService.create("c@a.com") } returns mockk { every { id } returns 3L } + every { socialAccountRepository.save(any()) } throws DataIntegrityViolationException("duplicate") + + assertFailsWith { + socialAccountService.resolveMember(OAuthProvider.GOOGLE, "sub-3", "c@a.com") + } + } } From 5b79ba8078912c17ba5ede3ed5c4a12d1db3da34 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Sat, 18 Jul 2026 01:59:00 +0900 Subject: [PATCH 26/27] =?UTF-8?q?fix:=20OAuth=20client-ids=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=8B=9C=20aud=20=EA=B2=80=EC=A6=9D=EC=9D=B4=20?= =?UTF-8?q?=EA=BA=BC=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllowedAudiences 가 허용 목록이 비면 모든 audience 를 통과시켜(fail-open), client-ids 설정을 빠뜨린 채 배포하면 aud 검증이 조용히 꺼진 상태로 운영될 수 있었다. 어떤 소셜 앱의 토큰이든 로그인이 되는 셈이다. - AllowedAudiences: 목록이 비면 전부 거부한다(fail-closed). 설정 누락이 보안을 끄는 게 아니라 로그인을 막아 드러나게 한다. - OAuthClientIdsValidator: dev/prod 기동 시 client-ids 가 비면 부팅을 실패시킨다. 런타임에 "모든 로그인 실패"로 발견하는 대신 배포 시점에 "서버가 안 뜸"으로 더 빨리 잡는다. local/test 는 설정 없이 뜰 수 있게 둔다. fail-open 동작을 정답으로 고정하던 테스트는 fail-closed 검증으로 교체했다. --- .../gamss/auth/oauth/AllowedAudiences.kt | 11 ++-- .../auth/oauth/OAuthClientIdsValidator.kt | 41 ++++++++++++++ .../gamss/auth/oauth/AllowedAudiencesTest.kt | 6 +- .../auth/oauth/OAuthClientIdsValidatorTest.kt | 55 +++++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidator.kt create mode 100644 src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidatorTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt index 2a99f1c..3181759 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt @@ -2,14 +2,13 @@ package com.nexters.gamss.auth.oauth /** * 허용된 대상(aud) 목록 일급 컬렉션. 토큰의 audience 가 허용 목록에 포함되는지 판단한다. + * + * 허용 목록이 비어 있으면(설정 누락) 어떤 audience 도 통과시키지 않는다(fail-closed). + * 설정을 빠뜨렸을 때 aud 검증이 조용히 꺼지는 대신 로그인이 막혀 문제가 드러나도록 한다. + * dev/prod 에서 목록이 비는 것은 OAuthClientIdsValidator 가 기동 시점에 먼저 막는다. */ class AllowedAudiences( private val values: List, ) { - fun accepts(audiences: List?): Boolean { - if (values.isEmpty()) { - return true - } - return !audiences.isNullOrEmpty() && audiences.any { it in values } - } + fun accepts(audiences: List?): Boolean = !audiences.isNullOrEmpty() && audiences.any { it in values } } diff --git a/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidator.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidator.kt new file mode 100644 index 0000000..e084376 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidator.kt @@ -0,0 +1,41 @@ +package com.nexters.gamss.auth.oauth + +import org.springframework.beans.factory.InitializingBean +import org.springframework.core.env.Environment +import org.springframework.stereotype.Component + +/** + * dev/prod 기동 시 OAuth client-ids 가 비어 있으면 부팅을 실패시킨다. + * + * client-ids 가 비면 aud 검증이 fail-closed 로 막혀 모든 로그인이 실패한다(AllowedAudiences). + * 그 상태로 배포된 걸 런타임에야 발견하지 않도록, 설정 누락을 기동 시점에 크게 드러낸다 — + * "모든 로그인 실패"보다 "서버가 안 뜸"이 더 빨리 잡힌다. + * + * local/test 프로필은 검증 없이 뜰 수 있게 둔다(설정 없이 로컬 구동·테스트 허용). + */ +@Component +class OAuthClientIdsValidator( + private val properties: OAuthProperties, + private val environment: Environment, +) : InitializingBean { + override fun afterPropertiesSet() { + if (isConfigOptional()) { + return + } + require(properties.google.clientIds.isNotEmpty()) { missingMessage("GOOGLE_CLIENT_IDS") } + require(properties.apple.clientIds.isNotEmpty()) { missingMessage("APPLE_CLIENT_IDS") } + } + + private fun isConfigOptional(): Boolean { + val active = environment.activeProfiles + return active.isEmpty() || active.all { it in CONFIG_OPTIONAL_PROFILES } + } + + private fun missingMessage(key: String): String = + "$key 가 비어 있습니다. 활성 프로필(${environment.activeProfiles.joinToString()})에서는 " + + "소셜 로그인 aud 검증을 위해 필수입니다." + + companion object { + private val CONFIG_OPTIONAL_PROFILES = setOf("local", "test") + } +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt index 781df9c..50488c2 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiencesTest.kt @@ -6,11 +6,11 @@ import kotlin.test.assertTrue class AllowedAudiencesTest { @Test - fun `허용 목록이 비어 있으면 모든 audience를 허용한다`() { + fun `허용 목록이 비어 있으면 모든 audience를 거부한다`() { val audiences = AllowedAudiences(emptyList()) - assertTrue(audiences.accepts(listOf("anything"))) - assertTrue(audiences.accepts(null)) + assertFalse(audiences.accepts(listOf("anything"))) + assertFalse(audiences.accepts(null)) } @Test diff --git a/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidatorTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidatorTest.kt new file mode 100644 index 0000000..16453f2 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientIdsValidatorTest.kt @@ -0,0 +1,55 @@ +package com.nexters.gamss.auth.oauth + +import io.mockk.every +import io.mockk.mockk +import org.springframework.core.env.Environment +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class OAuthClientIdsValidatorTest { + private fun properties( + google: List, + apple: List, + ) = OAuthProperties( + google = OAuthProperties.Provider("g-iss", "g-jwks", google), + apple = OAuthProperties.Provider("a-iss", "a-jwks", apple), + ) + + private fun environment(vararg activeProfiles: String): Environment = + mockk { every { this@mockk.activeProfiles } returns arrayOf(*activeProfiles) } + + @Test + fun `dev 프로필에서 client-ids가 모두 있으면 통과한다`() { + val validator = OAuthClientIdsValidator(properties(listOf("g"), listOf("a")), environment("dev")) + + validator.afterPropertiesSet() + } + + @Test + fun `dev 프로필에서 google client-ids가 비면 기동에 실패한다`() { + val validator = OAuthClientIdsValidator(properties(emptyList(), listOf("a")), environment("dev")) + + assertFailsWith { validator.afterPropertiesSet() } + } + + @Test + fun `prod 프로필에서 apple client-ids가 비면 기동에 실패한다`() { + val validator = OAuthClientIdsValidator(properties(listOf("g"), emptyList()), environment("prod")) + + assertFailsWith { validator.afterPropertiesSet() } + } + + @Test + fun `local 프로필에서는 client-ids가 비어도 통과한다`() { + val validator = OAuthClientIdsValidator(properties(emptyList(), emptyList()), environment("local")) + + validator.afterPropertiesSet() + } + + @Test + fun `활성 프로필이 없으면 client-ids가 비어도 통과한다`() { + val validator = OAuthClientIdsValidator(properties(emptyList(), emptyList()), environment()) + + validator.afterPropertiesSet() + } +} From 6913e376c0387d49304ffee3f33cd58b5b0a0d63 Mon Sep 17 00:00:00 2001 From: MinJun Choi Date: Sat, 18 Jul 2026 02:30:01 +0900 Subject: [PATCH 27/27] =?UTF-8?q?fix:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=9D=84=20=ED=95=B4=EC=8B=9C?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RefreshToken.token 에 서명된 JWT 원본을 그대로 저장하고 있어, DB가 유출되면 그 값을 그대로 재발급 요청에 써서 새 토큰을 받을 수 있었다. 유출된 값 자체가 유효하게 서명된 토큰이라 재사용을 막지 못한다. 비밀번호처럼 해시해서 저장한다. 원본은 클라이언트에만 주고 DB에는 해시만 남긴다. 재발급 시 제시된 토큰을 같은 방식으로 해시해 비교한다. - TokenHasher 인터페이스 + Sha256TokenHasher: 해싱 방식을 JwtIssuer 처럼 추상화한다. 리프레시 토큰은 서명된 JWT라 이미 고엔트로피이므로 비밀번호용 느린 해시가 아니라 SHA-256 으로 충분하다(bcrypt 는 72바이트 입력 제한도 있다). - 해싱은 저장 시점(persistRefreshToken)에 서비스가 수행한다. RefreshToken 도메인은 불투명한 문자열을 저장·비교만 하고 해시를 알지 못한다. --- .../gamss/auth/service/LoginService.kt | 10 ++++-- .../global/security/Sha256TokenHasher.kt | 21 ++++++++++++ .../gamss/global/security/TokenHasher.kt | 9 +++++ .../gamss/auth/service/LoginServiceTest.kt | 30 ++++++++++++---- .../global/security/Sha256TokenHasherTest.kt | 34 +++++++++++++++++++ 5 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/Sha256TokenHasher.kt create mode 100644 src/main/kotlin/com/nexters/gamss/global/security/TokenHasher.kt create mode 100644 src/test/kotlin/com/nexters/gamss/global/security/Sha256TokenHasherTest.kt diff --git a/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt b/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt index 817f1b1..2e0aa26 100644 --- a/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt +++ b/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt @@ -7,6 +7,7 @@ import com.nexters.gamss.auth.repository.RefreshTokenRepository import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.global.security.TokenHasher import com.nexters.gamss.member.service.MemberService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,6 +26,7 @@ class LoginService( private val memberService: MemberService, private val jwtIssuer: JwtIssuer, private val refreshTokenRepository: RefreshTokenRepository, + private val tokenHasher: TokenHasher, ) { @Transactional fun login( @@ -45,7 +47,7 @@ class LoginService( val stored = refreshTokenRepository.findByMemberId(memberId) ?: throw BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND) - if (!stored.matches(refreshToken)) { + if (!stored.matches(tokenHasher.hash(refreshToken))) { throw BusinessException(ErrorCode.INVALID_TOKEN) } if (memberService.getById(memberId).isWithdrawn()) { @@ -65,11 +67,13 @@ class LoginService( memberId: Long, refreshToken: String, ) { + // 원본 토큰은 클라이언트에만 주고, DB에는 해시만 저장한다(유출 시 재사용 방지). + val hashed = tokenHasher.hash(refreshToken) val stored = refreshTokenRepository.findByMemberId(memberId) if (stored == null) { - refreshTokenRepository.save(RefreshToken(memberId, refreshToken)) + refreshTokenRepository.save(RefreshToken(memberId, hashed)) return } - stored.rotate(refreshToken) + stored.rotate(hashed) } } diff --git a/src/main/kotlin/com/nexters/gamss/global/security/Sha256TokenHasher.kt b/src/main/kotlin/com/nexters/gamss/global/security/Sha256TokenHasher.kt new file mode 100644 index 0000000..1afa3a3 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/Sha256TokenHasher.kt @@ -0,0 +1,21 @@ +package com.nexters.gamss.global.security + +import org.springframework.stereotype.Component +import java.security.MessageDigest + +/** + * SHA-256 기반 TokenHasher. + * + * 리프레시 토큰은 서명된 JWT라 이미 고엔트로피다 — 무차별 대입 위험이 없어 비밀번호용 느린 해시 + * (bcrypt 등)가 필요 없고, bcrypt 는 입력 72바이트 제한이 있어 그보다 긴 JWT 에 부적합하다. + * 저장 유출 방지가 목적이므로 빠른 단방향 해시로 충분하다. + */ +@Component +class Sha256TokenHasher : TokenHasher { + // MessageDigest 는 스레드 안전하지 않아 호출마다 새로 생성한다. + override fun hash(token: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(token.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } +} diff --git a/src/main/kotlin/com/nexters/gamss/global/security/TokenHasher.kt b/src/main/kotlin/com/nexters/gamss/global/security/TokenHasher.kt new file mode 100644 index 0000000..f05c33e --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/TokenHasher.kt @@ -0,0 +1,9 @@ +package com.nexters.gamss.global.security + +/** + * 저장 전 토큰을 단방향 해시한다. DB가 유출돼도 저장된 값을 그대로 Bearer 토큰으로 재사용할 수 없게 한다. + * 구현(SHA-256 등)은 교체 가능하다. + */ +interface TokenHasher { + fun hash(token: String): String +} diff --git a/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt b/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt index 8f1822c..2d786ae 100644 --- a/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt +++ b/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt @@ -9,6 +9,7 @@ import com.nexters.gamss.auth.repository.RefreshTokenRepository import com.nexters.gamss.global.exception.BusinessException import com.nexters.gamss.global.exception.ErrorCode import com.nexters.gamss.global.security.JwtIssuer +import com.nexters.gamss.global.security.Sha256TokenHasher import com.nexters.gamss.member.domain.Member import com.nexters.gamss.member.service.MemberService import io.mockk.every @@ -24,8 +25,18 @@ class LoginServiceTest { private val memberService = mockk() private val jwtIssuer = mockk() private val refreshTokenRepository = mockk() + + // 실제 해시로 저장·비교되는지 검증하기 위해 진짜 해셔를 쓴다. + private val tokenHasher = Sha256TokenHasher() private val loginService = - LoginService(oAuthClientResolver, socialAccountService, memberService, jwtIssuer, refreshTokenRepository) + LoginService( + oAuthClientResolver, + socialAccountService, + memberService, + jwtIssuer, + refreshTokenRepository, + tokenHasher, + ) @Test fun `로그인 시 신규 회원이면 리프레시 토큰을 저장한다`() { @@ -46,8 +57,13 @@ class LoginServiceTest { val result = loginService.login(OAuthProvider.GOOGLE, "idtok") assertEquals("access", result.accessToken) + // 클라이언트에는 원본 토큰을, DB에는 해시를 저장한다. assertEquals("refresh", result.refreshToken) - verify { refreshTokenRepository.save(match { it.memberId == 100L && it.token == "refresh" }) } + verify { + refreshTokenRepository.save( + match { it.memberId == 100L && it.token == tokenHasher.hash("refresh") }, + ) + } } @Test @@ -68,7 +84,7 @@ class LoginServiceTest { loginService.login(OAuthProvider.APPLE, "t") - verify { stored.rotate("r") } + verify { stored.rotate(tokenHasher.hash("r")) } verify(exactly = 0) { refreshTokenRepository.save(any()) } } @@ -89,7 +105,7 @@ class LoginServiceTest { fun `재발급이 정상이면 새 토큰을 발급하고 회전한다`() { every { jwtIssuer.parseRefreshToken("refresh") } returns 100L val stored = mockk(relaxed = true) - every { stored.matches("refresh") } returns true + every { stored.matches(tokenHasher.hash("refresh")) } returns true every { refreshTokenRepository.findByMemberId(100L) } returns stored every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns false } every { jwtIssuer.issueAccessToken(100L) } returns "na" @@ -99,14 +115,14 @@ class LoginServiceTest { assertEquals("na", result.accessToken) assertEquals("nr", result.refreshToken) - verify { stored.rotate("nr") } + verify { stored.rotate(tokenHasher.hash("nr")) } } @Test fun `탈퇴한 회원이 재발급하면 WITHDRAWN_MEMBER`() { every { jwtIssuer.parseRefreshToken("refresh") } returns 100L val stored = mockk() - every { stored.matches("refresh") } returns true + every { stored.matches(tokenHasher.hash("refresh")) } returns true every { refreshTokenRepository.findByMemberId(100L) } returns stored every { memberService.getById(100L) } returns mockk { every { isWithdrawn() } returns true } @@ -129,7 +145,7 @@ class LoginServiceTest { fun `재발급 시 토큰이 일치하지 않으면 INVALID_TOKEN`() { every { jwtIssuer.parseRefreshToken("r") } returns 1L val stored = mockk() - every { stored.matches("r") } returns false + every { stored.matches(tokenHasher.hash("r")) } returns false every { refreshTokenRepository.findByMemberId(1L) } returns stored val exception = assertFailsWith { loginService.reissue("r") } diff --git a/src/test/kotlin/com/nexters/gamss/global/security/Sha256TokenHasherTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/Sha256TokenHasherTest.kt new file mode 100644 index 0000000..aa83834 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/security/Sha256TokenHasherTest.kt @@ -0,0 +1,34 @@ +package com.nexters.gamss.global.security + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class Sha256TokenHasherTest { + private val hasher = Sha256TokenHasher() + + @Test + fun `같은 토큰은 항상 같은 해시가 된다`() { + assertEquals(hasher.hash("token"), hasher.hash("token")) + } + + @Test + fun `다른 토큰은 다른 해시가 된다`() { + assertNotEquals(hasher.hash("token-a"), hasher.hash("token-b")) + } + + @Test + fun `해시는 원본을 노출하지 않으며 64자리 16진수다`() { + val hashed = hasher.hash("super-secret-refresh-token") + + assertNotEquals("super-secret-refresh-token", hashed) + assertEquals(64, hashed.length) + assertEquals(true, hashed.all { it in "0123456789abcdef" }) + } + + @Test + fun `SHA-256 표준 벡터와 일치한다`() { + // echo -n "abc" | sha256sum + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hasher.hash("abc")) + } +}