Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
416e900
chore: 소셜 로그인 의존성 및 설정 골격 추가
theminjunchoi Jul 14, 2026
30661e1
feat: 공통 응답 및 예외 처리 추가
theminjunchoi Jul 14, 2026
9832595
feat: Member 도메인 및 리포지토리 추가
theminjunchoi Jul 14, 2026
10c437a
feat: JWT 발급·검증 구현
theminjunchoi Jul 14, 2026
49f025b
feat: OAuthClient 인터페이스 및 Google/Apple 검증 구현
theminjunchoi Jul 14, 2026
bab7917
feat: 회원 조회·가입 서비스 추가
theminjunchoi Jul 14, 2026
0f273ee
refactor: 도메인 순수성 및 provider enum 구조 정리
theminjunchoi Jul 14, 2026
b652c91
feat: 소셜 로그인 및 토큰 재발급 구현
theminjunchoi Jul 14, 2026
f76d54b
feat: Spring Security 설정 및 JWT 인증 필터 추가
theminjunchoi Jul 14, 2026
0d81123
feat: Swagger(springdoc) API 문서화 추가
theminjunchoi Jul 14, 2026
db28a5d
chore: Flyway 도입 및 환경별 설정 프로필 분리
theminjunchoi Jul 14, 2026
446278b
chore: 미사용 dev·prod 설정 프로필 제거
theminjunchoi Jul 14, 2026
0629366
refactor: DTO를 파일 단위로 분리
theminjunchoi Jul 14, 2026
e89471c
refactor: else 제거하고 early return으로 정리
theminjunchoi Jul 14, 2026
c940599
test: 테스트 커버리지 보강 및 OAuth 클라이언트 테스트 용이성 개선
theminjunchoi Jul 14, 2026
a78770c
feat: 회원 닉네임 추가 및 수정 기능 구현
theminjunchoi Jul 14, 2026
9e3c870
feat: 회원 수정 시각 기록 및 소프트 삭제 탈퇴 구현
theminjunchoi Jul 14, 2026
8b25a3b
feat: 프로필 응답 보강 및 닉네임 정책 강화
theminjunchoi Jul 14, 2026
f8acd46
refactor: Nickname init 검증 로직을 함수로 분리
theminjunchoi Jul 14, 2026
9b9f5de
refactor: 함수 순서 정리 및 긴 함수 분리
theminjunchoi Jul 14, 2026
aaded75
chore: 로컬 실행 시 docker-compose 자동 기동 지원 추가
theminjunchoi Jul 14, 2026
c155de5
docs: Swagger API 문서 설명 보강
theminjunchoi Jul 14, 2026
4431054
fix: refresh 토큰으로 인증이 통과하던 문제 수정
theminjunchoi Jul 17, 2026
617b25b
fix: 동시 최초 로그인 시 유니크 충돌로 500이 나던 문제 수정
theminjunchoi Jul 17, 2026
ce30db2
refactor: 인증 책임 분리 — 오케스트레이션·재시도·트랜잭션 작업
theminjunchoi Jul 17, 2026
5b79ba8
fix: OAuth client-ids 누락 시 aud 검증이 꺼지던 문제 수정
theminjunchoi Jul 17, 2026
6913e37
fix: 리프레시 토큰을 해시해서 저장
theminjunchoi Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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")
}
Expand Down Expand Up @@ -54,4 +90,10 @@ tasks.jacocoTestReport {
xml.required.set(true)
html.required.set(true)
}
// 부트스트랩 진입점(main)은 단위 테스트 대상이 아니므로 커버리지에서 제외한다.
classDirectories.setFrom(
classDirectories.files.map {
fileTree(it) { exclude("**/GamssApplication*") }
},
)
}
18 changes: 18 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
2 changes: 2 additions & 0 deletions src/main/kotlin/com/nexters/gamss/GamssApplication.kt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<TokenResponse> {
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<TokenResponse> {
val result = authService.reissue(request.refreshToken)
return ApiResponse.success(TokenResponse.from(result))
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
36 changes: 36 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/domain/RefreshToken.kt
Original file line number Diff line number Diff line change
@@ -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
Comment thread
theminjunchoi marked this conversation as resolved.
}
33 changes: 33 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/domain/SocialAccount.kt
Original file line number Diff line number Diff line change
@@ -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"]),
],
)
Comment thread
theminjunchoi marked this conversation as resolved.
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
}
14 changes: 14 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/oauth/AllowedAudiences.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
) {
fun accepts(audiences: List<String>?): Boolean = !audiences.isNullOrEmpty() && audiences.any { it in values }
}
18 changes: 18 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/oauth/AppleOAuthClient.kt
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/oauth/GoogleOAuthClient.kt
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/oauth/JwkSources.kt
Original file line number Diff line number Diff line change
@@ -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<SecurityContext> = JWKSourceBuilder.create<SecurityContext>(URI(uri).toURL()).build()
}
10 changes: 10 additions & 0 deletions src/main/kotlin/com/nexters/gamss/auth/oauth/OAuthClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.nexters.gamss.auth.oauth

/**
* 소셜 제공자별 토큰 검증 전략. 새 제공자는 이 인터페이스 구현체(@Component)만 추가하면 된다(OCP).
*/
interface OAuthClient {
val provider: OAuthProvider

fun verify(idToken: String): OAuthUserInfo
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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<OAuthClient>,
) {
private val clientsByProvider: Map<OAuthProvider, OAuthClient> = clients.associateBy { it.provider }

fun resolve(provider: OAuthProvider): OAuthClient =
clientsByProvider[provider]
?: throw BusinessException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER)
}
Loading
Loading