diff --git a/build.gradle.kts b/build.gradle.kts index a188e89..2e18db5 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,45 @@ 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") + + // 로컬 실행 시 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") + implementation("org.flywaydb:flyway-mysql") + + // 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") } @@ -54,4 +90,10 @@ tasks.jacocoTestReport { xml.required.set(true) html.required.set(true) } + // 부트스트랩 진입점(main)은 단위 테스트 대상이 아니므로 커버리지에서 제외한다. + classDirectories.setFrom( + classDirectories.files.map { + fileTree(it) { exclude("**/GamssApplication*") } + }, + ) } 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/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/auth/controller/AuthController.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt new file mode 100644 index 0000000..06eb613 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/AuthController.kt @@ -0,0 +1,54 @@ +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 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 +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 { + val result = authService.login(OAuthProvider.from(provider), request.idToken) + return ApiResponse.success(TokenResponse.from(result)) + } + + @Operation( + summary = "토큰 재발급", + description = + "refreshToken으로 accessToken·refreshToken을 재발급합니다. " + + "refreshToken은 회전(rotate)되어 이전 토큰은 무효화됩니다.", + ) + @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/LoginRequest.kt b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt new file mode 100644 index 0000000..3ba0fa1 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/LoginRequest.kt @@ -0,0 +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 new file mode 100644 index 0000000..be50c59 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/ReissueRequest.kt @@ -0,0 +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 new file mode 100644 index 0000000..c7f1ffa --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/controller/dto/TokenResponse.kt @@ -0,0 +1,15 @@ +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 { + 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/oauth/AllowedAudiences.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt new file mode 100644 index 0000000..3181759 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt @@ -0,0 +1,14 @@ +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 = !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 new file mode 100644 index 0000000..04135fe --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt @@ -0,0 +1,18 @@ +package com.nexters.gamss.auth.oauth + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +@Component +class AppleOAuthClient( + private val verifier: OidcTokenVerifier, +) : OAuthClient { + @Autowired + constructor(properties: OAuthProperties) : this( + OidcTokenVerifier(properties.apple, JwkSources.remote(properties.apple.jwksUri)), + ) + + override val provider = OAuthProvider.APPLE + + 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..6299873 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt @@ -0,0 +1,18 @@ +package com.nexters.gamss.auth.oauth + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +@Component +class GoogleOAuthClient( + private val verifier: OidcTokenVerifier, +) : OAuthClient { + @Autowired + constructor(properties: OAuthProperties) : this( + OidcTokenVerifier(properties.google, JwkSources.remote(properties.google.jwksUri)), + ) + + override val provider = OAuthProvider.GOOGLE + + 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..09978d5 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt @@ -0,0 +1,10 @@ +package com.nexters.gamss.auth.oauth + +/** + * 소셜 제공자별 토큰 검증 전략. 새 제공자는 이 인터페이스 구현체(@Component)만 추가하면 된다(OCP). + */ +interface OAuthClient { + val provider: OAuthProvider + + fun verify(idToken: String): OAuthUserInfo +} 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/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/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/OAuthProvider.kt b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProvider.kt new file mode 100644 index 0000000..659b982 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthProvider.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 + +/** + * 지원하는 소셜 제공자. 변경 가능한 목록이므로 인프라(oauth) 계층에 둔다. + * 새 제공자 추가 = 상수 + OAuthClient 구현체 추가. (domain 은 provider 를 String 으로만 저장하므로 바뀌지 않는다) + */ +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/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..cb9a694 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifier.kt @@ -0,0 +1,54 @@ +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( + provider: OAuthProperties.Provider, + jwkSource: JWKSource, +) { + private val allowedAudiences = AllowedAudiences(provider.clientIds) + private val processor = + DefaultJWTProcessor().apply { + jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource) + jwtClaimsSetVerifier = + DefaultJWTClaimsVerifier( + JWTClaimsSet.Builder().issuer(provider.issuer).build(), + setOf("exp"), + ) + } + + fun verify(idToken: String): OAuthUserInfo { + 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/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..47d3767 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/AuthService.kt @@ -0,0 +1,24 @@ +package com.nexters.gamss.auth.service + +import com.nexters.gamss.auth.oauth.OAuthProvider +import org.springframework.stereotype.Service + +/** + * 인증 진입점. 로그인·재발급 흐름을 조율한다. + * + * 트랜잭션 작업은 LoginService 에, 동시 가입 경합의 재시도는 ConflictRetry 에 위임한다. + * 재시도를 별도 객체로 두는 것은 트랜잭션 경계 바깥에서 실행돼야 하기 때문이자(같은 빈 내부 + * 호출은 트랜잭션 프록시를 거치지 않는다), 재시도 정책과 흐름 조율을 분리하기 위함이다. + */ +@Service +class AuthService( + private val loginService: LoginService, + private val conflictRetry: ConflictRetry, +) { + fun login( + provider: OAuthProvider, + idToken: String, + ): TokenResult = conflictRetry.execute { loginService.login(provider, idToken) } + + 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..2e0aa26 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/LoginService.kt @@ -0,0 +1,79 @@ +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.global.security.TokenHasher +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, + private val tokenHasher: TokenHasher, +) { + @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(tokenHasher.hash(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, + ) { + // 원본 토큰은 클라이언트에만 주고, DB에는 해시만 저장한다(유출 시 재사용 방지). + val hashed = tokenHasher.hash(refreshToken) + val stored = refreshTokenRepository.findByMemberId(memberId) + if (stored == null) { + refreshTokenRepository.save(RefreshToken(memberId, hashed)) + return + } + stored.rotate(hashed) + } +} 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..0e41f73 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/auth/service/SocialAccountService.kt @@ -0,0 +1,42 @@ +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.dao.DataIntegrityViolationException +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) + // 동시 최초 로그인 시 (provider, providerId) 유니크 제약에 걸릴 수 있다. + // 영속성 예외를 도메인 예외로 번역해, 재시도 판단이 특정 영속성 기술에 의존하지 않게 한다. + try { + socialAccountRepository.save(SocialAccount(member.id, storedProvider, providerId)) + } catch (e: DataIntegrityViolationException) { + throw ConcurrentRegistrationException(provider, 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/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/config/SwaggerConfig.kt b/src/main/kotlin/com/nexters/gamss/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..e9551a8 --- /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("GAMSS 서비스 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/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..ef30830 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.kt @@ -0,0 +1,32 @@ +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, "회원을 찾을 수 없습니다."), + 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/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..ace2118 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/response/ApiResponse.kt @@ -0,0 +1,18 @@ +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) + } +} 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, +) 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/JjwtIssuer.kt b/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt new file mode 100644 index 0000000..6dcc16b --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt @@ -0,0 +1,82 @@ +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 + +/** + * jjwt 기반 JwtIssuer 구현. 서명은 HMAC-SHA256. + */ +@Component +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 + + override fun issueAccessToken(memberId: Long): String = build(memberId, TokenType.ACCESS, accessTokenValidity) + + override fun issueRefreshToken(memberId: Long): String = build(memberId, TokenType.REFRESH, refreshTokenValidity) + + 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 + .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) + } + + companion object { + private const val TYPE_CLAIM = "type" + } +} 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..80ed373 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,49 @@ +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을 응답하게 한다. + * + * access 토큰만 받는다. refresh 토큰은 재발급(reissue)에만 쓰인다. + */ +@Component +class JwtAuthenticationFilter( + private val jwtIssuer: JwtIssuer, +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + resolveToken(request)?.let { token -> + runCatching { jwtIssuer.parseAccessToken(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/JwtIssuer.kt b/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt new file mode 100644 index 0000000..b53e593 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.kt @@ -0,0 +1,19 @@ +package com.nexters.gamss.global.security + +/** + * 서비스 자체 토큰(access/refresh) 발급·파싱 추상화. 구현(JWT 등)은 교체 가능하다. + * + * 발급과 파싱을 종류별로 대칭되게 둔다. 파싱을 하나로 합치면 호출부가 토큰 종류를 + * 확인할 방법이 없어져, refresh 토큰으로 인증이 통과하는 것을 막지 못한다. + */ +interface JwtIssuer { + fun issueAccessToken(memberId: Long): String + + fun issueRefreshToken(memberId: Long): String + + /** 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/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/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/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/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/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..281eda5 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/controller/MemberController.kt @@ -0,0 +1,63 @@ +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 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 +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 + +@Tag(name = "회원", description = "내 프로필 조회·수정·탈퇴 API (모두 로그인 필요)") +@RestController +@RequestMapping("/api/members") +class MemberController( + private val memberService: MemberService, +) { + @Operation( + summary = "내 정보 조회", + description = "로그인한 회원의 정보(이메일·닉네임·가입일·상태)를 반환합니다.", + ) + @GetMapping("/me") + fun me( + @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( + @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( + @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 new file mode 100644 index 0000000..3dd1833 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/MemberResponse.kt @@ -0,0 +1,29 @@ +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 { + 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 new file mode 100644 index 0000000..06b432a --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/controller/dto/UpdateNicknameRequest.kt @@ -0,0 +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, +) 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..126ed1a --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Member.kt @@ -0,0 +1,68 @@ +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, + @Embedded + var nickname: Nickname? = null, +) { + @Id + @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) + 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 + } + + 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/domain/Nickname.kt b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt new file mode 100644 index 0000000..db3de5d --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/domain/Nickname.kt @@ -0,0 +1,65 @@ +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 +class Nickname( + value: String, +) { + @Column(name = "nickname", length = MAX_LENGTH) + val value: String = value.trim() + + init { + 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 + } + 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 { + 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/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..03b5480 --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt @@ -0,0 +1,6 @@ +package com.nexters.gamss.member.repository + +import com.nexters.gamss.member.domain.Member +import org.springframework.data.jpa.repository.JpaRepository + +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 new file mode 100644 index 0000000..40e581d --- /dev/null +++ b/src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt @@ -0,0 +1,42 @@ +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 + +@Service +class MemberService( + private val memberRepository: MemberRepository, +) { + @Transactional + fun create(email: String?): Member = memberRepository.save(Member(email)) + + @Transactional(readOnly = true) + fun getById(id: Long): Member = + 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 + } + + @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/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.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..4b7890d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,33 @@ +spring: + application: + name: gamss + profiles: + default: local + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + format_sql: false + flyway: + enabled: true + +jwt: + secret: ${JWT_SECRET} + 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/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..60af352 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,27 @@ +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; + +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/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/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..fa46a44 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/domain/RefreshTokenTest.kt @@ -0,0 +1,30 @@ +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")) + } + + @Test + fun `식별자 기본값은 0이다`() { + assertEquals(0L, RefreshToken(memberId = 1L, token = "abc").id) + } +} 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..50488c2 --- /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()) + + assertFalse(audiences.accepts(listOf("anything"))) + assertFalse(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/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() + } +} 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..5c299c3 --- /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 { + @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) + } + + 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/OAuthClientTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt new file mode 100644 index 0000000..948ee5c --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthClientTest.kt @@ -0,0 +1,70 @@ +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 = + 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) + } + + @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/OAuthProviderTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthProviderTest.kt new file mode 100644 index 0000000..cd8cc15 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OAuthProviderTest.kt @@ -0,0 +1,23 @@ +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 + +class OAuthProviderTest { + @Test + fun `대소문자 무관하게 provider를 파싱한다`() { + assertEquals(OAuthProvider.GOOGLE, OAuthProvider.from("google")) + assertEquals(OAuthProvider.GOOGLE, OAuthProvider.from("GOOGLE")) + assertEquals(OAuthProvider.APPLE, OAuthProvider.from("Apple")) + } + + @Test + 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/auth/oauth/OidcTokenVerifierTest.kt b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt new file mode 100644 index 0000000..59e18e5 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/oauth/OidcTokenVerifierTest.kt @@ -0,0 +1,107 @@ +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) + + @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") + } + + @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) + } + + 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/auth/repository/SocialAccountRepositoryTest.kt b/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt new file mode 100644 index 0000000..6b014a4 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/repository/SocialAccountRepositoryTest.kt @@ -0,0 +1,30 @@ +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로 소셜 계정을 조회한다`() { + 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 + 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..e8cfe5b --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/AuthServiceTest.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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AuthServiceTest { + private val loginService = mockk() + + // 실제 재시도 정책을 그대로 사용해 위임이 올바른지 검증한다. + private val authService = AuthService(loginService, ConflictRetry()) + + @Test + fun `로그인은 LoginService에 위임하고 정상이면 재시도하지 않는다`() { + every { loginService.login(OAuthProvider.GOOGLE, "t") } returns TokenResult("a", "r") + + val result = authService.login(OAuthProvider.GOOGLE, "t") + + assertEquals("a", result.accessToken) + verify(exactly = 1) { loginService.login(OAuthProvider.GOOGLE, "t") } + } + + @Test + fun `동시 가입 경합이 나면 재시도해 성공한다`() { + every { loginService.login(OAuthProvider.GOOGLE, "t") } throws + ConcurrentRegistrationException(OAuthProvider.GOOGLE, "sub") andThen TokenResult("a", "r") + + val result = authService.login(OAuthProvider.GOOGLE, "t") + + assertEquals("a", result.accessToken) + verify(exactly = 2) { loginService.login(OAuthProvider.GOOGLE, "t") } + } + + @Test + fun `재발급은 재시도 없이 LoginService에 위임한다`() { + every { loginService.reissue("r") } returns TokenResult("na", "nr") + + val result = authService.reissue("r") + + assertEquals("na", result.accessToken) + verify(exactly = 1) { loginService.reissue("r") } + } + + @Test + fun `재발급 중 예외는 그대로 전파한다`() { + every { loginService.reissue("bad") } throws IllegalStateException("boom") + + 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 new file mode 100644 index 0000000..f06ea2e --- /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 authService: AuthService + + @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() // 모든 스레드를 동시에 출발시켜 경합을 유도한다 + authService.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(), "리프레시 토큰은 하나만 남아야 한다") + } +} 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..2d786ae --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/LoginServiceTest.kt @@ -0,0 +1,164 @@ +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.global.security.Sha256TokenHasher +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 tokenHasher = Sha256TokenHasher() + private val loginService = + LoginService( + oAuthClientResolver, + socialAccountService, + memberService, + jwtIssuer, + refreshTokenRepository, + tokenHasher, + ) + + @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) + // 클라이언트에는 원본 토큰을, DB에는 해시를 저장한다. + assertEquals("refresh", result.refreshToken) + verify { + refreshTokenRepository.save( + match { it.memberId == 100L && it.token == tokenHasher.hash("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(tokenHasher.hash("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(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" + every { jwtIssuer.issueRefreshToken(100L) } returns "nr" + + val result = loginService.reissue("refresh") + + assertEquals("na", result.accessToken) + assertEquals("nr", result.refreshToken) + verify { stored.rotate(tokenHasher.hash("nr")) } + } + + @Test + fun `탈퇴한 회원이 재발급하면 WITHDRAWN_MEMBER`() { + every { jwtIssuer.parseRefreshToken("refresh") } returns 100L + val stored = mockk() + every { stored.matches(tokenHasher.hash("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(tokenHasher.hash("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 new file mode 100644 index 0000000..a76183c --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/auth/service/SocialAccountServiceTest.kt @@ -0,0 +1,61 @@ +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 org.springframework.dao.DataIntegrityViolationException +import kotlin.test.Test +import kotlin.test.assertFailsWith +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" }, + ) + } + } + + @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") + } + } +} 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") } + } + } +} 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) + } +} diff --git a/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt new file mode 100644 index 0000000..a5049e2 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.kt @@ -0,0 +1,85 @@ +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 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 jwtIssuer = JjwtIssuer(properties) + + @Test + fun `액세스 토큰을 발급하고 memberId를 파싱한다`() { + val token = jwtIssuer.issueAccessToken(42L) + + assertEquals(42L, jwtIssuer.parseAccessToken(token)) + } + + @Test + fun `리프레시 토큰을 발급하고 memberId를 파싱한다`() { + val token = jwtIssuer.issueRefreshToken(7L) + + 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 + fun `서명이 다른 토큰은 INVALID_TOKEN 예외를 던진다`() { + val other = JjwtIssuer(properties.copy(secret = "another-secret-value-also-long-enough-for-hs256-abcdefghijklmnop")) + val forged = other.issueAccessToken(1L) + + val exception = assertFailsWith { jwtIssuer.parseAccessToken(forged) } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } + + @Test + fun `만료된 토큰은 EXPIRED_TOKEN 예외를 던진다`() { + val shortLived = JjwtIssuer(properties.copy(accessTokenValidity = Duration.ofMillis(1))) + val token = shortLived.issueAccessToken(1L) + Thread.sleep(50) + + val exception = assertFailsWith { shortLived.parseAccessToken(token) } + + assertEquals(ErrorCode.EXPIRED_TOKEN, exception.errorCode) + } + + @Test + fun `형식이 잘못된 토큰은 INVALID_TOKEN 예외를 던진다`() { + val exception = assertFailsWith { jwtIssuer.parseAccessToken("not-a-valid-jwt") } + + assertEquals(ErrorCode.INVALID_TOKEN, exception.errorCode) + } + + @Test + fun `빈 토큰은 INVALID_TOKEN 예외를 던진다`() { + 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 new file mode 100644 index 0000000..5bd4f32 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/global/security/SecurityIntegrationTest.kt @@ -0,0 +1,128 @@ +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.delete +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") } + jsonPath("$.data.status") { value("ACTIVE") } + jsonPath("$.data.createdAt") { exists() } + } + } + + @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 + .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") } + } + } + + @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/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")) + } +} 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..9f2046f --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/controller/MemberControllerIntegrationTest.kt @@ -0,0 +1,164 @@ +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.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) +@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() + } + + @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_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") } + } + } + + @Test + fun `인증 없이 닉네임을 수정하면 401을 반환한다`() { + mockMvc + .patch("/api/members/me/nickname") { + contentType = MediaType.APPLICATION_JSON + content = """{"nickname":"바다"}""" + }.andExpect { + 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() } + } + } + + private fun bearerFor(member: Member): String = "Bearer ${jwtIssuer.issueAccessToken(member.id)}" +} 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..7a95733 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/domain/MemberTest.kt @@ -0,0 +1,68 @@ +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 + fun `이메일로 회원을 생성한다`() { + val member = Member("user@example.com") + + 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) + } + + @Test + fun `이메일을 수정한다`() { + val member = Member("old@example.com") + + member.updateEmail("new@example.com") + + assertEquals("new@example.com", member.email) + } + + @Test + fun `닉네임을 수정한다`() { + val member = Member("user@example.com") + + member.updateNickname(Nickname("바다")) + + 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/domain/NicknameTest.kt b/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt new file mode 100644 index 0000000..023e367 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/domain/NicknameTest.kt @@ -0,0 +1,59 @@ +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 NicknameTest { + @Test + fun `유효한 값으로 닉네임을 생성한다`() { + assertEquals("바다", Nickname("바다").value) + } + + @Test + fun `앞뒤 공백을 제거한다`() { + assertEquals("바다", Nickname(" 바다 ").value) + } + + @Test + fun `최대 길이까지 허용한다`() { + val value = "가".repeat(Nickname.MAX_LENGTH) + + assertEquals(value, Nickname(value).value) + } + + @Test + fun `최소 길이 미만이면 INVALID_NICKNAME`() { + val exception = assertFailsWith { Nickname("가") } + + assertEquals(ErrorCode.INVALID_NICKNAME, exception.errorCode) + } + + @Test + 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 + fun `값이 같으면 동등하다`() { + assertEquals(Nickname("바다"), Nickname("바다")) + } +} 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..47e5110 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.kt @@ -0,0 +1,38 @@ +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 + lateinit var memberRepository: MemberRepository + + @Test + fun `회원을 저장하고 조회한다`() { + val saved = memberRepository.save(Member("user@example.com")) + + val found = memberRepository.findById(saved.id).orElse(null) + + 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 new file mode 100644 index 0000000..3804445 --- /dev/null +++ b/src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt @@ -0,0 +1,78 @@ +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 +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 `회원을 생성한다`() { + every { memberRepository.save(any()) } answers { firstArg() } + + val member = memberService.create("a@example.com") + + assertEquals("a@example.com", member.email) + verify(exactly = 1) { memberRepository.save(any()) } + } + + @Test + fun `getById로 회원을 조회한다`() { + val member = Member("b@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) + } + + @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) + } + + @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) + } +} 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 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..2fce933 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,24 @@ +spring: + flyway: + enabled: true + jpa: + hibernate: + ddl-auto: validate + 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