주석 수정
This commit is contained in:
@@ -14,6 +14,13 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* [인증 비즈니스 로직 서비스]
|
||||
*
|
||||
* 회원가입, 로그인, 토큰 재발급 등 계정 보안과 관련된 핵심 로직을 담당합니다.
|
||||
* DB(Member), Redis(RefreshToken), Email(Verification) 등 여러 인프라 자원을 오케스트레이션하여
|
||||
* 안전하고 무결한 인증 프로세스를 보장합니다.
|
||||
*/
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class AuthService(
|
||||
@@ -23,12 +30,15 @@ class AuthService(
|
||||
private val jwtProvider: JwtProvider,
|
||||
private val refreshTokenRepository: RefreshTokenRepository,
|
||||
private val emailService: EmailService,
|
||||
// 👇 추가: DB에서 유저 정보를 다시 로드하기 위해 필요
|
||||
private val userDetailsService: UserDetailsService
|
||||
) {
|
||||
|
||||
/**
|
||||
* 회원가입
|
||||
* 신규 회원을 등록합니다.
|
||||
*
|
||||
* [스팸 방지 전략]
|
||||
* 무분별한 가입을 막기 위해 가입 즉시 활성화(Active)하지 않고,
|
||||
* `isVerified = false` 상태로 저장한 뒤 이메일 인증을 강제합니다.
|
||||
*/
|
||||
@Transactional
|
||||
fun signup(request: SignupRequest) {
|
||||
@@ -44,98 +54,115 @@ class AuthService(
|
||||
password = passwordEncoder.encode(request.password),
|
||||
nickname = request.nickname,
|
||||
role = Role.ROLE_USER,
|
||||
isVerified = false
|
||||
isVerified = false // 초기 상태는 미인증
|
||||
)
|
||||
|
||||
memberRepository.save(member)
|
||||
|
||||
// 비동기 처리를 고려할 수 있으나, 가입 직후 메일 수신이 중요하므로 동기 호출
|
||||
emailService.sendVerificationCode(request.email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
* 사용자 자격 증명을 검증하고 초기 토큰을 발급합니다.
|
||||
*
|
||||
* 단순 ID/PW 검사뿐만 아니라, 이메일 인증 여부(Business Rule)를 체크하여
|
||||
* 미인증 계정의 접근을 원천 차단합니다.
|
||||
*/
|
||||
@Transactional
|
||||
fun login(request: LoginRequest): TokenDto {
|
||||
val member = memberRepository.findByEmail(request.email)
|
||||
?: throw IllegalArgumentException("가입되지 않은 이메일입니다.")
|
||||
|
||||
// 비밀번호 체크
|
||||
// 비밀번호 체크 (Bcrypt)
|
||||
if (!passwordEncoder.matches(request.password, member.password)) {
|
||||
throw IllegalArgumentException("비밀번호가 일치하지 않습니다.")
|
||||
}
|
||||
|
||||
// 이메일 인증 여부 체크
|
||||
// 계정 활성화 여부 체크
|
||||
if (!member.isVerified) {
|
||||
throw IllegalStateException("이메일 인증이 필요합니다.")
|
||||
}
|
||||
|
||||
// 1. ID/PW 기반의 인증 토큰 생성
|
||||
// 1. Spring Security 인증 토큰 생성
|
||||
val authenticationToken = UsernamePasswordAuthenticationToken(request.email, request.password)
|
||||
|
||||
// 2. 실제 검증 (사용자 비밀번호 체크)
|
||||
// 2. 실제 검증 수행 (CustomUserDetailsService 호출됨)
|
||||
val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
|
||||
|
||||
// 3. 인증 정보를 기반으로 JWT 토큰 생성
|
||||
// 3. 인증 정보를 기반으로 JWT(Access + Refresh) 생성
|
||||
val tokenDto = jwtProvider.generateTokenDto(authentication)
|
||||
|
||||
// 4. RefreshToken Redis 저장 (RTR: 기존 토큰 덮어쓰기)
|
||||
// 4. Refresh Token을 Redis에 저장 (RTR 전략의 기준점)
|
||||
refreshTokenRepository.save(authentication.name, tokenDto.refreshToken)
|
||||
|
||||
return tokenDto
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 재발급 (RTR 적용)
|
||||
* Access Token 만료 시 토큰을 갱신합니다.
|
||||
*
|
||||
* [핵심 보안 전략: Refresh Token Rotation (RTR)]
|
||||
* 보안성을 높이기 위해 Refresh Token을 일회용으로 사용합니다.
|
||||
* 토큰 재발급 요청 시 기존 Refresh Token을 폐기하고, 새로운 Refresh Token을 발급합니다.
|
||||
*
|
||||
* [토큰 탈취 감지]
|
||||
* 만약 이미 사용된(폐기된) Refresh Token으로 요청이 들어온다면, 이는 토큰이 탈취된 것으로 간주하여
|
||||
* 해당 사용자의 저장된 모든 토큰을 삭제하고 강제 로그아웃 처리합니다.
|
||||
*/
|
||||
@Transactional
|
||||
fun reissue(accessToken: String, refreshToken: String): TokenDto {
|
||||
// 1. 리프레시 토큰 검증 (만료 여부, 위변조 여부)
|
||||
// 1. 토큰 자체의 유효성 검증 (위변조 여부)
|
||||
if (!jwtProvider.validateToken(refreshToken)) {
|
||||
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다.")
|
||||
}
|
||||
|
||||
// 2. 액세스 토큰에서 User ID(Email) 가져오기
|
||||
// (주의: 여기서 authentication.principal은 CustomUserDetails가 아닐 수 있음)
|
||||
// 2. Access Token에서 사용자 정보 추출
|
||||
val tempAuthentication = jwtProvider.getAuthentication(accessToken)
|
||||
|
||||
// 3. Redis에서 저장된 Refresh Token 가져오기
|
||||
// 3. Redis에 저장된 최신 Refresh Token 조회
|
||||
val savedRefreshToken = refreshTokenRepository.findByEmail(tempAuthentication.name)
|
||||
?: throw IllegalArgumentException("로그아웃 된 사용자입니다.")
|
||||
|
||||
// 4. 토큰 일치 여부 확인 (재사용 방지)
|
||||
// 4. [RTR 핵심] 토큰 불일치 감지 (재사용 시도 -> 탈취 의심)
|
||||
if (savedRefreshToken != refreshToken) {
|
||||
refreshTokenRepository.delete(tempAuthentication.name)
|
||||
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다.")
|
||||
refreshTokenRepository.delete(tempAuthentication.name) // 보안 조치: 세션 전체 파기
|
||||
throw IllegalArgumentException("토큰 정보가 일치하지 않습니다. (재사용 감지됨)")
|
||||
}
|
||||
|
||||
// ✨ 5. [수정됨] DB에서 유저 정보(CustomUserDetails) 다시 로드
|
||||
// JwtProvider.generateTokenDto()가 CustomUserDetails를 필요로 하므로 필수
|
||||
// 5. DB에서 최신 유저 정보 다시 로드
|
||||
// (토큰 갱신 시점의 권한 변경이나 닉네임 변경 등을 반영하기 위함)
|
||||
val userDetails = userDetailsService.loadUserByUsername(tempAuthentication.name)
|
||||
|
||||
// ✨ 로드한 userDetails로 새로운 Authentication 생성
|
||||
// 6. 새로운 Authentication 객체 생성
|
||||
val newAuthentication = UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.authorities
|
||||
)
|
||||
|
||||
// 6. 새 토큰 생성 (Rotation)
|
||||
// 7. 새 토큰 쌍 발급 (Rotate)
|
||||
val newTokenDto = jwtProvider.generateTokenDto(newAuthentication)
|
||||
|
||||
// 7. Redis 업데이트 (한번 쓴 토큰 폐기 -> 새 토큰 저장)
|
||||
// 8. Redis 업데이트 (기존 토큰 덮어쓰기)
|
||||
refreshTokenRepository.save(newAuthentication.name, newTokenDto.refreshToken)
|
||||
|
||||
return newTokenDto
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
* 로그아웃 처리
|
||||
*
|
||||
* 서버 측에서 Refresh Token을 삭제함으로써, Access Token이 만료되는 즉시
|
||||
* 더 이상 갱신할 수 없도록 세션을 종료시킵니다.
|
||||
*/
|
||||
@Transactional
|
||||
fun logout(email: String) {
|
||||
refreshTokenRepository.delete(email)
|
||||
}
|
||||
|
||||
// 3. 이메일 인증 확인
|
||||
/**
|
||||
* 이메일 인증 코드를 검증하고 계정 상태를 활성화(Verify)합니다.
|
||||
* 상태 변경(update)이 발생하므로 트랜잭션 내에서 처리됩니다.
|
||||
*/
|
||||
@Transactional
|
||||
fun verifyEmail(email: String, code: String) {
|
||||
val member = memberRepository.findByEmail(email)
|
||||
@@ -145,12 +172,12 @@ class AuthService(
|
||||
throw IllegalArgumentException("이미 인증된 회원입니다.")
|
||||
}
|
||||
|
||||
// 코드 검증
|
||||
// Redis에 저장된 코드와 대조
|
||||
if (!emailService.verifyCode(email, code)) {
|
||||
throw IllegalArgumentException("인증 코드가 올바르지 않거나 만료되었습니다.")
|
||||
}
|
||||
|
||||
// 인증 상태 업데이트
|
||||
// 인증 성공 시 회원 상태 변경
|
||||
member.verify()
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,25 @@ package me.wypark.blogbackend.domain.auth
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.userdetails.User
|
||||
|
||||
/**
|
||||
* [Spring Security 사용자 정보 확장 구현체]
|
||||
*
|
||||
* Spring Security의 표준 UserDetails(User) 클래스를 상속받아
|
||||
* 비즈니스 로직에 필요한 추가 식별자들을 포함하도록 확장한 클래스입니다.
|
||||
*
|
||||
* [설계 의도]
|
||||
* 기본 User 객체는 username(email)과 password, 권한 정보만 가지고 있습니다.
|
||||
* 하지만 실제 서비스 로직이나 JWT 토큰 생성 시에는 사용자의 DB PK(id)나 닉네임이 자주 필요합니다.
|
||||
* 매 요청마다 DB를 다시 조회하는 오버헤드를 줄이기 위해, 인증 객체(Authentication) 내부에
|
||||
* 이 정보들을 함께 캐싱(Caching)하여 운반하도록 설계했습니다.
|
||||
*/
|
||||
class CustomUserDetails(
|
||||
// DB의 Primary Key (비즈니스 로직에서 조인이나 조회 시 사용)
|
||||
val memberId: Long,
|
||||
|
||||
// UI 표시용 닉네임 (매번 회원 정보를 조회하지 않기 위함)
|
||||
val nickname: String,
|
||||
|
||||
username: String,
|
||||
password: String,
|
||||
authorities: Collection<GrantedAuthority>
|
||||
|
||||
@@ -8,22 +8,43 @@ import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* [Spring Security 사용자 로드 서비스]
|
||||
*
|
||||
* Spring Security의 인증 매니저(AuthenticationManager)가 실제 DB에 저장된 사용자 정보를
|
||||
* 조회할 수 있도록 지원하는 핵심 인터페이스(UserDetailsService)의 구현체입니다.
|
||||
*
|
||||
* 도메인 영역의 [Member] 엔티티를 시큐리티 영역의 [UserDetails] 객체로 변환(Adapt)하는 역할을 수행합니다.
|
||||
*/
|
||||
@Service
|
||||
class CustomUserDetailsService(
|
||||
private val memberRepository: MemberRepository
|
||||
) : UserDetailsService {
|
||||
|
||||
/**
|
||||
* 사용자의 식별자(여기서는 이메일)로 DB에서 사용자 정보를 조회합니다.
|
||||
* 로그인 요청 시 내부적으로 호출되며, 조회 실패 시 시큐리티 규격에 맞는 예외를 던집니다.
|
||||
*/
|
||||
override fun loadUserByUsername(username: String): UserDetails {
|
||||
return memberRepository.findByEmail(username)
|
||||
?.let { createUserDetails(it) }
|
||||
?: throw UsernameNotFoundException("해당 유저를 찾을 수 없습니다: $username")
|
||||
}
|
||||
|
||||
/**
|
||||
* [UserDetails 변환 로직]
|
||||
*
|
||||
* 조회된 Member 엔티티를 기반으로 인증 객체(CustomUserDetails)를 생성합니다.
|
||||
*
|
||||
* [최적화 전략]
|
||||
* Spring Security가 제공하는 기본 User 객체 대신, 직접 정의한 CustomUserDetails를 반환함으로써
|
||||
* 추후 컨트롤러나 서비스 계층에서 @AuthenticationPrincipal을 통해
|
||||
* DB 추가 조회 없이도 사용자 식별자(ID)와 닉네임에 즉시 접근할 수 있도록 설계했습니다.
|
||||
*/
|
||||
private fun createUserDetails(member: Member): UserDetails {
|
||||
// [수정] 표준 User 객체 대신, ID와 닉네임을 포함하는 CustomUserDetails 반환
|
||||
return CustomUserDetails(
|
||||
memberId = member.id!!, // 토큰에 넣을 ID
|
||||
nickname = member.nickname, // 토큰에 넣을 닉네임
|
||||
memberId = member.id!!, // 비즈니스 로직용 PK 캐싱
|
||||
nickname = member.nickname, // UI 렌더링용 닉네임 캐싱
|
||||
username = member.email,
|
||||
password = member.password,
|
||||
authorities = listOf(SimpleGrantedAuthority(member.role.name))
|
||||
|
||||
@@ -7,17 +7,32 @@ import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* [이메일 인증 서비스]
|
||||
*
|
||||
* 회원가입 시 본인 확인을 위한 OTP(One Time Password) 발송 및 검증 로직을 담당합니다.
|
||||
*
|
||||
* [아키텍처 설계]
|
||||
* 인증 코드의 상태(State) 관리를 위해 인메모리 DB인 Redis를 사용합니다.
|
||||
* RDB를 사용하지 않음으로써 만료된 코드의 정리(Cleanup) 비용을 없애고, 빠른 액세스 속도를 보장합니다.
|
||||
*/
|
||||
@Service
|
||||
class EmailService(
|
||||
private val javaMailSender: JavaMailSender,
|
||||
private val redisTemplate: RedisTemplate<String, String>
|
||||
) {
|
||||
|
||||
// 인증 코드 전송
|
||||
/**
|
||||
* 인증 코드를 생성하고 이메일로 발송합니다.
|
||||
*
|
||||
* 생성된 코드는 Redis에 저장되며, 보안을 위해 짧은 유효시간(TTL)을 가집니다.
|
||||
* 이메일 발송은 외부 SMTP 서버를 이용하므로, 트래픽 급증 시 비동기 큐(RabbitMQ/Kafka) 도입을 고려할 수 있습니다.
|
||||
*/
|
||||
fun sendVerificationCode(email: String) {
|
||||
val code = createVerificationCode()
|
||||
|
||||
// 1. Redis에 저장 (Key: "Verify:이메일", Value: 코드, 유효시간: 5분)
|
||||
// Redis 저장 전략: Key에 Prefix("Verify:")를 붙여 네임스페이스를 구분하고,
|
||||
// 5분의 TTL(Time-To-Live)을 설정하여 별도의 삭제 로직 없이 자동 만료되도록 처리함.
|
||||
redisTemplate.opsForValue().set(
|
||||
"Verify:$email",
|
||||
code,
|
||||
@@ -25,20 +40,30 @@ class EmailService(
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
|
||||
// 2. 메일 발송
|
||||
sendMail(email, code)
|
||||
}
|
||||
|
||||
// 인증 코드 검증
|
||||
/**
|
||||
* 사용자가 입력한 코드와 Redis에 저장된 원본 코드를 대조합니다.
|
||||
* 코드가 만료되었거나 일치하지 않을 경우 false를 반환합니다.
|
||||
*/
|
||||
fun verifyCode(email: String, code: String): Boolean {
|
||||
val savedCode = redisTemplate.opsForValue().get("Verify:$email")
|
||||
return savedCode != null && savedCode == code
|
||||
}
|
||||
|
||||
/**
|
||||
* 6자리 숫자(100000 ~ 999999)로 구성된 난수를 생성합니다.
|
||||
* 보안성과 사용자 입력 편의성(Usability) 사이의 균형을 맞춘 길이입니다.
|
||||
*/
|
||||
private fun createVerificationCode(): String {
|
||||
return Random.nextInt(100000, 999999).toString() // 6자리 난수
|
||||
return Random.nextInt(100000, 999999).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 템플릿을 사용하여 인증 메일을 발송합니다.
|
||||
* 단순 텍스트보다 신뢰감을 주고 브랜드 아이덴티티를 전달하기 위해 인라인 스타일(CSS)을 적용했습니다.
|
||||
*/
|
||||
private fun sendMail(email: String, code: String) {
|
||||
val mimeMessage = javaMailSender.createMimeMessage()
|
||||
val helper = MimeMessageHelper(mimeMessage, "utf-8")
|
||||
@@ -46,6 +71,7 @@ class EmailService(
|
||||
helper.setTo(email)
|
||||
helper.setSubject("[Blog] 회원가입 인증 코드입니다.")
|
||||
|
||||
// HTML 본문 구성 (이메일 클라이언트 호환성을 위해 Inline CSS 사용 권장)
|
||||
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;">
|
||||
@@ -71,7 +97,7 @@ class EmailService(
|
||||
</div>
|
||||
""".trimIndent()
|
||||
|
||||
helper.setText(htmlContent, true) // true: HTML 모드 켜기
|
||||
helper.setText(htmlContent, true) // true: HTML 모드 활성화
|
||||
javaMailSender.send(mimeMessage)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,31 @@ import org.springframework.data.redis.core.RedisTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* [Refresh Token 저장소]
|
||||
*
|
||||
* JWT 인증 방식의 핵심인 Refresh Token의 생명주기(저장, 조회, 삭제)를 관리하는 리포지토리입니다.
|
||||
*
|
||||
* [기술적 의사결정: Redis]
|
||||
* RDB 대신 In-Memory DB인 Redis를 선택한 이유는 다음과 같습니다.
|
||||
* 1. TTL(Time-To-Live): 토큰 만료 시 별도의 배치 작업 없이 자동으로 데이터를 삭제하여 스토리지 공간을 효율적으로 관리할 수 있습니다.
|
||||
* 2. Performance: 잦은 I/O가 발생하는 토큰 검증 과정에서 디스크 기반 DB보다 훨씬 빠른 응답 속도를 보장합니다.
|
||||
*/
|
||||
@Repository
|
||||
class RefreshTokenRepository(
|
||||
private val redisTemplate: RedisTemplate<String, String>,
|
||||
@Value("\${jwt.refresh-token-validity}") private val refreshTokenValidity: Long
|
||||
) {
|
||||
// 저장 (Key: Email, Value: RefreshToken)
|
||||
|
||||
/**
|
||||
* Refresh Token을 저장합니다.
|
||||
*
|
||||
* @param email 사용자 식별자 (Key)
|
||||
* @param refreshToken 발급된 토큰 (Value)
|
||||
*
|
||||
* Key에는 "RT:" 접두어(Prefix)를 붙여 Redis 내의 다른 데이터와 네임스페이스를 분리합니다.
|
||||
* 유효 기간(refreshTokenValidity)을 설정하여 해당 시간이 지나면 Redis에서 자동 소멸되도록 합니다.
|
||||
*/
|
||||
fun save(email: String, refreshToken: String) {
|
||||
redisTemplate.opsForValue().set(
|
||||
"RT:$email",
|
||||
@@ -20,12 +39,22 @@ class RefreshTokenRepository(
|
||||
)
|
||||
}
|
||||
|
||||
// 조회
|
||||
/**
|
||||
* 사용자의 이메일로 저장된 Refresh Token을 조회합니다.
|
||||
*
|
||||
* 토큰 재발급(Reissue) 요청 시 클라이언트가 보낸 토큰과 서버에 저장된 토큰의 일치 여부를
|
||||
* 검증하기 위해 사용됩니다. (Refresh Token Rotation 전략의 핵심)
|
||||
*/
|
||||
fun findByEmail(email: String): String? {
|
||||
return redisTemplate.opsForValue().get("RT:$email")
|
||||
}
|
||||
|
||||
// 삭제 (로그아웃 시)
|
||||
/**
|
||||
* Refresh Token을 삭제합니다.
|
||||
*
|
||||
* 사용자가 로그아웃하거나, 보안상의 이유로 토큰을 무효화해야 할 때 호출됩니다.
|
||||
* Redis에서 즉시 제거(Evict)하므로, 이후 해당 토큰으로는 액세스 토큰을 재발급받을 수 없습니다.
|
||||
*/
|
||||
fun delete(email: String) {
|
||||
redisTemplate.delete("RT:$email")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user