Compare commits
2 Commits
main
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fbfcaf90b | ||
|
|
49d435079f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
HELP.md
|
HELP.md
|
||||||
.gradle
|
.gradle
|
||||||
|
.env
|
||||||
build/
|
build/
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
@@ -38,4 +39,6 @@ out/
|
|||||||
|
|
||||||
### Kotlin ###
|
### Kotlin ###
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
|
### docker ###
|
||||||
postgres_data
|
postgres_data
|
||||||
@@ -30,6 +30,7 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||||
|
|
||||||
// 2. Kotlin Modules
|
// 2. Kotlin Modules
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Database
|
# Database
|
||||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db
|
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db
|
||||||
- SPRING_DATASOURCE_USERNAME=wypark
|
- SPRING_DATASOURCE_USERNAME=${DB_USER}
|
||||||
- SPRING_DATASOURCE_PASSWORD=your_password
|
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
|
||||||
# Redis
|
# Redis
|
||||||
- SPRING_DATA_REDIS_HOST=redis
|
- SPRING_DATA_REDIS_HOST=redis
|
||||||
- SPRING_DATA_REDIS_PORT=6379
|
- SPRING_DATA_REDIS_PORT=6379
|
||||||
@@ -19,6 +19,18 @@ services:
|
|||||||
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
|
- CLOUD_AWS_CREDENTIALS_SECRET_KEY=password
|
||||||
- CLOUD_AWS_REGION_STATIC=ap-northeast-2
|
- CLOUD_AWS_REGION_STATIC=ap-northeast-2
|
||||||
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000
|
- CLOUD_AWS_S3_ENDPOINT=http://minio:9000
|
||||||
|
# SMTP 메일 설정
|
||||||
|
- SPRING_MAIL_HOST=smtp.gmail.com
|
||||||
|
- SPRING_MAIL_PORT=587
|
||||||
|
- SPRING_MAIL_USERNAME=${MAIL_USER}
|
||||||
|
- SPRING_MAIL_PASSWORD=${MAIL_PASS}
|
||||||
|
# SMTP 세부 설정
|
||||||
|
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true
|
||||||
|
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true
|
||||||
|
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED=true
|
||||||
|
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT=5000
|
||||||
|
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT=5000
|
||||||
|
- SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT=5000
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -35,13 +47,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: wypark
|
POSTGRES_USER: ${DB_USER}
|
||||||
POSTGRES_PASSWORD: your_password
|
POSTGRES_PASSWORD: ${DB_PASS}
|
||||||
POSTGRES_DB: blog_db
|
POSTGRES_DB: blog_db
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres_data:/var/lib/postgresql/data
|
- ./postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U wypark -d blog_db"]
|
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d blog_db" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
@@ -53,7 +65,7 @@ services:
|
|||||||
container_name: blog-redis
|
container_name: blog-redis
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: [ "CMD", "redis-cli", "ping" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
37
src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt
Normal file
37
src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt
Normal 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("서버 내부 오류가 발생했습니다."))
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt
Normal file
144
src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
37
src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt
Normal file
37
src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -21,6 +21,22 @@ spring:
|
|||||||
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
|
highlight_sql: true # 쿼리 색상 강조 (가독성 UP)
|
||||||
open-in-view: false # OSIV 종료 (DB 커넥션 최적화)
|
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 설정
|
# 3. Redis 설정
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
@@ -45,5 +61,5 @@ cloud:
|
|||||||
jwt:
|
jwt:
|
||||||
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
|
# 보안 경고: 실제 배포 시에는 절대 코드에 비밀키를 남기지 마세요. 환경변수로 주입받으세요.
|
||||||
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!}
|
secret: ${JWT_SECRET:v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!}
|
||||||
access-token-validity: 1800000 # 30분
|
access-token-validity: 600000
|
||||||
refresh-token-validity: 604800000 # 7일
|
refresh-token-validity: 604800000
|
||||||
Reference in New Issue
Block a user