diff --git a/docker/init.sql b/docker/init.sql index 3d67db07..3a7ae605 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -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, @@ -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) ); diff --git a/photi-apis/enduser/build.gradle.kts b/photi-apis/enduser/build.gradle.kts index 55833bb2..5de0fb30 100644 --- a/photi-apis/enduser/build.gradle.kts +++ b/photi-apis/enduser/build.gradle.kts @@ -1,3 +1,9 @@ +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") @@ -5,6 +11,7 @@ dependencies { 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") diff --git a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/common/exception/CustomExceptionHandler.kt b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/common/exception/CustomExceptionHandler.kt index af469c45..e88eb19e 100644 --- a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/common/exception/CustomExceptionHandler.kt +++ b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/common/exception/CustomExceptionHandler.kt @@ -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 @@ -71,10 +72,18 @@ class CustomExceptionHandler : ResponseEntityExceptionHandler() { status: HttpStatusCode, request: WebRequest ): ResponseEntity? { - 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) } diff --git a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/InfraConfig.kt b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/InfraConfig.kt index 796d9a00..89144275 100644 --- a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/InfraConfig.kt +++ b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/InfraConfig.kt @@ -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 diff --git a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/oidc/JwtOidcProvider.kt b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/oidc/JwtOidcProvider.kt new file mode 100644 index 00000000..65db6d52 --- /dev/null +++ b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/config/oidc/JwtOidcProvider.kt @@ -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 { + 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" + } +} diff --git a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/OAuthController.kt b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/OAuthController.kt new file mode 100644 index 00000000..6eef298b --- /dev/null +++ b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/OAuthController.kt @@ -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 { + 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 { + 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) + } +} diff --git a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/OAuthProviderConverter.kt b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/OAuthProviderConverter.kt new file mode 100644 index 00000000..4df72922 --- /dev/null +++ b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/OAuthProviderConverter.kt @@ -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 { + + override fun convert(source: String): OAuthProviderType? { + return try { + OAuthProviderType.valueOf(source.uppercase()) + } catch (e: IllegalArgumentException) { + throw UserException.InvalidOAuthProviderException() + } + } +} diff --git a/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/request/OAuthSignUpRequest.kt b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/request/OAuthSignUpRequest.kt new file mode 100644 index 00000000..bb4dce3e --- /dev/null +++ b/photi-apis/enduser/src/main/kotlin/com/photi/apis/enduser/controller/oauth/request/OAuthSignUpRequest.kt @@ -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) +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/AppleOAuthProperties.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/AppleOAuthProperties.kt new file mode 100644 index 00000000..53c41ab2 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/AppleOAuthProperties.kt @@ -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 diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/GoogleOAuthProperties.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/GoogleOAuthProperties.kt new file mode 100644 index 00000000..163fcfaf --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/GoogleOAuthProperties.kt @@ -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 diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/KakaoOAuthProperties.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/KakaoOAuthProperties.kt new file mode 100644 index 00000000..18a9de01 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/KakaoOAuthProperties.kt @@ -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 diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/OAuthProperties.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/OAuthProperties.kt new file mode 100644 index 00000000..d0531dc8 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/common/properties/OAuthProperties.kt @@ -0,0 +1,6 @@ +package com.photi.core.domain.common.properties + +interface OAuthProperties { + val baseUrl: String + val nativeAppKey: String +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/dto/OAuthSignUpDto.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/dto/OAuthSignUpDto.kt new file mode 100644 index 00000000..3debca70 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/dto/OAuthSignUpDto.kt @@ -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, + ) +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/dto/OidcPayload.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/dto/OidcPayload.kt new file mode 100644 index 00000000..7b579173 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/dto/OidcPayload.kt @@ -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, +) diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserErrorCode.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserErrorCode.kt index fb725166..129f863f 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserErrorCode.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserErrorCode.kt @@ -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 입니다."), } diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserException.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserException.kt index 9d1acc1d..57496b6e 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserException.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/exception/UserException.kt @@ -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) } diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/OAuthInfo.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/OAuthInfo.kt new file mode 100644 index 00000000..481dc133 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/OAuthInfo.kt @@ -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) + } +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/OAuthProviderType.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/OAuthProviderType.kt new file mode 100644 index 00000000..c619ef4f --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/OAuthProviderType.kt @@ -0,0 +1,7 @@ +package com.photi.core.domain.user.model + +enum class OAuthProviderType(private val value: String) { + KAKAO("카카오"), + GOOGLE("구글"), + APPLE("애플"), +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/User.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/User.kt index db383a2c..7bdcf6e4 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/User.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/User.kt @@ -10,11 +10,22 @@ import com.photi.core.domain.user.validator.UserValidator import jakarta.persistence.* import java.time.LocalDateTime -@Table(name = "users") +@Table( + name = "users", + uniqueConstraints = [UniqueConstraint( + name = "uq_user_provider_sub", + columnNames = ["provider", "sub"] + )] +) @Entity class User( email: String, - authenticationCode: String, + authenticationCode: String? = null, + isAuthenticated: Boolean = false, + username: String? = null, + oAuthInfo: OAuthInfo? = null, + role: RoleType = RoleType.UNAUTHENTICATED_USER, + imageUrl: String? = null, ) : BaseTimeEntity() { @Id @@ -23,20 +34,24 @@ class User( var id: Long? = null protected set - @Column(nullable = false, unique = true, length = 100) + @Embedded + var oAuthInfo: OAuthInfo? = oAuthInfo + protected set + + @Column(nullable = false, length = 100) var email: String = email protected set - @Column(nullable = false, length = 6) - var authenticationCode: String = authenticationCode + @Column(nullable = true, length = 6) + var authenticationCode: String? = authenticationCode protected set @Column(nullable = false) - var isAuthenticated: Boolean = false + var isAuthenticated: Boolean = isAuthenticated protected set @Column(nullable = true, unique = true, length = 20) - var username: String? = null + var username: String? = username protected set @Column(nullable = true, length = 255) @@ -44,12 +59,12 @@ class User( protected set @Column(nullable = true, length = 500) - var imageUrl: String? = null + var imageUrl: String? = imageUrl protected set @Enumerated(value = EnumType.STRING) @Column(nullable = false, length = 25) - var role: RoleType = RoleType.UNAUTHENTICATED_USER + var role: RoleType = role protected set @Column(nullable = true) @@ -114,5 +129,9 @@ class User( deletedDate = null } + fun login(userValidator: UserValidator) { + userValidator.validateDeletedUser(this) + } + private fun isImageNullOrEmpty() = this.imageUrl.isNullOrEmpty() } diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/repository/UserRepository.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/repository/UserRepository.kt index 9821dee3..b463052b 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/repository/UserRepository.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/model/repository/UserRepository.kt @@ -1,6 +1,7 @@ package com.photi.core.domain.user.model.repository import com.photi.core.domain.user.dto.FindInfoDto +import com.photi.core.domain.user.model.OAuthInfo import com.photi.core.domain.user.model.RoleType import com.photi.core.domain.user.model.User import org.springframework.data.jpa.repository.JpaRepository @@ -12,6 +13,8 @@ interface UserRepository : JpaRepository, UserCustomRepository { fun existsByUsername(username: String): Boolean + fun existsByOAuthInfo(oAuthInfo: OAuthInfo): Boolean + fun findByEmailAndRole(email: String, role: RoleType): User? fun findByUsername(username: String): User? @@ -22,6 +25,8 @@ interface UserRepository : JpaRepository, UserCustomRepository { fun findByEmailAndUsernameAndIsAuthenticatedTrue(email: String, username: String): User? + fun findByOAuthInfo(oAuthInfo: OAuthInfo): User? + @Query("select new com.photi.core.domain.user.dto.FindInfoDto(u.imageUrl, u.username, u.email) from User u where u.id = :id") fun findInfoById(id: Long): FindInfoDto? } diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/port/OAuthFactoryPort.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/port/OAuthFactoryPort.kt new file mode 100644 index 00000000..ac393d69 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/port/OAuthFactoryPort.kt @@ -0,0 +1,8 @@ +package com.photi.core.domain.user.port + +import com.photi.core.domain.user.model.OAuthProviderType + +interface OAuthFactoryPort { + + fun getOAuthAdapter(provider: OAuthProviderType): OAuthPort +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/port/OAuthPort.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/port/OAuthPort.kt new file mode 100644 index 00000000..333f6998 --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/port/OAuthPort.kt @@ -0,0 +1,14 @@ +package com.photi.core.domain.user.port + +import com.photi.core.domain.common.properties.OAuthProperties +import com.photi.core.domain.user.dto.OidcPayload +import com.photi.core.domain.user.model.OAuthInfo + +interface OAuthPort { + + fun getIdTokenPayload(idToken: String, iss: String, aud: String): OidcPayload + + fun getProperties(): OAuthProperties + + fun createOAuthInfo(sub: String): OAuthInfo +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/OAuthService.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/OAuthService.kt new file mode 100644 index 00000000..a4222c1b --- /dev/null +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/OAuthService.kt @@ -0,0 +1,55 @@ +package com.photi.core.domain.user.service + +import com.photi.core.domain.user.dto.LoginDto +import com.photi.core.domain.user.dto.OAuthSignUpDto +import com.photi.core.domain.user.dto.OidcPayload +import com.photi.core.domain.user.dto.SignUpDto +import com.photi.core.domain.user.exception.UserException +import com.photi.core.domain.user.model.OAuthProviderType +import com.photi.core.domain.user.port.OAuthFactoryPort +import com.photi.core.domain.user.port.OAuthPort +import com.photi.core.domain.user.service.command.UserCommandService +import com.photi.core.domain.user.service.query.UserQueryService +import com.photi.core.domain.user.validator.UserValidator +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class OAuthService( + private val oAuthFactory: OAuthFactoryPort, + private val userCommandService: UserCommandService, + private val userQueryService: UserQueryService, + private val userValidator: UserValidator, +) { + + @Transactional + fun signUp(provider: OAuthProviderType, idToken: String, dto: OAuthSignUpDto): SignUpDto { + val oAuthPort = oAuthFactory.getOAuthAdapter(provider) + val idTokenPayload = getOidcPayload(idToken, oAuthPort) + val oAuthInfo = oAuthPort.createOAuthInfo(idTokenPayload.sub) + if (userQueryService.existsBy(oAuthInfo)) throw UserException.ExistsUserException() + val user = userCommandService.createUser( + dto, + oAuthInfo, + idTokenPayload.email, + idTokenPayload.image, + ) + return SignUpDto.of(user) + } + + fun login(provider: OAuthProviderType, idToken: String): LoginDto { + val oAuthPort = oAuthFactory.getOAuthAdapter(provider) + val idTokenPayload = getOidcPayload(idToken, oAuthPort) + val oAuthInfo = oAuthPort.createOAuthInfo(idTokenPayload.sub) + val user = userQueryService.getLoginUserBy(oAuthInfo) + ?: throw UserException.NotFoundUserException() + user.login(userValidator) + return LoginDto.of(user) + } + + private fun getOidcPayload(idToken: String, oAuthPort: OAuthPort): OidcPayload { + val properties = oAuthPort.getProperties() + return oAuthPort.getIdTokenPayload(idToken, properties.baseUrl, properties.nativeAppKey) + } +} diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/command/UserCommandService.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/command/UserCommandService.kt index 4cd25670..f08d4a07 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/command/UserCommandService.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/command/UserCommandService.kt @@ -1,6 +1,8 @@ package com.photi.core.domain.user.service.command +import com.photi.core.domain.user.dto.OAuthSignUpDto import com.photi.core.domain.user.dto.SendEmailAuthenticationCodeDto +import com.photi.core.domain.user.model.OAuthInfo import com.photi.core.domain.user.model.repository.UserRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,4 +16,7 @@ class UserCommandService( fun createUser(dto: SendEmailAuthenticationCodeDto, authenticationCode: String) { userRepository.save(dto.toEntity(authenticationCode)) } + + fun createUser(dto: OAuthSignUpDto, oAuthInfo: OAuthInfo, email: String, image: String) = + userRepository.save(dto.toEntity(oAuthInfo, email, image)) } diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/query/UserQueryService.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/query/UserQueryService.kt index 3dc727d9..bfd23a4a 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/query/UserQueryService.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/service/query/UserQueryService.kt @@ -1,5 +1,6 @@ package com.photi.core.domain.user.service.query +import com.photi.core.domain.user.model.OAuthInfo import com.photi.core.domain.user.model.RoleType import com.photi.core.domain.user.model.repository.UserRepository import org.springframework.data.domain.PageRequest @@ -22,6 +23,8 @@ class UserQueryService( fun getLoginUserBy(username: String) = userRepository.findByUsername(username) + fun getLoginUserBy(oAuthInfo: OAuthInfo) = userRepository.findByOAuthInfo(oAuthInfo) + fun getAuthenticatedUserBy(email: String) = userRepository.findByEmailAndIsAuthenticatedTrue(email) @@ -53,4 +56,6 @@ class UserQueryService( fun existsEmail(email: String) = userRepository.existsByEmailAndRole(email, RoleType.USER) fun existsUsername(username: String) = userRepository.existsByUsername(username) + + fun existsBy(oAuthInfo: OAuthInfo) = userRepository.existsByOAuthInfo(oAuthInfo) } diff --git a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/validator/UserValidator.kt b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/validator/UserValidator.kt index d4c5eefc..cb4ab718 100644 --- a/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/validator/UserValidator.kt +++ b/photi-core/domain/src/main/kotlin/com/photi/core/domain/user/validator/UserValidator.kt @@ -48,7 +48,7 @@ class UserValidator( validateUsername(username) } - private fun validateDeletedUser(user: User) { + fun validateDeletedUser(user: User) { if (user.role == RoleType.DELETED_USER) { throw UserException.ExistsDeletedUserException() } diff --git a/photi-core/domain/src/main/resources/db/migration/V8__add_provider_sub_in_user.sql b/photi-core/domain/src/main/resources/db/migration/V8__add_provider_sub_in_user.sql new file mode 100644 index 00000000..71554ab6 --- /dev/null +++ b/photi-core/domain/src/main/resources/db/migration/V8__add_provider_sub_in_user.sql @@ -0,0 +1,11 @@ +ALTER TABLE users + ADD COLUMN provider VARCHAR(15) NULL, + ADD COLUMN sub VARCHAR(255) NULL, + ALTER COLUMN authentication_code DROP NOT NULL; + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS uq_email; + +ALTER TABLE users + ADD CONSTRAINT uq_user_provider_sub + UNIQUE (provider, sub); \ No newline at end of file diff --git a/photi-core/infra/build.gradle.kts b/photi-core/infra/build.gradle.kts index 0f180006..c133b26b 100644 --- a/photi-core/infra/build.gradle.kts +++ b/photi-core/infra/build.gradle.kts @@ -6,9 +6,16 @@ val bootJar: BootJar by tasks bootJar.enabled = false jar.enabled = true +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0") + } +} + dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4") implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta") implementation(project(":photi-core:domain")) diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/PhotiConfigGroup.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/PhotiConfigGroup.kt index 7447c871..f82f2190 100644 --- a/photi-core/infra/src/main/kotlin/com/photi/core/infra/PhotiConfigGroup.kt +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/PhotiConfigGroup.kt @@ -1,8 +1,11 @@ package com.photi.core.infra import com.photi.core.infra.async.AsyncConfig +import com.photi.core.infra.feign.FeignConfig import com.photi.core.infra.jpa.JpaConfig +import com.photi.core.infra.properties.ConfigurationPropertiesConfig import com.photi.core.infra.querydsl.QuerydslConfig +import com.photi.core.infra.redis.RedisCacheConfig import com.photi.core.infra.redis.RedisConfig import com.photi.core.infra.s3.S3Config @@ -14,4 +17,7 @@ enum class PhotiConfigGroup( REDIS(RedisConfig::class.java), S3(S3Config::class.java), ASYNC(AsyncConfig::class.java), + REDIS_CACHE(RedisCacheConfig::class.java), + FEIGN(FeignConfig::class.java), + CONFIGURATION_PROPERTIES(ConfigurationPropertiesConfig::class.java), } diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/feign/FeignConfig.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/feign/FeignConfig.kt new file mode 100644 index 00000000..d2f508aa --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/feign/FeignConfig.kt @@ -0,0 +1,16 @@ +package com.photi.core.infra.feign + +import com.photi.core.infra.PhotiConfig +import com.photi.core.infra.oauth.client.BaseFeignClientsPackage +import feign.Logger +import org.springframework.cloud.openfeign.EnableFeignClients +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableFeignClients(basePackageClasses = [BaseFeignClientsPackage::class]) +class FeignConfig : PhotiConfig { + + @Bean + fun feignLoggerLevel() = Logger.Level.FULL +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/AppleOAuthAdapter.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/AppleOAuthAdapter.kt new file mode 100644 index 00000000..0fe846ac --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/AppleOAuthAdapter.kt @@ -0,0 +1,28 @@ +package com.photi.core.infra.oauth.adapter + +import com.photi.core.domain.common.properties.AppleOAuthProperties +import com.photi.core.domain.user.dto.OidcPayload +import com.photi.core.domain.user.model.OAuthInfo +import com.photi.core.domain.user.port.OAuthPort +import com.photi.core.infra.oauth.client.AppleOAuthClient +import com.photi.core.infra.oauth.port.JwtOidcPort +import org.springframework.stereotype.Component + +@Component +class AppleOAuthAdapter( + private val appleOAuthProperties: AppleOAuthProperties, + private val appleOAuthClient: AppleOAuthClient, + private val jwtOidcPort: JwtOidcPort, +) : OAuthPort { + + override fun getIdTokenPayload(idToken: String, iss: String, aud: String): OidcPayload { + val publicKeys = appleOAuthClient.getOidcPublicKeys() + val kid = jwtOidcPort.getKidFromUnsignedIdToken(idToken, iss, aud) + val jwk = publicKeys.keys.first { it.kid == kid } + return jwtOidcPort.getIdTokenPayload(idToken, jwk.n, jwk.e) + } + + override fun getProperties() = appleOAuthProperties + + override fun createOAuthInfo(sub: String) = OAuthInfo.ofApple(sub) +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/GoogleOAuthAdapter.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/GoogleOAuthAdapter.kt new file mode 100644 index 00000000..c32e7072 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/GoogleOAuthAdapter.kt @@ -0,0 +1,28 @@ +package com.photi.core.infra.oauth.adapter + +import com.photi.core.domain.common.properties.GoogleOAuthProperties +import com.photi.core.domain.user.dto.OidcPayload +import com.photi.core.domain.user.model.OAuthInfo +import com.photi.core.domain.user.port.OAuthPort +import com.photi.core.infra.oauth.client.GoogleOAuthClient +import com.photi.core.infra.oauth.port.JwtOidcPort +import org.springframework.stereotype.Component + +@Component +class GoogleOAuthAdapter( + private val googleOAuthProperties: GoogleOAuthProperties, + private val googleOAuthClient: GoogleOAuthClient, + private val jwtOidcPort: JwtOidcPort, +) : OAuthPort { + + override fun getIdTokenPayload(idToken: String, iss: String, aud: String): OidcPayload { + val publicKeys = googleOAuthClient.getOidcPublicKeys() + val kid = jwtOidcPort.getKidFromUnsignedIdToken(idToken, iss, aud) + val jwk = publicKeys.keys.first { it.kid == kid } + return jwtOidcPort.getIdTokenPayload(idToken, jwk.n, jwk.e) + } + + override fun getProperties() = googleOAuthProperties + + override fun createOAuthInfo(sub: String) = OAuthInfo.ofGoogle(sub) +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/KakaoOAuthAdapter.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/KakaoOAuthAdapter.kt new file mode 100644 index 00000000..ad9e5837 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/KakaoOAuthAdapter.kt @@ -0,0 +1,28 @@ +package com.photi.core.infra.oauth.adapter + +import com.photi.core.domain.common.properties.KakaoOAuthProperties +import com.photi.core.domain.user.dto.OidcPayload +import com.photi.core.domain.user.model.OAuthInfo +import com.photi.core.domain.user.port.OAuthPort +import com.photi.core.infra.oauth.client.KakaoOAuthClient +import com.photi.core.infra.oauth.port.JwtOidcPort +import org.springframework.stereotype.Component + +@Component +class KakaoOAuthAdapter( + private val kakaoOAuthProperties: KakaoOAuthProperties, + private val kakaoOAuthClient: KakaoOAuthClient, + private val jwtOidcPort: JwtOidcPort, +) : OAuthPort { + + override fun getIdTokenPayload(idToken: String, iss: String, aud: String): OidcPayload { + val publicKeys = kakaoOAuthClient.getOidcPublicKeys() + val kid = jwtOidcPort.getKidFromUnsignedIdToken(idToken, iss, aud) + val jwk = publicKeys.keys.first { it.kid == kid } + return jwtOidcPort.getIdTokenPayload(idToken, jwk.n, jwk.e) + } + + override fun getProperties() = kakaoOAuthProperties + + override fun createOAuthInfo(sub: String) = OAuthInfo.ofKakao(sub) +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/OAuthFactoryAdapter.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/OAuthFactoryAdapter.kt new file mode 100644 index 00000000..5334d7f1 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/adapter/OAuthFactoryAdapter.kt @@ -0,0 +1,19 @@ +package com.photi.core.infra.oauth.adapter + +import com.photi.core.domain.user.model.OAuthProviderType +import com.photi.core.domain.user.port.OAuthFactoryPort +import org.springframework.stereotype.Component + +@Component +class OAuthFactoryAdapter( + private val kakaoOAuthAdapter: KakaoOAuthAdapter, + private val googleOAuthAdapter: GoogleOAuthAdapter, + private val appleOAuthAdapter: AppleOAuthAdapter, +) : OAuthFactoryPort { + + override fun getOAuthAdapter(provider: OAuthProviderType) = when (provider) { + OAuthProviderType.KAKAO -> kakaoOAuthAdapter + OAuthProviderType.GOOGLE -> googleOAuthAdapter + OAuthProviderType.APPLE -> appleOAuthAdapter + } +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/AppleOAuthClient.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/AppleOAuthClient.kt new file mode 100644 index 00000000..ee7e3bfe --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/AppleOAuthClient.kt @@ -0,0 +1,14 @@ +package com.photi.core.infra.oauth.client + +import com.photi.core.infra.oauth.dto.OidcPublicKeysResponse +import org.springframework.cache.annotation.Cacheable +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping + +@FeignClient(name = "AppleOAuthClient", url = "https://appleid.apple.com") +interface AppleOAuthClient : OAuthClient { + + @Cacheable(cacheNames = ["AppleOidc"], cacheManager = "oidcCacheManager") + @GetMapping("/auth/keys") + override fun getOidcPublicKeys(): OidcPublicKeysResponse +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/BaseFeignClientsPackage.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/BaseFeignClientsPackage.kt new file mode 100644 index 00000000..b04df191 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/BaseFeignClientsPackage.kt @@ -0,0 +1,3 @@ +package com.photi.core.infra.oauth.client + +interface BaseFeignClientsPackage diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/GoogleOAuthClient.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/GoogleOAuthClient.kt new file mode 100644 index 00000000..c46c3841 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/GoogleOAuthClient.kt @@ -0,0 +1,14 @@ +package com.photi.core.infra.oauth.client + +import com.photi.core.infra.oauth.dto.OidcPublicKeysResponse +import org.springframework.cache.annotation.Cacheable +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping + +@FeignClient(name = "GoogleOAuthClient", url = "https://www.googleapis.com") +interface GoogleOAuthClient : OAuthClient { + + @Cacheable(cacheNames = ["GoogleOidc"], cacheManager = "oidcCacheManager") + @GetMapping("/oauth2/v3/certs") + override fun getOidcPublicKeys(): OidcPublicKeysResponse +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/KakaoOAuthClient.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/KakaoOAuthClient.kt new file mode 100644 index 00000000..56c96c70 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/KakaoOAuthClient.kt @@ -0,0 +1,14 @@ +package com.photi.core.infra.oauth.client + +import com.photi.core.infra.oauth.dto.OidcPublicKeysResponse +import org.springframework.cache.annotation.Cacheable +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping + +@FeignClient(name = "KakaoOAuthClient", url = "https://kauth.kakao.com") +interface KakaoOAuthClient : OAuthClient { + + @Cacheable(cacheNames = ["KakaoOidc"], cacheManager = "oidcCacheManager") + @GetMapping("/.well-known/jwks.json") + override fun getOidcPublicKeys(): OidcPublicKeysResponse +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/OAuthClient.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/OAuthClient.kt new file mode 100644 index 00000000..ba3687a5 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/client/OAuthClient.kt @@ -0,0 +1,8 @@ +package com.photi.core.infra.oauth.client + +import com.photi.core.infra.oauth.dto.OidcPublicKeysResponse + +interface OAuthClient { + + fun getOidcPublicKeys(): OidcPublicKeysResponse +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/dto/OidcPublicKeysResponse.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/dto/OidcPublicKeysResponse.kt new file mode 100644 index 00000000..2f919d80 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/dto/OidcPublicKeysResponse.kt @@ -0,0 +1,14 @@ +package com.photi.core.infra.oauth.dto + +data class OidcPublicKeysResponse( + val keys: List = listOf(), +) + +data class Jwk( + val kid: String = "", + val kty: String = "", + val alg: String = "", + val use: String = "", + val n: String = "", + val e: String = "", +) diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/port/JwtOidcPort.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/port/JwtOidcPort.kt new file mode 100644 index 00000000..91f11589 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/oauth/port/JwtOidcPort.kt @@ -0,0 +1,10 @@ +package com.photi.core.infra.oauth.port + +import com.photi.core.domain.user.dto.OidcPayload + +interface JwtOidcPort { + + fun getKidFromUnsignedIdToken(idToken: String, iss: String, aud: String): String + + fun getIdTokenPayload(idToken: String, modulus: String, exponent: String): OidcPayload +} diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/properties/ConfigurationPropertiesConfig.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/properties/ConfigurationPropertiesConfig.kt new file mode 100644 index 00000000..ba239008 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/properties/ConfigurationPropertiesConfig.kt @@ -0,0 +1,18 @@ +package com.photi.core.infra.properties + +import com.photi.core.domain.common.properties.AppleOAuthProperties +import com.photi.core.domain.common.properties.GoogleOAuthProperties +import com.photi.core.domain.common.properties.KakaoOAuthProperties +import com.photi.core.infra.PhotiConfig +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties( + value = [ + KakaoOAuthProperties::class, + GoogleOAuthProperties::class, + AppleOAuthProperties::class, + ] +) +class ConfigurationPropertiesConfig : PhotiConfig diff --git a/photi-core/infra/src/main/kotlin/com/photi/core/infra/redis/RedisCacheConfig.kt b/photi-core/infra/src/main/kotlin/com/photi/core/infra/redis/RedisCacheConfig.kt new file mode 100644 index 00000000..4e24c1c4 --- /dev/null +++ b/photi-core/infra/src/main/kotlin/com/photi/core/infra/redis/RedisCacheConfig.kt @@ -0,0 +1,38 @@ +package com.photi.core.infra.redis + +import com.photi.core.infra.PhotiConfig +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.cache.RedisCacheConfiguration +import org.springframework.data.redis.cache.RedisCacheManager +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.RedisSerializationContext +import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration + +@Configuration +@EnableCaching +class RedisCacheConfig : PhotiConfig { + + @Bean + fun oidcCacheManager(connectionFactory: RedisConnectionFactory): CacheManager { + val cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer( + StringRedisSerializer() + ) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + GenericJackson2JsonRedisSerializer() + ) + ) + .entryTtl(Duration.ofDays(7L)) + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfiguration) + .build() + } +} diff --git a/photi-core/infra/src/main/resources/application-infra.yml b/photi-core/infra/src/main/resources/application-infra.yml index 785d8ced..5e090ee6 100644 --- a/photi-core/infra/src/main/resources/application-infra.yml +++ b/photi-core/infra/src/main/resources/application-infra.yml @@ -17,6 +17,17 @@ cloud: bucket: ${AWS_BUCKET} stack: auto: false + +oauth: + kakao: + base-url: ${KAKAO_BASE_URL} + native-app-key: ${KAKAO_NATIVE_APP_KEY} + google: + base-url: ${GOOGLE_BASE_URL} + native-app-key: ${GOOGLE_NATIVE_APP_KEY} + apple: + base-url: ${APPLE_BASE_URL} + native-app-key: ${APPLE_NATIVE_APP_KEY} --- spring: config: @@ -37,6 +48,17 @@ cloud: bucket: ${AWS_BUCKET} stack: auto: false + +oauth: + kakao: + base-url: ${KAKAO_BASE_URL} + native-app-key: ${KAKAO_NATIVE_APP_KEY} + google: + base-url: ${GOOGLE_BASE_URL} + native-app-key: ${GOOGLE_NATIVE_APP_KEY} + apple: + base-url: ${APPLE_BASE_URL} + native-app-key: ${APPLE_NATIVE_APP_KEY} --- spring: config: