Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
17f4bab
#277 [chore] openfeign 라이브러리 추가
YuGyeong98 Nov 28, 2025
1636d56
#277 [chore] 카카오 configuration properties 추가
YuGyeong98 Nov 28, 2025
23c4b72
#277 [feat] 카카오 공개키 목록 조회 feign client 구현
YuGyeong98 Nov 28, 2025
8e00878
#277 [feat] feign config 구현
YuGyeong98 Nov 28, 2025
4e642d2
#277 [feat] oidc 페이로드, 서명 검증
YuGyeong98 Nov 28, 2025
e95cf87
#277 [feat] config group 추가
YuGyeong98 Nov 28, 2025
406221b
#277 [feat] 카카오 oidc 페이로드 조회 구현
YuGyeong98 Nov 28, 2025
74c2eda
#277 [feat] user 엔티티 oauthinfo 추가
YuGyeong98 Nov 28, 2025
8d83974
#277 [feat] user query, command 서비스 구현
YuGyeong98 Nov 28, 2025
b9fce57
#277 [feat] 카카오 회원가입 서비스 구현
YuGyeong98 Nov 28, 2025
c6b7cc6
#277 [feat] 카카오 회원가입 컨트롤러 구현
YuGyeong98 Nov 28, 2025
80c23c7
#277 [chore] 로컬 db 예시 데이터 테이블 수정
YuGyeong98 Nov 28, 2025
6254961
#277 [feat] 카카오 로그인 query 서비스 구현
YuGyeong98 Nov 28, 2025
b9b4f3b
#277 [feat] user 엔티티 로그인 구현
YuGyeong98 Nov 28, 2025
8d72272
#277 [feat] 카카오 로그인 서비스 구현
YuGyeong98 Nov 28, 2025
1d3a194
#277 [feat] 카카오 로그인 컨트롤러 구현
YuGyeong98 Nov 28, 2025
70b2e55
#278 [refactor] 문자열 상수로 추출
YuGyeong98 Dec 1, 2025
f8b48ab
#278 [feat] 구글 configuration properties 추가
YuGyeong98 Dec 1, 2025
db7b4f5
#278 [feat] 구글 공개키 목록 조회 feign client 구현
YuGyeong98 Dec 1, 2025
d8b99d2
#278 [feat] 구글 oidc 페이로드 조회 및 properties, oauthinfo 조회 메소드 추가
YuGyeong98 Dec 1, 2025
1229146
#278 [feat] oauth 팩토리 클래스 구현
YuGyeong98 Dec 1, 2025
c0db85c
#278 [refactor] oauth 회원가입, 로그인 서비스 수정
YuGyeong98 Dec 1, 2025
cbb3a2c
#278 [refactor] oauth 회원가입, 로그인 컨트롤러 수정
YuGyeong98 Dec 1, 2025
fefa216
#278 [feat] oauth provider 컨버터 추가
YuGyeong98 Dec 1, 2025
4949012
#278 [fix] provider 소문자도 허용
YuGyeong98 Dec 1, 2025
cac3811
#279 [feat] 애플 configuration properties 추가
YuGyeong98 Dec 1, 2025
65a2926
#279 [feat] 애플 공개키 목록 조회 feign client 구현
YuGyeong98 Dec 1, 2025
25e5635
#279 [feat] 애플 oauth adapter 구현
YuGyeong98 Dec 1, 2025
11fded1
#279 [fix] json 역직렬화 오류 수정
YuGyeong98 Dec 21, 2025
b428215
#279 [refactor] 페이로드에 이미지 url 추가
YuGyeong98 Dec 21, 2025
530deb7
#279 [fix] jwt oidc 파싱 오류 수정
YuGyeong98 Dec 21, 2025
967ade5
#279 [refactor] restapi 키 -> 네이티브 앱 키로 변경
YuGyeong98 Dec 21, 2025
188be94
#279 [feat] oauth 회원가입, 로그인 에러 코드 추가
YuGyeong98 Dec 21, 2025
302baf4
#279 [refactor] oidc nonce 제거
YuGyeong98 Dec 21, 2025
1a7dc80
#279 [chore] 데이터베이스 마이그레이션
YuGyeong98 Dec 21, 2025
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
6 changes: 4 additions & 2 deletions docker/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ CREATE TABLE users
(
user_id BIGSERIAL PRIMARY KEY,
email VARCHAR(100) NOT NULL,
authentication_code VARCHAR(6) NOT NULL,
provider VARCHAR(15) NULL,
sub VARCHAR(255) NULL,
authentication_code VARCHAR(6) NULL,
is_authenticated BOOLEAN NOT NULL,
username VARCHAR(20) NULL,
password VARCHAR(255) NULL,
Expand All @@ -29,7 +31,7 @@ CREATE TABLE users
deleted_date TIMESTAMP(6) NULL,
created_date_time TIMESTAMP(6) NULL,
last_modified_date_time TIMESTAMP(6) NULL,
CONSTRAINT uq_email UNIQUE (email),
CONSTRAINT uq_user_provider_sub UNIQUE (provider, sub),
CONSTRAINT uq_username UNIQUE (username)
);

Expand Down
7 changes: 7 additions & 0 deletions photi-apis/enduser/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0")
}
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
implementation("io.micrometer:micrometer-registry-prometheus")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.photi.apis.enduser.common.exception
import com.photi.apis.enduser.common.exception.dto.ErrorResponse
import com.photi.core.domain.common.exception.GlobalErrorCode
import com.photi.core.domain.common.exception.PhotiException
import com.photi.core.domain.user.exception.UserErrorCode
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.ConstraintViolationException
import org.springframework.beans.TypeMismatchException
Expand Down Expand Up @@ -71,10 +72,18 @@ class CustomExceptionHandler : ResponseEntityExceptionHandler() {
status: HttpStatusCode,
request: WebRequest
): ResponseEntity<Any>? {
val response = ErrorResponse(
GlobalErrorCode.DATE_FORMAT_INVALID.name,
GlobalErrorCode.DATE_FORMAT_INVALID.message,
)
val isEnum = ex.requiredType?.isEnum == true
val response = if (isEnum) {
ErrorResponse(
UserErrorCode.OAUTH_PROVIDER_INVALID.name,
UserErrorCode.OAUTH_PROVIDER_INVALID.message,
)
} else {
ErrorResponse(
GlobalErrorCode.DATE_FORMAT_INVALID.name,
GlobalErrorCode.DATE_FORMAT_INVALID.message,
)
}
return ResponseEntity.status(BAD_REQUEST).body(response)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import org.springframework.context.annotation.Configuration
PhotiConfigGroup.REDIS,
PhotiConfigGroup.S3,
PhotiConfigGroup.ASYNC,
PhotiConfigGroup.REDIS_CACHE,
PhotiConfigGroup.FEIGN,
PhotiConfigGroup.CONFIGURATION_PROPERTIES,
]
)
class InfraConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.photi.apis.enduser.config.oidc

import com.fasterxml.jackson.databind.ObjectMapper
import com.photi.core.domain.common.exception.GlobalException
import com.photi.core.domain.user.dto.OidcPayload
import com.photi.core.infra.oauth.port.JwtOidcPort
import io.jsonwebtoken.*
import org.springframework.stereotype.Component
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.RSAPublicKeySpec
import java.util.*

@Component
class JwtOidcProvider(
private val objectMapper: ObjectMapper,
) : JwtOidcPort {

override fun getKidFromUnsignedIdToken(idToken: String, iss: String, aud: String): String {
val splitIdToken = getSplitIdToken(idToken)
validatePayload(splitIdToken[1], iss, aud)
val headerJson = String(Base64.getUrlDecoder().decode(splitIdToken[0]))
val headerMap = objectMapper.readValue(headerJson, Map::class.java)
return headerMap[KID].toString()
}

override fun getIdTokenPayload(
idToken: String,
modulus: String,
exponent: String,
): OidcPayload {
val claims = getIdTokenClaims(idToken, modulus, exponent)
return OidcPayload(
claims.issuer,
claims.audience.first(),
claims.subject,
claims[EMAIL].toString(),
claims[PICTURE].toString(),
)
}

private fun getSplitIdToken(idToken: String): List<String> {
val splitToken = idToken.split(".")
if (splitToken.size != 3) {
throw GlobalException.InvalidTokenException()
}
return splitToken
}

private fun validatePayload(payload: String, iss: String, aud: String) {
val payloadJson = String(Base64.getUrlDecoder().decode(payload))
val payloadMap = objectMapper.readValue(payloadJson, Map::class.java)
if (payloadMap[ISS] != iss || payloadMap[AUD] != aud) {
throw GlobalException.InvalidTokenException()
}
val exp = (payloadMap[EXP] as? Number)?.toLong()
?: throw GlobalException.InvalidTokenException()
if (Date().time / 1000 > exp) throw GlobalException.ExpiredTokenException()
}

private fun getIdTokenClaims(idToken: String, modulus: String, exponent: String): Claims {
return try {
Jwts.parser()
.verifyWith(getRsaPublicKey(modulus, exponent))
.build()
.parseSignedClaims(idToken)
.payload
} catch (e: ExpiredJwtException) {
throw GlobalException.ExpiredTokenException()
} catch (e: Exception) {
throw GlobalException.InvalidTokenException()
}
}

private fun getRsaPublicKey(modulus: String, exponent: String): PublicKey {
val keyFactory = KeyFactory.getInstance(ALGORITHM)
val decodeN = Base64.getUrlDecoder().decode(modulus)
val decodeE = Base64.getUrlDecoder().decode(exponent)
val n = BigInteger(1, decodeN)
val e = BigInteger(1, decodeE)
val keySpec = RSAPublicKeySpec(n, e)
return keyFactory.generatePublic(keySpec)
}

companion object {
private const val KID = "kid"
private const val ISS = "iss"
private const val AUD = "aud"
private const val EXP = "exp"
private const val EMAIL = "email"
private const val PICTURE = "picture"
private const val ALGORITHM = "RSA"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.photi.apis.enduser.controller.oauth

import com.photi.apis.enduser.common.exception.GlobalApiErrorResponses
import com.photi.apis.enduser.common.exception.UserApiErrorResponses
import com.photi.apis.enduser.config.security.JwtTokenProvider
import com.photi.apis.enduser.controller.auth.dto.response.LoginResponse
import com.photi.apis.enduser.controller.auth.dto.response.SignUpResponse
import com.photi.apis.enduser.controller.oauth.request.OAuthSignUpRequest
import com.photi.core.domain.common.exception.GlobalErrorCode.EXPIRED_TOKEN
import com.photi.core.domain.common.exception.GlobalErrorCode.INVALID_TOKEN
import com.photi.core.domain.user.exception.UserErrorCode.*
import com.photi.core.domain.user.model.OAuthProviderType
import com.photi.core.domain.user.model.RoleType
import com.photi.core.domain.user.model.RoleType.Companion.getRole
import com.photi.core.domain.user.service.OAuthService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.HttpStatus.OK
import org.springframework.http.ResponseEntity
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*

@Validated
@RestController
@RequestMapping("/api/v2/oauth")
@Tag(name = "OAuth", description = "OAuth API")
class OAuthController(
private val oAuthService: OAuthService,
private val jwtTokenProvider: JwtTokenProvider,
) {

@PostMapping("/{provider}/signup")
@Operation(summary = "OAuth 회원가입")
@ApiResponse(responseCode = "201")
@UserApiErrorResponses([EXISTING_USER])
@GlobalApiErrorResponses([INVALID_TOKEN, EXPIRED_TOKEN])
fun signUp(
@PathVariable provider: OAuthProviderType,
@RequestParam("id_token") @Parameter(description = "ID 토큰") idToken: String,
@RequestBody @Valid request: OAuthSignUpRequest,
): ResponseEntity<SignUpResponse> {
val signUpUser = oAuthService.signUp(provider, idToken, request.toServiceDto())
val response = SignUpResponse.of(signUpUser)
val headers = jwtTokenProvider.createToken(response.userId, RoleType.USER)
return ResponseEntity.status(CREATED).headers(headers).body(response)
}

@GetMapping("/{provider}/login")
@Operation(summary = "OAuth 로그인")
@ApiResponse(responseCode = "200")
@UserApiErrorResponses([USER_NOT_FOUND, DELETED_USER])
@GlobalApiErrorResponses([INVALID_TOKEN, EXPIRED_TOKEN])
fun login(
@PathVariable provider: OAuthProviderType,
@RequestParam("id_token") @Parameter(description = "ID 토큰") idToken: String,
): ResponseEntity<LoginResponse> {
val loginUser = oAuthService.login(provider, idToken)
val response = LoginResponse.of(loginUser)
val headers = jwtTokenProvider.createToken(response.userId, getRole(response.username))
return ResponseEntity.status(OK).headers(headers).body(response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.photi.apis.enduser.controller.oauth

import com.photi.core.domain.user.exception.UserException
import com.photi.core.domain.user.model.OAuthProviderType
import org.springframework.core.convert.converter.Converter
import org.springframework.stereotype.Component

@Component
class OAuthProviderConverter : Converter<String, OAuthProviderType> {

override fun convert(source: String): OAuthProviderType? {
return try {
OAuthProviderType.valueOf(source.uppercase())
} catch (e: IllegalArgumentException) {
throw UserException.InvalidOAuthProviderException()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.photi.apis.enduser.controller.oauth.request

import com.photi.core.domain.common.consts.RegexPattern.LOWERCASE_NUMBER_UNDERSCORE
import com.photi.core.domain.user.dto.OAuthSignUpDto
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size

@Schema(description = "OAuth 회원가입 요청 객체")
data class OAuthSignUpRequest(

@Schema(description = "아이디", example = "photi")
@field:NotBlank(message = "아이디는 필수 입력입니다.")
@field:Size(min = 5, max = 20, message = "아이디는 5~20자만 가능합니다.")
@field:Pattern(
regexp = LOWERCASE_NUMBER_UNDERSCORE,
message = "아이디는 소문자 영어, 숫자, 특수문자(_)의 조합으로 입력해 주세요."
)
val username: String,
) {

fun toServiceDto() = OAuthSignUpDto(username)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.photi.core.domain.common.properties

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties("oauth.apple")
data class AppleOAuthProperties(
override val baseUrl: String,
override val nativeAppKey: String,
) : OAuthProperties
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.photi.core.domain.common.properties

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties("oauth.google")
data class GoogleOAuthProperties(
override val baseUrl: String,
override val nativeAppKey: String,
) : OAuthProperties
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.photi.core.domain.common.properties

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties("oauth.kakao")
data class KakaoOAuthProperties(
override val baseUrl: String,
override val nativeAppKey: String,
) : OAuthProperties
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.photi.core.domain.common.properties

interface OAuthProperties {
val baseUrl: String
val nativeAppKey: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.photi.core.domain.user.dto

import com.photi.core.domain.user.model.OAuthInfo
import com.photi.core.domain.user.model.RoleType
import com.photi.core.domain.user.model.User

data class OAuthSignUpDto(
val username: String,
) {

fun toEntity(oAuthInfo: OAuthInfo, email: String, image: String) =
User(
email = email,
oAuthInfo = oAuthInfo,
username = username,
role = RoleType.USER,
isAuthenticated = true,
imageUrl = image,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.photi.core.domain.user.dto

data class OidcPayload(
val iss: String,
val aud: String,
val sub: String,
val email: String,
val image: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ enum class UserErrorCode(
"USERNAME_FORMAT_INVALID",
"아이디는 소문자 영어, 숫자, 특수문자(_)의 조합으로 입력해 주세요.",
"아이디는 5~20자만 가능하고, 정규식은 ^[a-z0-9_]+$ 입니다.",
);
),
OAUTH_PROVIDER_INVALID(BAD_REQUEST, "OAUTH_PROVIDER_INVALID", "지원하지 않는 OAuth Provider 입니다."),
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ sealed class UserException(errorCode: UserErrorCode) : PhotiException(errorCode)
class ExistsUsernameException : UserException(UserErrorCode.EXISTING_USERNAME)

class NotAvailableUsernameException : UserException(UserErrorCode.UNAVAILABLE_USERNAME)

class InvalidOAuthProviderException : UserException(UserErrorCode.OAUTH_PROVIDER_INVALID)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.photi.core.domain.user.model

import jakarta.persistence.Column
import jakarta.persistence.Embeddable
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated

@Embeddable
class OAuthInfo(

@Enumerated(value = EnumType.STRING)
@Column(nullable = true, length = 15)
val provider: OAuthProviderType?,

@Column(nullable = true, length = 255)
val sub: String?,
) {

companion object {

fun ofKakao(sub: String) = OAuthInfo(OAuthProviderType.KAKAO, sub)

fun ofGoogle(sub: String) = OAuthInfo(OAuthProviderType.GOOGLE, sub)

fun ofApple(sub: String) = OAuthInfo(OAuthProviderType.APPLE, sub)
}
}
Loading