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

Commit 90dead8

Browse files
committed
[#28] 가입 인증코드 기능구현
1 parent 6c7f49b commit 90dead8

File tree

20 files changed

+416
-35
lines changed

20 files changed

+416
-35
lines changed

build.gradle.kts

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ project(":user-api") {
6363
implementation("org.springframework.boot:spring-boot-starter-security")
6464
implementation("org.springframework.security:spring-security-test")
6565
implementation("org.springframework.boot:spring-boot-starter-mail")
66+
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
67+
6668
implementation("org.springframework.boot:spring-boot-starter-security")
6769
runtimeOnly("com.h2database:h2")
6870
runtimeOnly("mysql:mysql-connector-java")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.sns.user.component.authcode.application
2+
3+
import com.sns.user.component.authcode.domain.AuthCode
4+
import com.sns.user.component.authcode.domain.Purpose
5+
import com.sns.user.component.authcode.repositories.AuthCodeRepository
6+
import com.sns.user.component.user.repositories.DefaultUserRepository
7+
import com.sns.user.core.exceptions.NoAuthorityException
8+
import com.sns.user.core.infrastructures.mail.MailService
9+
import org.springframework.data.repository.findByIdOrNull
10+
import org.springframework.stereotype.Service
11+
12+
@Service
13+
class AuthCodeCommand(
14+
val authCodeRepository: AuthCodeRepository,
15+
val mailService: MailService,
16+
val userRepository: DefaultUserRepository
17+
) {
18+
fun create(userId: String): AuthCode {
19+
val user = userRepository.findByIdOrNull(userId) ?: throw NoAuthorityException()
20+
21+
val authCode = AuthCode.createSignUp(user.id)
22+
authCodeRepository.save(authCode)
23+
24+
mailService.sendSignUpAuthCodeMail(authCode.code, user.infoEmailAddress)
25+
return authCode
26+
}
27+
28+
fun verify(userId: String, purpose: Purpose, code: String): Boolean {
29+
val authCode: AuthCode? = authCodeRepository.findByUserIdAndPurpose(userId, purpose)
30+
return authCode?.isCorrect(userId, code, purpose)
31+
.takeIf { it == true }.apply {
32+
// TOOD update STATUS userRepository.save()
33+
} ?: false
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.sns.user.component.authcode.domain
2+
3+
import kotlin.random.Random
4+
import java.sql.ResultSet
5+
import java.time.Instant
6+
import javax.validation.constraints.NotBlank
7+
import org.springframework.data.annotation.CreatedDate
8+
import org.springframework.data.annotation.Id
9+
import org.springframework.jdbc.core.RowMapper
10+
11+
data class AuthCode(
12+
@Id
13+
val id: Int? = null,
14+
// TODO 복합키 구현가능한지 확인.
15+
@NotBlank
16+
val purpose: Purpose,
17+
@NotBlank
18+
val userId: String,
19+
@NotBlank
20+
val code: String = (1..CODE_LENGTH)
21+
.map { Random.nextInt(0, charPool.size) }
22+
.map(charPool::get)
23+
.joinToString(""),
24+
@CreatedDate
25+
val createdAt: Instant = Instant.MIN
26+
) {
27+
28+
fun isCorrect(userId: String, code: String, purpose: Purpose): Boolean =
29+
(this.userId == userId) and (this.code == code) and (this.purpose == purpose)
30+
31+
companion object {
32+
private const val CODE_LENGTH = 10;
33+
private val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
34+
fun createSignUp(userId: String) = AuthCode(purpose = Purpose.SIGN_UP, userId = userId)
35+
val MAPPER: RowMapper<AuthCode> = AuthCodeRowMapper()
36+
}
37+
}
38+
39+
// purpose enum 매핑이 안되서 수동으로 작성함. 확인필요.
40+
class AuthCodeRowMapper : RowMapper<AuthCode> {
41+
override fun mapRow(rs: ResultSet, rowNum: Int): AuthCode? {
42+
return AuthCode(
43+
id = rs.getInt("id"),
44+
purpose = Purpose.valueOf(rs.getString("purpose")),
45+
userId = rs.getString("user_id"),
46+
code = rs.getString("code"),
47+
createdAt = Instant.ofEpochMilli(rs.getTimestamp("created_at").time),
48+
)
49+
}
50+
}
51+
52+
enum class Purpose {
53+
SIGN_UP
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.sns.user.component.authcode.repositories
2+
3+
import com.sns.user.component.authcode.domain.AuthCode
4+
import org.springframework.data.repository.CrudRepository
5+
import org.springframework.stereotype.Repository
6+
7+
@Repository
8+
interface AuthCodeCrudRepository : CrudRepository<AuthCode, Int> {
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.sns.user.component.authcode.repositories
2+
3+
import com.sns.user.component.authcode.domain.AuthCode
4+
import com.sns.user.component.authcode.domain.Purpose
5+
import org.springframework.data.repository.CrudRepository
6+
import org.springframework.data.repository.NoRepositoryBean
7+
8+
@NoRepositoryBean
9+
interface AuthCodeRepository : CrudRepository<AuthCode, Int> {
10+
fun findByUserIdAndPurpose(userId: String, purpose: Purpose): AuthCode?
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.sns.user.component.authcode.repositories
2+
3+
import com.sns.user.component.authcode.domain.AuthCode
4+
import com.sns.user.component.authcode.domain.Purpose
5+
import org.springframework.data.repository.CrudRepository
6+
import org.springframework.jdbc.core.JdbcTemplate
7+
import org.springframework.stereotype.Repository
8+
9+
@Repository
10+
class DefaultAuthCodeRepository(
11+
val jdbcTemplate: JdbcTemplate,
12+
val authCodeCrudRepository: AuthCodeCrudRepository
13+
) : AuthCodeRepository, CrudRepository<AuthCode, Int> by authCodeCrudRepository {
14+
15+
override fun findByUserIdAndPurpose(userId: String, purpose: Purpose): AuthCode? = jdbcTemplate.queryForObject(
16+
"""
17+
SELECT id,user_id,`code`,created_at,purpose
18+
FROM auth_code
19+
WHERE user_id = ? AND purpose = ?
20+
ORDER BY id DESC
21+
LIMIT 1
22+
""".trimIndent(),
23+
AuthCode.MAPPER, userId, purpose.name,
24+
)
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.sns.user.core.config
2+
3+
class SwaggerTag {
4+
companion object {
5+
const val SIGN_UP: String = "SIGN_UP"
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.sns.user.core.exceptions
2+
3+
class NoAuthorityException(msg: String? = "권한이 없습니다") : RuntimeException(msg) {
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.sns.user.core.infrastructures.mail
2+
3+
import java.nio.charset.StandardCharsets
4+
import java.util.*
5+
import javax.mail.Message
6+
import javax.mail.internet.InternetAddress
7+
import javax.mail.internet.MimeMessage
8+
import org.springframework.beans.factory.annotation.Value
9+
import org.springframework.mail.javamail.JavaMailSender
10+
import org.springframework.stereotype.Service
11+
import org.thymeleaf.context.Context
12+
import org.thymeleaf.spring5.ISpringTemplateEngine
13+
14+
@Service
15+
class MailService(
16+
val javaMailSender: JavaMailSender,
17+
val templateEngine: ISpringTemplateEngine,
18+
@Value("\${spring.mail.username}") val fromId: String
19+
) {
20+
/**
21+
* 가입 인증코드 메일 발송
22+
* @param authCode 인증 코드
23+
* @param toAddress 수신인 주소
24+
*/
25+
fun sendSignUpAuthCodeMail(authCode: String, toAddress: String) {
26+
javaMailSender.send(javaMailSender.createMimeMessage().setBase("가입 인증 코드", createSignUpAuthCodeMailTemplate(authCode), toAddress))
27+
}
28+
29+
private fun createSignUpAuthCodeMailTemplate(authCode: String): String =
30+
templateEngine.process("signUpAuthCode", Context(Locale.KOREAN, mapOf<String, Any>("code" to authCode)))
31+
32+
fun MimeMessage.setBase(title: String, content: String, toAddress: String): MimeMessage {
33+
setRecipient(Message.RecipientType.TO, InternetAddress(toAddress))
34+
setSubject("[DDD SNS] $title", StandardCharsets.UTF_8.displayName())
35+
setContent(content, "text/html;charset=euc-kr")
36+
return this
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
package com.sns.user.endpoints.user
22

3+
import com.sns.user.component.authcode.application.AuthCodeCommand
4+
import com.sns.user.component.authcode.domain.Purpose
5+
import com.sns.user.core.config.SwaggerTag
36
import com.sns.user.endpoints.user.requests.SignUpRequest
4-
import com.sns.user.endpoints.user.responses.IdExistsCheckResponse
7+
import io.swagger.v3.oas.annotations.media.Content
8+
import io.swagger.v3.oas.annotations.media.Schema
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse
10+
import io.swagger.v3.oas.annotations.tags.Tag
511
import javax.validation.constraints.Email
612
import org.springframework.http.HttpStatus
13+
import org.springframework.http.ResponseEntity
714
import org.springframework.validation.annotation.Validated
815
import org.springframework.web.bind.annotation.GetMapping
916
import org.springframework.web.bind.annotation.PathVariable
@@ -16,31 +23,43 @@ import org.springframework.web.bind.annotation.RestController
1623

1724
@Validated
1825
@RestController
26+
@Tag(name = SwaggerTag.SIGN_UP)
1927
@RequestMapping("/api")
20-
class SignUpController {
28+
class SignUpController(val authCodeCommand: AuthCodeCommand) {
2129

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

37+
@ApiResponse(
38+
description = "이메일 중복 검사", responseCode = "200",
39+
content = [Content(schema = Schema(implementation = Boolean::class))],
40+
)
2841
@ResponseStatus(HttpStatus.OK)
2942
@GetMapping("/v1/sign-up/verifications/emails/{email}")
30-
fun verifyEmail(@Email @PathVariable email: String): IdExistsCheckResponse {
43+
fun verifyEmail(@Email @PathVariable email: String): ResponseEntity<Boolean> {
3144
// TODO email 중복 검사
32-
return IdExistsCheckResponse(false)
45+
return ResponseEntity.ok(false)
3346
}
3447

48+
@ApiResponse(description = "가입 인증 코드 재발송", responseCode = "202")
3549
@ResponseStatus(HttpStatus.CREATED)
36-
@PutMapping("/v1/sign-up/verifications/ids/{userId}/auth-code")
50+
@PutMapping("/v1/sign-up/verifications/auth-code/ids/{userId}")
3751
fun createAuthenticationCode(@PathVariable userId: String) {
38-
// TODO 인증번호 재발송
52+
53+
authCodeCommand.create(userId)
3954
}
4055

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

user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/IdExistsCheckResponse.kt

-6
This file was deleted.

user-api/src/main/resources/application.yml

+11
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,15 @@ spring:
2727
implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
2828
show-sql: true
2929
open-in-view: false
30+
mail:
31+
host: smtp.gmail.com
32+
port: 587
33+
username: ${GMAIL_ADD}
34+
password: ${GMAIL_PW}
35+
properties:
36+
mail:
37+
smtp:
38+
auth: true
39+
starttls:
40+
enable: true
3041

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!DOCTYPE html>
2+
3+
<html lang="en" xmlns:th="http://www.thymeleaf.org">
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>DDD SNS 인증 코드</title>
7+
</head>
8+
<body>
9+
<div class="p-5 mb-4 bg-light rounded-3">
10+
<div class="container-fluid py-5">
11+
<h1 class="display-5 fw-bold">DDD SNS 가입 인증 코드</h1>
12+
<p class="col-md-8 fs-4">가입페이지에 인증코드를 입력해주세요. 바로 인증 링크는 TODO</p>
13+
<p class="col-md-8 fs-4">인증 코드 :
14+
<span th:text="${code}"></span>
15+
</p>
16+
<button class="btn btn-primary btn-lg" type="button">인증 하기</button>
17+
</div>
18+
</div>
19+
</body>
20+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.sns.user.component.authcode.application
2+
3+
import com.sns.user.component.authcode.domain.AuthCode
4+
import com.sns.user.component.authcode.domain.Purpose
5+
import com.sns.user.component.authcode.repositories.AuthCodeRepository
6+
import com.sns.user.component.user.domains.User
7+
import com.sns.user.component.user.repositories.DefaultUserRepository
8+
import com.sns.user.core.infrastructures.mail.MailService
9+
import com.sns.user.isEqualTo
10+
import io.mockk.MockKAnnotations
11+
import io.mockk.every
12+
import io.mockk.impl.annotations.InjectMockKs
13+
import io.mockk.impl.annotations.MockK
14+
import io.mockk.verify
15+
import org.junit.jupiter.api.BeforeEach
16+
import org.junit.jupiter.api.DisplayName
17+
import org.junit.jupiter.api.Test
18+
import org.springframework.data.repository.findByIdOrNull
19+
20+
class AuthCodeCommandMockTest() {
21+
@MockK
22+
private lateinit var authCodeRepository: AuthCodeRepository
23+
24+
@MockK
25+
private lateinit var mailService: MailService
26+
27+
@MockK
28+
private lateinit var userRepository: DefaultUserRepository
29+
30+
@InjectMockKs
31+
private lateinit var authCodeCommand: AuthCodeCommand
32+
33+
@BeforeEach
34+
internal fun setUp() {
35+
MockKAnnotations.init(this)
36+
every { mailService.sendSignUpAuthCodeMail(ofType(String::class), ofType(String::class)) } returns Unit
37+
every { authCodeRepository.save(any()) } returnsArgument 0
38+
every { userRepository.findByIdOrNull(any()) } returns User.create("id", "pass", "name", "[email protected]")
39+
}
40+
41+
@Test
42+
fun create() {
43+
val authCode = authCodeCommand.create("id")
44+
45+
verify { authCodeRepository.save(eq(authCode)) }
46+
verify { mailService.sendSignUpAuthCodeMail(any(), any()) }
47+
}
48+
49+
@DisplayName("userId, purpose에 맞는 authcode 기록이 없다면, 인증 실패해야한다.")
50+
@Test
51+
fun verify_null() {
52+
every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns null
53+
54+
authCodeCommand.verify("userId", Purpose.SIGN_UP, "123") isEqualTo false
55+
}
56+
57+
@DisplayName("정상 케이스인 경우, 인증에 성공해야한다.")
58+
@Test
59+
fun verify_success() {
60+
val authCode = AuthCode.createSignUp("userId")
61+
every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns authCode
62+
63+
authCodeCommand.verify("userId", Purpose.SIGN_UP, authCode.code) isEqualTo true
64+
}
65+
66+
@DisplayName("인증 코드가 다른 경우, 인증에 실패해야한다.")
67+
@Test
68+
fun verify_different_code() {
69+
val authCode = AuthCode.createSignUp("userId")
70+
every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns authCode
71+
72+
authCodeCommand.verify("userId", Purpose.SIGN_UP, "different") isEqualTo false
73+
}
74+
}

0 commit comments

Comments
 (0)