Skip to content
This repository has been archived by the owner on Nov 21, 2023. It is now read-only.

Commit

Permalink
[#28] 가입 인증코드 기능구현
Browse files Browse the repository at this point in the history
  • Loading branch information
youngvly committed Nov 13, 2021
1 parent 6c7f49b commit 90dead8
Show file tree
Hide file tree
Showing 20 changed files with 416 additions and 35 deletions.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ project(":user-api") {
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")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.sns.user.component.authcode.application

import com.sns.user.component.authcode.domain.AuthCode
import com.sns.user.component.authcode.domain.Purpose
import com.sns.user.component.authcode.repositories.AuthCodeRepository
import com.sns.user.component.user.repositories.DefaultUserRepository
import com.sns.user.core.exceptions.NoAuthorityException
import com.sns.user.core.infrastructures.mail.MailService
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service

@Service
class AuthCodeCommand(
val authCodeRepository: AuthCodeRepository,
val mailService: MailService,
val userRepository: DefaultUserRepository
) {
fun create(userId: String): AuthCode {
val user = userRepository.findByIdOrNull(userId) ?: throw NoAuthorityException()

val authCode = AuthCode.createSignUp(user.id)
authCodeRepository.save(authCode)

mailService.sendSignUpAuthCodeMail(authCode.code, user.infoEmailAddress)
return authCode
}

fun verify(userId: String, purpose: Purpose, code: String): Boolean {
val authCode: AuthCode? = authCodeRepository.findByUserIdAndPurpose(userId, purpose)
return authCode?.isCorrect(userId, code, purpose)
.takeIf { it == true }.apply {
// TOOD update STATUS userRepository.save()
} ?: false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.jdbc.core.RowMapper

data class AuthCode(
@Id
val id: Int? = null,
// TODO 복합키 구현가능한지 확인.
@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(""),
@CreatedDate
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<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun createSignUp(userId: String) = AuthCode(purpose = Purpose.SIGN_UP, userId = userId)
val MAPPER: RowMapper<AuthCode> = AuthCodeRowMapper()
}
}

// purpose enum 매핑이 안되서 수동으로 작성함. 확인필요.
class AuthCodeRowMapper : RowMapper<AuthCode> {
override fun mapRow(rs: ResultSet, rowNum: Int): AuthCode? {
return AuthCode(
id = rs.getInt("id"),
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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sns.user.component.authcode.repositories

import com.sns.user.component.authcode.domain.AuthCode
import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository

@Repository
interface AuthCodeCrudRepository : CrudRepository<AuthCode, Int> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.sns.user.component.authcode.repositories

import com.sns.user.component.authcode.domain.AuthCode
import com.sns.user.component.authcode.domain.Purpose
import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.NoRepositoryBean

@NoRepositoryBean
interface AuthCodeRepository : CrudRepository<AuthCode, Int> {
fun findByUserIdAndPurpose(userId: String, purpose: Purpose): AuthCode?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.sns.user.component.authcode.repositories

import com.sns.user.component.authcode.domain.AuthCode
import com.sns.user.component.authcode.domain.Purpose
import org.springframework.data.repository.CrudRepository
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository

@Repository
class DefaultAuthCodeRepository(
val jdbcTemplate: JdbcTemplate,
val authCodeCrudRepository: AuthCodeCrudRepository
) : AuthCodeRepository, CrudRepository<AuthCode, Int> by authCodeCrudRepository {

override fun findByUserIdAndPurpose(userId: String, purpose: Purpose): AuthCode? = jdbcTemplate.queryForObject(
"""
SELECT id,user_id,`code`,created_at,purpose
FROM auth_code
WHERE user_id = ? AND purpose = ?
ORDER BY id DESC
LIMIT 1
""".trimIndent(),
AuthCode.MAPPER, userId, purpose.name,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.sns.user.core.config

class SwaggerTag {
companion object {
const val SIGN_UP: String = "SIGN_UP"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.sns.user.core.exceptions

class NoAuthorityException(msg: String? = "권한이 없습니다") : RuntimeException(msg) {
}
Original file line number Diff line number Diff line change
@@ -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<String, Any>("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
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package com.sns.user.endpoints.user

import com.sns.user.component.authcode.application.AuthCodeCommand
import com.sns.user.component.authcode.domain.Purpose
import com.sns.user.core.config.SwaggerTag
import com.sns.user.endpoints.user.requests.SignUpRequest
import com.sns.user.endpoints.user.responses.IdExistsCheckResponse
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.tags.Tag
import javax.validation.constraints.Email
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
Expand All @@ -16,31 +23,43 @@ import org.springframework.web.bind.annotation.RestController

@Validated
@RestController
@Tag(name = SwaggerTag.SIGN_UP)
@RequestMapping("/api")
class SignUpController {
class SignUpController(val authCodeCommand: AuthCodeCommand) {

@ApiResponse(description = "회원 가입", responseCode = "202")
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/v1/sign-up")
fun signUp(@RequestBody request: SignUpRequest) {
// TODO 패스워드 유효성 검증
}

@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): IdExistsCheckResponse {
fun verifyEmail(@Email @PathVariable email: String): ResponseEntity<Boolean> {
// TODO email 중복 검사
return IdExistsCheckResponse(false)
return ResponseEntity.ok(false)
}

@ApiResponse(description = "가입 인증 코드 재발송", responseCode = "202")
@ResponseStatus(HttpStatus.CREATED)
@PutMapping("/v1/sign-up/verifications/ids/{userId}/auth-code")
@PutMapping("/v1/sign-up/verifications/auth-code/ids/{userId}")
fun createAuthenticationCode(@PathVariable userId: String) {
// TODO 인증번호 재발송

authCodeCommand.create(userId)
}

@ApiResponse(
description = "가입 인증 코드 검사", responseCode = "200",
content = [Content(schema = Schema(implementation = Boolean::class))],
)
@ResponseStatus(HttpStatus.OK)
@PostMapping("/v1/sign-up/verifications/ids/{userId}/auth-code")
fun verifyAuthenticationCode(@PathVariable userId: String, @RequestBody code: String) {
// TODO 인증번호 검사
@PostMapping("/v1/sign-up/verifications/auth-code/ids/{userId}")
fun verifyAuthenticationCode(@PathVariable userId: String, @RequestBody code: String): ResponseEntity<Boolean> {
return ResponseEntity.ok(authCodeCommand.verify(userId, Purpose.SIGN_UP, code))
}
}

This file was deleted.

11 changes: 11 additions & 0 deletions user-api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,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

20 changes: 20 additions & 0 deletions user-api/src/main/resources/templates/signUpAuthCode.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>

<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>DDD SNS 인증 코드</title>
</head>
<body>
<div class="p-5 mb-4 bg-light rounded-3">
<div class="container-fluid py-5">
<h1 class="display-5 fw-bold">DDD SNS 가입 인증 코드</h1>
<p class="col-md-8 fs-4">가입페이지에 인증코드를 입력해주세요. 바로 인증 링크는 TODO</p>
<p class="col-md-8 fs-4">인증 코드 :
<span th:text="${code}"></span>
</p>
<button class="btn btn-primary btn-lg" type="button">인증 하기</button>
</div>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.sns.user.component.authcode.application

import com.sns.user.component.authcode.domain.AuthCode
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.component.user.repositories.DefaultUserRepository
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
import org.springframework.data.repository.findByIdOrNull

class AuthCodeCommandMockTest() {
@MockK
private lateinit var authCodeRepository: AuthCodeRepository

@MockK
private lateinit var mailService: MailService

@MockK
private lateinit var userRepository: DefaultUserRepository

@InjectMockKs
private lateinit var authCodeCommand: AuthCodeCommand

@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 { userRepository.findByIdOrNull(any()) } returns User.create("id", "pass", "name", "[email protected]")
}

@Test
fun create() {
val authCode = authCodeCommand.create("id")

verify { authCodeRepository.save(eq(authCode)) }
verify { mailService.sendSignUpAuthCodeMail(any(), any()) }
}

@DisplayName("userId, purpose에 맞는 authcode 기록이 없다면, 인증 실패해야한다.")
@Test
fun verify_null() {
every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns null

authCodeCommand.verify("userId", Purpose.SIGN_UP, "123") isEqualTo false
}

@DisplayName("정상 케이스인 경우, 인증에 성공해야한다.")
@Test
fun verify_success() {
val authCode = AuthCode.createSignUp("userId")
every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns authCode

authCodeCommand.verify("userId", Purpose.SIGN_UP, authCode.code) isEqualTo true
}

@DisplayName("인증 코드가 다른 경우, 인증에 실패해야한다.")
@Test
fun verify_different_code() {
val authCode = AuthCode.createSignUp("userId")
every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns authCode

authCodeCommand.verify("userId", Purpose.SIGN_UP, "different") isEqualTo false
}
}
Loading

0 comments on commit 90dead8

Please sign in to comment.