Skip to content

Commit

Permalink
메일 인증 API 분리 및 수정 (#99)
Browse files Browse the repository at this point in the history
  • Loading branch information
kih00 authored Feb 22, 2025
1 parent 87be7fa commit 168a1b3
Show file tree
Hide file tree
Showing 9 changed files with 69 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class EmailSendingException : UserException(
msg = "인증 이메일 전송에 실패했습니다."
)

// 회원가입 시 메일 인증 과정을 거치지 않은 경우
class EmailNotVerifiedException : UserException(
errorCode = 0,
httpErrorCode = HttpStatus.BAD_REQUEST,
msg = "인증이 완료되지 않은 이메일입니다."
)

// 로그인 실패
class SignInInvalidException : UserException(
errorCode = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ class EmailVerification(
)
}
}

override fun toString(): String {
return "{id: $id, email: $email, code: $code, expiryTime: $expiryTime}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class EmailVerificationEntity(
val email: String,
@Column(name = "code", nullable = false)
val code: String,
@Column(name = "verified", nullable = false)
var verified: Boolean = false,
@Column(name = "expiryTime", nullable = false)
val expiryTime: LocalDateTime
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import java.time.LocalDateTime

interface EmailVerificationRepository : JpaRepository<EmailVerificationEntity, Long> {
fun deleteByExpiryTimeBefore(expiryTime: LocalDateTime)
fun findByEmail(email: String): EmailVerificationEntity?
fun findByEmailAndCode(email: String, code: String): EmailVerificationEntity?
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ class User(
)
}
}

override fun toString(): String {
return "{id: $id, userNumber: $userNumber, nickname: $nickname, email: $email, isSocial: $isSocial}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.ForgotPassw
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.LoginRequest
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.RefreshTokenRequest
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.RegisterRequest
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.ResetPasswordRequest
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.UpdateNicknameRequest
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.UpdatePasswordRequest
import com.wafflestudio.toyproject.memoWithTags.user.dto.UserRequest.VerifyEmailRequest
Expand All @@ -14,7 +13,6 @@ import com.wafflestudio.toyproject.memoWithTags.user.dto.UserResponse.RefreshTok
import com.wafflestudio.toyproject.memoWithTags.user.service.UserService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
Expand All @@ -32,13 +30,20 @@ class UserController(
) {
@Operation(summary = "사용자 회원가입")
@PostMapping("/auth/register")
fun register(@RequestBody request: RegisterRequest): ResponseEntity<Unit> {
fun register(@RequestBody request: RegisterRequest): ResponseEntity<LoginResponse> {
userService.register(request.email, request.password, request.nickname)
val (_, accessToken, refreshToken) = userService.login(request.email, request.password)
return ResponseEntity.ok(LoginResponse(accessToken, refreshToken))
}

@Operation(summary = "메일 인증 요청")
@PostMapping("/auth/send-email")
fun sendEmail(@RequestBody request: SendEmailRequest): ResponseEntity<Unit> {
userService.sendCodeToEmail(request.email)
return ResponseEntity.status(HttpStatus.CREATED).build()
return ResponseEntity.ok().build()
}

@Operation(summary = "회원가입 이메일 인증")
@Operation(summary = "메일 인증 확인")
@PostMapping("/auth/verify-email")
fun verifyEmail(@RequestBody request: VerifyEmailRequest): ResponseEntity<Unit> {
userService.verifyEmail(request.email, request.verificationCode)
Expand All @@ -52,17 +57,10 @@ class UserController(
return ResponseEntity.ok(LoginResponse(accessToken, refreshToken))
}

@Operation(summary = "비밀번호 찾기 이메일 인증")
@PostMapping("/auth/forgot-password")
fun forgotPassword(@RequestBody request: ForgotPasswordRequest): ResponseEntity<Unit> {
userService.sendCodeToEmail(request.email)
return ResponseEntity.ok().build()
}

@Operation(summary = "비밀번호 초기화")
@PostMapping("/auth/reset-password")
fun resetPassword(@RequestBody request: ResetPasswordRequest): ResponseEntity<Unit> {
userService.resetPasswordWithEmailVerification(request.email, request.verificationCode, request.password)
userService.resetPasswordWithEmailVerification(request.email, request.password)
return ResponseEntity.ok().build()
}

Expand Down Expand Up @@ -95,7 +93,7 @@ class UserController(
@DeleteMapping("/auth/withdrawal")
fun withdrawal(
@AuthUser user: User,
@RequestBody request: ForgotPasswordRequest
@RequestBody request: WithdrawalRequest
): ResponseEntity<Unit> {
userService.deleteUser(user, request.email)
return ResponseEntity.noContent().build()
Expand All @@ -111,3 +109,5 @@ class UserController(
}

typealias WithdrawalRequest = ForgotPasswordRequest
typealias SendEmailRequest = ForgotPasswordRequest
typealias ResetPasswordRequest = LoginRequest
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,6 @@ sealed class UserRequest {
val email: String
) : UserRequest()

data class ResetPasswordRequest(
val email: String,
val verificationCode: String,
val password: String
) : UserRequest()

data class UpdatePasswordRequest(
val originalPassword: String,
val newPassword: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class UserEntity(
var hashedPassword: String,

@Column(name = "verified", nullable = false)
var verified: Boolean = false,
var verified: Boolean = true,

@Column(name = "role", nullable = false)
var role: RoleType = RoleType.ROLE_USER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.wafflestudio.toyproject.memoWithTags.user.service
import com.wafflestudio.toyproject.memoWithTags.exception.AuthenticationFailedException
import com.wafflestudio.toyproject.memoWithTags.exception.EmailAlreadyExistsException
import com.wafflestudio.toyproject.memoWithTags.exception.EmailNotMatchException
import com.wafflestudio.toyproject.memoWithTags.exception.EmailNotVerifiedException
import com.wafflestudio.toyproject.memoWithTags.exception.EmailSendingException
import com.wafflestudio.toyproject.memoWithTags.exception.InValidTokenException
import com.wafflestudio.toyproject.memoWithTags.exception.MailVerificationException
Expand Down Expand Up @@ -43,11 +44,18 @@ class UserService(
password: String,
nickname: String
): User {
// 소셜 로그인 사용 여부와 무관하게 동일 이메일이 존재하기만 하면 예외 처리한다.
if (userRepository.findByEmail(email) != null) throw EmailAlreadyExistsException()

// 메일 인증 과정을 거치지 않고 바로 회원가입 시도 시 예외 처리한다. 검증 후 인증 데이터는 삭제한다.
val verification = emailVerificationRepository.findByEmail(email) ?: throw EmailNotVerifiedException()
if (!verification.verified) throw EmailNotVerifiedException()
emailVerificationRepository.deleteById(verification.id!!)

val encryptedPassword = BCrypt.hashpw(password, BCrypt.gensalt())
// 클라이언트에서 쓸 유저 식별 번호인 userNumber는 해당 유저가 서비스에 가입한 순서 + 1로 한다.
val userNumber = userRepository.getMaxUserNumber() + 1
// 메일 인증이 이루어지기 전까지 User의 verified 필드는 false이다.

val userEntity = userRepository.save(
UserEntity(
userNumber = userNumber,
Expand All @@ -57,16 +65,21 @@ class UserService(
createdAt = Instant.now()
)
)
logger.info("User registered: ${userEntity.id}, ${userEntity.email}")
return User.fromEntity(userEntity)
val user = User.fromEntity(userEntity)
logger.info("User registered: $user")
return user
}

/**
* 회원가입 또는 비밀번호 변경 요청 후 인증용 메일을 발송하는 함수
*/
@Transactional
fun sendCodeToEmail(
email: String
) {
// 이미 인증 메일을 보낸 주소로 또 시도하는 경우에는 예외를 발생시킨다.
if (emailVerificationRepository.findByEmail(email) != null) throw EmailAlreadyExistsException()

val verification: EmailVerification = createVerificationCode(email)
val title = "Memo with tags 이메일 인증 번호"
val content: String = "<html>" +
Expand All @@ -90,7 +103,8 @@ class UserService(
/**
* 인증 메일에 포함될 인증 코드를 랜덤으로 생성하는 함수. 6자리 숫자를 생성한다.
*/
private fun createVerificationCode(email: String): EmailVerification {
@Transactional
fun createVerificationCode(email: String): EmailVerification {
val randomCode: String = (100000..999999).random().toString()
val codeEntity = EmailVerificationEntity(
email = email,
Expand All @@ -110,9 +124,9 @@ class UserService(
): Boolean {
val verification = emailVerificationRepository.findByEmailAndCode(email, code) ?: throw MailVerificationException()
if (verification.expiryTime.isBefore(LocalDateTime.now())) throw AuthenticationFailedException()
val userEntity = userRepository.findByEmail(verification.email)
// 인증 성공 시, user의 Verified 필드가 true로 바뀌어 정식 회원이 된다.
userEntity!!.verified = true
// 인증 성공 시, verification의 Verified 필드가 true로 바뀌어 회원가입의 검증 절차를 통과한다.
verification.verified = true
logger.info("verified email code")
return true
}

Expand All @@ -127,9 +141,10 @@ class UserService(
val userEntity = userRepository.findByEmail(email) ?: throw SignInInvalidException()
if (userEntity.socialType != null) throw SignInInvalidException()
if (!BCrypt.checkpw(password, userEntity.hashedPassword)) throw SignInInvalidException()
logger.info("User logged in: ${userEntity.id}, ${userEntity.email}")
val user = User.fromEntity(userEntity)
logger.info("User logged in: $user")
return Triple(
User.fromEntity(userEntity),
user,
JwtUtil.generateAccessToken(userEntity.email),
JwtUtil.generateRefreshToken(userEntity.email)
)
Expand All @@ -141,13 +156,18 @@ class UserService(
@Transactional
fun resetPasswordWithEmailVerification(
email: String,
code: String,
newPassword: String
) {
if (verifyEmail(email, code)) {
val userEntity = userRepository.findByEmail(email) ?: throw UserNotFoundException()
userEntity.hashedPassword = BCrypt.hashpw(newPassword, BCrypt.gensalt())
}
// 인증된 이메일인지 확인하고, 검증 후 인증 데이터를 삭제한다.
val verification = emailVerificationRepository.findByEmail(email) ?: throw EmailNotVerifiedException()
if (!verification.verified) throw EmailNotVerifiedException()
emailVerificationRepository.deleteById(verification.id!!)

// 해당하는 유저가 없으면 예외를 발생시킨다.
val userEntity = userRepository.findByEmail(email) ?: throw UserNotFoundException()
// 비밀번호를 변경한다.
userEntity.hashedPassword = BCrypt.hashpw(newPassword, BCrypt.gensalt())
logger.info("password reset: $email")
}

/**
Expand Down Expand Up @@ -176,6 +196,7 @@ class UserService(
val userEntity = userRepository.findByEmail(user.email) ?: throw UserNotFoundException()
if (!BCrypt.checkpw(originalPassword, userEntity.hashedPassword)) throw UpdatePasswordInvalidException()
userEntity.hashedPassword = BCrypt.hashpw(newPassword, BCrypt.gensalt())
logger.info("password updated: $user")
return User.fromEntity(userRepository.save(userEntity))
}

Expand All @@ -199,11 +220,8 @@ class UserService(
if (!JwtUtil.isValidToken(refreshToken)) {
throw InValidTokenException()
}
logger.info("Refreshing token: $refreshToken 1")
val userEmail = JwtUtil.extractUserEmail(refreshToken) ?: throw InValidTokenException()
logger.info("Refreshing token: $refreshToken 2")
userRepository.findByEmail(userEmail) ?: throw UserNotFoundException()
logger.info("Refreshing token: $refreshToken 3")
val newAccessToken = JwtUtil.generateAccessToken(userEmail)
logger.info("Refreshing token: $refreshToken, new Access token: $newAccessToken 4")
return RefreshTokenResponse(
Expand All @@ -223,6 +241,7 @@ class UserService(
) {
if (user.email != email) throw EmailNotMatchException()
userRepository.deleteById(user.id)
logger.info("User deleted: $user")
}

/**
Expand Down

0 comments on commit 168a1b3

Please sign in to comment.