feat: JWT 기반 인증 시스템 및 이메일 가입 구현

[인프라]
- Docker Compose 구성 (DB, Redis, MinIO)
- Spring Boot 3.5.9 + Kotlin + Gradle 설정

[인증/보안]
- Spring Security 및 JWT 필터 설정
- RTR(Refresh Token Rotation) 방식의 토큰 재발급 로직 구현
- Redis를 활용한 Refresh Token 및 이메일 인증 코드 관리

[기능 구현]
- 회원가입 (이메일 인증 포함)
- 로그인/로그아웃/토큰재발급 API 구현
- 공통 응답(ApiResponse) 및 전역 예외 처리(GlobalExceptionHandler) 적용
This commit is contained in:
pwy3282040@msecure.co
2025-12-26 12:58:51 +09:00
parent 02909894db
commit 49d435079f
15 changed files with 549 additions and 6 deletions

View File

@@ -0,0 +1,17 @@
package me.wypark.blogbackend.api.common
data class ApiResponse<T>(
val code: String = "SUCCESS",
val message: String = "요청이 성공했습니다.",
val data: T? = null
) {
companion object {
fun <T> success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse<T> {
return ApiResponse("SUCCESS", message, data)
}
fun error(message: String, code: String = "ERROR"): ApiResponse<Nothing> {
return ApiResponse(code, message, null)
}
}
}

View File

@@ -0,0 +1,49 @@
package me.wypark.blogbackend.api.controller
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.*
import me.wypark.blogbackend.domain.auth.AuthService
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService
) {
@PostMapping("/signup")
fun signup(@RequestBody @Valid request: SignupRequest): ResponseEntity<ApiResponse<Nothing>> {
authService.signup(request)
return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요."))
}
@PostMapping("/verify")
fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity<ApiResponse<Nothing>> {
authService.verifyEmail(request.email, request.code)
return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다."))
}
@PostMapping("/login")
fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity<ApiResponse<TokenDto>> {
val tokenDto = authService.login(request)
return ResponseEntity.ok(ApiResponse.success(tokenDto))
}
@PostMapping("/reissue")
fun reissue(@RequestBody request: ReissueRequest): ResponseEntity<ApiResponse<TokenDto>> {
val tokenDto = authService.reissue(request.accessToken, request.refreshToken)
return ResponseEntity.ok(ApiResponse.success(tokenDto))
}
@PostMapping("/logout")
fun logout(@AuthenticationPrincipal user: User): ResponseEntity<ApiResponse<Nothing>> {
authService.logout(user.username) // user.username은 email입니다.
return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다."))
}
}
data class ReissueRequest(val accessToken: String, val refreshToken: String)

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.api.dto
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
// 회원가입 요청
data class SignupRequest(
@field:NotBlank(message = "이메일은 필수입니다.")
@field:Email(message = "올바른 이메일 형식이 아닙니다.")
val email: String,
@field:NotBlank(message = "비밀번호는 필수입니다.")
@field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.")
val password: String,
@field:NotBlank(message = "닉네임은 필수입니다.")
@field:Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하로 입력해주세요.")
val nickname: String
)
// 로그인 요청
data class LoginRequest(
@field:NotBlank(message = "이메일을 입력해주세요.")
val email: String,
@field:NotBlank(message = "비밀번호를 입력해주세요.")
val password: String
)
data class VerifyEmailRequest(
@field:NotBlank(message = "이메일을 입력해주세요")
val email: String,
@field:NotBlank(message = "인증 코드를 입력해주세요")
val code: String
)

View File

@@ -0,0 +1,50 @@
package me.wypark.blogbackend.core.config
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.JavaMailSenderImpl
import java.util.*
@Configuration
class MailConfig(
@Value("\${spring.mail.host}") private val host: String,
@Value("\${spring.mail.port}") private val port: Int,
@Value("\${spring.mail.username}") private val username: String,
@Value("\${spring.mail.password}") private val password: String,
@Value("\${spring.mail.properties.mail.smtp.auth}") private val auth: String,
@Value("\${spring.mail.properties.mail.smtp.starttls.enable}") private val starttlsEnable: String,
@Value("\${spring.mail.properties.mail.smtp.starttls.required}") private val starttlsRequired: String,
@Value("\${spring.mail.properties.mail.smtp.connectiontimeout}") private val connectionTimeout: Int,
@Value("\${spring.mail.properties.mail.smtp.timeout}") private val timeout: Int,
@Value("\${spring.mail.properties.mail.smtp.writetimeout}") private val writeTimeout: Int
) {
@Bean
fun javaMailSender(): JavaMailSender {
val mailSender = JavaMailSenderImpl()
// 기본 설정
mailSender.host = host
mailSender.port = port
mailSender.username = username
mailSender.password = password
mailSender.defaultEncoding = "UTF-8"
// 세부 프로퍼티 설정
val props: Properties = mailSender.javaMailProperties
props["mail.transport.protocol"] = "smtp"
props["mail.smtp.auth"] = auth
props["mail.smtp.starttls.enable"] = starttlsEnable
props["mail.smtp.starttls.required"] = starttlsRequired
props["mail.debug"] = "true" // 디버깅용 로그 출력 (배포 시 false로 변경 추천)
// 타임아웃 설정 (서버 응답 없을 때 무한 대기 방지)
props["mail.smtp.connectiontimeout"] = connectionTimeout
props["mail.smtp.timeout"] = timeout
props["mail.smtp.writetimeout"] = writeTimeout
return mailSender
}
}

View File

@@ -0,0 +1,38 @@
package me.wypark.blogbackend.core.error
import me.wypark.blogbackend.api.common.ApiResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {
// 1. 비즈니스 로직 에러 (의도적인 throw)
@ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class)
fun handleBusinessException(e: RuntimeException): ResponseEntity<ApiResponse<Nothing>> {
return ResponseEntity
.badRequest()
.body(ApiResponse.error(e.message ?: "잘못된 요청입니다."))
}
// 2. @Valid 검증 실패 (DTO 유효성 체크)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Nothing>> {
val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다."
return ResponseEntity
.badRequest()
.body(ApiResponse.error(message))
}
// 3. 나머지 알 수 없는 에러
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ApiResponse<Nothing>> {
e.printStackTrace() // 로그 남기기
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다."))
}
}

View File

@@ -0,0 +1,144 @@
package me.wypark.blogbackend.domain.auth
import me.wypark.blogbackend.api.dto.LoginRequest
import me.wypark.blogbackend.api.dto.SignupRequest
import me.wypark.blogbackend.api.dto.TokenDto
import me.wypark.blogbackend.core.config.jwt.JwtProvider
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import me.wypark.blogbackend.domain.user.Role
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class AuthService(
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder,
private val authenticationManagerBuilder: AuthenticationManagerBuilder,
private val jwtProvider: JwtProvider,
private val refreshTokenRepository: RefreshTokenRepository,
private val emailService: EmailService
) {
/**
* 회원가입
*/
@Transactional
fun signup(request: SignupRequest) {
if (memberRepository.existsByEmail(request.email)) {
throw IllegalArgumentException("이미 가입된 이메일입니다.")
}
if (memberRepository.existsByNickname(request.nickname)) {
throw IllegalArgumentException("이미 사용 중인 닉네임입니다.")
}
val member = Member(
email = request.email,
password = passwordEncoder.encode(request.password),
nickname = request.nickname,
role = Role.ROLE_USER,
isVerified = false
)
memberRepository.save(member)
emailService.sendVerificationCode(request.email)
}
/**
* 로그인
*/
@Transactional
fun login(request: LoginRequest): TokenDto {
val member = memberRepository.findByEmail(request.email)
?: throw IllegalArgumentException("가입되지 않은 이메일입니다.")
// 비밀번호 체크
if (!passwordEncoder.matches(request.password, member.password)) {
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
}
// 이메일 인증 여부 체크
if (!member.isVerified) {
throw IllegalStateException("이메일 인증이 필요합니다.")
}
// 1. ID/PW 기반의 인증 토큰 생성
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
// 2. 실제 검증 (사용자 비밀번호 체크)
// authenticate() 실행 시 CustomUserDetailsService.loadUserByUsername 실행됨
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
// 3. 인증 정보를 기반으로 JWT 토큰 생성
val tokenDto = jwtProvider.generateTokenDto(authentication)
// 4. RefreshToken Redis 저장 (RTR: 기존 토큰 덮어쓰기)
refreshTokenRepository.save(authentication.name, tokenDto.refreshToken)
return tokenDto
}
/**
* 토큰 재발급 (RTR 적용)
*/
@Transactional
fun reissue(accessToken: String, refreshToken: String): TokenDto {
// 1. 리프레시 토큰 검증 (만료 여부, 위변조 여부)
if (!jwtProvider.validateToken(refreshToken)) {
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
}
// 2. 액세스 토큰에서 User ID(Email) 가져오기 (만료된 토큰이어도 파싱 가능하도록 JwtProvider가 설계됨)
val authentication = jwtProvider.getAuthentication(accessToken)
// 3. Redis에서 저장된 Refresh Token 가져오기
val savedRefreshToken = refreshTokenRepository.findByEmail(authentication.name)
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
// 4. 토큰 일치 여부 확인 (재사용 방지)
if (savedRefreshToken != refreshToken) {
refreshTokenRepository.delete(authentication.name)
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
}
// 5. 새 토큰 생성 (Rotation)
val newTokenDto = jwtProvider.generateTokenDto(authentication)
// 6. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
refreshTokenRepository.save(authentication.name, newTokenDto.refreshToken)
return newTokenDto
}
/**
* 로그아웃
*/
@Transactional
fun logout(email: String) {
refreshTokenRepository.delete(email)
}
// 3. 이메일 인증 확인
@Transactional
fun verifyEmail(email: String, code: String) {
val member = memberRepository.findByEmail(email)
?: throw IllegalArgumentException("존재하지 않는 회원입니다.")
if (member.isVerified) {
throw IllegalArgumentException("이미 인증된 회원입니다.")
}
// 코드 검증
if (!emailService.verifyCode(email, code)) {
throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.")
}
// 인증 상태 업데이트
member.verify()
}
}

View File

@@ -0,0 +1,30 @@
package me.wypark.blogbackend.domain.auth
import me.wypark.blogbackend.domain.user.Member
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service
class CustomUserDetailsService(
private val memberRepository: MemberRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
return memberRepository.findByEmail(username)
?.let { createUserDetails(it) }
?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username")
}
private fun createUserDetails(member: Member): UserDetails {
return User(
member.email,
member.password,
listOf(SimpleGrantedAuthority(member.role.name))
)
}
}

View File

@@ -0,0 +1,77 @@
package me.wypark.blogbackend.domain.auth
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
import kotlin.random.Random
@Service
class EmailService(
private val javaMailSender: JavaMailSender,
private val redisTemplate: RedisTemplate<String, String>
) {
// 인증 코드 전송
fun sendVerificationCode(email: String) {
val code = createVerificationCode()
// 1. Redis에 저장 (Key: "Verify:이메일", Value: 코드, 유효시간: 5분)
redisTemplate.opsForValue().set(
"Verify:$email",
code,
5,
TimeUnit.MINUTES
)
// 2. 메일 발송
sendMail(email, code)
}
// 인증 코드 검증
fun verifyCode(email: String, code: String): Boolean {
val savedCode = redisTemplate.opsForValue().get("Verify:$email")
return savedCode != null && savedCode == code
}
private fun createVerificationCode(): String {
return Random.nextInt(100000, 999999).toString() // 6자리 난수
}
private fun sendMail(email: String, code: String) {
val mimeMessage = javaMailSender.createMimeMessage()
val helper = MimeMessageHelper(mimeMessage, "utf-8")
helper.setTo(email)
helper.setSubject("[Blog] 회원가입 인증 코드입니다.")
val htmlContent = """
<div style="font-family: 'Apple SD Gothic Neo', 'sans-serif' !important; width: 540px; height: 600px; border-top: 4px solid #00C73C; margin: 100px auto; padding: 30px 0; box-sizing: border-box;">
<h1 style="margin: 0; padding: 0 5px; font-size: 28px; font-weight: 400;">
<span style="font-size: 15px; margin: 0 0 10px 3px;">Wypark Blog</span><br />
<span style="color: #00C73C;">메일인증</span> 안내입니다.
</h1>
<p style="font-size: 16px; line-height: 26px; margin-top: 50px; padding: 0 5px;">
안녕하세요.<br />
Wypark Blog에 가입해 주셔서 진심으로 감사드립니다.<br />
아래 <b style="color: #00C73C;">'인증 코드'</b>를 입력하여 회원가입을 완료해 주세요.<br />
감사합니다.
</p>
<div style="margin-top: 50px; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; padding: 25px; background-color: #F9F9F9; text-align: center;">
<div style="font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #333;">
$code
</div>
</div>
<div style="margin-top: 30px; text-align: center;">
<p style="font-size: 14px; color: #888;">이 코드는 5분간 유효합니다.</p>
</div>
</div>
""".trimIndent()
helper.setText(htmlContent, true) // true: HTML 모드 켜기
javaMailSender.send(mimeMessage)
}
}

View File

@@ -0,0 +1,22 @@
package me.wypark.blogbackend.domain.common
import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
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) // JPA Auditing 기능 활성화
abstract class BaseTimeEntity {
@CreatedDate
@Column(nullable = false, updatable = false) // 생성일은 수정 불가
var createdAt: LocalDateTime = LocalDateTime.now()
@LastModifiedDate
@Column(nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
}

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.domain.user
import jakarta.persistence.*
import me.wypark.blogbackend.domain.common.BaseTimeEntity
@Entity
@Table(name = "member")
class Member(
@Column(nullable = false, unique = true)
val email: String,
@Column(nullable = false)
var password: String,
@Column(nullable = false)
var nickname: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val role: Role,
@Column(nullable = false)
var isVerified: Boolean = false
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun verify() {
this.isVerified = true
}
}
enum class Role {
ROLE_USER, ROLE_ADMIN
}

View File

@@ -0,0 +1,10 @@
package me.wypark.blogbackend.domain.user
import org.springframework.data.jpa.repository.JpaRepository
interface MemberRepository : JpaRepository<Member, Long> {
// 로그인 및 중복 가입 방지를 위한 핵심 메소드들입니다.
fun findByEmail(email: String): Member?
fun existsByEmail(email: String): Boolean
fun existsByNickname(nickname: String): Boolean
}

View File

@@ -21,6 +21,22 @@ spring:
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME} # 환경변수 처리 추천
password: ${MAIL_PASSWORD} # 앱 비밀번호 (16자리)
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
# 3. Redis 설정
data:
redis: