diff --git a/build.gradle.kts b/build.gradle.kts index 71d5484..010ad46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -62,6 +62,10 @@ project(":user-api") { implementation("io.springfox:springfox-boot-starter:3.0.0") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.security:spring-security-test") + implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + + implementation("org.springframework.boot:spring-boot-starter-security") runtimeOnly("com.h2database:h2") runtimeOnly("mysql:mysql-connector-java") } diff --git a/submodules/commons/src/main/kotlin/com/sns/commons/utils/Booleans.kt b/submodules/commons/src/main/kotlin/com/sns/commons/utils/Booleans.kt new file mode 100644 index 0000000..33c18e6 --- /dev/null +++ b/submodules/commons/src/main/kotlin/com/sns/commons/utils/Booleans.kt @@ -0,0 +1,15 @@ +package com.sns.commons.utils + +inline fun Boolean?.ifTrue(block: Boolean.() -> Unit): Boolean? { + if (this == true) { + block() + } + return this +} + +inline fun Boolean?.ifFalse(block: Boolean.() -> Unit): Boolean? { + if (this == true) { + block() + } + return this +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandService.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandService.kt new file mode 100644 index 0000000..3267fba --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandService.kt @@ -0,0 +1,35 @@ +package com.sns.user.component.authcode.application + +import com.sns.commons.utils.ifTrue +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.AuthCodeKey +import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.component.authcode.repositories.AuthCodeRepository +import com.sns.user.component.user.domains.User +import com.sns.user.core.infrastructures.mail.MailService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AuthCodeCommandService( + val authCodeRepository: AuthCodeRepository, + val mailService: MailService, +) { + + @Transactional + fun create(user: User): AuthCode { + val authCode = AuthCode.createSignUp(user.id) + authCodeRepository.save(authCode) + + mailService.sendSignUpAuthCodeMail(authCode.code, user.infoEmailAddress) + return authCode + } + + @Transactional + fun verify(userId: String, purpose: Purpose, code: String): Boolean { + val authCodeKey = AuthCodeKey(purpose, userId) + val authCode: AuthCode? = authCodeRepository.findByAuthCodeKey(authCodeKey) + return authCode?.isCorrect(userId, code, purpose) + .ifTrue { authCodeRepository.delete(authCodeKey) } ?: false + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/domain/AuthCode.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/domain/AuthCode.kt new file mode 100644 index 0000000..5c300e2 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/domain/AuthCode.kt @@ -0,0 +1,59 @@ +package com.sns.user.component.authcode.domain + +import kotlin.random.Random +import java.sql.ResultSet +import java.time.Instant +import javax.validation.constraints.NotBlank +import org.springframework.jdbc.core.RowMapper + +data class AuthCode( + @NotBlank + val purpose: Purpose, + @NotBlank + val userId: String, + @NotBlank + val code: String = (1..CODE_LENGTH) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString(""), + val createdAt: Instant = Instant.MIN +) { + + fun isCorrect(userId: String, code: String, purpose: Purpose): Boolean = + (this.userId == userId) and (this.code == code) and (this.purpose == purpose) + + companion object { + private const val CODE_LENGTH = 10; + private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + fun createSignUp(userId: String) = AuthCode(purpose = Purpose.SIGN_UP, userId = userId) + val MAPPER: RowMapper = AuthCodeRowMapper() + } +} + +data class AuthCodeKey( + @NotBlank + val purpose: Purpose, + @NotBlank + val userId: String, +) { + fun toMap(): MutableMap = mutableMapOf( + "userId" to userId, + "purpose" to purpose.name, + ) +} + +// purpose enum 매핑이 안되서 수동으로 작성함. 확인필요. +class AuthCodeRowMapper : RowMapper { + override fun mapRow(rs: ResultSet, rowNum: Int): AuthCode? { + return AuthCode( + purpose = Purpose.valueOf(rs.getString("purpose")), + userId = rs.getString("user_id"), + code = rs.getString("code"), + createdAt = Instant.ofEpochMilli(rs.getTimestamp("created_at").time), + ) + } +} + +enum class Purpose { + SIGN_UP +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepository.kt new file mode 100644 index 0000000..a0a41a2 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepository.kt @@ -0,0 +1,10 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.AuthCodeKey + +interface AuthCodeRepository { + fun save(authCode: AuthCode): AuthCode + fun findByAuthCodeKey(authCodeKey: AuthCodeKey): AuthCode? + fun delete(authCodeKey: AuthCodeKey) +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/DefaultAuthCodeRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/DefaultAuthCodeRepository.kt new file mode 100644 index 0000000..fdcb34f --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/DefaultAuthCodeRepository.kt @@ -0,0 +1,51 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.AuthCodeKey +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +class DefaultAuthCodeRepository( + val jdbcTemplate: NamedParameterJdbcTemplate, +) : AuthCodeRepository { + + override fun findByAuthCodeKey(authCodeKey: AuthCodeKey): AuthCode? = jdbcTemplate.queryForObject( + """ + SELECT user_id,`code`,created_at,purpose + FROM auth_code + WHERE user_id = :userId AND purpose = :purpose + LIMIT 1 + """.trimIndent(), + authCodeKey.toMap(), AuthCode.MAPPER, + ) + + @Transactional + override fun save(authCode: AuthCode): AuthCode { + jdbcTemplate.update( + """ + REPLACE INTO auth_code (user_id, `code`, created_at, purpose) + VALUES (:userId, :code, NOW(), :purpose) + """.trimIndent(), + mutableMapOf( + "userId" to authCode.userId, + "purpose" to authCode.purpose.name, + "code" to authCode.code, + ), + ) + return authCode + } + + @Transactional + override fun delete(authCodeKey: AuthCodeKey) { + jdbcTemplate.update( + """ + DELETE FROM auth_code + WHERE user_id = :userId AND purpose = :purpose + LIMIT 1 + """.trimIndent(), + authCodeKey.toMap(), + ) + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/application/UserCommandService.kt b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserCommandService.kt new file mode 100644 index 0000000..31f630a --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserCommandService.kt @@ -0,0 +1,40 @@ +package com.sns.user.component.user.application + +import com.sns.commons.service.EventPublisher +import com.sns.user.component.user.domains.User +import com.sns.user.component.user.repositories.UserRepository +import com.sns.user.core.exceptions.AlreadyExistException +import com.sns.user.core.exceptions.NoAuthorityException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserCommandService( + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder, + private val eventPublisher: EventPublisher +) { + + @Transactional + fun create(name: String, password: String, email: String): User { + userRepository.findById(email).ifPresent { throw AlreadyExistException() } + + val user = User.create(email, passwordEncoder.encode(password), name, email) { + eventPublisher.publish(it) + } + userRepository.save(user) + return user + } + + @Transactional + fun activate(userId: String) { + val user = userRepository.findByIdOrNull(userId) ?: throw NoAuthorityException() + + user.activate() { + eventPublisher.publish(it) + } + userRepository.save(user) + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt index 35ff64f..77909a7 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt @@ -10,4 +10,6 @@ class UserQueryService( private val userRepository: UserRepository ) { fun getById(id: String): User? = userRepository.findByIdOrNull(id) + + fun getByEmail(email: String): User? = userRepository.findByInfoEmailAddress(email).orElse(null) } diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt b/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt index 0fc32d5..a339681 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt @@ -1,5 +1,9 @@ package com.sns.user.component.user.domains +import com.sns.commons.DomainEvent +import com.sns.user.component.user.events.UserStatusChangedEvent +import com.sns.user.core.exceptions.AlreadyExistException +import java.sql.ResultSet import java.time.Instant import javax.validation.constraints.Max import javax.validation.constraints.NotBlank @@ -8,6 +12,7 @@ import org.springframework.data.annotation.Id import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.Transient import org.springframework.data.domain.Persistable +import org.springframework.jdbc.core.RowMapper data class User( @Id @@ -32,6 +37,9 @@ data class User( @LastModifiedDate var updatedAt: Instant = Instant.MIN, + + @NotBlank + var status: Status = Status.ACTIVATED ) : Persistable { @Transient private var new: Boolean = false @@ -39,20 +47,59 @@ data class User( override fun getId() = this.id override fun isNew() = new + fun activate(publish: (DomainEvent) -> Unit = { _ -> }) { + status.checkAlready(Status.ACTIVATED) + status = Status.ACTIVATED + publish(UserStatusChangedEvent(this)) + } + companion object { + val MAPPER: RowMapper = UserRowMapper() + fun create( id: String, password: String, name: String, - infoEmailAddress: String? = null + infoEmailAddress: String? = null, + publish: (DomainEvent) -> Unit = { _ -> } ): User { // TODO validation - return User( + val user = User( id = id, - password = password, // TODO encrypt + password = password, name = name, infoEmailAddress = infoEmailAddress ?: id, + status = Status.CREATED, ).apply { new = true } + + publish(UserStatusChangedEvent(user)) + + return user } } } + +// purpose enum 매핑이 안되서 수동으로 작성함. 확인필요. +class UserRowMapper : RowMapper { + override fun mapRow(rs: ResultSet, rowNum: Int): User? { + return User( + id = rs.getString("id"), + password = rs.getString("password"), + name = rs.getString("name"), + infoEmailAddress = rs.getString("info_email_address"), + status = Status.valueOf(rs.getString("status")), + createdAt = Instant.ofEpochMilli(rs.getTimestamp("created_at").time), + updatedAt = Instant.ofEpochMilli(rs.getTimestamp("updated_at").time), + ) + } +} + +enum class Status { + CREATED, + ACTIVATED; + // 비활 등등? + + fun checkAlready(status: Status) { + if (status == this) throw AlreadyExistException() + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/events/UserStatusChangedEvent.kt b/user-api/src/main/kotlin/com/sns/user/component/user/events/UserStatusChangedEvent.kt new file mode 100644 index 0000000..473a85f --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/events/UserStatusChangedEvent.kt @@ -0,0 +1,12 @@ +package com.sns.user.component.user.events + +import com.sns.commons.DomainEvent +import com.sns.user.component.user.domains.User +import com.sns.user.core.config.IntegrationConfig + +class UserStatusChangedEvent(val user: User) : DomainEvent { + override val eventId: String + get() = "$channel-$user.id-${System.currentTimeMillis()}" + + override val channel = IntegrationConfig.Channels.USER_STATUS +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/listeners/UserStatusListener.kt b/user-api/src/main/kotlin/com/sns/user/component/user/listeners/UserStatusListener.kt new file mode 100644 index 0000000..0ea789b --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/listeners/UserStatusListener.kt @@ -0,0 +1,18 @@ +package com.sns.user.component.user.listeners + +import com.sns.commons.annotation.CustomEventListener +import com.sns.user.component.authcode.application.AuthCodeCommandService +import com.sns.user.component.user.events.UserStatusChangedEvent + +@CustomEventListener +class UserStatusListener(val authCodeCommandService: AuthCodeCommandService) { + // 인증 전, 기초 가입만 마친 상태 + fun onCreated(createdEvent: UserStatusChangedEvent) { + val user = createdEvent.user + authCodeCommandService.create(user) + } + + fun onActivated(activatedEvent: UserStatusChangedEvent) { + // TODO 타임라인생성, 프로필생성,, + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt index 5c21564..d142174 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt @@ -1,9 +1,11 @@ package com.sns.user.component.user.repositories +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @Repository class DefaultUserRepository( - userCrudRepository: UserCrudRepository + userCrudRepository: UserCrudRepository, + private val jdbcTemplate: JdbcTemplate ) : UserRepository, UserCrudRepository by userCrudRepository diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserCrudRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserCrudRepository.kt index 82a7f0a..12284d3 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserCrudRepository.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserCrudRepository.kt @@ -1,8 +1,11 @@ package com.sns.user.component.user.repositories import com.sns.user.component.user.domains.User +import java.util.* import org.springframework.data.repository.CrudRepository import org.springframework.stereotype.Repository @Repository -interface UserCrudRepository : CrudRepository +interface UserCrudRepository : CrudRepository { + fun findByInfoEmailAddress(email: String): Optional +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt index eb557ee..1589a91 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt @@ -1,8 +1,11 @@ package com.sns.user.component.user.repositories import com.sns.user.component.user.domains.User +import java.util.* import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.NoRepositoryBean @NoRepositoryBean -interface UserRepository : CrudRepository +interface UserRepository : CrudRepository { + fun findByInfoEmailAddress(email: String): Optional +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/config/DataConfig.kt b/user-api/src/main/kotlin/com/sns/user/core/config/DataConfig.kt new file mode 100644 index 0000000..3a3161a --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/config/DataConfig.kt @@ -0,0 +1,16 @@ +package com.sns.user.core.config + +import javax.sql.DataSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jdbc.datasource.DataSourceTransactionManager + +@Configuration +class DataConfig { + @Bean + fun transactionManager(dataSource: DataSource?): DataSourceTransactionManager? { + val dataSourceTransactionManager = DataSourceTransactionManager() + dataSourceTransactionManager.dataSource = dataSource + return dataSourceTransactionManager + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt b/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt index b088abf..e6aa2ff 100644 --- a/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt +++ b/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt @@ -1,8 +1,12 @@ package com.sns.user.core.config import com.sns.commons.config.IntegrationEventBaseConfig +import com.sns.commons.utils.log import com.sns.user.component.test.dtos.LaughingEvent import com.sns.user.component.test.listeners.EmotionListener +import com.sns.user.component.user.domains.Status +import com.sns.user.component.user.events.UserStatusChangedEvent +import com.sns.user.component.user.listeners.UserStatusListener import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import @@ -11,6 +15,7 @@ import org.springframework.integration.dsl.integrationFlow @Import(IntegrationEventBaseConfig::class) @Configuration class IntegrationConfig { + val log = this.log() @Bean fun emotionFlow(emotionListener: EmotionListener) = integrationFlow { @@ -20,7 +25,19 @@ class IntegrationConfig { } } + @Bean + fun userStatusFlow(userStatusListener: UserStatusListener) = integrationFlow { + channel { publishSubscribe(Channels.USER_STATUS) } + handle { event, _ -> + when (event.user.status) { + Status.CREATED -> userStatusListener.onCreated(event) + Status.ACTIVATED -> userStatusListener.onActivated(event) + } + } + } + object Channels { const val EMOTION = "EMOTION_CHANNEL" + const val USER_STATUS = "USER_STATUS_CHANNEL" } } diff --git a/user-api/src/main/kotlin/com/sns/user/core/config/SecurityConfig.kt b/user-api/src/main/kotlin/com/sns/user/core/config/SecurityConfig.kt new file mode 100644 index 0000000..bf87b69 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/config/SecurityConfig.kt @@ -0,0 +1,54 @@ +package com.sns.user.core.config + +import com.sns.user.core.supports.securities.authentications.LoginUserService +import com.sns.user.core.supports.securities.authentications.Role +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.builders.WebSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val loginUserService: LoginUserService +) : WebSecurityConfigurerAdapter() { + + val WHITE_LIST = arrayOf( + "/", + "/swagger-resources/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/webjars/**", + "/api/*/sign-up/**", + ) + + override fun configure(auth: AuthenticationManagerBuilder?) { + auth?.userDetailsService(loginUserService) + ?.passwordEncoder(passwordEncoder()) + } + + override fun configure(http: HttpSecurity?) { + if (http == null) return + http.authorizeRequests() + .antMatchers(*WHITE_LIST).permitAll() + .antMatchers("/admin-api").hasRole(Role.ADMIN.name) + .anyRequest().authenticated() + .and().logout().logoutSuccessUrl("/").invalidateHttpSession(true).permitAll() + .and().formLogin().loginPage("/sign-in") + .and().csrf().disable() + } + + override fun configure(web: WebSecurity?) { + super.configure(web) + } + + @Bean + fun passwordEncoder(): PasswordEncoder? { + return BCryptPasswordEncoder() + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/config/SwaggerTag.kt b/user-api/src/main/kotlin/com/sns/user/core/config/SwaggerTag.kt new file mode 100644 index 0000000..7092fcb --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/config/SwaggerTag.kt @@ -0,0 +1,5 @@ +package com.sns.user.core.config + +object SwaggerTag { + const val SIGN_UP: String = "SIGN_UP" +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/exceptions/AlreadyExistException.kt b/user-api/src/main/kotlin/com/sns/user/core/exceptions/AlreadyExistException.kt new file mode 100644 index 0000000..e0c2102 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/exceptions/AlreadyExistException.kt @@ -0,0 +1,8 @@ +package com.sns.user.core.exceptions + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +@ResponseStatus(HttpStatus.CONFLICT) +class AlreadyExistException(msg: String = "이미 존재합니다.") : RuntimeException(msg) { +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/exceptions/NoAuthorityException.kt b/user-api/src/main/kotlin/com/sns/user/core/exceptions/NoAuthorityException.kt new file mode 100644 index 0000000..830bda4 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/exceptions/NoAuthorityException.kt @@ -0,0 +1,8 @@ +package com.sns.user.core.exceptions + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +@ResponseStatus(HttpStatus.UNAUTHORIZED) +class NoAuthorityException(msg: String? = "권한이 없습니다") : RuntimeException(msg) { +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/infrastructures/mail/MailService.kt b/user-api/src/main/kotlin/com/sns/user/core/infrastructures/mail/MailService.kt new file mode 100644 index 0000000..18f382a --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/infrastructures/mail/MailService.kt @@ -0,0 +1,38 @@ +package com.sns.user.core.infrastructures.mail + +import java.nio.charset.StandardCharsets +import java.util.* +import javax.mail.Message +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage +import org.springframework.beans.factory.annotation.Value +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.stereotype.Service +import org.thymeleaf.context.Context +import org.thymeleaf.spring5.ISpringTemplateEngine + +@Service +class MailService( + val javaMailSender: JavaMailSender, + val templateEngine: ISpringTemplateEngine, + @Value("\${spring.mail.username}") val fromId: String +) { + /** + * 가입 인증코드 메일 발송 + * @param authCode 인증 코드 + * @param toAddress 수신인 주소 + */ + fun sendSignUpAuthCodeMail(authCode: String, toAddress: String) { + javaMailSender.send(javaMailSender.createMimeMessage().setBase("가입 인증 코드", createSignUpAuthCodeMailTemplate(authCode), toAddress)) + } + + private fun createSignUpAuthCodeMailTemplate(authCode: String): String = + templateEngine.process("signUpAuthCode", Context(Locale.KOREAN, mapOf("code" to authCode))) + + fun MimeMessage.setBase(title: String, content: String, toAddress: String): MimeMessage { + setRecipient(Message.RecipientType.TO, InternetAddress(toAddress)) + setSubject("[DDD SNS] $title", StandardCharsets.UTF_8.displayName()) + setContent(content, "text/html;charset=euc-kr") + return this + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/IsLoginUser.kt b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/IsLoginUser.kt new file mode 100644 index 0000000..0d7ed0d --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/IsLoginUser.kt @@ -0,0 +1,9 @@ +package com.sns.user.core.supports.securities.authentications + +import org.springframework.security.access.annotation.Secured + +// Role.USER.role +@Secured("ROLE_USER") +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class IsLoginUser() diff --git a/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/LoginUser.kt b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/LoginUser.kt new file mode 100644 index 0000000..bb4d928 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/LoginUser.kt @@ -0,0 +1,30 @@ +package com.sns.user.core.supports.securities.authentications + +import com.sns.user.component.user.domains.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +class LoginUser(val user: User) : UserDetails { + override fun getAuthorities(): MutableCollection = mutableListOf(Role.USER.createSimpleGrantedAuthority()) + override fun getUsername(): String = user.name + override fun getPassword(): String = user.password + override fun isAccountNonExpired(): Boolean = true + override fun isAccountNonLocked(): Boolean = true + override fun isCredentialsNonExpired(): Boolean = true + override fun isEnabled(): Boolean = true + + companion object { + fun create(user: User?): UserDetails = + if (user == null) InvalidUser() else LoginUser(user) + } +} + +class InvalidUser : UserDetails { + override fun getAuthorities(): MutableCollection = mutableListOf() + override fun getUsername(): String = "" + override fun getPassword(): String = "" + override fun isAccountNonExpired(): Boolean = false + override fun isAccountNonLocked(): Boolean = false + override fun isCredentialsNonExpired(): Boolean = false + override fun isEnabled(): Boolean = false +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/LoginUserService.kt b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/LoginUserService.kt new file mode 100644 index 0000000..49633f5 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/LoginUserService.kt @@ -0,0 +1,16 @@ +package com.sns.user.core.supports.securities.authentications + +import com.sns.user.component.user.repositories.UserCrudRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class LoginUserService( + private val userCrudRepository: UserCrudRepository +) : UserDetailsService { + override fun loadUserByUsername(username: String?): UserDetails { + if (username.isNullOrEmpty()) return InvalidUser() + return LoginUser.create(userCrudRepository.findById(username).orElse(null)) + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/Role.kt b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/Role.kt new file mode 100644 index 0000000..3739c4c --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/supports/securities/authentications/Role.kt @@ -0,0 +1,10 @@ +package com.sns.user.core.supports.securities.authentications + +import org.springframework.security.core.authority.SimpleGrantedAuthority + +enum class Role(val role: String) { + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + fun createSimpleGrantedAuthority(): SimpleGrantedAuthority = SimpleGrantedAuthority(this.role) +} diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignInController.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignInController.kt new file mode 100644 index 0000000..6d06e00 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignInController.kt @@ -0,0 +1,12 @@ +package com.sns.user.endpoints.user + +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api") +class SignInController { + + fun signIn() { + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignOutController.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignOutController.kt new file mode 100644 index 0000000..925c3dd --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignOutController.kt @@ -0,0 +1,15 @@ +package com.sns.user.endpoints.user + +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api") +class SignOutController { + + @PostMapping("/v1/sign-out") + fun signOut() { + // TODO 탈퇴 + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt new file mode 100644 index 0000000..0198817 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt @@ -0,0 +1,98 @@ +package com.sns.user.endpoints.user + +import com.sns.commons.utils.ifTrue +import com.sns.user.component.authcode.application.AuthCodeCommandService +import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.component.user.application.UserCommandService +import com.sns.user.component.user.application.UserQueryService +import com.sns.user.core.config.SwaggerTag +import com.sns.user.core.exceptions.NoAuthorityException +import com.sns.user.endpoints.user.requests.SignUpRequest +import com.sns.user.endpoints.user.responses.SignUpVerifiedResponse +import io.swagger.annotations.ApiOperation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import javax.validation.constraints.Email +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Validated +@RestController +@Tag(name = SwaggerTag.SIGN_UP) +@RequestMapping("/api") +class SignUpController( + val authCodeCommandService: AuthCodeCommandService, + val userQueryService: UserQueryService, + val userCommandService: UserCommandService, + val signUpAggregator: SignUpAggregator +) { + + @ApiOperation("회원 가입") + @ApiResponses( + value = [ + ApiResponse(description = "성공", responseCode = "202"), + ApiResponse(description = "이미 존재하는 유저", responseCode = "409"), + ], + ) + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/v1/sign-up") + fun signUp(@RequestBody request: SignUpRequest) { + userCommandService.create(request.name, request.password, request.email) + } + + @ApiOperation("이메일 중복 검사") + @ApiResponse( + description = "이메일 중복 검사 통과 여부", responseCode = "200", + content = [Content(schema = Schema(implementation = Boolean::class))], + ) + @ResponseStatus(HttpStatus.OK) + @GetMapping("/v1/sign-up/verifications/emails/{email}") + fun verifyEmail(@Email @PathVariable email: String): SignUpVerifiedResponse { + return SignUpVerifiedResponse(userQueryService.getByEmail(email) != null) + } + + @ApiOperation("가입 인증 코드 재발송") + @ApiResponse(description = "성공", responseCode = "202") + @ResponseStatus(HttpStatus.CREATED) + @PutMapping("/v1/sign-up/verifications/auth-code/ids/{userId}") + fun createAuthenticationCode(@PathVariable userId: String) { + val user = userQueryService.getById(userId) ?: throw NoAuthorityException() + authCodeCommandService.create(user) + } + + @ApiOperation("가입 인증 코드 검사") + @ApiResponse( + description = "가입 인증 성공", responseCode = "200", + content = [Content(schema = Schema(implementation = Boolean::class))], + ) + @ResponseStatus(HttpStatus.OK) + @PostMapping("/v1/sign-up/verifications/auth-code/ids/{userId}") + fun verifyAuthenticationCode(@PathVariable userId: String, @RequestBody code: String): SignUpVerifiedResponse { + return SignUpVerifiedResponse(signUpAggregator.verifyAuthentication(userId, code)) + } +} + +@Component +class SignUpAggregator( + val authCodeCommandService: AuthCodeCommandService, + val userCommandService: UserCommandService +) { + + @Transactional + fun verifyAuthentication(userId: String, code: String): Boolean = + authCodeCommandService.verify(userId, Purpose.SIGN_UP, code) + .ifTrue { userCommandService.activate(userId) } ?: false +} diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt new file mode 100644 index 0000000..f0842f5 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt @@ -0,0 +1,23 @@ +package com.sns.user.endpoints.user.requests + +import javax.validation.constraints.Email +import javax.validation.constraints.Max +import javax.validation.constraints.NotEmpty +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size + +data class SignUpRequest( + @NotEmpty + @Max(15) + val name: String, + + @NotEmpty + @Size(min = 8, max = 30, message = "비밀번호는 8자 이상 30자 미만이어야 합니다.") + @Pattern(regexp = "(?=.*[A-z])(?=.*[0-9])", message = "비밀번호는 영문자와 숫자가 포함되어야 합니다.") + val password: String, + + @NotEmpty + @Email + val email: String, +) { +} diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/SignUpVerifiedResponse.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/SignUpVerifiedResponse.kt new file mode 100644 index 0000000..2e0cd87 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/SignUpVerifiedResponse.kt @@ -0,0 +1,5 @@ +package com.sns.user.endpoints.user.responses + +data class SignUpVerifiedResponse( + val verified: Boolean = false +) diff --git a/user-api/src/main/resources/application-dev.yml b/user-api/src/main/resources/application-dev.yml index 47acbe4..a9da714 100644 --- a/user-api/src/main/resources/application-dev.yml +++ b/user-api/src/main/resources/application-dev.yml @@ -25,4 +25,14 @@ spring: implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy show-sql: true open-in-view: false - + mail: + host: smtp.gmail.com + port: 587 + username: ${GMAIL_ADD} + password: ${GMAIL_PW} + properties: + mail: + smtp: + auth: true + starttls: + enable: true diff --git a/user-api/src/main/resources/application.yml b/user-api/src/main/resources/application.yml index f7b77b5..92f1685 100644 --- a/user-api/src/main/resources/application.yml +++ b/user-api/src/main/resources/application.yml @@ -3,12 +3,16 @@ server: logging: level: root: info + org: + springframework: + jdbc: + core: trace pattern: console: "%d{HH:mm:ss} [%t][%-5level] %msg \\(%F:%L\\)%n" spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DDD_DB_URL}?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&tinyInt1isBit=false + url: jdbc:mysql://${DDD_DB_URL}/users?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&tinyInt1isBit=false username: ${DDD_DB_ID} password: ${DDD_DB_PW} hikari: @@ -27,4 +31,15 @@ spring: implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy show-sql: true open-in-view: false + mail: + host: smtp.gmail.com + port: 587 + username: ${GMAIL_ADD} + password: ${GMAIL_PW} + properties: + mail: + smtp: + auth: true + starttls: + enable: true diff --git a/user-api/src/main/resources/templates/signUpAuthCode.html b/user-api/src/main/resources/templates/signUpAuthCode.html new file mode 100644 index 0000000..10b470e --- /dev/null +++ b/user-api/src/main/resources/templates/signUpAuthCode.html @@ -0,0 +1,20 @@ + + + + + + DDD SNS 인증 코드 + + +
+
+

DDD SNS 가입 인증 코드

+

가입페이지에 인증코드를 입력해주세요. 바로 인증 링크는 TODO

+

인증 코드 : + +

+ +
+
+ + diff --git a/user-api/src/test/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandServiceMockTest.kt b/user-api/src/test/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandServiceMockTest.kt new file mode 100644 index 0000000..2578f10 --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandServiceMockTest.kt @@ -0,0 +1,70 @@ +package com.sns.user.component.authcode.application + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.AuthCodeKey +import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.component.authcode.repositories.AuthCodeRepository +import com.sns.user.component.user.domains.User +import com.sns.user.core.infrastructures.mail.MailService +import com.sns.user.isEqualTo +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class AuthCodeCommandServiceMockTest() { + @MockK + private lateinit var authCodeRepository: AuthCodeRepository + + @MockK + private lateinit var mailService: MailService + + @InjectMockKs + private lateinit var authCodeCommandService: AuthCodeCommandService + + @BeforeEach + internal fun setUp() { + MockKAnnotations.init(this) + every { mailService.sendSignUpAuthCodeMail(ofType(String::class), ofType(String::class)) } returns Unit + every { authCodeRepository.save(any()) } returnsArgument 0 + every { authCodeRepository.delete(any()) } returns Unit + } + + @Test + fun create() { + val authCode = authCodeCommandService.create(User.create("id", "passwd", "name", "email")) + + verify { authCodeRepository.save(eq(authCode)) } + verify { mailService.sendSignUpAuthCodeMail(any(), any()) } + } + + @DisplayName("userId, purpose에 맞는 authcode 기록이 없다면, 인증 실패해야한다.") + @Test + fun verify_null() { + every { authCodeRepository.findByAuthCodeKey(ofType(AuthCodeKey::class)) } returns null + + authCodeCommandService.verify("userId", Purpose.SIGN_UP, "123") isEqualTo false + } + + @DisplayName("정상 케이스인 경우, 인증에 성공해야한다.") + @Test + fun verify_success() { + val authCode = AuthCode.createSignUp("userId") + every { authCodeRepository.findByAuthCodeKey(ofType(AuthCodeKey::class)) } returns authCode + + authCodeCommandService.verify("userId", Purpose.SIGN_UP, authCode.code) isEqualTo true + } + + @DisplayName("인증 코드가 다른 경우, 인증에 실패해야한다.") + @Test + fun verify_different_code() { + val authCode = AuthCode.createSignUp("userId") + every { authCodeRepository.findByAuthCodeKey(ofType(AuthCodeKey::class)) } returns authCode + + authCodeCommandService.verify("userId", Purpose.SIGN_UP, "different") isEqualTo false + } +} diff --git a/user-api/src/test/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepositoryTest.kt b/user-api/src/test/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepositoryTest.kt new file mode 100644 index 0000000..d2a996a --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepositoryTest.kt @@ -0,0 +1,30 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.AuthCodeKey +import com.sns.user.component.authcode.domain.Purpose +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class AuthCodeRepositoryTest { + @Autowired + lateinit var authCodeRepository: AuthCodeRepository + + @Test + internal fun save() { + authCodeRepository.save(AuthCode.createSignUp("userId")) + } + + @Test + internal fun findByUserIdAndPurpose() { + assertThat(authCodeRepository.findByAuthCodeKey(AuthCodeKey(Purpose.SIGN_UP, "userId"))).isNotNull + } + + @Test + internal fun delete() { + authCodeRepository.delete(AuthCodeKey(Purpose.SIGN_UP, "userId")) + } +} diff --git a/user-api/src/test/kotlin/com/sns/user/component/user/application/UserCommandServiceMockTest.kt b/user-api/src/test/kotlin/com/sns/user/component/user/application/UserCommandServiceMockTest.kt new file mode 100644 index 0000000..0de61c5 --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/component/user/application/UserCommandServiceMockTest.kt @@ -0,0 +1,73 @@ +package com.sns.user.component.user.application + +import com.sns.commons.DomainEvent +import com.sns.commons.service.EventPublisher +import com.sns.user.component.user.domains.User +import com.sns.user.component.user.events.UserStatusChangedEvent +import com.sns.user.component.user.repositories.UserRepository +import com.sns.user.core.exceptions.AlreadyExistException +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import java.util.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.crypto.password.PasswordEncoder + +internal class UserCommandServiceMockTest { + @MockK + private lateinit var userRepository: UserRepository + + @MockK + private lateinit var passwordEncoder: PasswordEncoder + + @MockK + private lateinit var eventPublisher: EventPublisher + + @InjectMockKs + lateinit var userCommandService: UserCommandService + + @BeforeEach + internal fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + val user = User.create("id", "passwd", "이름", "dev123@gmail.com") + every { eventPublisher.publish(ofType(DomainEvent::class)) } returns Unit + every { userRepository.save(any()) } returnsArgument 0 + every { userRepository.findByInfoEmailAddress(ofType(String::class)) } returns Optional.of(user) + every { userRepository.findByIdOrNull(ofType(String::class)) } returns user + every { passwordEncoder.encode(any()) } returnsArgument 0 + } + + @Test + fun create() { + userCommandService.create("이름", "passwd", "dev123@gmail.com") + + verify { eventPublisher.publish(ofType(UserStatusChangedEvent::class)) } + verify { userRepository.save(ofType(User::class)) } + } + + @Test + fun activate() { + userCommandService.activate("dev123@gmail") + + verify { eventPublisher.publish(ofType(UserStatusChangedEvent::class)) } + verify { userRepository.save(ofType(User::class)) } + } + + @DisplayName("이미 ACTIVATED 상태라면, exception이 던져져야 한다.") + @Test + fun activate_already() { + every { userRepository.findByIdOrNull(ofType(String::class)) } returns User("id", "passwd", "name") + + assertThrows { userCommandService.activate("dev123@gmail") } + + verify(exactly = 0) { eventPublisher.publish(ofType(UserStatusChangedEvent::class)) } + verify(exactly = 0) { userRepository.save(ofType(User::class)) } + } +} diff --git a/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt b/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt index 7ab387a..3b09185 100644 --- a/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt +++ b/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt @@ -5,6 +5,7 @@ import com.sns.user.component.user.repositories.UserRepository import com.sns.user.hasValueSatisfying import com.sns.user.isEqualTo import com.sns.user.isNotEqualTo +import com.sns.user.satisfies import java.time.Instant import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -32,4 +33,9 @@ class UserRepositoryTest { savedUser.updatedAt isNotEqualTo Instant.MIN } } + + @Test + internal fun findByInfoEmailAddress() { + userRepository.findByInfoEmailAddress("dev@gm1.com").orElseGet(null)!! satisfies { it.name isEqualTo "김개발" } + } } diff --git a/user-api/src/test/kotlin/com/sns/user/core/infrastructures/mail/MailServiceTest.kt b/user-api/src/test/kotlin/com/sns/user/core/infrastructures/mail/MailServiceTest.kt new file mode 100644 index 0000000..a23b084 --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/core/infrastructures/mail/MailServiceTest.kt @@ -0,0 +1,25 @@ +package com.sns.user.core.infrastructures.mail + +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +// https://myaccount.google.com/lesssecureapps 보안이 낮은 수준의 앱 액세스 허용 필요. +@SpringBootTest( + properties = [ + "spring.mail.username=***@gmail.com", + "spring.mail.password=***", + ], +) +class MailServiceTest @Autowired constructor( + val mailService: MailService +) { + + @Disabled("진짜 메일 발송용") + @Test + internal fun sendSignUpAuthCodeMail() { + mailService.sendSignUpAuthCodeMail("ABC123", "***@gmail.com") + // then checkout your mail + } +} diff --git a/user-api/src/test/resources/application.properties b/user-api/src/test/resources/application.properties deleted file mode 100644 index 305a3f2..0000000 --- a/user-api/src/test/resources/application.properties +++ /dev/null @@ -1,17 +0,0 @@ -spring.profiles.active=test -server.port=10001 -spring.application.name=demo -# datasource -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=root -spring.datasource.password=test -spring.sql.init.mode=always -spring.sql.init.schema-locations=classpath*:db/*/*schema.sql -spring.sql.init.data-locations=classpath*:db/*/*data.sql -spring.sql.init.continue-on-error=false -logging.level.org.springframework.jdbc.core=debug -# h2 -#spring.h2.console.enabled=true -#spring.h2.console.path=/h2-console - diff --git a/user-api/src/test/resources/application.yml b/user-api/src/test/resources/application.yml new file mode 100644 index 0000000..9407c56 --- /dev/null +++ b/user-api/src/test/resources/application.yml @@ -0,0 +1,37 @@ +server: + port: 10001 +logging: + level: + root: info + org: trace + # springframework: + # jdbc: + # core: debug + pattern: + console: "%d{HH:mm:ss} [%t][%-5level] %msg \\(%F:%L\\)%n" +spring: + profiles: + active: test + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1 + username: root + password: test + sql: + init: + mode: embedded + schema-locations: classpath*:db/*/*schema.sql + data-locations: classpath*:db/*/*data.sql + continue-on-error: false + mail: + host: smtp.gmail.com + port: 587 + username: "none" + password: "none" + properties: + mail: + smtp: + auth: true + starttls: + enable: true + diff --git a/user-api/src/test/resources/db/users/data.sql b/user-api/src/test/resources/db/users/data.sql new file mode 100644 index 0000000..d6218fa --- /dev/null +++ b/user-api/src/test/resources/db/users/data.sql @@ -0,0 +1,7 @@ +/** user **/ +INSERT INTO `user` (`id`, `password`, `name`, `info_email_address`, `status`, `created_at`, `updated_at`) +VALUES ('test_user', 'passworrd123', '김개발', 'dev@gm1.com', 'ACTIVATED', NOW(), NOW()); + +/** auth_code **/ +INSERT INTO auth_code (`user_id`, `code`, created_at, purpose) +VALUES ('test_user', 'ABC123', NOW(), 'SIGN_UP'); diff --git a/user-api/src/test/resources/db/users/schema.sql b/user-api/src/test/resources/db/users/schema.sql index 2773f91..e5fdbef 100644 --- a/user-api/src/test/resources/db/users/schema.sql +++ b/user-api/src/test/resources/db/users/schema.sql @@ -1,9 +1,21 @@ +DROP TABLE IF EXISTS `user`; CREATE TABLE IF NOT EXISTS `user` ( id VARCHAR(50) NOT NULL PRIMARY KEY COMMENT '아이디 (이메일)', password VARCHAR(100) NOT NULL COMMENT '비밀번호', `name` VARCHAR(50) NOT NULL COMMENT '이름', info_email_address VARCHAR(50) NOT NULL COMMENT '서비스 정보 수신 이메일주소', + status VARCHAR(10) NOT NULL COMMENT '상태', created_at DATETIME NOT NULL COMMENT '생성 시간', updated_at DATETIME NOT NULL COMMENT '마지막 수정 시간' ); + +DROP TABLE IF EXISTS `auth_code`; +CREATE TABLE IF NOT EXISTS `auth_code` +( + user_id VARCHAR(50) NOT NULL COMMENT 'user.id', + purpose VARCHAR(50) NOT NULL COMMENT '사용 목적', + code VARCHAR(50) NOT NULL COMMENT '인증 코드', + created_at DATETIME NOT NULL COMMENT '생성 시각', + PRIMARY KEY (`user_id`, purpose) +);