From 1d105b21d1d85f5ae5d013ab644e198407c3e5c6 Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sat, 31 Aug 2024 00:26:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?ASAP-69=20feat:=20JWT(Register,=20Acess,=20?= =?UTF-8?q?Refresh)=20=EC=83=9D=EC=84=B1=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...agementPort.kt => UserTokenConvertPort.kt} | 2 +- .../MemoryUserTokenManagementAdapter.kt | 43 ---------- .../user/service/RegisterUserService.kt | 11 ++- .../user/service/SocialLoginService.kt | 11 +-- .../user/service/RegisterUserServiceTest.kt | 18 ++-- .../user/service/SocialLoginServiceTest.kt | 12 +-- Bootstrap-Module/build.gradle.kts | 2 + .../asap/bootstrap/BootstrapApplication.kt | 4 +- .../src/main/resources/application.yml | 19 ++--- .../auth/AuthApiIntegrationTest.kt | 2 + .../user/UserApiIntegrationTest.kt | 32 ++++++-- .../com/asap/domain/user/entity/UserToken.kt | 11 +++ .../com/asap/domain/user/enums/TokenType.kt | 7 ++ Infrastructure-Module/Client/build.gradle.kts | 5 -- .../Security/build.gradle.kts | 5 ++ .../com/asap/security/SecurityConfig.kt | 9 ++ .../com/asap/security/jwt/JwtPayload.kt | 50 +++++++++++ .../com/asap/security/jwt/JwtProvider.kt | 53 ++++++++++++ .../com/asap/security/jwt/common/JwtConfig.kt | 10 +++ .../security/jwt/exception/TokenException.kt | 29 +++++++ .../asap/security/jwt/user/UserJwtClaims.kt | 20 +++++ .../security/jwt/user/UserJwtProperties.kt | 17 ++++ .../jwt/user/UserTokenConvertAdapter.kt | 82 +++++++++++++++++++ .../resources/application-security-local.yml | 3 + .../main/resources/application-security.yml | 3 + .../com/asap/security/jwt/JwtTestConfig.kt | 26 ++++++ .../asap/security/jwt/TestJwtDataGenerator.kt | 38 +++++++++ Infrastructure-Module/build.gradle.kts | 2 + settings.gradle.kts | 3 +- 29 files changed, 431 insertions(+), 98 deletions(-) rename Application-Module/src/main/kotlin/com/asap/application/user/port/out/{UserTokenManagementPort.kt => UserTokenConvertPort.kt} (92%) delete mode 100644 Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt create mode 100644 Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserToken.kt create mode 100644 Domain-Module/src/main/kotlin/com/asap/domain/user/enums/TokenType.kt create mode 100644 Infrastructure-Module/Security/build.gradle.kts create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/SecurityConfig.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtPayload.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtProvider.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/common/JwtConfig.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/exception/TokenException.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtClaims.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt create mode 100644 Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserTokenConvertAdapter.kt create mode 100644 Infrastructure-Module/Security/src/main/resources/application-security-local.yml create mode 100644 Infrastructure-Module/Security/src/main/resources/application-security.yml create mode 100644 Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/JwtTestConfig.kt create mode 100644 Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/TestJwtDataGenerator.kt diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenManagementPort.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenConvertPort.kt similarity index 92% rename from Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenManagementPort.kt rename to Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenConvertPort.kt index f32c57f..fee5ce2 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenManagementPort.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/UserTokenConvertPort.kt @@ -3,7 +3,7 @@ package com.asap.application.user.port.out import com.asap.application.user.vo.UserClaims import com.asap.domain.user.entity.User -interface UserTokenManagementPort { +interface UserTokenConvertPort { fun resolveRegisterToken(token: String): UserClaims.Register fun generateRegisterToken( diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt b/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt deleted file mode 100644 index 0bfde7c..0000000 --- a/Application-Module/src/main/kotlin/com/asap/application/user/port/out/memory/MemoryUserTokenManagementAdapter.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.asap.application.user.port.out.memory - -import com.asap.application.user.port.out.UserTokenManagementPort -import com.asap.application.user.vo.UserClaims -import com.asap.common.exception.DefaultException -import com.asap.domain.user.entity.User -import com.asap.domain.user.enums.SocialLoginProvider -import org.springframework.context.annotation.Primary -import org.springframework.stereotype.Component - -@Component -@Primary -class MemoryUserTokenManagementAdapter( - -) : UserTokenManagementPort{ - override fun resolveRegisterToken(token: String): UserClaims.Register { - return when(token){ - "valid" -> UserClaims.Register( - socialId = "123", - socialLoginProvider = SocialLoginProvider.KAKAO, - username = "test" - ) - "duplicate" -> UserClaims.Register( - socialId = "duplicate", - socialLoginProvider = SocialLoginProvider.KAKAO, - username = "test" - ) - else -> throw DefaultException.InvalidArgumentException() // TODO: jwt 구현할 때 수정 - } - } - - override fun generateRegisterToken(socialId: String, socialLoginProvider: String, username: String): String { - return "registerToken" - } - - override fun generateAccessToken(user: User): String { - return "accessToken" - } - - override fun generateRefreshToken(user: User): String { - return "refreshToken" - } -} \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt b/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt index 350c8c7..65905cd 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/user/service/RegisterUserService.kt @@ -4,7 +4,7 @@ import com.asap.application.user.exception.UserException import com.asap.application.user.port.`in`.RegisterUserUsecase import com.asap.application.user.port.out.UserAuthManagementPort import com.asap.application.user.port.out.UserManagementPort -import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.port.out.UserTokenConvertPort import com.asap.domain.user.entity.User import com.asap.domain.user.entity.UserAuth import com.asap.domain.user.vo.UserPermission @@ -12,7 +12,7 @@ import org.springframework.stereotype.Service @Service class RegisterUserService( - private val userTokenManagementPort: UserTokenManagementPort, + private val userTokenConvertPort: UserTokenConvertPort, private val userAuthManagementPort: UserAuthManagementPort, private val userManagementPort: UserManagementPort ) : RegisterUserUsecase { @@ -23,9 +23,8 @@ class RegisterUserService( * 3. 추출한 사용자 정보와 함께 사용자 동의 검증 -> 동의하지 않으면 에러 * 4. 사용자 정보 저장 및 jwt 토큰 반환 */ - override fun registerUser(command: RegisterUserUsecase.Command): RegisterUserUsecase.Response { - val userClaims = userTokenManagementPort.resolveRegisterToken(command.registerToken) + val userClaims = userTokenConvertPort.resolveRegisterToken(command.registerToken) if (userAuthManagementPort.isExistsUserAuth(userClaims.socialId, userClaims.socialLoginProvider)) { throw UserException.UserAlreadyRegisteredException() } @@ -47,8 +46,8 @@ class RegisterUserService( userAuthManagementPort.saveUserAuth(userAuth) return RegisterUserUsecase.Response( - userTokenManagementPort.generateAccessToken(registerUser), - userTokenManagementPort.generateRefreshToken(registerUser) + userTokenConvertPort.generateAccessToken(registerUser), + userTokenConvertPort.generateRefreshToken(registerUser) ) } } \ No newline at end of file diff --git a/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt b/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt index 7e03800..b16f0d9 100644 --- a/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt +++ b/Application-Module/src/main/kotlin/com/asap/application/user/service/SocialLoginService.kt @@ -4,7 +4,7 @@ import com.asap.application.user.port.`in`.SocialLoginUsecase import com.asap.application.user.port.out.AuthInfoRetrievePort import com.asap.application.user.port.out.UserAuthManagementPort import com.asap.application.user.port.out.UserManagementPort -import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.port.out.UserTokenConvertPort import com.asap.common.exception.DefaultException import com.asap.domain.user.enums.SocialLoginProvider import org.springframework.stereotype.Service @@ -14,7 +14,7 @@ import org.springframework.stereotype.Service class SocialLoginService( private val userAuthManagementPort: UserAuthManagementPort, private val authInfoRetrievePort: AuthInfoRetrievePort, - private val userTokenManagementPort: UserTokenManagementPort, + private val userTokenConvertPort: UserTokenConvertPort, private val userManagementPort: UserManagementPort ) : SocialLoginUsecase { @@ -25,18 +25,19 @@ class SocialLoginService( return userAuth?.let { userManagementPort.getUser(userAuth.userId)?.let { SocialLoginUsecase.Success( - userTokenManagementPort.generateAccessToken(it), - userTokenManagementPort.generateRefreshToken(it) + userTokenConvertPort.generateAccessToken(it), + userTokenConvertPort.generateRefreshToken(it) ) } ?: run { throw DefaultException.InvalidStateException("사용자 인증정보만 존재합니다. - ${userAuth.userId}") } } ?: run { - val registerToken = userTokenManagementPort.generateRegisterToken( + val registerToken = userTokenConvertPort.generateRegisterToken( authInfo.socialId, authInfo.socialLoginProvider.name, authInfo.username ) + SocialLoginUsecase.NonRegistered(registerToken) } } diff --git a/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt b/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt index e6a6758..f6a6c95 100644 --- a/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt +++ b/Application-Module/src/test/kotlin/com/asap/application/user/service/RegisterUserServiceTest.kt @@ -4,7 +4,7 @@ import com.asap.application.user.exception.UserException import com.asap.application.user.port.`in`.RegisterUserUsecase import com.asap.application.user.port.out.UserAuthManagementPort import com.asap.application.user.port.out.UserManagementPort -import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.port.out.UserTokenConvertPort import com.asap.application.user.vo.UserClaims import com.asap.common.exception.DefaultException import com.asap.domain.user.enums.SocialLoginProvider @@ -21,22 +21,22 @@ class RegisterUserServiceTest: BehaviorSpec({ val mockUserManagementPort = mockk(relaxed = true) val mockUserAuthManagementPort = mockk(relaxed=true) - val mockUserTokenManagementPort = mockk() + val mockUserTokenConvertPort = mockk() - val registerUserService = RegisterUserService(mockUserTokenManagementPort, mockUserAuthManagementPort, mockUserManagementPort) + val registerUserService = RegisterUserService(mockUserTokenConvertPort, mockUserAuthManagementPort, mockUserManagementPort) given("회원 가입 요청이 들어왔을 때") { val successCommand = RegisterUserUsecase.Command("valid", true, true, true, LocalDate.now()) - every { mockUserTokenManagementPort.resolveRegisterToken("valid") } returns UserClaims.Register( + every { mockUserTokenConvertPort.resolveRegisterToken("valid") } returns UserClaims.Register( socialId = "123", socialLoginProvider = SocialLoginProvider.KAKAO, username = "test" ) every { mockUserAuthManagementPort.isExistsUserAuth("123", SocialLoginProvider.KAKAO) } returns false - every { mockUserTokenManagementPort.generateAccessToken(any()) } returns "accessToken" - every { mockUserTokenManagementPort.generateRefreshToken(any()) } returns "refreshToken" + every { mockUserTokenConvertPort.generateAccessToken(any()) } returns "accessToken" + every { mockUserTokenConvertPort.generateRefreshToken(any()) } returns "refreshToken" `when`("회원 가입이 성공하면") { val response = registerUserService.registerUser(successCommand) then("access token과 refresh token을 반환한다.") { @@ -47,7 +47,7 @@ class RegisterUserServiceTest: BehaviorSpec({ } - every { mockUserTokenManagementPort.resolveRegisterToken("duplicate") } returns UserClaims.Register( + every { mockUserTokenConvertPort.resolveRegisterToken("duplicate") } returns UserClaims.Register( socialId = "duplicate", socialLoginProvider = SocialLoginProvider.KAKAO, username = "test" @@ -62,7 +62,7 @@ class RegisterUserServiceTest: BehaviorSpec({ } } - every { mockUserTokenManagementPort.resolveRegisterToken("invalid") } throws IllegalArgumentException("Invalid token") + every { mockUserTokenConvertPort.resolveRegisterToken("invalid") } throws IllegalArgumentException("Invalid token") `when`("register token이 유요하지 않다면") { val failCommandWithoutRegisterToken = RegisterUserUsecase.Command("invalid", true, true, true, LocalDate.now()) @@ -74,7 +74,7 @@ class RegisterUserServiceTest: BehaviorSpec({ } - every { mockUserTokenManagementPort.resolveRegisterToken("valid") } returns UserClaims.Register( + every { mockUserTokenConvertPort.resolveRegisterToken("valid") } returns UserClaims.Register( socialId = "123", socialLoginProvider = SocialLoginProvider.KAKAO, username = "test" diff --git a/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt b/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt index 4b6ea76..77fda5c 100644 --- a/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt +++ b/Application-Module/src/test/kotlin/com/asap/application/user/service/SocialLoginServiceTest.kt @@ -4,7 +4,7 @@ import com.asap.application.user.port.`in`.SocialLoginUsecase import com.asap.application.user.port.out.AuthInfoRetrievePort import com.asap.application.user.port.out.UserAuthManagementPort import com.asap.application.user.port.out.UserManagementPort -import com.asap.application.user.port.out.UserTokenManagementPort +import com.asap.application.user.port.out.UserTokenConvertPort import com.asap.application.user.vo.AuthInfo import com.asap.common.exception.DefaultException import com.asap.domain.common.DomainId @@ -24,12 +24,12 @@ class SocialLoginServiceTest : BehaviorSpec({ val mockUserAuthManagementPort = mockk() val mockAuthInfoRetrievePort = mockk() val mockUserManagementPort = mockk() - val mockUserTokenManagementPort = mockk() + val mockUserTokenConvertPort = mockk() val socialLoginService = SocialLoginService( mockUserAuthManagementPort, mockAuthInfoRetrievePort, - mockUserTokenManagementPort, + mockUserTokenConvertPort, mockUserManagementPort ) @@ -54,8 +54,8 @@ class SocialLoginServiceTest : BehaviorSpec({ ) } returns getUserAuth every{ mockUserManagementPort.getUser(any()) } returns getUser - every { mockUserTokenManagementPort.generateAccessToken(getUser) } returns "accessToken" - every { mockUserTokenManagementPort.generateRefreshToken(getUser) } returns "refreshToken" + every { mockUserTokenConvertPort.generateAccessToken(getUser) } returns "accessToken" + every { mockUserTokenConvertPort.generateRefreshToken(getUser) } returns "refreshToken" `when`("기존에 존재한다면") { val response = socialLoginService.login(command) then("access token과 refresh token을 반환하는 success 인스턴스를 반환한다.") { @@ -78,7 +78,7 @@ class SocialLoginServiceTest : BehaviorSpec({ command = SocialLoginUsecase.Command(SocialLoginProvider.KAKAO.name, "nonRegistered") every { mockAuthInfoRetrievePort.getAuthInfo(SocialLoginProvider.KAKAO, "nonRegistered") } returns authInfo every { mockUserAuthManagementPort.getUserAuth(authInfo.socialId, authInfo.socialLoginProvider) } returns null - every { mockUserTokenManagementPort.generateRegisterToken(authInfo.socialId, authInfo.socialLoginProvider.name, authInfo.username) } returns "registerToken" + every { mockUserTokenConvertPort.generateRegisterToken(authInfo.socialId, authInfo.socialLoginProvider.name, authInfo.username) } returns "registerToken" `when`("가입되지 않았다면") { val response = socialLoginService.login(command) then("register token을 반환하는 nonRegistered 인스턴스를 반환한다.") { diff --git a/Bootstrap-Module/build.gradle.kts b/Bootstrap-Module/build.gradle.kts index 1eb8835..a23a7ae 100644 --- a/Bootstrap-Module/build.gradle.kts +++ b/Bootstrap-Module/build.gradle.kts @@ -9,4 +9,6 @@ dependencies{ implementation(project(":Infrastructure-Module:Client")) testImplementation(testFixtures(project(":Infrastructure-Module:Client"))) + implementation(project(":Infrastructure-Module:Security")) + testImplementation(testFixtures(project(":Infrastructure-Module:Security"))) } \ No newline at end of file diff --git a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt index 5ea0b2a..e71a346 100644 --- a/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt +++ b/Bootstrap-Module/src/main/kotlin/com/asap/bootstrap/BootstrapApplication.kt @@ -2,6 +2,7 @@ package com.asap.bootstrap import com.asap.application.ApplicationConfig import com.asap.client.ClientConfig +import com.asap.security.SecurityConfig import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.context.annotation.Import @@ -10,7 +11,8 @@ import org.springframework.context.annotation.Import @Import( value = [ ApplicationConfig::class, - ClientConfig::class + ClientConfig::class, + SecurityConfig::class ] ) class BootstrapApplication {} diff --git a/Bootstrap-Module/src/main/resources/application.yml b/Bootstrap-Module/src/main/resources/application.yml index 108f5a5..515d08d 100644 --- a/Bootstrap-Module/src/main/resources/application.yml +++ b/Bootstrap-Module/src/main/resources/application.yml @@ -1,13 +1,8 @@ spring: - config: - activate: - on-profile: dev - - ---- - - -spring: - config: - activate: - on-profile: default \ No newline at end of file + profiles: + group: + dev: + - security + local: + - security-local + active: local \ No newline at end of file diff --git a/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt index d7d3775..ca27b49 100644 --- a/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt +++ b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/auth/AuthApiIntegrationTest.kt @@ -76,6 +76,8 @@ class AuthApiIntegrationTest { isString() isNotEmpty() } + }.andDo { + print() } } diff --git a/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt index d72e7e1..cb92988 100644 --- a/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt +++ b/Bootstrap-Module/src/test/java/com/asap/bootstrap/integration/user/UserApiIntegrationTest.kt @@ -1,12 +1,15 @@ package com.asap.bootstrap.integration.user import com.asap.bootstrap.user.dto.RegisterUserRequest +import com.asap.security.jwt.JwtTestConfig +import com.asap.security.jwt.TestJwtDataGenerator import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post @@ -17,17 +20,22 @@ import java.time.LocalDate */ @SpringBootTest @AutoConfigureMockMvc +@Import(JwtTestConfig::class) class UserApiIntegrationTest { @Autowired lateinit var mockMvc: MockMvc + @Autowired + lateinit var testJwtDataGenerator: TestJwtDataGenerator + val objectMapper: ObjectMapper = ObjectMapper().registerModules(JavaTimeModule()) @Test fun registerUserSuccessTest() { // given - val request = RegisterUserRequest("valid", true, true, true, LocalDate.now()) + val registerToken = testJwtDataGenerator.generateRegisterToken() + val request = RegisterUserRequest(registerToken, true, true, true, LocalDate.now()) // when val response = mockMvc.post("/api/v1/users") { contentType = MediaType.APPLICATION_JSON @@ -50,9 +58,12 @@ class UserApiIntegrationTest { } @Test - fun registerUserInvalidTest_with_DuplicateUser(){ + fun registerUserInvalidTest_with_DuplicateUser() { // given - val request = RegisterUserRequest("duplicate", true, true, true, LocalDate.now()) + val duplicateRegisterToken = testJwtDataGenerator.generateRegisterToken( + socialId = "duplicate", + ) + val request = RegisterUserRequest(duplicateRegisterToken, true, true, true, LocalDate.now()) // when val response = mockMvc.post("/api/v1/users") { contentType = MediaType.APPLICATION_JSON @@ -65,9 +76,10 @@ class UserApiIntegrationTest { } @Test - fun registerUserInvalidTest_with_InvalidRegisterToken(){ + fun registerUserInvalidTest_with_InvalidRegisterToken() { // given - val request = RegisterUserRequest("invalid", true, true, true, LocalDate.now()) + val registerToken = testJwtDataGenerator.generateInvalidToken() + val request = RegisterUserRequest(registerToken, true, true, true, LocalDate.now()) // when val response = mockMvc.post("/api/v1/users") { contentType = MediaType.APPLICATION_JSON @@ -80,9 +92,10 @@ class UserApiIntegrationTest { } @Test - fun registerUserInvalidTest_with_InvalidServicePermission(){ + fun registerUserInvalidTest_with_InvalidServicePermission() { //given - val request = RegisterUserRequest("valid", false, true, true, LocalDate.now()) + val registerToken = testJwtDataGenerator.generateRegisterToken() + val request = RegisterUserRequest(registerToken, false, true, true, LocalDate.now()) //when val response = mockMvc.post("/api/v1/users") { contentType = MediaType.APPLICATION_JSON @@ -95,9 +108,10 @@ class UserApiIntegrationTest { } @Test - fun registerUserInvalidTest_with_InvalidPrivatePermission(){ + fun registerUserInvalidTest_with_InvalidPrivatePermission() { //given - val request = RegisterUserRequest("valid", true, false, true, LocalDate.now()) + val registerToken = testJwtDataGenerator.generateRegisterToken() + val request = RegisterUserRequest(registerToken, true, false, true, LocalDate.now()) //when val response = mockMvc.post("/api/v1/users") { contentType = MediaType.APPLICATION_JSON diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserToken.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserToken.kt new file mode 100644 index 0000000..de393ff --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/entity/UserToken.kt @@ -0,0 +1,11 @@ +package com.asap.domain.user.entity + +import com.asap.domain.common.DomainId +import com.asap.domain.user.enums.TokenType + +data class UserToken( + val userId: DomainId, + val token: String, + val type: TokenType +) { +} \ No newline at end of file diff --git a/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/TokenType.kt b/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/TokenType.kt new file mode 100644 index 0000000..de3f39b --- /dev/null +++ b/Domain-Module/src/main/kotlin/com/asap/domain/user/enums/TokenType.kt @@ -0,0 +1,7 @@ +package com.asap.domain.user.enums + +enum class TokenType { + ACCESS, + REFRESH, + REGISTER +} \ No newline at end of file diff --git a/Infrastructure-Module/Client/build.gradle.kts b/Infrastructure-Module/Client/build.gradle.kts index b5163f3..54bcbda 100644 --- a/Infrastructure-Module/Client/build.gradle.kts +++ b/Infrastructure-Module/Client/build.gradle.kts @@ -1,9 +1,4 @@ dependencies{ - implementation(project(":Application-Module")) - implementation(project(":Domain-Module")) implementation("org.springframework.boot:spring-boot-starter-webflux") testImplementation("io.projectreactor:reactor-test") - - - } \ No newline at end of file diff --git a/Infrastructure-Module/Security/build.gradle.kts b/Infrastructure-Module/Security/build.gradle.kts new file mode 100644 index 0000000..9f91ef8 --- /dev/null +++ b/Infrastructure-Module/Security/build.gradle.kts @@ -0,0 +1,5 @@ +dependencies{ + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/SecurityConfig.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/SecurityConfig.kt new file mode 100644 index 0000000..9f276d7 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/SecurityConfig.kt @@ -0,0 +1,9 @@ +package com.asap.security + +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration + +@Configuration +@ComponentScan("com.asap.security") +class SecurityConfig { +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtPayload.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtPayload.kt new file mode 100644 index 0000000..6a560d2 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtPayload.kt @@ -0,0 +1,50 @@ +package com.asap.security.jwt + +import com.asap.domain.user.enums.SocialLoginProvider +import io.jsonwebtoken.Claims +import java.util.* +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor + + +data class JwtPayload ( + val issuedAt: Date = Date(), + val issuer: String, + val subject: String, + val expireTime: Long, + val claims: T +) { +} + + +interface JwtClaims{ + + fun getClaims(): Map{ + val claims = mutableMapOf() + // JwtClaims 구현체의 프로퍼티를 순회하며 claims에 추가 + this::class.memberProperties.forEach { + claims[it.name] = it.getter.call(this) as Any + } + return claims + } + + companion object{ + inline fun convertFromClaims(claims: Claims): T { + val claimsMap = claims.toMap() + val constructor = T::class.primaryConstructor ?: throw IllegalArgumentException("Primary constructor not found") + + val args = constructor.parameters.associateWith { + val value = claimsMap[it.name]?: throw IllegalArgumentException("Claim not found") + when(it.type.classifier){ + String::class -> value.toString() + Long::class -> value.toString().toLong() + SocialLoginProvider::class -> SocialLoginProvider.parse(value.toString()) + else -> throw IllegalArgumentException("Unsupported type") + } + } + return constructor.callBy(args) + } + } + + +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtProvider.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtProvider.kt new file mode 100644 index 0000000..5f68404 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/JwtProvider.kt @@ -0,0 +1,53 @@ +package com.asap.security.jwt + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import java.util.* +import javax.crypto.SecretKey + + +object JwtProvider { + + inline fun createToken( + jwtPayload: JwtPayload, + secretKey: String + ): String{ + return Jwts.builder() + .issuer(jwtPayload.issuer) + .subject(jwtPayload.subject) + .claims(jwtPayload.claims.getClaims()) + .issuedAt(jwtPayload.issuedAt) + .signWith(generateKey(secretKey)) + .expiration(Date(jwtPayload.issuedAt.time + jwtPayload.expireTime)) + .compact() + + } + + fun generateKey( + secretKey: String + ): SecretKey{ + return Keys.hmacShaKeyFor(secretKey.toByteArray()) + } + + inline fun resolveToken( + token: String, + secret: String, + ): JwtPayload{ + val claims = Jwts.parser() + .verifyWith(generateKey(secret)) + .build() + .parseSignedClaims(token) + .payload + + return JwtPayload( + issuedAt = claims.issuedAt, + issuer = claims.issuer, + subject = claims.subject, + expireTime = claims.expiration.time - claims.issuedAt.time, + claims = JwtClaims.convertFromClaims(claims) + ) + } + + + +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/common/JwtConfig.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/common/JwtConfig.kt new file mode 100644 index 0000000..d8687f4 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/common/JwtConfig.kt @@ -0,0 +1,10 @@ +package com.asap.security.jwt.common + +import com.asap.security.jwt.user.UserJwtProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(UserJwtProperties::class) +class JwtConfig { +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/exception/TokenException.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/exception/TokenException.kt new file mode 100644 index 0000000..56cd4ff --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/exception/TokenException.kt @@ -0,0 +1,29 @@ +package com.asap.security.jwt.exception + +import com.asap.common.exception.BusinessException + +sealed class TokenException( + codePrefix: String = CODE_PREFIX, + errorCode: Int, + httpStatus: Int = 400, + message: String +): BusinessException(codePrefix, errorCode, httpStatus, message) { + + class InvalidTokenException( + message : String = "유효하지 않은 토큰입니다." + ): TokenException( + errorCode = 1, + message = message + ) + + class ExpiredTokenException( + message : String = "만료된 토큰입니다." + ): TokenException( + errorCode = 2, + message = message + ) + + companion object{ + const val CODE_PREFIX = "TOKEN" + } +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtClaims.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtClaims.kt new file mode 100644 index 0000000..2818393 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtClaims.kt @@ -0,0 +1,20 @@ +package com.asap.security.jwt.user + +import com.asap.domain.user.enums.SocialLoginProvider +import com.asap.domain.user.enums.TokenType +import com.asap.security.jwt.JwtClaims + +class UserJwtClaims( + val tokenType: TokenType, + val userId: String, +): JwtClaims { +} + +class UserRegisterJwtClaims( + val socialId: String, + val socialLoginProvider: SocialLoginProvider, + val username: String, +): JwtClaims { + val tokenType: TokenType = TokenType.REGISTER +} + diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt new file mode 100644 index 0000000..69c42f1 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt @@ -0,0 +1,17 @@ +package com.asap.security.jwt.user + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "jwt.user") +class UserJwtProperties( + val secret: String +) { + + companion object{ + const val ISSUER = "asap" + const val SUBJECT = "asap-auth" + const val ACCESS_TOKEN_EXPIRE_TIME: Long = 1000 * 60 // 1시간 + const val REFRESH_TOKEN_EXPIRE_TIME: Long = 1000 * 60 * 60 * 24 // 1일 + const val REGISTER_TOKEN_EXPIRE_TIME: Long = 1000 * 60 * 10 // 10분 + } +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserTokenConvertAdapter.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserTokenConvertAdapter.kt new file mode 100644 index 0000000..2d2e424 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserTokenConvertAdapter.kt @@ -0,0 +1,82 @@ +package com.asap.security.jwt.user + +import com.asap.application.user.port.out.UserTokenConvertPort +import com.asap.application.user.vo.UserClaims +import com.asap.domain.user.entity.User +import com.asap.domain.user.enums.SocialLoginProvider +import com.asap.domain.user.enums.TokenType +import com.asap.security.jwt.JwtClaims +import com.asap.security.jwt.JwtPayload +import com.asap.security.jwt.JwtProvider +import com.asap.security.jwt.JwtProvider.resolveToken +import com.asap.security.jwt.exception.TokenException +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.MalformedJwtException +import org.springframework.stereotype.Component + +@Component +class UserTokenConvertAdapter( + private val userJwtProperties: UserJwtProperties +) : UserTokenConvertPort { + override fun resolveRegisterToken(token: String): UserClaims.Register { + val jwtPayload: JwtPayload = resolveToken { resolveToken(token, userJwtProperties.secret) } + val jwtClaims = jwtPayload.claims + return UserClaims.Register( + socialId = jwtClaims.socialId, + socialLoginProvider = jwtClaims.socialLoginProvider, + username = jwtClaims.username + ) + } + + override fun generateRegisterToken(socialId: String, socialLoginProvider: String, username: String): String { + val jwtClaims = UserRegisterJwtClaims( + socialId = socialId, + socialLoginProvider = SocialLoginProvider.parse(socialLoginProvider), + username = username, + ) + val payload = getDefaultPayload(jwtClaims, UserJwtProperties.REGISTER_TOKEN_EXPIRE_TIME) + return JwtProvider.createToken(payload, userJwtProperties.secret) + } + + override fun generateAccessToken(user: User): String { + val jwtClaims = UserJwtClaims( + tokenType = TokenType.ACCESS, + userId = user.id.id + ) + val payload = getDefaultPayload(jwtClaims, UserJwtProperties.ACCESS_TOKEN_EXPIRE_TIME) + return JwtProvider.createToken(payload, userJwtProperties.secret) + } + + override fun generateRefreshToken(user: User): String { + val jwtClaims = UserJwtClaims( + tokenType = TokenType.REFRESH, + userId = user.id.id + ) + val payload = getDefaultPayload(jwtClaims, UserJwtProperties.REFRESH_TOKEN_EXPIRE_TIME) + return JwtProvider.createToken(payload, userJwtProperties.secret) + } + + private fun getDefaultPayload( + jwtClaims: T, + expireTime: Long + ): JwtPayload { + return JwtPayload( + issuer = UserJwtProperties.ISSUER, + subject = UserJwtProperties.SUBJECT, + expireTime = expireTime, + claims = jwtClaims, + ) + } + + private fun resolveToken(resolve: () -> JwtPayload): JwtPayload { + try { + return resolve() + } catch (e: Exception){ + when(e){ + is MalformedJwtException -> throw TokenException.InvalidTokenException() + is ExpiredJwtException -> throw TokenException.ExpiredTokenException() + else -> throw e + } + } + } +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/resources/application-security-local.yml b/Infrastructure-Module/Security/src/main/resources/application-security-local.yml new file mode 100644 index 0000000..4c11336 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/resources/application-security-local.yml @@ -0,0 +1,3 @@ +jwt: + user: + secret: "yjtyhjrtyjfyhjfkjhnnhafgdvczgvnsdjhfgldvbkuhgjh" \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/main/resources/application-security.yml b/Infrastructure-Module/Security/src/main/resources/application-security.yml new file mode 100644 index 0000000..8f98ed9 --- /dev/null +++ b/Infrastructure-Module/Security/src/main/resources/application-security.yml @@ -0,0 +1,3 @@ +jwt: + user: + secret: ${JWT_USER_SECRET} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/JwtTestConfig.kt b/Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/JwtTestConfig.kt new file mode 100644 index 0000000..11f11fa --- /dev/null +++ b/Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/JwtTestConfig.kt @@ -0,0 +1,26 @@ +package com.asap.security.jwt + +import com.asap.security.jwt.user.UserJwtProperties +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary + +@TestConfiguration +class JwtTestConfig { + + @Bean + @Primary + fun userJwtProperties(): UserJwtProperties { + return UserJwtProperties(TEST_SECRET) + } + + @Bean + fun testJwtDataGenerator(userJwtProperties: UserJwtProperties): TestJwtDataGenerator { + return TestJwtDataGenerator(userJwtProperties) + } + + + companion object { + const val TEST_SECRET = "hdcksljdfaklsdjfnakjcbvzcnxvbaikaklsjdflhiuasdvbzmxncbvaksd" + } +} \ No newline at end of file diff --git a/Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/TestJwtDataGenerator.kt b/Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/TestJwtDataGenerator.kt new file mode 100644 index 0000000..d71ea7f --- /dev/null +++ b/Infrastructure-Module/Security/src/testFixtures/kotlin/com/asap/security/jwt/TestJwtDataGenerator.kt @@ -0,0 +1,38 @@ +package com.asap.security.jwt + +import com.asap.domain.user.enums.SocialLoginProvider +import com.asap.security.jwt.user.UserJwtProperties +import com.asap.security.jwt.user.UserRegisterJwtClaims +import java.util.* + + +class TestJwtDataGenerator( + private val userJwtProperties: UserJwtProperties +) { + + + fun generateRegisterToken( + socialId: String = "socialId", + socialLoginProvider: String = SocialLoginProvider.KAKAO.name, + username: String = "username", + issuedAt: Date = Date() + ): String{ + return JwtProvider.createToken( + JwtPayload( + issuedAt = issuedAt, + issuer = UserJwtProperties.ISSUER, + subject= UserJwtProperties.SUBJECT, + expireTime = UserJwtProperties.REGISTER_TOKEN_EXPIRE_TIME, + claims = UserRegisterJwtClaims( + socialId = socialId, + socialLoginProvider = SocialLoginProvider.parse(socialLoginProvider), + username = username + ) + ), + userJwtProperties.secret + ) + } + + fun generateInvalidToken(): String = "invalidToken" + +} \ No newline at end of file diff --git a/Infrastructure-Module/build.gradle.kts b/Infrastructure-Module/build.gradle.kts index 5c09cc7..630c7a6 100644 --- a/Infrastructure-Module/build.gradle.kts +++ b/Infrastructure-Module/build.gradle.kts @@ -1,5 +1,7 @@ subprojects{ dependencies{ implementation(project(":Common-Module")) + implementation(project(":Application-Module")) + implementation(project(":Domain-Module")) } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 550b3f2..58172b2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,4 +7,5 @@ include(":Common-Module") include(":Infrastructure-Module") -include(":Infrastructure-Module:Client") \ No newline at end of file +include(":Infrastructure-Module:Client") +include(":Infrastructure-Module:Security") \ No newline at end of file From 16dec2cd0bf333c86663acc418300f52d15d4b8f Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sat, 31 Aug 2024 00:35:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?ASAP-69=20chore:=20jwt=20secret=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .deploy/task/task-definition.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.deploy/task/task-definition.json b/.deploy/task/task-definition.json index 35f38a4..ac07ba9 100644 --- a/.deploy/task/task-definition.json +++ b/.deploy/task/task-definition.json @@ -24,6 +24,10 @@ { "valueFrom": "arn:aws:secretsmanager:ap-northeast-2:396608783702:secret:dev/mysql-vHY6zz:DB_PASSWORD::", "name": "DB_PASSWORD" + }, + { + "valueFrom": "arn:aws:ssm:ap-northeast-2:396608783702:parameter/jwt/secret", + "name": "JWT_SECRET" } ] } From 2dd46e72c8b9aba8a1c49be016b04ccfe832e159 Mon Sep 17 00:00:00 2001 From: Sim-km Date: Sat, 31 Aug 2024 00:35:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?ASAP-69=20fix:=20jwt=20secret=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C=20val=20->=20v?= =?UTF-8?q?ar=20=EB=B3=80=EA=B2=BD=ED=95=A8=EC=9C=BC=EB=A1=9C=EC=8D=A8=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0(No?= =?UTF-8?q?=20setter=20found=20for=20property)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt index 69c42f1..29a03bc 100644 --- a/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt +++ b/Infrastructure-Module/Security/src/main/kotlin/com/asap/security/jwt/user/UserJwtProperties.kt @@ -4,7 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "jwt.user") class UserJwtProperties( - val secret: String + var secret: String ) { companion object{