diff --git a/.gitignore b/.gitignore index 5a979af..bfa2fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ out/ ### Kotlin ### .kotlin + +### mac OS ### +**/.DS_Store + +/src/main/resources/application-mail.yml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f49b035 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# kotlin-api-server + +### πŸ”₯ κ°œμš” + +- [μ½”ν‹€λ¦° μŠ€ν„°λ””](https://github.com/brdm-study/atomic-kotlin-study)와 개인 ν•™μŠ΅ λ‚΄μš©μ„ 기반으둜 +[java-api-server](https://github.com/yoonseon12/java-api-server) ν”„λ‘œμ νŠΈλ₯Ό μ½”ν‹€λ¦°μœΌλ‘œ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ ν–ˆμŠ΅λ‹ˆλ‹€. + +### πŸ”₯ ν”„λ‘œμ νŠΈ κ΅¬ν˜„ λ‚΄μš© ν¬μŠ€νŒ… + +- πŸ’‘ [@TransactionEventListener의 phase μ˜΅μ…˜μ„ μ£Όμ˜ν•΄μ„œ μ‚¬μš©ν•˜μž](https://yoonseon.tistory.com/157) +- πŸ’‘ [μŠ€ν”„λ§+μ½”ν‹€λ¦°μ—μ„œ @Valid이 λ™μž‘ν•˜μ§€ μ•ŠλŠ” 경우](https://yoonseon.tistory.com/156) \ No newline at end of file diff --git a/build.gradle b/build.gradle index 374c096..0c1070f 100644 --- a/build.gradle +++ b/build.gradle @@ -22,11 +22,27 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 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' + + // kotlin implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'org.jetbrains.kotlin:kotlin-reflect' + + // jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // H2 runtimeOnly 'com.h2database:h2' + + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' + testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/http/get_memberInfo.http b/http/get_memberInfo.http new file mode 100644 index 0000000..be153b1 --- /dev/null +++ b/http/get_memberInfo.http @@ -0,0 +1,4 @@ +GET localhost:8080/api/members/1 +x-api-version: v1 +Content-Type: application/json +Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ5b29uc2VvbjNAZ21haWwuY29tIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTcyMTEyMzQ4MH0.3-MFaAiWwAgICiTFxej4E-BvBNjoAhVD_8s6igLl-6U1jLcxEJwv-YE0v3hmTlbegQeOcfH8WHnFMp9vvr-UKA diff --git a/http/post_resetPassword.http b/http/post_resetPassword.http new file mode 100644 index 0000000..a934857 --- /dev/null +++ b/http/post_resetPassword.http @@ -0,0 +1,7 @@ +POST localhost:8080/api/members/reset-password +x-api-version: v1 +Content-Type: application/json + +{ + "email": "yoonseon3@gmail.com" +} diff --git a/http/post_signin.http b/http/post_signin.http new file mode 100644 index 0000000..222e7d7 --- /dev/null +++ b/http/post_signin.http @@ -0,0 +1,8 @@ +POST localhost:8080/api/members/signin +x-api-version: v1 +Content-Type: application/json + +{ + "email": "yoonseon3@gmail.com", + "password": "aa1234@@@@" +} diff --git a/http/post_signup.http b/http/post_signup.http new file mode 100644 index 0000000..9d2c5d1 --- /dev/null +++ b/http/post_signup.http @@ -0,0 +1,9 @@ +POST localhost:8080/api/members +x-api-version: v1 +Content-Type: application/json + +{ + "nickname": "Kevin3", + "email": "yoonseon3@gmail.com", + "password": "aa1234@@@@" +} diff --git a/src/main/kotlin/io/study/kotlinapiserver/KotlinApiServerApplication.kt b/src/main/kotlin/io/study/kotlinapiserver/KotlinApiServerApplication.kt index 9d227c8..b9a8a7b 100644 --- a/src/main/kotlin/io/study/kotlinapiserver/KotlinApiServerApplication.kt +++ b/src/main/kotlin/io/study/kotlinapiserver/KotlinApiServerApplication.kt @@ -1,9 +1,11 @@ package io.study.kotlinapiserver import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication @SpringBootApplication +@ConfigurationPropertiesScan class KotlinApiServerApplication fun main(args: Array) { diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberInfoService.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberInfoService.kt new file mode 100644 index 0000000..93e3cd0 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberInfoService.kt @@ -0,0 +1,21 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.MemberDomainService +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberInfoResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class MemberInfoService( + + private val memberDomainService: MemberDomainService, + +) { + + @Transactional(readOnly = true) + fun getMemberInfo(id: Long): MemberInfoResponse { + return memberDomainService.getInfo(id) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberResetService.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberResetService.kt new file mode 100644 index 0000000..398d5ea --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberResetService.kt @@ -0,0 +1,83 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.MemberDomainService +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberResetPasswordRequest +import io.study.kotlinapiserver.api.domain.member.domain.event.ResetPasswordEvent +import io.study.kotlinapiserver.web.base.event.Events +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.security.SecureRandom +import java.util.* + +@Service +@Transactional(readOnly = true) +class MemberResetService( + + private val memberDomainService: MemberDomainService, + +) { + companion object { + private const val VALID_CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + private const val SPECIAL_CHARACTERS = "!@#$%^&*()\\-_=+" + private const val MAX_SPECIAL_CHARACTER = 3; + private const val MIN_LENGTH = 8; + private const val MAX_LENGTH = 16; + } + + @Transactional + fun resetPassword(request: MemberResetPasswordRequest) { + val tempPassword = createTempPassword() + + memberDomainService.resetPassword(MemberResetPasswordRequest(request.email, tempPassword)) + + /** λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ 메일 전솑 **/ + publishResetPasswordSuccessEmailEvent(tempPassword, request.email) + } + + private fun createTempPassword(): String { + val random = SecureRandom() + val passwordLength = MIN_LENGTH + random.nextInt(MAX_LENGTH - MIN_LENGTH + 1) + val password = StringBuilder(passwordLength) + + val specialCharacterLength = addSpecialCharacters(password) + addValidCharacters(specialCharacterLength, passwordLength, password) + + return shufflePassword(password) + } + + private fun publishResetPasswordSuccessEmailEvent( + password: String, + registeredEmail: String, + ) { + Events.raise(ResetPasswordEvent.of(password, registeredEmail)) + } + + private fun shufflePassword(password: StringBuilder): String { + val map = password.toList() + Collections.shuffle(map) + return map.joinToString("") + } + + private fun addSpecialCharacters(password: StringBuilder): Int { + val random = SecureRandom() + val specialCharacterLength = random.nextInt(MAX_SPECIAL_CHARACTER) + 1 + + for (i in 0 until specialCharacterLength) { + password.append(SPECIAL_CHARACTERS[random.nextInt(SPECIAL_CHARACTERS.length)]) + } + + return specialCharacterLength + } + + private fun addValidCharacters( + specialCharacterLength: Int, + passwordLength: Int, + password: StringBuilder + ) { + val random = SecureRandom() + for (i in specialCharacterLength until passwordLength) { + val index = random.nextInt(VALID_CHARACTERS.length) + password.append(VALID_CHARACTERS[index]) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSigninService.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSigninService.kt new file mode 100644 index 0000000..cbab1c9 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSigninService.kt @@ -0,0 +1,26 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSigninRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSigninResponse +import io.study.kotlinapiserver.web.jwt.JwtProvider +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class MemberSigninService( + private val jwtProvider: JwtProvider, + private val authenticationManagerBuilder: AuthenticationManagerBuilder, +) { + + fun signin(request: MemberSigninRequest): MemberSigninResponse { + val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password) + val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken) + val token = jwtProvider.createToken(authentication) + + return MemberSigninResponse.of(token) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSignupService.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSignupService.kt new file mode 100644 index 0000000..e8f1ef4 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSignupService.kt @@ -0,0 +1,32 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.MemberDomainService +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSignupResponse +import io.study.kotlinapiserver.api.domain.member.domain.event.SignupEvent +import io.study.kotlinapiserver.web.base.event.Events +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class MemberSignupService( + private val memberDomainService: MemberDomainService, +) { + + @Transactional + fun signup(command: MemberSignupRequest): MemberSignupResponse { + /** νšŒμ›κ°€μž… **/ + val savedInfo = memberDomainService.register(command) + + /** 이메일 전솑 **/ + publishSignupSuccessEmailEvent(savedInfo.email) + + return savedInfo + } + + private fun publishSignupSuccessEmailEvent(registeredEmail: String) { + Events.raise(SignupEvent.of(registeredEmail)) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainService.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainService.kt new file mode 100644 index 0000000..f24545a --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainService.kt @@ -0,0 +1,16 @@ +package io.study.kotlinapiserver.api.domain.member.domain + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberResetPasswordRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberInfoResponse +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSignupResponse + +interface MemberDomainService { + + fun register(request: MemberSignupRequest): MemberSignupResponse + + fun getInfo(id: Long): MemberInfoResponse + + fun resetPassword(request: MemberResetPasswordRequest) + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainServiceImpl.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainServiceImpl.kt new file mode 100644 index 0000000..93f2a31 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainServiceImpl.kt @@ -0,0 +1,56 @@ +package io.study.kotlinapiserver.api.domain.member.domain + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberResetPasswordRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberInfoResponse +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSignupResponse +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberRepository +import io.study.kotlinapiserver.api.domain.member.domain.validation.MemberValidator +import io.study.kotlinapiserver.web.exception.ApiException +import io.study.kotlinapiserver.web.exception.error.MemberErrorCode +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Component + +@Component +class MemberDomainServiceImpl( + + private val memberRepository: MemberRepository, + private val memberQueryRepository: MemberQueryRepository, + private val memberValidator: MemberValidator, + private val passwordEncoder: PasswordEncoder, + +) : MemberDomainService { + + override fun register(request: MemberSignupRequest): MemberSignupResponse { + memberValidator.signinValidate(request) + val initBasicMember = Member.createBasicMember(request.email, request.nickname, encodePassword(request.password)) + val savedMember = memberRepository.save(initBasicMember) + + return MemberSignupResponse(savedMember.email) + } + + override fun getInfo(id: Long): MemberInfoResponse { + val findMember = memberQueryRepository.findByIdWithAuthorities(id) + ?: throw ApiException(MemberErrorCode.NOT_FOUND_MEMBER) + + return MemberInfoResponse( + email = findMember.email, + nickname = findMember.nickname, + roles = findMember.authorities.map { it.authority } + ) + } + + override fun resetPassword(request: MemberResetPasswordRequest) { + val findMember = memberQueryRepository.findByEmail(request.email) + ?: throw ApiException(MemberErrorCode.NOT_FOUND_MEMBER) + + findMember.changePassword(encodePassword(request.tempPassword!!)) + } + + private fun encodePassword(password: String): String { + return passwordEncoder.encode(password) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberResetPasswordRequest.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberResetPasswordRequest.kt new file mode 100644 index 0000000..4c6a7fb --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberResetPasswordRequest.kt @@ -0,0 +1,6 @@ +package io.study.kotlinapiserver.api.domain.member.domain.dto.request + +data class MemberResetPasswordRequest( + val email: String, + val tempPassword: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberSigninRequest.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberSigninRequest.kt new file mode 100644 index 0000000..d064a87 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberSigninRequest.kt @@ -0,0 +1,17 @@ +package io.study.kotlinapiserver.api.domain.member.domain.dto.request + +import io.study.kotlinapiserver.api.domain.member.ui.dto.request.PostMemberSigninRequest + +data class MemberSigninRequest( + val email: String, + val password: String, +) { + companion object { + fun of(request: PostMemberSigninRequest): MemberSigninRequest { + return MemberSigninRequest( + email = request.email, + password = request.password, + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberSignupRequest.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberSignupRequest.kt new file mode 100644 index 0000000..09eb143 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/request/MemberSignupRequest.kt @@ -0,0 +1,19 @@ +package io.study.kotlinapiserver.api.domain.member.domain.dto.request + +import io.study.kotlinapiserver.api.domain.member.ui.dto.request.PostMemberSignupRequest + +data class MemberSignupRequest( + val nickname: String, + val email: String, + val password: String, +) { + companion object { + fun of(request: PostMemberSignupRequest): MemberSignupRequest { + return MemberSignupRequest( + nickname = request.nickname, + email = request.email, + password = request.password, + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberInfoResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberInfoResponse.kt new file mode 100644 index 0000000..5266ac6 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberInfoResponse.kt @@ -0,0 +1,9 @@ +package io.study.kotlinapiserver.api.domain.member.domain.dto.response + +import io.study.kotlinapiserver.api.domain.member.domain.entity.authority.AuthorityType + +data class MemberInfoResponse( + val email: String, + val nickname: String, + val roles: List +) \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberSigninResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberSigninResponse.kt new file mode 100644 index 0000000..02ca621 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberSigninResponse.kt @@ -0,0 +1,17 @@ +package io.study.kotlinapiserver.api.domain.member.domain.dto.response + +import io.study.kotlinapiserver.web.jwt.TokenInfo + +data class MemberSigninResponse( + val accessToken: String, + val refreshToken: String, +) { + companion object { + fun of(tokenInfo: TokenInfo): MemberSigninResponse { + return MemberSigninResponse( + accessToken = tokenInfo.accessToken, + refreshToken = tokenInfo.refreshToken, + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberSignupResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberSignupResponse.kt new file mode 100644 index 0000000..5f20b60 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/dto/response/MemberSignupResponse.kt @@ -0,0 +1,5 @@ +package io.study.kotlinapiserver.api.domain.member.domain.dto.response + +data class MemberSignupResponse( + val email: String, +) \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/Member.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/Member.kt new file mode 100644 index 0000000..7e693fd --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/Member.kt @@ -0,0 +1,50 @@ +package io.study.kotlinapiserver.api.domain.member.domain.entity + +import io.study.kotlinapiserver.api.domain.member.domain.entity.authority.MemberAuthority +import io.study.kotlinapiserver.web.base.entity.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "member") +class Member( + + @Column(name="email", nullable = false) + var email: String, + + @Embedded + var password: MemberPassword, + + @Column(name = "nickname", nullable = false) + val nickname: String, + + @Enumerated(EnumType.STRING) + @Column(name="member_status", nullable = false) + val status: MemberStatus, + + @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL], orphanRemoval = true) + var authorities: MutableSet = mutableSetOf() + +) : BaseEntity() { + + fun addAuthority(memberAuthority: MemberAuthority) { + authorities.add(memberAuthority) + memberAuthority.addMember(this) + } + + fun changePassword(newPassword: String) { + password = MemberPassword(newPassword) + } + + companion object { + fun createBasicMember(email:String, nickname:String, password: String): Member { + val newMember = Member( + email = email, + password = MemberPassword(password), + nickname = nickname, + status = MemberStatus.ACTIVE, + ) + newMember.addAuthority(MemberAuthority.setRoleUser(newMember)) + return newMember + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/MemberPassword.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/MemberPassword.kt new file mode 100644 index 0000000..0c32330 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/MemberPassword.kt @@ -0,0 +1,24 @@ +package io.study.kotlinapiserver.api.domain.member.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +class MemberPassword( + + @Column(name="password", nullable = false) + val value: String, + + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } + + override fun hashCode(): Int { + return javaClass.hashCode() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/MemberStatus.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/MemberStatus.kt new file mode 100644 index 0000000..25bd3ec --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/MemberStatus.kt @@ -0,0 +1,8 @@ +package io.study.kotlinapiserver.api.domain.member.domain.entity + +enum class MemberStatus( + val status: String, +) { + ACTIVE("ν™œμ„±ν™”"), + INACTIVE("λΉ„ν™œμ„±ν™”"), +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/authority/AuthorityType.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/authority/AuthorityType.kt new file mode 100644 index 0000000..77c196a --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/authority/AuthorityType.kt @@ -0,0 +1,6 @@ +package io.study.kotlinapiserver.api.domain.member.domain.entity.authority + +enum class AuthorityType { + ROLE_ADMIN, + ROLE_USER, +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/authority/MemberAuthority.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/authority/MemberAuthority.kt new file mode 100644 index 0000000..679c578 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/entity/authority/MemberAuthority.kt @@ -0,0 +1,30 @@ +package io.study.kotlinapiserver.api.domain.member.domain.entity.authority + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import io.study.kotlinapiserver.web.base.entity.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "members_authority") +class MemberAuthority( + + @Enumerated(EnumType.STRING) + @Column(name="authority") + val authority: AuthorityType, + + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + +) : BaseEntity() { + + fun addMember(newMember: Member) { + this.member = newMember + } + + companion object { + fun setRoleUser(member: Member): MemberAuthority { + return MemberAuthority(AuthorityType.ROLE_USER, member) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/MemberEventListener.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/MemberEventListener.kt new file mode 100644 index 0000000..954cb2e --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/MemberEventListener.kt @@ -0,0 +1,31 @@ +package io.study.kotlinapiserver.api.domain.member.domain.event + +import io.study.kotlinapiserver.infra.mail.MailService +import io.study.kotlinapiserver.web.base.log.logger +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class MemberEventListener( + private val mailService: MailService +) { + private val log = logger() + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + fun signupEventListener(signupEvent: SignupEvent) { + log.info("MemberEventListener.signupEventListener !! ") + + mailService.sendMail(signupEvent.emails, "νšŒμ›κ°€μž… μ™„λ£Œ μ•ˆλ‚΄", "νšŒμ›κ°€μž…μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.") + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + fun resetPasswordEventListener(resetPasswordEvent: ResetPasswordEvent) { + log.info("MemberEventListener.resetPasswordEventListener !! ") + + mailService.sendMail(resetPasswordEvent.emails, "μž„μ‹œλΉ„λ°€λ²ˆν˜Έ λ°œκΈ‰ μ•ˆλ‚΄", "μž„μ‹œλ°œκΈ‰ λΉ„λ°€λ²ˆν˜Έ : ${resetPasswordEvent.tempPassword}") + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/ResetPasswordEvent.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/ResetPasswordEvent.kt new file mode 100644 index 0000000..b294497 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/ResetPasswordEvent.kt @@ -0,0 +1,14 @@ +package io.study.kotlinapiserver.api.domain.member.domain.event + +class ResetPasswordEvent( + val emails: Array, + val tempPassword: String, +) { + companion object { + fun of(tempPassword:String, vararg emails: String) = + ResetPasswordEvent( + tempPassword = tempPassword, + emails = emails, + ) + } +} diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/SignupEvent.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/SignupEvent.kt new file mode 100644 index 0000000..62c1e10 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/event/SignupEvent.kt @@ -0,0 +1,9 @@ +package io.study.kotlinapiserver.api.domain.member.domain.event + +class SignupEvent( + val emails: Array +) { + companion object { + fun of(vararg emails: String) = SignupEvent(emails) + } +} diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/principal/MemberDetails.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/principal/MemberDetails.kt new file mode 100644 index 0000000..5004e01 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/principal/MemberDetails.kt @@ -0,0 +1,28 @@ +package io.study.kotlinapiserver.api.domain.member.domain.principal + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +class MemberDetails( + + private val member: Member + +) : UserDetails { + + override fun getAuthorities(): MutableCollection { + return member.authorities + .map { SimpleGrantedAuthority(it.authority.toString()) } + .toMutableList() + } + + override fun getPassword(): String { + return member.password.value + } + + override fun getUsername(): String { + return member.email + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/principal/MemberDetailsService.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/principal/MemberDetailsService.kt new file mode 100644 index 0000000..efa8f73 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/principal/MemberDetailsService.kt @@ -0,0 +1,26 @@ +package io.study.kotlinapiserver.api.domain.member.domain.principal + +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.web.exception.ApiException +import io.study.kotlinapiserver.web.exception.error.AuthErrorCode +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Transactional(readOnly = true) +class MemberDetailsService( + + private val memberQueryRepository: MemberQueryRepository + +) : UserDetailsService { + + override fun loadUserByUsername(username: String?): UserDetails { + val member = memberQueryRepository.findByEmail(username!!) + ?: throw ApiException(AuthErrorCode.INVALID_ACCOUNT) + + return MemberDetails(member) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/repository/MemberQueryRepository.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/repository/MemberQueryRepository.kt new file mode 100644 index 0000000..6af43e0 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/repository/MemberQueryRepository.kt @@ -0,0 +1,17 @@ +package io.study.kotlinapiserver.api.domain.member.domain.repository + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member + +interface MemberQueryRepository { + + fun existsByNickname(nickname: String): Boolean + + fun existsByEmail(nickname: String): Boolean + + fun findByEmail(email: String): Member? + + fun findById(id: Long): Member? + + fun findByIdWithAuthorities(id: Long): Member? + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/repository/MemberRepository.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/repository/MemberRepository.kt new file mode 100644 index 0000000..bcedb18 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/repository/MemberRepository.kt @@ -0,0 +1,11 @@ +package io.study.kotlinapiserver.api.domain.member.domain.repository + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import org.springframework.stereotype.Component + +@Component +interface MemberRepository { + + fun save(member: Member): Member + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/validation/MemberValidator.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/validation/MemberValidator.kt new file mode 100644 index 0000000..03c83dc --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/domain/validation/MemberValidator.kt @@ -0,0 +1,31 @@ +package io.study.kotlinapiserver.api.domain.member.domain.validation + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.web.exception.ApiException +import io.study.kotlinapiserver.web.exception.error.MemberErrorCode +import org.springframework.stereotype.Component + +@Component +class MemberValidator( + private val memberQueryRepository: MemberQueryRepository +) { + + fun signinValidate(request: MemberSignupRequest) { + validateDuplicateNickname(request.nickname) + validationDuplicateEmail(request.email) + } + + private fun validateDuplicateNickname(nickname: String) { + if (memberQueryRepository.existsByNickname(nickname)) { + throw ApiException(MemberErrorCode.CONFLICT_DUPLICATE_NICKNAME) + } + } + + private fun validationDuplicateEmail(email: String) { + if (memberQueryRepository.existsByEmail(email)) { + throw ApiException(MemberErrorCode.CONFLICT_DUPLICATE_EMAIL) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/JpaRepositoryExtensions.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/JpaRepositoryExtensions.kt new file mode 100644 index 0000000..d5eb23c --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/JpaRepositoryExtensions.kt @@ -0,0 +1,7 @@ +package io.study.kotlinapiserver.api.domain.member.infrasturcture + +import org.springframework.data.repository.CrudRepository + +fun CrudRepository.findByIdOrElseNull(id: ID): T? { + return this.findById(id!!).orElse(null) +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberJpaRepository.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberJpaRepository.kt new file mode 100644 index 0000000..d7c0b04 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberJpaRepository.kt @@ -0,0 +1,10 @@ +package io.study.kotlinapiserver.api.domain.member.infrasturcture + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import org.springframework.data.repository.Repository + +interface MemberJpaRepository : Repository { + + fun save(member: Member): Member + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberQueryJpaRepository.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberQueryJpaRepository.kt new file mode 100644 index 0000000..ea63da4 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberQueryJpaRepository.kt @@ -0,0 +1,18 @@ +package io.study.kotlinapiserver.api.domain.member.infrasturcture + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository + +interface MemberQueryJpaRepository : CrudRepository { + + fun existsByNickname(nickname: String): Boolean + + fun existsByEmail(nickname: String): Boolean + + fun findByEmail(email: String): Member? + + @Query("select m from Member m left join fetch m.authorities where m.id = :id") + fun findByIdWithAuthorities(id: Long) : Member? + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberQueryRepositoryAdaptor.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberQueryRepositoryAdaptor.kt new file mode 100644 index 0000000..4c3b9a6 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberQueryRepositoryAdaptor.kt @@ -0,0 +1,26 @@ +package io.study.kotlinapiserver.api.domain.member.infrasturcture + +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import org.springframework.stereotype.Component + +@Component +class MemberQueryRepositoryAdaptor( + private val memberQueryJpaRepository: MemberQueryJpaRepository, +) : MemberQueryRepository { + + override fun existsByNickname(nickname: String) = + memberQueryJpaRepository.existsByNickname(nickname) + + override fun existsByEmail(nickname: String) = + memberQueryJpaRepository.existsByEmail(nickname) + + override fun findByEmail(email: String) = + memberQueryJpaRepository.findByEmail(email) + + override fun findById(id: Long) = + memberQueryJpaRepository.findByIdOrElseNull(id) + + override fun findByIdWithAuthorities(id: Long) = + memberQueryJpaRepository.findByIdWithAuthorities(id) + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberRepositoryAdaptor.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberRepositoryAdaptor.kt new file mode 100644 index 0000000..3136380 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/infrasturcture/MemberRepositoryAdaptor.kt @@ -0,0 +1,15 @@ +package io.study.kotlinapiserver.api.domain.member.infrasturcture + +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberRepository +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import org.springframework.stereotype.Component + +@Component +class MemberRepositoryAdaptor( + private val memberJpaRepository: MemberJpaRepository +) : MemberRepository { + + override fun save(member: Member) = + memberJpaRepository.save(member) + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/MemberController.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/MemberController.kt new file mode 100644 index 0000000..8aec913 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/MemberController.kt @@ -0,0 +1,74 @@ +package io.study.kotlinapiserver.api.domain.member.ui + +import io.study.kotlinapiserver.api.domain.member.application.MemberInfoService +import io.study.kotlinapiserver.api.domain.member.application.MemberResetService +import io.study.kotlinapiserver.api.domain.member.application.MemberSigninService +import io.study.kotlinapiserver.api.domain.member.application.MemberSignupService +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberInfoResponse +import io.study.kotlinapiserver.api.domain.member.ui.dto.request.PostMemberResetPassword +import io.study.kotlinapiserver.api.domain.member.ui.dto.request.PostMemberSigninRequest +import io.study.kotlinapiserver.api.domain.member.ui.dto.request.PostMemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.ui.dto.response.PostMemberSigninResponse +import io.study.kotlinapiserver.api.domain.member.ui.dto.response.PostMemberSignupResponse +import io.study.kotlinapiserver.web.annotation.OnlyOwnerAllowed +import io.study.kotlinapiserver.web.base.BaseController +import io.study.kotlinapiserver.web.base.response.BaseResponse +import io.study.kotlinapiserver.web.base.response.SuccessResponse +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* + +@RestController +class MemberController( + + private val memberSignupService: MemberSignupService, + private val memberSigninService: MemberSigninService, + private val memberInfoService: MemberInfoService, + private val memberResetService: MemberResetService, + + ) : BaseController() { + + @PostMapping("/members", headers = [X_API_VERSION]) + fun signup( + @RequestBody @Validated request: PostMemberSignupRequest, + ): ResponseEntity> { + val command = request.toDomainDto() + val info = memberSignupService.signup(command) + val response = PostMemberSignupResponse.of(info) + + return ResponseEntity.ok(SuccessResponse.of(response)) + } + + @PostMapping("/members/signin", headers = [X_API_VERSION]) + fun signin( + @RequestBody @Validated request: PostMemberSigninRequest, + ): ResponseEntity> { + val command = request.toDomainDto() + val info = memberSigninService.signin(command) + val response = PostMemberSigninResponse.of(info) + + return ResponseEntity.ok(SuccessResponse.of(response)) + } + + @GetMapping("/members/{memberId}", headers = [X_API_VERSION]) + @OnlyOwnerAllowed + fun getMemberInfo( + @PathVariable memberId: Long + ): ResponseEntity> { + val response = memberInfoService.getMemberInfo(memberId) + + return ResponseEntity.ok(SuccessResponse.of(response)) + } + + @PostMapping("/members/reset-password", headers = [X_API_VERSION]) + fun resetPassword( + @RequestBody @Validated request: PostMemberResetPassword, + ) : ResponseEntity{ + val command = request.toDomainDto() + memberResetService.resetPassword(command) + + return ResponseEntity.ok(BaseResponse()) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberResetPassword.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberResetPassword.kt new file mode 100644 index 0000000..89dc20a --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberResetPassword.kt @@ -0,0 +1,17 @@ +package io.study.kotlinapiserver.api.domain.member.ui.dto.request + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberResetPasswordRequest +import io.study.kotlinapiserver.web.annotation.ValidEmail + +data class PostMemberResetPassword( + + @field:ValidEmail + val email: String, + +) { + fun toDomainDto(): MemberResetPasswordRequest { + return MemberResetPasswordRequest( + email = this.email, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberSigninRequest.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberSigninRequest.kt new file mode 100644 index 0000000..d38ddcf --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberSigninRequest.kt @@ -0,0 +1,19 @@ +package io.study.kotlinapiserver.api.domain.member.ui.dto.request + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSigninRequest +import io.study.kotlinapiserver.web.annotation.ValidEmail + +data class PostMemberSigninRequest( + + @field:ValidEmail + val email: String, + + val password: String, +) { + fun toDomainDto(): MemberSigninRequest { + return MemberSigninRequest( + email = this.email, + password = this.password + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberSignupRequest.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberSignupRequest.kt new file mode 100644 index 0000000..3edff42 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/request/PostMemberSignupRequest.kt @@ -0,0 +1,29 @@ +package io.study.kotlinapiserver.api.domain.member.ui.dto.request + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.web.annotation.ValidEmail +import io.study.kotlinapiserver.web.annotation.ValidPassword +import jakarta.validation.constraints.NotBlank + +data class PostMemberSignupRequest( + + @field:NotBlank(message = "λ‹‰λ„€μž„μ€ 곡백일 수 μ—†μŠ΅λ‹ˆλ‹€.") + val nickname: String, + + @field:ValidEmail + val email: String, + + @field:ValidPassword + val password: String, + +) { + + fun toDomainDto(): MemberSignupRequest { + return MemberSignupRequest( + nickname = this.nickname, + email = this.email, + password = this.password, + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/response/PostMemberSigninResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/response/PostMemberSigninResponse.kt new file mode 100644 index 0000000..3ef217c --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/response/PostMemberSigninResponse.kt @@ -0,0 +1,16 @@ +package io.study.kotlinapiserver.api.domain.member.ui.dto.response + +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSigninResponse + +data class PostMemberSigninResponse( + val accessToken: String, + val refreshToken: String, +) { + companion object { + fun of(info: MemberSigninResponse): PostMemberSigninResponse { + return PostMemberSigninResponse( + accessToken = info.accessToken, + refreshToken = info.refreshToken,) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/response/PostMemberSignupResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/response/PostMemberSignupResponse.kt new file mode 100644 index 0000000..bf18a9b --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/api/domain/member/ui/dto/response/PostMemberSignupResponse.kt @@ -0,0 +1,15 @@ +package io.study.kotlinapiserver.api.domain.member.ui.dto.response + +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSignupResponse + +data class PostMemberSignupResponse( + val email: String, +) { + companion object { + fun of(response: MemberSignupResponse): PostMemberSignupResponse { + return PostMemberSignupResponse( + email = response.email + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/infra/mail/MailService.kt b/src/main/kotlin/io/study/kotlinapiserver/infra/mail/MailService.kt new file mode 100644 index 0000000..00f2c11 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/infra/mail/MailService.kt @@ -0,0 +1,7 @@ +package io.study.kotlinapiserver.infra.mail + +interface MailService { + + fun sendMail(toEmailArray: Array, title: String, content: String) + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/infra/mail/infrastucture/JavaMailService.kt b/src/main/kotlin/io/study/kotlinapiserver/infra/mail/infrastucture/JavaMailService.kt new file mode 100644 index 0000000..1053efe --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/infra/mail/infrastucture/JavaMailService.kt @@ -0,0 +1,34 @@ +package io.study.kotlinapiserver.infra.mail.infrastucture + +import io.study.kotlinapiserver.infra.mail.MailService +import jakarta.mail.internet.InternetAddress +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.stereotype.Component + +@Component +class JavaMailService( + + private val mailSender: JavaMailSender, + +) : MailService { + + companion object { + const val UTF_8 = "UTF-8" + const val FROM_EMAIL = "noreply.yoonseon3@gmail.com" + const val FROM_NAME = "μ΄μœ€μ„ " + } + + override fun sendMail(toEmailArray: Array, title: String, content: String) { + val message = mailSender.createMimeMessage() + + val messageHelper = MimeMessageHelper(message, true, UTF_8) + messageHelper.setTo(toEmailArray.map { InternetAddress(it) }.toTypedArray()) + messageHelper.setFrom(InternetAddress(FROM_EMAIL, FROM_NAME, UTF_8)); + messageHelper.setSubject(title) + messageHelper.setText(content, true) + + mailSender.send(message) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/annotation/OnlyOwnerAllowed.kt b/src/main/kotlin/io/study/kotlinapiserver/web/annotation/OnlyOwnerAllowed.kt new file mode 100644 index 0000000..52b030f --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/annotation/OnlyOwnerAllowed.kt @@ -0,0 +1,6 @@ +package io.study.kotlinapiserver.web.annotation + + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class OnlyOwnerAllowed diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/annotation/ValidEmail.kt b/src/main/kotlin/io/study/kotlinapiserver/web/annotation/ValidEmail.kt new file mode 100644 index 0000000..31fbf1c --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/annotation/ValidEmail.kt @@ -0,0 +1,25 @@ +package io.study.kotlinapiserver.web.annotation + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import kotlin.reflect.KClass + +@Constraint(validatedBy = []) +@Target(allowedTargets = [AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER]) +@Retention(AnnotationRetention.RUNTIME) +@Pattern( + regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9]+(\\.[a-zA-Z]{2,})+$", + message = "{valid-messages.invalid-email}", +) +@NotBlank +annotation class ValidEmail( + + val message: String = "Invalid email format", + + val groups: Array> = [], + + val payload: Array> = [], + +) diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/annotation/ValidPassword.kt b/src/main/kotlin/io/study/kotlinapiserver/web/annotation/ValidPassword.kt new file mode 100644 index 0000000..4b66f67 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/annotation/ValidPassword.kt @@ -0,0 +1,25 @@ +package io.study.kotlinapiserver.web.annotation + +import jakarta.validation.Constraint +import jakarta.validation.Payload +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import kotlin.reflect.KClass + +@Constraint(validatedBy = []) +@Target(allowedTargets = [AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER]) +@Retention(AnnotationRetention.RUNTIME) +@Pattern( + regexp = "^(?=.*[!@#$%^&*()\\-_=+])[a-zA-Z0-9!@#$%^&*()\\-_=+]{8,16}$", + message = "{valid-messages.invalid-password}", +) +@NotBlank +annotation class ValidPassword( + + val message: String = "Invalid email format", + + val groups: Array> = [], + + val payload: Array> = [], + +) diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/aop/OnlyOwnerAllowedAspect.kt b/src/main/kotlin/io/study/kotlinapiserver/web/aop/OnlyOwnerAllowedAspect.kt new file mode 100644 index 0000000..c9bcfb6 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/aop/OnlyOwnerAllowedAspect.kt @@ -0,0 +1,44 @@ +package io.study.kotlinapiserver.web.aop + +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.web.exception.ApiException +import io.study.kotlinapiserver.web.exception.error.AuthErrorCode +import io.study.kotlinapiserver.web.exception.error.MemberErrorCode +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.annotation.Before +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component + +@Aspect +@Component +class OnlyOwnerAllowedAspect( + + private val memberQueryRepository: MemberQueryRepository, + +) { + + @Before("@annotation(io.study.kotlinapiserver.web.annotation.OnlyOwnerAllowed) && args(memberId)") + fun beforeFunctionWithOnlyOwnerAllowed( + memberId: Long + ) { + + println("AOP beforeFunctionWithOnlyOwnerAllowed called with memberId: $memberId") + + val loginEmail = SecurityContextHolder.getContext().authentication.name + + val findMember = memberQueryRepository.findById(memberId) + ?: throw ApiException(MemberErrorCode.NOT_FOUND_MEMBER) + + validateSelfEmail(loginEmail, findMember.email) + } + + private fun validateSelfEmail( + loginEmail: String, + findMemberEmail: String, + ) { + if (loginEmail != findMemberEmail) { + throw ApiException(AuthErrorCode.FORBIDDEN) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/BaseController.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/BaseController.kt new file mode 100644 index 0000000..1a0d780 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/BaseController.kt @@ -0,0 +1,10 @@ +package io.study.kotlinapiserver.web.base + +import org.springframework.web.bind.annotation.RequestMapping + +@RequestMapping("/api") +open class BaseController() { + companion object { + const val X_API_VERSION: String = "x-api-version=v1" + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/entity/BaseEntity.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/entity/BaseEntity.kt new file mode 100644 index 0000000..1888b8c --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/entity/BaseEntity.kt @@ -0,0 +1,26 @@ +package io.study.kotlinapiserver.web.base.entity + +import jakarta.persistence.* +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="id", nullable = false) + val id: Long? = null + + @CreatedDate + @Column(name="created_date", columnDefinition = "datetime", nullable = false, updatable = false) + var createdDate: LocalDateTime? = null + + @LastModifiedDate + @Column(name="modified_date", columnDefinition = "datetime", nullable = false) + var modifiedDate: LocalDateTime? = null + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/event/Events.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/event/Events.kt new file mode 100644 index 0000000..6359e15 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/event/Events.kt @@ -0,0 +1,15 @@ +package io.study.kotlinapiserver.web.base.event + +import org.springframework.context.ApplicationEventPublisher + +object Events { + private var eventPublisher: ApplicationEventPublisher? = null + + fun setPublisher(applicationEventPublisher: ApplicationEventPublisher) { + eventPublisher = applicationEventPublisher + } + + fun raise(event: Any) { + eventPublisher?.publishEvent(event) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/log/LoggerExtension.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/log/LoggerExtension.kt new file mode 100644 index 0000000..7e6f143 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/log/LoggerExtension.kt @@ -0,0 +1,5 @@ +package io.study.kotlinapiserver.web.base.log + +import org.slf4j.LoggerFactory + +inline fun T.logger() = LoggerFactory.getLogger(T::class.java)!! \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/response/BaseResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/response/BaseResponse.kt new file mode 100644 index 0000000..b63871e --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/response/BaseResponse.kt @@ -0,0 +1,5 @@ +package io.study.kotlinapiserver.web.base.response + +open class BaseResponse( + val message: String = "success", +) \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/response/ErrorResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/response/ErrorResponse.kt new file mode 100644 index 0000000..b103799 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/response/ErrorResponse.kt @@ -0,0 +1,23 @@ +package io.study.kotlinapiserver.web.base.response + +import io.study.kotlinapiserver.web.exception.error.ErrorCode + +class ErrorResponse( + val status: Int, + val message: String, +) { + companion object { + fun of(errorCode: ErrorCode): ErrorResponse { + return ErrorResponse( + errorCode.getHttpStatus().value(), + errorCode.getDescription(), + ) + } + + fun of(status: Int, message: String?): ErrorResponse { + return ErrorResponse(status, + message ?: "μœ νš¨μ„± 검증에 ν†΅κ³Όν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.", + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/base/response/SuccessResponse.kt b/src/main/kotlin/io/study/kotlinapiserver/web/base/response/SuccessResponse.kt new file mode 100644 index 0000000..07cccc7 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/base/response/SuccessResponse.kt @@ -0,0 +1,16 @@ +package io.study.kotlinapiserver.web.base.response + +import com.fasterxml.jackson.annotation.JsonPropertyOrder + +@JsonPropertyOrder("message", "data") +class SuccessResponse( + val data: T, +) : BaseResponse() { + + companion object { + fun of(data: T): SuccessResponse { + return SuccessResponse(data) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/config/AsyncConfig.kt b/src/main/kotlin/io/study/kotlinapiserver/web/config/AsyncConfig.kt new file mode 100644 index 0000000..5d85426 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/config/AsyncConfig.kt @@ -0,0 +1,45 @@ +package io.study.kotlinapiserver.web.config + +import io.study.kotlinapiserver.web.config.mdc.MdcTaskDecorator +import io.study.kotlinapiserver.web.exception.AsyncExceptionHandler +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.AsyncConfigurer +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor + +@Configuration +@EnableAsync +class AsyncConfig : AsyncConfigurer { + companion object { + const val CORE_POOL_SIZE = 10 + const val MAX_POOL_SIZE = 30 + const val QUEUE_CAPACITY = 100 + const val AWAIT_TERMINATION_SECONDS = 30 + const val THREAD_NAME_PREFIX = "executor-" + const val WAIT_TASK_COMPLETE = true + } + + @Bean + fun taskExecutor(): ThreadPoolTaskExecutor { + val executor = ThreadPoolTaskExecutor() + executor.corePoolSize = CORE_POOL_SIZE // λ™μ‹œμ— μ‹€ν–‰ ν•  κΈ°λ³Έ μŠ€λ ˆλ“œμ˜ 수λ₯Ό μ„€μ • + executor.maxPoolSize = MAX_POOL_SIZE // μŠ€λ ˆλ“œν’€μ˜ μ‚¬μš©ν•  수 μžˆλŠ” μ΅œλŒ€ μŠ€λ ˆλ“œ 수λ₯Ό μ„€μ • + executor.queueCapacity = QUEUE_CAPACITY // μŠ€λ ˆλ“œν’€ executor의 μž‘μ—… 큐의 크기λ₯Ό μ„€μ • + executor.setThreadNamePrefix(THREAD_NAME_PREFIX) // μŠ€λ ˆλ“œμ˜ 이름 μ„€μ • + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS) // μ΅œλŒ€ λŒ€κΈ° νƒ€μž„ 아웃 μ„€μ • + + // true μ„€μ •μ‹œ μ–΄ν”Œλ¦¬μΌ€μ΄μ…˜ μ’…λ£Œ μš”μ²­μ‹œ queue에 남아 μžˆλŠ” λͺ¨λ“  μž‘μ—…λ“€μ΄ μ™„λ£Œλ  λ•ŒκΉŒμ§€ κΈ°λ‹€λ¦° ν›„ μ’…λ£Œ + executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE); + + executor.setTaskDecorator(MdcTaskDecorator()) + executor.initialize() + + return executor + } + + override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler { + return AsyncExceptionHandler() + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/config/EventConfig.kt b/src/main/kotlin/io/study/kotlinapiserver/web/config/EventConfig.kt new file mode 100644 index 0000000..fefb7ef --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/config/EventConfig.kt @@ -0,0 +1,21 @@ +package io.study.kotlinapiserver.web.config + +import io.study.kotlinapiserver.web.base.event.Events +import org.springframework.beans.factory.InitializingBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class EventConfig( + private val publisher: ApplicationEventPublisher +) { + + @Bean + fun eventInitializer(): InitializingBean { + return InitializingBean { + Events.setPublisher(publisher) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/config/JpaAuditingConfig.kt b/src/main/kotlin/io/study/kotlinapiserver/web/config/JpaAuditingConfig.kt new file mode 100644 index 0000000..89eeee1 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/config/JpaAuditingConfig.kt @@ -0,0 +1,8 @@ +package io.study.kotlinapiserver.web.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaAuditingConfig \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/config/SecurityConfig.kt b/src/main/kotlin/io/study/kotlinapiserver/web/config/SecurityConfig.kt new file mode 100644 index 0000000..495baec --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/config/SecurityConfig.kt @@ -0,0 +1,69 @@ +package io.study.kotlinapiserver.web.config + +import io.study.kotlinapiserver.web.jwt.JwtAccessDeniedHandler +import io.study.kotlinapiserver.web.jwt.JwtAuthenticationEntryPoint +import io.study.kotlinapiserver.web.jwt.JwtProvider +import io.study.kotlinapiserver.web.jwt.JwtSecurityConfig +import org.springframework.boot.autoconfigure.security.servlet.PathRequest +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configurers.* +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint, + private val jwtAccessDeniedHandler: JwtAccessDeniedHandler, + private val jwtProvider: JwtProvider, +) { + companion object { + private val PERMIT_ALL_POSTS = arrayOf( + "/api/members", + "/api/members/signin", + "/api/members/reset-password" + ) + } + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + + http.csrf(CsrfConfigurer::disable) + .formLogin(FormLoginConfigurer::disable) + .httpBasic(HttpBasicConfigurer::disable) + + + http.sessionManagement { sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + + http.authorizeHttpRequests { authorize -> + authorize + .requestMatchers(HttpMethod.POST, *PERMIT_ALL_POSTS).permitAll() + .requestMatchers(PathRequest.toH2Console()).permitAll() + .anyRequest().authenticated() + } + + http.headers { header -> + header.frameOptions { frameOptions -> frameOptions.sameOrigin() } + } + + http.exceptionHandling {exception -> + exception.authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + } + + http.apply(JwtSecurityConfig(jwtProvider)) + + return http.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/config/mdc/MdcTaskDecorator.kt b/src/main/kotlin/io/study/kotlinapiserver/web/config/mdc/MdcTaskDecorator.kt new file mode 100644 index 0000000..0508ff4 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/config/mdc/MdcTaskDecorator.kt @@ -0,0 +1,17 @@ +package io.study.kotlinapiserver.web.config.mdc + + +import org.slf4j.MDC +import org.springframework.core.task.TaskDecorator + +class MdcTaskDecorator : TaskDecorator { + + override fun decorate(runnable: Runnable): Runnable { + val threadContext = MDC.getCopyOfContextMap() + return Runnable { + MDC.setContextMap(threadContext) + runnable.run() + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/config/properties/JwtProperties.kt b/src/main/kotlin/io/study/kotlinapiserver/web/config/properties/JwtProperties.kt new file mode 100644 index 0000000..62493c7 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/config/properties/JwtProperties.kt @@ -0,0 +1,12 @@ +package io.study.kotlinapiserver.web.config.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.bind.ConstructorBinding + + +@ConfigurationProperties(prefix = "jwt") +data class JwtProperties @ConstructorBinding constructor( + val secret: String, + val accessTokenExpireTime: Long, + val refreshTokenExpireTime: Long, +) \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/ApiException.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/ApiException.kt new file mode 100644 index 0000000..4d5d3ef --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/ApiException.kt @@ -0,0 +1,7 @@ +package io.study.kotlinapiserver.web.exception + +import io.study.kotlinapiserver.web.exception.error.ErrorCode + +class ApiException( + val errorCode: ErrorCode, +) : RuntimeException(errorCode.getDescription()) \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/AsyncExceptionHandler.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/AsyncExceptionHandler.kt new file mode 100644 index 0000000..1edec41 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/AsyncExceptionHandler.kt @@ -0,0 +1,15 @@ +package io.study.kotlinapiserver.web.exception + +import io.study.kotlinapiserver.web.base.log.logger +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler +import java.lang.reflect.Method + +class AsyncExceptionHandler : AsyncUncaughtExceptionHandler { + + private val log = logger() + + override fun handleUncaughtException(ex: Throwable, method: Method, vararg params: Any?) { + log.error("AsyncExceptionHandler handleUncaughtException !!", ex) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/GlobalExceptionHandler.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..6784cbb --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/GlobalExceptionHandler.kt @@ -0,0 +1,55 @@ +package io.study.kotlinapiserver.web.exception + +import io.study.kotlinapiserver.web.base.log.logger +import io.study.kotlinapiserver.web.base.response.ErrorResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log = logger() + + @ExceptionHandler(ApiException::class) + fun handleApiException(e: ApiException): ResponseEntity { + return ResponseEntity.status(e.errorCode.getHttpStatus()) + .body(ErrorResponse.of(e.errorCode)) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handlerMethodArgumentNotValidException( + e: MethodArgumentNotValidException + ): ResponseEntity { + return ResponseEntity.status(e.statusCode) + .body(ErrorResponse.of(e.statusCode.value(), e.bindingResult.fieldError?.defaultMessage)) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handlerHttpMessageNotReadableException( + e: HttpMessageNotReadableException + ): ResponseEntity { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(HttpStatus.BAD_REQUEST.value(), "μš”μ²­μ •λ³΄κ°€ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")) + } + + @ExceptionHandler(Exception::class) + fun handlerException( + e: Exception + ): ResponseEntity { + // todo : + // MemberDetailsServiceμ—μ„œ λ˜μ§€λŠ” ApiExceptionκ°€ ν•΄λ‹Ή λ©”μ„œλ“œλ‘œ λ˜μ Έμ§„λ‹€. + // 디버깅을 λκΉŒμ§€ ν•΄λ΄€μœΌλ‚˜ 원인을 μ°Ύμ§€ λͺ»ν•΄μ„œ μž„μ‹œλ‘œ λΆ„κΈ°μ²˜λ¦¬ν•¨. + if (e.cause is ApiException) { + val apiException = e.cause as ApiException + return handleApiException(apiException) + } + + log.error(e.message, e) + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR.value()) + .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR.value(), "μ„œλ²„ μ—λŸ¬ μž…λ‹ˆλ‹€.")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/AuthErrorCode.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/AuthErrorCode.kt new file mode 100644 index 0000000..a019641 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/AuthErrorCode.kt @@ -0,0 +1,32 @@ +package io.study.kotlinapiserver.web.exception.error + +import org.springframework.http.HttpStatus + +enum class AuthErrorCode( + + private val httpStatus: HttpStatus, + private val message: String, + +) : ErrorCode { + + INVALID_JWT_SIGNATURE(HttpStatus.BAD_REQUEST, "잘λͺ»λœ μ„œλͺ…μž…λ‹ˆλ‹€."), + EXPIRED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 ν† ν°μž…λ‹ˆλ‹€."), + UNSUPPORTED_JWT_TOKEN(HttpStatus.BAD_REQUEST, "μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν† ν°μž…λ‹ˆλ‹€."), + INVALID_JWT_TOKEN(HttpStatus.BAD_REQUEST, "잘λͺ»λœ ν† ν°μž…λ‹ˆλ‹€."), + JWT_UNKNOWN_ERROR(HttpStatus.BAD_REQUEST, "토큰 처리 쀑 μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), + + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "μœ νš¨ν•˜μ§€ μ•Šμ€ 자격증λͺ… μž…λ‹ˆλ‹€."), + FORBIDDEN(HttpStatus.FORBIDDEN, "κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + + INVALID_ACCOUNT(HttpStatus.UNAUTHORIZED, "μœ νš¨ν•˜μ§€ μ•Šμ€ 둜그인 μ •λ³΄μž…λ‹ˆλ‹€."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") + ; + + override fun getHttpStatus(): HttpStatus { + return httpStatus + } + + override fun getDescription(): String { + return message + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/ErrorCode.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/ErrorCode.kt new file mode 100644 index 0000000..27cf31a --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/ErrorCode.kt @@ -0,0 +1,15 @@ +package io.study.kotlinapiserver.web.exception.error + +import org.springframework.http.HttpStatus + +interface ErrorCode { + + fun getHttpStatus(): HttpStatus + + fun getDescription(): String + + companion object { + val serverErrorMessage: String = "μ„œλ²„μ—μ„œ μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/MemberErrorCode.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/MemberErrorCode.kt new file mode 100644 index 0000000..46a19bc --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/MemberErrorCode.kt @@ -0,0 +1,23 @@ +package io.study.kotlinapiserver.web.exception.error + +import org.springframework.http.HttpStatus + +enum class MemberErrorCode( + private val httpStatus: HttpStatus, + private val message: String, +): ErrorCode { + + NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, "νšŒμ› 정보가 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + CONFLICT_DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "μ€‘λ³΅λœ λ‹‰λ„€μž„μ΄ μ‘΄μž¬ν•©λ‹ˆλ‹€."), + CONFLICT_DUPLICATE_EMAIL(HttpStatus.CONFLICT, "μ€‘λ³΅λœ 이메일이 μ‘΄μž¬ν•©λ‹ˆλ‹€.") + ; + + override fun getHttpStatus(): HttpStatus { + return httpStatus + } + + override fun getDescription(): String { + return message + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/ServerErrorCode.kt b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/ServerErrorCode.kt new file mode 100644 index 0000000..519f7f1 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/exception/error/ServerErrorCode.kt @@ -0,0 +1,20 @@ +package io.study.kotlinapiserver.web.exception.error + +import org.springframework.http.HttpStatus + +enum class ServerErrorCode( + private val httpStatus: HttpStatus, + private val description: String, +) : ErrorCode { + + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "μ„œλ²„μ—μ„œ μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.") + ; + + override fun getHttpStatus(): HttpStatus { + return httpStatus + } + + override fun getDescription(): String { + return description + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtAccessDeniedHandler.kt b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtAccessDeniedHandler.kt new file mode 100644 index 0000000..d274f08 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtAccessDeniedHandler.kt @@ -0,0 +1,38 @@ +package io.study.kotlinapiserver.web.jwt + +import com.fasterxml.jackson.databind.ObjectMapper +import io.study.kotlinapiserver.web.base.response.ErrorResponse +import io.study.kotlinapiserver.web.exception.error.AuthErrorCode +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + +@Component +class JwtAccessDeniedHandler : AccessDeniedHandler { + + private val objectMapper = ObjectMapper() + + override fun handle( + request: HttpServletRequest?, + response: HttpServletResponse?, + accessDeniedException: AccessDeniedException? + ) { + responseBuilder(response!!, AuthErrorCode.FORBIDDEN) + } + + private fun responseBuilder(response: HttpServletResponse, authErrorCode: AuthErrorCode) { + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print( + objectMapper.writeValueAsString( + ErrorResponse.of(authErrorCode) + ) + ) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtAuthenticationEntryPoint.kt b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtAuthenticationEntryPoint.kt new file mode 100644 index 0000000..82195c2 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtAuthenticationEntryPoint.kt @@ -0,0 +1,50 @@ +package io.study.kotlinapiserver.web.jwt + +import com.fasterxml.jackson.databind.ObjectMapper +import io.study.kotlinapiserver.web.base.response.ErrorResponse +import io.study.kotlinapiserver.web.exception.error.AuthErrorCode +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class JwtAuthenticationEntryPoint : AuthenticationEntryPoint { + + companion object { + private const val ATTRIBUTE = "token_exception" + } + + private val objectMapper = ObjectMapper() + + override fun commence( + request: HttpServletRequest?, + response: HttpServletResponse?, + authException: AuthenticationException? + ) { + + val attribute = request!!.getAttribute(ATTRIBUTE) as? AuthErrorCode + + when (attribute) { + AuthErrorCode.INVALID_JWT_SIGNATURE, + AuthErrorCode.EXPIRED_JWT_TOKEN, + AuthErrorCode.UNSUPPORTED_JWT_TOKEN, + AuthErrorCode.INVALID_JWT_TOKEN, + AuthErrorCode.JWT_UNKNOWN_ERROR -> response?.let { responseBuilder(it, attribute!!) } + else -> response?.let { responseBuilder(it, AuthErrorCode.FORBIDDEN) } + } + } + + private fun responseBuilder(response: HttpServletResponse, authErrorCode: AuthErrorCode) { + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print( + objectMapper.writeValueAsString( + ErrorResponse.of(authErrorCode) + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtFilter.kt b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtFilter.kt new file mode 100644 index 0000000..c1a51f2 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtFilter.kt @@ -0,0 +1,94 @@ +package io.study.kotlinapiserver.web.jwt + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.security.SecurityException +import io.study.kotlinapiserver.web.base.log.logger +import io.study.kotlinapiserver.web.exception.error.AuthErrorCode +import io.study.kotlinapiserver.web.exception.error.ServerErrorCode +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.util.StringUtils +import org.springframework.web.filter.OncePerRequestFilter + +class JwtFilter( + private val jwtProvider: JwtProvider +) : OncePerRequestFilter() { + + private val log = logger() + + companion object { + const val ATTRIBUTE = "token_exception" + const val TOKEN_PREFIX = "Bearer" + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val jwt = resolveToken(request) + + if (StringUtils.hasText(jwt) && validationToken(request, jwt)) { + val authentication = jwtProvider.getAuthentication(jwt) + log.info("인증정보 μ €μž₯ {}", authentication) + SecurityContextHolder.getContext().authentication = authentication + } + + filterChain.doFilter(request, response) + } + + private fun validationToken(request: HttpServletRequest, token: String): Boolean { + try { + return jwtProvider.validateAccessToken(token) + } catch (e : Exception ) { + when (e) { + is SecurityException, is MalformedJwtException -> { + log.info("잘λͺ»λœ JWT μ„œλͺ…μž…λ‹ˆλ‹€.") + request.setAttribute(ATTRIBUTE, AuthErrorCode.INVALID_JWT_SIGNATURE) + } + is ExpiredJwtException -> { + log.info("만료된 JWT ν† ν°μž…λ‹ˆλ‹€.") + request.setAttribute(ATTRIBUTE, AuthErrorCode.EXPIRED_JWT_TOKEN); + } + is UnsupportedJwtException -> { + log.info("μ§€μ›λ˜μ§€ μ•ŠλŠ” JWT ν† ν°μž…λ‹ˆλ‹€.") + request.setAttribute(ATTRIBUTE, AuthErrorCode.UNSUPPORTED_JWT_TOKEN) + } + is IllegalArgumentException -> { + log.error("JWT 토큰이 잘λͺ»λ˜μ—ˆμŠ΅λ‹ˆλ‹€.") + request.setAttribute(ATTRIBUTE, AuthErrorCode.INVALID_JWT_TOKEN) + } + else -> { + log.error("================================================") + log.error("JwtFilter - doFilterInternal() 였λ₯˜λ°œμƒ") + log.error("token : {}", token) + log.error("Exception Message : {}", e.message) + log.error("Exception StackTrace : {") + e.printStackTrace() + log.error("}") + log.error("================================================") + request.setAttribute(ATTRIBUTE, ServerErrorCode.SERVER_ERROR) + } + } + } + return false + } + + private fun resolveToken(request: HttpServletRequest): String { + val bearerToken: String? = request.getHeader(HttpHeaders.AUTHORIZATION) + bearerToken.let { + if (isExistBearer(bearerToken)) { + return bearerToken!!.substring(TOKEN_PREFIX.length).trim() + } + } + return "" + } + + private fun isExistBearer(bearerToken: String?) = + StringUtils.hasText(bearerToken) && bearerToken!!.startsWith(TOKEN_PREFIX) +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtProvider.kt b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtProvider.kt new file mode 100644 index 0000000..4fd8434 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtProvider.kt @@ -0,0 +1,103 @@ +package io.study.kotlinapiserver.web.jwt + +import io.jsonwebtoken.* +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import io.study.kotlinapiserver.web.config.properties.JwtProperties +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.stereotype.Component +import java.security.Key +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + + +@Component +class JwtProvider( + private val jwtProperties: JwtProperties +) { + + private val key: Key + + init { + val keyBytes = Decoders.BASE64.decode(jwtProperties.secret) + this.key = Keys.hmacShaKeyFor(keyBytes) + } + + companion object { + private const val AUTHORITIES_KEY = "auth" + } + + fun createToken(authentication: Authentication): TokenInfo { + val authirities = authentication.authorities + .map { it.authority } + .joinToString(", ") + + val accessToken = createAccessToken(authentication.name, authirities) + val refreshToken = createRefreshToken(authentication.name) + + return TokenInfo(accessToken, refreshToken) + } + + private fun createAccessToken(sub: String, authorities: String): String { + return Jwts.builder() + .setSubject(sub) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(getExpirationTime(jwtProperties.accessTokenExpireTime)) + .signWith(key, SignatureAlgorithm.HS512) + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .compact() + } + + private fun createRefreshToken(sub: String): String { + return Jwts.builder() + .setSubject(sub) + .setExpiration(getExpirationTime(jwtProperties.refreshTokenExpireTime)) + .signWith(key, SignatureAlgorithm.HS512) + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .compact() + } + + private fun getExpirationTime(tokenExpireTime: Long) : Date { + val localDateTime = LocalDateTime.now() + .plusSeconds(tokenExpireTime) + val toInstant = localDateTime.atZone(ZoneId.systemDefault()).toInstant() + + return Date.from(toInstant) + } + + fun validateAccessToken(token: String): Boolean { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + + return true + } + + fun getAuthentication(token: String): Authentication { + val claims = parseClaims(token) + val authorities = claims[AUTHORITIES_KEY].toString().split(",") + .map { SimpleGrantedAuthority(it)} + .toList() + + val principal = User(claims.subject, "", authorities) + + return UsernamePasswordAuthenticationToken(principal, token, authorities) + } + + private fun parseClaims(accessToken: String): Claims { + return try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .body + } catch (e: ExpiredJwtException) { + e.claims + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtSecurityConfig.kt b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtSecurityConfig.kt new file mode 100644 index 0000000..4d5c3a2 --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/JwtSecurityConfig.kt @@ -0,0 +1,19 @@ +package io.study.kotlinapiserver.web.jwt + +import org.springframework.security.config.annotation.SecurityConfigurer +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +class JwtSecurityConfig( + private val jwtProvider: JwtProvider +) : SecurityConfigurer { + + override fun init(builder: HttpSecurity?) {} + + override fun configure(http: HttpSecurity?) { + http?.addFilterBefore(JwtFilter(jwtProvider), + UsernamePasswordAuthenticationFilter::class.java) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/study/kotlinapiserver/web/jwt/TokenInfo.kt b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/TokenInfo.kt new file mode 100644 index 0000000..1dad53c --- /dev/null +++ b/src/main/kotlin/io/study/kotlinapiserver/web/jwt/TokenInfo.kt @@ -0,0 +1,10 @@ +package io.study.kotlinapiserver.web.jwt + +class TokenInfo( + val accessToken: String, + val refreshToken: String +) { + companion object { + fun of(accessToken: String, refreshToken: String) = TokenInfo(accessToken, refreshToken) + } +} \ No newline at end of file diff --git a/src/main/resources/application-jwt.yml b/src/main/resources/application-jwt.yml new file mode 100644 index 0000000..de64fd4 --- /dev/null +++ b/src/main/resources/application-jwt.yml @@ -0,0 +1,4 @@ +jwt: + secret: c3ByaW5nLWJvb3QtbGF5ZXJlZC1hcHBsaWNhdGlvbi1zYW1wbGUtMjAyNDA2MTQyMDMwLWp3dC10b2tlbi1oczUxMg== + access-token-expire-time: 1_800 # 30λΆ„ + refresh-token-expire-time: 604_800 # 7μ£Ό \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 12575c1..3061f5b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,10 @@ spring: + config: + import: + - optional:classpath:/application-jwt.yml + - optional:classpath:/application-mail.yml + h2: console: enabled: true diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..19bcb51 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,3 @@ +valid-messages.invalid-email = \uC774\uBA54\uC77C \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. +valid-messages.invalid-password = \uBE44\uBC00\uBC88\uD638 \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.(\uD2B9\uC218\uBB38\uC790, \uC601\uBB38\uC790\uB97C \uD3EC\uD568\uD55C 8\uC790\uB9AC\uC774\uC0C1 16\uC790\uB9AC \uC774\uD558) + diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberInfoServiceTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberInfoServiceTest.kt new file mode 100644 index 0000000..97d4664 --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberInfoServiceTest.kt @@ -0,0 +1,47 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.MemberDomainService +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberInfoResponse +import io.study.kotlinapiserver.api.domain.member.domain.entity.authority.AuthorityType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.* +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class MemberInfoServiceTest { + + @InjectMocks + private lateinit var memberInfoService: MemberInfoService + + @Mock + private lateinit var memberDomainService: MemberDomainService + + @Test + @DisplayName("idκ°€ μ£Όμ–΄μ‘Œμ„ λ•Œ, νšŒμ› 정보λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun should_return_memberInfo_when_memberId_isProvided() { + // given + val memberId = 1L + val memberInfo = MemberInfoResponse( + "test@test.com", + "test", + listOf(AuthorityType.ROLE_USER) + ) + given(memberDomainService.getInfo(memberId)) + .willReturn(memberInfo) + + // when + val response = memberInfoService.getMemberInfo(memberId) + + // then + assertThat(response.email).isEqualTo(memberInfo.email) + assertThat(response.nickname).isEqualTo(memberInfo.nickname) + assertThat(response.roles).isEqualTo(memberInfo.roles) + then(memberDomainService).should(times(1)) + .getInfo(memberId) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberResetServiceTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberResetServiceTest.kt new file mode 100644 index 0000000..7da2c94 --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberResetServiceTest.kt @@ -0,0 +1,36 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.MemberDomainService +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberResetPasswordRequest +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.* +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class MemberResetServiceTest{ + + @InjectMocks + private lateinit var memberResetService: MemberResetService + + @Mock + private lateinit var memberDomainService: MemberDomainService + + @Test + @DisplayName("이메일이 μ£Όμ–΄μ‘Œμ„ λ•Œ, λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ ν•¨μˆ˜κ°€ 1회 μ‹€ν–‰ν•œλ‹€.") + fun should_execute_function_when_email_isProvided() { + // given + val request = MemberResetPasswordRequest("test@test.com") + willDoNothing().given(memberDomainService).resetPassword(org.mockito.kotlin.any()) + + // when + memberResetService.resetPassword(request) + + // then + then(memberDomainService).should(times(1)) + .resetPassword(org.mockito.kotlin.any()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSigninServiceTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSigninServiceTest.kt new file mode 100644 index 0000000..95bf923 --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSigninServiceTest.kt @@ -0,0 +1,55 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSigninRequest +import io.study.kotlinapiserver.web.jwt.JwtProvider +import io.study.kotlinapiserver.web.jwt.TokenInfo +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.* +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.core.Authentication + +@ExtendWith(MockitoExtension::class) +class MemberSigninServiceTest { + + @InjectMocks + private lateinit var memberSigninService: MemberSigninService + + @Mock + private lateinit var jwtProvider: JwtProvider + + @Mock + private lateinit var authenticationManager: AuthenticationManager + + @Mock + private lateinit var authenticationManagerBuilder: AuthenticationManagerBuilder + + @Test + @DisplayName("μ˜¬λ°”λ₯Έ 둜그인 정보가 μ£Όμ–΄μ‘Œμ„λ•Œ, 토큰 정보λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun should_return_tokenInfo_when_validLoginInfo_isProvided() { + // given + val loginInfo = MemberSigninRequest("test@test.com", "test123!",) + val token = TokenInfo("accessToken", "refreshToken") + + val authentication = mock(Authentication::class.java) + given(authenticationManagerBuilder.`object`).willReturn(authenticationManager) + given(authenticationManager.authenticate(any())).willReturn(authentication) + given(jwtProvider.createToken(authentication)).willReturn(token) + + // when + val response = memberSigninService.signin(loginInfo) + + // then + assertThat(response.accessToken).isEqualTo(token.accessToken) + assertThat(response.refreshToken).isEqualTo(token.refreshToken) + then(jwtProvider).should(times(1)) + .createToken(authentication) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSignupServiceTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSignupServiceTest.kt new file mode 100644 index 0000000..35c4ed7 --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/application/MemberSignupServiceTest.kt @@ -0,0 +1,42 @@ +package io.study.kotlinapiserver.api.domain.member.application + +import io.study.kotlinapiserver.api.domain.member.domain.MemberDomainService +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSignupResponse +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.* +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class MemberSignupServiceTest { + + @InjectMocks + private lateinit var memberSignupService: MemberSignupService + + @Mock + private lateinit var memberDomainService: MemberDomainService + + @Test + @DisplayName("μ˜¬λ°”λ₯Έ νšŒμ›κ°€μž… 정보가 μ£Όμ–΄μ‘Œμ„λ•Œ, νšŒμ›κ°€μž… 정보λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun should_return_signupInfo_when_validSignupInfo_isProvided() { + // given + val request = MemberSignupRequest("test", "test@test.com","Password!!!") + val signupInfo = MemberSignupResponse(request.email) + + given(memberDomainService.register(request)) + .willReturn(signupInfo) + + // when + val response = memberSignupService.signup(request) + + // then + Assertions.assertThat(request.email).isEqualTo(response.email) + then(memberDomainService).should(times(1)) + .register(request) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainServiceImplTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainServiceImplTest.kt new file mode 100644 index 0000000..7046d38 --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/domain/MemberDomainServiceImplTest.kt @@ -0,0 +1,82 @@ +package io.study.kotlinapiserver.api.domain.member.domain + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import io.study.kotlinapiserver.api.domain.member.domain.entity.authority.AuthorityType +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberRepository +import io.study.kotlinapiserver.api.domain.member.domain.validation.MemberValidator +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.* +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.security.crypto.password.PasswordEncoder + +@ExtendWith(MockitoExtension::class) +class MemberDomainServiceImplTest { + + @InjectMocks + private lateinit var memberDomainServiceImpl: MemberDomainServiceImpl + @Mock + private lateinit var memberRepository: MemberRepository + @Mock + private lateinit var memberQueryRepository: MemberQueryRepository + @Mock + private lateinit var memberValidator: MemberValidator + @Mock + private lateinit var passwordEncoder: PasswordEncoder + + @Test + @DisplayName("νšŒμ› 정보가 μ£Όμ–΄μ‘Œμ„ λ•Œ, νšŒμ›λ“±λ‘ ν›„ λ“±λ‘λœ νšŒμ›μ •λ³΄λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun should_return_savedMemberInfo_when_memberInfo_isProvided() { + // given + val request = MemberSignupRequest("test", "test@test.com","Password!!!") + val encodePassword = "ABCDE" + val member = Member.createBasicMember(request.email, request.nickname, request.password) + + given(passwordEncoder.encode(request.password)).willReturn(encodePassword) + given(memberRepository.save(org.mockito.kotlin.any())).willReturn(member) + + // when + val response = memberDomainServiceImpl.register(request) + + // then + assertThat(response.email).isEqualTo(request.email) + then(memberValidator).should(times(1)) + .signinValidate(request) + then(memberRepository).should(times(1)) + .save(org.mockito.kotlin.any()) + then(passwordEncoder).should(times(1)) + .encode(member.password.value) + } + + @Test + @DisplayName("νšŒμ› idκ°€ μ£Όμ–΄μ‘Œμ„ λ•Œ, νšŒμ› 정보λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun should_return_memberInfo_when_memberId_isNotProvided() { + // given + val memberId = 1L + val member = Member.createBasicMember( + email = "test@test.com", + nickname = "test", + password = "password" + ) + + given(memberQueryRepository.findByIdWithAuthorities(memberId)) + .willReturn(member) + + // when + val response = memberDomainServiceImpl.getInfo(memberId) + + // then + assertThat(response.email).isEqualTo(response.email) + assertThat(response.nickname).isEqualTo(response.nickname) + assertThat(response.roles).hasSize(1) + assertThat(response.roles[0]).isEqualTo(AuthorityType.ROLE_USER) + then(memberQueryRepository).should(times(1)) + .findByIdWithAuthorities(memberId) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/domain/validation/MemberValidatorTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/domain/validation/MemberValidatorTest.kt new file mode 100644 index 0000000..2593e2b --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/domain/validation/MemberValidatorTest.kt @@ -0,0 +1,57 @@ +package io.study.kotlinapiserver.api.domain.member.domain.validation + +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.web.exception.ApiException +import io.study.kotlinapiserver.web.exception.error.MemberErrorCode +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class MemberValidatorTest { + + @InjectMocks + lateinit var memberValidator: MemberValidator + + @Mock + lateinit var memberQueryRepository: MemberQueryRepository + + @Test + @DisplayName("쀑볡 이메일 검증 ν…ŒμŠ€νŠΈ") + fun emailDuplicateValidationTest() { + // given + var request = MemberSignupRequest("","이메일","") + given(memberQueryRepository.existsByEmail(request.email)) + .willReturn(true) + + // when & then + val exception = assertThrows { + memberValidator.signinValidate(request) + } + assertThat(exception.errorCode) + .isEqualTo(MemberErrorCode.CONFLICT_DUPLICATE_EMAIL) + } + + @Test + @DisplayName("쀑볡 λ‹‰λ„€μž„ 검증 ν…ŒμŠ€νŠΈ") + fun nicknameDuplicateValidationTest() { + // given + var request = MemberSignupRequest("λ‹‰λ„€μž„","","") + given(memberQueryRepository.existsByNickname(request.nickname)) + .willReturn(true) + + // when & then + val exception = assertThrows { + memberValidator.signinValidate(request) + } + assertThat(exception.errorCode) + .isEqualTo(MemberErrorCode.CONFLICT_DUPLICATE_NICKNAME) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/ui/MemberControllerTest.kt b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/ui/MemberControllerTest.kt new file mode 100644 index 0000000..e54646f --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/api/domain/member/ui/MemberControllerTest.kt @@ -0,0 +1,138 @@ +package io.study.kotlinapiserver.api.domain.member.ui + +import com.fasterxml.jackson.databind.ObjectMapper +import io.study.kotlinapiserver.api.domain.member.application.MemberInfoService +import io.study.kotlinapiserver.api.domain.member.application.MemberResetService +import io.study.kotlinapiserver.api.domain.member.application.MemberSigninService +import io.study.kotlinapiserver.api.domain.member.application.MemberSignupService +import io.study.kotlinapiserver.api.domain.member.domain.dto.request.MemberSignupRequest +import io.study.kotlinapiserver.api.domain.member.domain.dto.response.MemberSignupResponse +import io.study.kotlinapiserver.api.domain.member.ui.dto.request.PostMemberSignupRequest +import io.study.kotlinapiserver.web.config.SecurityConfig +import io.study.kotlinapiserver.web.jwt.JwtAccessDeniedHandler +import io.study.kotlinapiserver.web.jwt.JwtAuthenticationEntryPoint +import io.study.kotlinapiserver.web.jwt.JwtProvider +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.MessageSource +import org.springframework.context.annotation.Import +import org.springframework.context.support.MessageSourceAccessor +import org.springframework.http.HttpStatus +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@Import(SecurityConfig::class) +@WebMvcTest(controllers = [MemberController::class]) +class MemberControllerTest { + + companion object { + private const val APPLICATION_JSON: String = "application/json" + private const val X_API_VERSION: String = "x-api-version" + private const val VERSION_V1: String = "v1" + private const val SUCCESS_MESSAGE: String = "success" + } + + @Autowired + private lateinit var mockMvc: MockMvc + + // MemberController μ£Όμž… 빈 + @MockBean + private lateinit var memberSignupService: MemberSignupService + @MockBean + private lateinit var memberSigninService: MemberSigninService + @MockBean + private lateinit var memberInfoService: MemberInfoService + @MockBean + private lateinit var memberResetService: MemberResetService + + // SecurityConfig μ£Όμž… 빈 + @MockBean + private lateinit var jwtAuthenticationEntryPoint: JwtAuthenticationEntryPoint + @MockBean + private lateinit var jwtAccessDeniedHandler: JwtAccessDeniedHandler + @MockBean + private lateinit var jwtProvider: JwtProvider + + @Autowired + private lateinit var messageSource: MessageSource + + private fun serializeToString(request: PostMemberSignupRequest): String = ObjectMapper().writeValueAsString(request) + + @Test + @DisplayName("잘λͺ»λœ 이메일 ν˜•μ‹μœΌλ‘œ νšŒμ›κ°€μž…ν–ˆμ„ λ•Œ, μ˜¬λ°”λ₯Έ μ—λŸ¬ λ©”μ‹œμ§€λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun shouldReturnCorrectErrorMessageWhenSignUpWithInvalidEmail() { + // given + val invalidEmail = "test@example..com" + val request = PostMemberSignupRequest("test_user", invalidEmail, "Password123!") + + // when + val resultActions = mockMvc.perform( + post("/api/members") + .contentType(APPLICATION_JSON) + .content(serializeToString(request)) + .header(X_API_VERSION, VERSION_V1) + ) + // then + val errorMessage = MessageSourceAccessor(messageSource) + .getMessage("valid-messages.invalid-email") + + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(jsonPath("$.message").value(errorMessage)) + } + + @Test + @DisplayName("잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έ ν˜•μ‹μœΌλ‘œ νšŒμ›κ°€μž…ν–ˆμ„ λ•Œ, μ˜¬λ°”λ₯Έ μ—λŸ¬ λ©”μ‹œμ§€λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun shouldReturnCorrectErrorMessageWhenSignUpWithInvalidPassword() { + // given + val invalidPassword = "12345678" + val request = PostMemberSignupRequest("test_user", "test@example.com", invalidPassword) + + // when + val resultActions = mockMvc.perform( + post("/api/members") + .contentType(APPLICATION_JSON) + .content(serializeToString(request)) + .header(X_API_VERSION, VERSION_V1) + ) + + // then + val errorMessage = MessageSourceAccessor(messageSource) + .getMessage("valid-messages.invalid-password") + + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(jsonPath("$.message").value(errorMessage)) + } + + @Test + @DisplayName("μ˜¬λ°”λ₯Έ ν˜•μ‹μ˜ λ°μ΄ν„°λ‘œ νšŒμ›κ°€μž…ν–ˆμ„ λ•Œ, 성곡 응닡 λ©”μ‹œμ§€λ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun shouldReturnCorrectSuccessMessageWhenSignUpWithValidPassword() { + // given + val validRequest = PostMemberSignupRequest("test_user", "test@example.com", "Password123!") + val mockResponse = MemberSignupResponse(validRequest.email) + given(memberSignupService.signup(MemberSignupRequest.of(validRequest))) + .willReturn(mockResponse) + + // when + val resultActions = mockMvc.perform( + post("/api/members") + .contentType(APPLICATION_JSON) + .content(serializeToString(validRequest)) + .header(X_API_VERSION, VERSION_V1) + ) + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.email").value(validRequest.email)) + .andExpect(jsonPath("$.message").value(SUCCESS_MESSAGE)) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/study/kotlinapiserver/web/aop/OnlyOwnerAllowedAspectTest.kt b/src/test/kotlin/io/study/kotlinapiserver/web/aop/OnlyOwnerAllowedAspectTest.kt new file mode 100644 index 0000000..03ab457 --- /dev/null +++ b/src/test/kotlin/io/study/kotlinapiserver/web/aop/OnlyOwnerAllowedAspectTest.kt @@ -0,0 +1,52 @@ +package io.study.kotlinapiserver.web.aop + +import io.study.kotlinapiserver.api.domain.member.domain.entity.Member +import io.study.kotlinapiserver.api.domain.member.domain.repository.MemberQueryRepository +import io.study.kotlinapiserver.web.exception.ApiException +import io.study.kotlinapiserver.web.exception.error.AuthErrorCode +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder + +@ExtendWith(MockitoExtension::class) +class OnlyOwnerAllowedAspectTest { + + @Mock + lateinit var memberQueryRepository: MemberQueryRepository + + @InjectMocks + lateinit var onlyOwnerAllowedAspect: OnlyOwnerAllowedAspect + + @Test + @DisplayName("μš”μ²­ 받은 memberId의 emailκ³Ό μ‹œνλ¦¬ν‹° μ»¨ν…μŠ€νŠΈ μ•ˆμ˜ email이 λ‹€λ₯΄λ©΄ μ˜ˆμ™Έλ₯Ό λ°˜ν™˜ν•œλ‹€.") + fun shouldReturnForbiddenExceptionWhenRequestingDifferentMemberId() { + // given + val myLoginEmail = "test@example.com" + val othersMemberId = 100L + val othersMemberEmail = "others@example.com" + val othersMember = Member.createBasicMember(othersMemberEmail, "test", "") + + SecurityContextHolder.getContext().authentication = mock(Authentication::class.java) + given(SecurityContextHolder.getContext().authentication.name) + .willReturn(myLoginEmail) + given(memberQueryRepository.findById(othersMemberId)) + .willReturn(othersMember) + + // when & then + val exception = assertThrows { + onlyOwnerAllowedAspect.beforeFunctionWithOnlyOwnerAllowed(othersMemberId) + } + assertThat(exception.errorCode) + .isEqualTo(AuthErrorCode.FORBIDDEN) + } + +} \ No newline at end of file