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,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)
}
}