From 49d435079f5a918424e097a0fc365052df17b0d6 Mon Sep 17 00:00:00 2001 From: "pwy3282040@msecure.co" Date: Fri, 26 Dec 2025 12:58:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EA=B0=80=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [인프라] - 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) 적용 --- .gitignore | 3 + build.gradle.kts | 1 + docker-compose.yml | 24 ++- .../blogbackend/api/common/ApiResponse.kt | 17 +++ .../api/controller/AuthController.kt | 49 ++++++ .../me/wypark/blogbackend/api/dto/AuthDtos.kt | 37 +++++ .../blogbackend/core/config/MailConfig.kt | 50 ++++++ .../core/error/GlobalExceptionHandler.kt | 38 +++++ .../blogbackend/domain/auth/AuthService.kt | 144 ++++++++++++++++++ .../domain/auth/CustomUserDetailsService.kt | 30 ++++ .../blogbackend/domain/auth/EmailService.kt | 77 ++++++++++ .../domain/common/BaseTimeEntity.kt | 22 +++ .../wypark/blogbackend/domain/user/Member.kt | 37 +++++ .../domain/user/MemberRepository.kt | 10 ++ src/main/resources/application-prod.yml | 16 ++ 15 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt create mode 100644 src/main/kotlin/me/wypark/blogbackend/domain/user/MemberRepository.kt diff --git a/.gitignore b/.gitignore index 1627f2f..2c4af83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md .gradle +.env build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ @@ -38,4 +39,6 @@ out/ ### Kotlin ### .kotlin + +### docker ### postgres_data \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e0b3b82..307b0cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-mail") // 2. Kotlin Modules implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/docker-compose.yml b/docker-compose.yml index a9d0b66..5c046d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,8 @@ services: environment: # Database - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/blog_db - - SPRING_DATASOURCE_USERNAME=wypark - - SPRING_DATASOURCE_PASSWORD=your_password + - SPRING_DATASOURCE_USERNAME=${DB_USER} + - SPRING_DATASOURCE_PASSWORD=${DB_PASS} # Redis - SPRING_DATA_REDIS_HOST=redis - SPRING_DATA_REDIS_PORT=6379 @@ -19,6 +19,18 @@ services: - CLOUD_AWS_CREDENTIALS_SECRET_KEY=password - CLOUD_AWS_REGION_STATIC=ap-northeast-2 - 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: db: condition: service_healthy @@ -35,13 +47,13 @@ services: ports: - "5432:5432" environment: - POSTGRES_USER: wypark - POSTGRES_PASSWORD: your_password + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} POSTGRES_DB: blog_db volumes: - ./postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U wypark -d blog_db"] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d blog_db" ] interval: 5s retries: 5 networks: @@ -53,7 +65,7 @@ services: container_name: blog-redis restart: always healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 5s retries: 5 networks: diff --git a/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt b/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt new file mode 100644 index 0000000..0c75650 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/common/ApiResponse.kt @@ -0,0 +1,17 @@ +package me.wypark.blogbackend.api.common + +data class ApiResponse( + val code: String = "SUCCESS", + val message: String = "요청이 성공했습니다.", + val data: T? = null +) { + companion object { + fun success(data: T? = null, message: String = "요청이 성공했습니다."): ApiResponse { + return ApiResponse("SUCCESS", message, data) + } + + fun error(message: String, code: String = "ERROR"): ApiResponse { + return ApiResponse(code, message, null) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt new file mode 100644 index 0000000..d11014c --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/AuthController.kt @@ -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> { + authService.signup(request) + return ResponseEntity.ok(ApiResponse.success(message = "회원가입에 성공했습니다. 이메일 인증을 완료해주세요.")) + } + + @PostMapping("/verify") + fun verifyEmail(@RequestBody @Valid request: VerifyEmailRequest): ResponseEntity> { + authService.verifyEmail(request.email, request.code) + return ResponseEntity.ok(ApiResponse.success(message = "이메일 인증이 완료되었습니다.")) + } + + @PostMapping("/login") + fun login(@RequestBody @Valid request: LoginRequest): ResponseEntity> { + val tokenDto = authService.login(request) + return ResponseEntity.ok(ApiResponse.success(tokenDto)) + } + + @PostMapping("/reissue") + fun reissue(@RequestBody request: ReissueRequest): ResponseEntity> { + val tokenDto = authService.reissue(request.accessToken, request.refreshToken) + return ResponseEntity.ok(ApiResponse.success(tokenDto)) + } + + @PostMapping("/logout") + fun logout(@AuthenticationPrincipal user: User): ResponseEntity> { + authService.logout(user.username) // user.username은 email입니다. + return ResponseEntity.ok(ApiResponse.success(message = "로그아웃 되었습니다.")) + } +} + +data class ReissueRequest(val accessToken: String, val refreshToken: String) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt new file mode 100644 index 0000000..ac4b2df --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/AuthDtos.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt new file mode 100644 index 0000000..4e5b9f3 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/MailConfig.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt b/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..21a4fb7 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/core/error/GlobalExceptionHandler.kt @@ -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> { + return ResponseEntity + .badRequest() + .body(ApiResponse.error(e.message ?: "잘못된 요청입니다.")) + } + + // 2. @Valid 검증 실패 (DTO 유효성 체크) + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity> { + val message = e.bindingResult.fieldErrors.firstOrNull()?.defaultMessage ?: "입력값이 올바르지 않습니다." + return ResponseEntity + .badRequest() + .body(ApiResponse.error(message)) + } + + // 3. 나머지 알 수 없는 에러 + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity> { + e.printStackTrace() // 로그 남기기 + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("서버 내부 오류가 발생했습니다.")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt new file mode 100644 index 0000000..f60e0f1 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/AuthService.kt @@ -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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt new file mode 100644 index 0000000..08e1c37 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/CustomUserDetailsService.kt @@ -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)) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt new file mode 100644 index 0000000..5b743a4 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/auth/EmailService.kt @@ -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 +) { + + // 인증 코드 전송 + 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 = """ +
+

+ Wypark Blog
+ 메일인증 안내입니다. +

+

+ 안녕하세요.
+ Wypark Blog에 가입해 주셔서 진심으로 감사드립니다.
+ 아래 '인증 코드'를 입력하여 회원가입을 완료해 주세요.
+ 감사합니다. +

+ +
+
+ $code +
+
+ +
+

이 코드는 5분간 유효합니다.

+
+
+ """.trimIndent() + + helper.setText(htmlContent, true) // true: HTML 모드 켜기 + javaMailSender.send(mimeMessage) + } +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt b/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt new file mode 100644 index 0000000..5d73e9f --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/common/BaseTimeEntity.kt @@ -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() +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt b/src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt new file mode 100644 index 0000000..65a07ad --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/user/Member.kt @@ -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 +} \ No newline at end of file diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/user/MemberRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/user/MemberRepository.kt new file mode 100644 index 0000000..6191486 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/user/MemberRepository.kt @@ -0,0 +1,10 @@ +package me.wypark.blogbackend.domain.user + +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberRepository : JpaRepository { + // 로그인 및 중복 가입 방지를 위한 핵심 메소드들입니다. + fun findByEmail(email: String): Member? + fun existsByEmail(email: String): Boolean + fun existsByNickname(nickname: String): Boolean +} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7e8f51e..82123e6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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: