From b64b8f35a86290b59641209e7c14882890204bf8 Mon Sep 17 00:00:00 2001 From: s26049 Date: Thu, 22 Oct 2026 18:29:00 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=EB=94=94=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 14 ++++++++++- .../moss/domain/auth/dto/FindIdRequest.java | 14 +++++++++++ .../moss/domain/auth/dto/FindIdResponse.java | 6 +++++ .../auth/repository/UserRepository.java | 2 ++ .../domain/auth/service/FindIdService.java | 25 +++++++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/teamnative/moss/domain/auth/dto/FindIdRequest.java create mode 100644 src/main/java/com/teamnative/moss/domain/auth/dto/FindIdResponse.java create mode 100644 src/main/java/com/teamnative/moss/domain/auth/service/FindIdService.java diff --git a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java index c4aa7b1..bc2eff4 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java +++ b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java @@ -1,12 +1,15 @@ package com.teamnative.moss.domain.auth.controller; import com.teamnative.moss.domain.auth.dto.BusinessSignupRequest; +import com.teamnative.moss.domain.auth.dto.FindIdRequest; +import com.teamnative.moss.domain.auth.dto.FindIdResponse; import com.teamnative.moss.domain.auth.dto.LoginRequest; import com.teamnative.moss.domain.auth.dto.LoginResponse; import com.teamnative.moss.domain.auth.dto.RefreshTokenRequest; import com.teamnative.moss.domain.auth.dto.SignupResponse; import com.teamnative.moss.domain.auth.dto.StudentSignupRequest; import com.teamnative.moss.domain.auth.service.BusinessSignupService; +import com.teamnative.moss.domain.auth.service.FindIdService; import com.teamnative.moss.domain.auth.service.LoginService; import com.teamnative.moss.domain.auth.service.StudentSignupService; import com.teamnative.moss.global.dto.ApiResponse; @@ -27,15 +30,18 @@ public class AuthController { private final StudentSignupService studentSignupService; private final BusinessSignupService businessSignupService; private final LoginService loginService; + private final FindIdService findIdService; public AuthController( StudentSignupService studentSignupService, BusinessSignupService businessSignupService, - LoginService loginService + LoginService loginService, + FindIdService findIdService ) { this.studentSignupService = studentSignupService; this.businessSignupService = businessSignupService; this.loginService = loginService; + this.findIdService = findIdService; } @PostMapping("/signup/business") @@ -65,4 +71,10 @@ public ApiResponse login(@Valid @RequestBody LoginRequest request public ApiResponse refresh(@Valid @RequestBody RefreshTokenRequest request) { return ApiResponse.success("토큰이 갱신되었습니다.", loginService.refresh(request)); } + + @PostMapping("/find-id") + @Operation(summary = "아이디 찾기", description = "이름과 전화번호로 가입된 이메일과 계정 생성일을 조회합니다.") + public ApiResponse findId(@Valid @RequestBody FindIdRequest request) { + return ApiResponse.success("계정 정보를 찾았습니다.", findIdService.findId(request)); + } } diff --git a/src/main/java/com/teamnative/moss/domain/auth/dto/FindIdRequest.java b/src/main/java/com/teamnative/moss/domain/auth/dto/FindIdRequest.java new file mode 100644 index 0000000..4d24380 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/dto/FindIdRequest.java @@ -0,0 +1,14 @@ +package com.teamnative.moss.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record FindIdRequest( + @NotBlank(message = "이름을 입력해 주세요.") + String name, + + @NotBlank(message = "전화번호를 입력해 주세요.") + @Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "전화번호 형식이 올바르지 않습니다.") + String number +) { +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/dto/FindIdResponse.java b/src/main/java/com/teamnative/moss/domain/auth/dto/FindIdResponse.java new file mode 100644 index 0000000..ec7c45a --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/dto/FindIdResponse.java @@ -0,0 +1,6 @@ +package com.teamnative.moss.domain.auth.dto; + +import java.time.LocalDate; + +public record FindIdResponse(String email, LocalDate createdAt) { +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java b/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java index 194e0e3..5d45e36 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java @@ -15,6 +15,8 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + Optional findByNameAndNumber(String name, String number); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select user from UserAccount user where user.email = :email") Optional findByEmailForUpdate(@Param("email") String email); diff --git a/src/main/java/com/teamnative/moss/domain/auth/service/FindIdService.java b/src/main/java/com/teamnative/moss/domain/auth/service/FindIdService.java new file mode 100644 index 0000000..4be94d6 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/service/FindIdService.java @@ -0,0 +1,25 @@ +package com.teamnative.moss.domain.auth.service; + +import com.teamnative.moss.domain.auth.dto.FindIdRequest; +import com.teamnative.moss.domain.auth.dto.FindIdResponse; +import com.teamnative.moss.domain.auth.entity.UserAccount; +import com.teamnative.moss.domain.auth.repository.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class FindIdService { + private final UserRepository userRepository; + + public FindIdService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public FindIdResponse findId(FindIdRequest request) { + UserAccount user = userRepository.findByNameAndNumber(request.name(), request.number()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "입력한 정보와 일치하는 계정을 찾을 수 없습니다.")); + return new FindIdResponse(user.getEmail(), user.getCreatedAt().toLocalDate()); + } +} From a07581f8c09b6f8c15eec7001724bd414440d303 Mon Sep 17 00:00:00 2001 From: s26049 Date: Thu, 22 Oct 2026 20:58:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../auth/controller/AuthController.java | 14 +++- .../domain/auth/dto/SendEmailRequest.java | 11 +++ .../domain/auth/entity/EmailVerification.java | 44 ++++++++++ .../EmailVerificationRepository.java | 7 ++ .../service/EmailVerificationService.java | 81 +++++++++++++++++++ .../domain/auth/service/TokenService.java | 8 +- .../moss/global/config/SecurityConfig.java | 2 +- .../exception/GlobalExceptionHandler.java | 9 ++- .../global/logging/HttpLoggingFilter.java | 4 +- src/main/resources/application.yml | 15 ++++ .../global/config/SecurityConfigTest.java | 2 +- src/test/resources/application.yml | 3 +- 14 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/teamnative/moss/domain/auth/dto/SendEmailRequest.java create mode 100644 src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java create mode 100644 src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java create mode 100644 src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java diff --git a/build.gradle b/build.gradle index ae16bdf..542f161 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.1.0' + id 'org.springframework.boot' version '3.5.16' id 'io.spring.dependency-management' version '1.1.7' } @@ -16,15 +16,17 @@ java { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1' + implementation 'org.yaml:snakeyaml' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.eclipse.angus:angus-mail' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1e922f4..d4081da 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java index bc2eff4..43c542c 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java +++ b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java @@ -7,9 +7,11 @@ import com.teamnative.moss.domain.auth.dto.LoginResponse; import com.teamnative.moss.domain.auth.dto.RefreshTokenRequest; import com.teamnative.moss.domain.auth.dto.SignupResponse; +import com.teamnative.moss.domain.auth.dto.SendEmailRequest; import com.teamnative.moss.domain.auth.dto.StudentSignupRequest; import com.teamnative.moss.domain.auth.service.BusinessSignupService; import com.teamnative.moss.domain.auth.service.FindIdService; +import com.teamnative.moss.domain.auth.service.EmailVerificationService; import com.teamnative.moss.domain.auth.service.LoginService; import com.teamnative.moss.domain.auth.service.StudentSignupService; import com.teamnative.moss.global.dto.ApiResponse; @@ -31,17 +33,20 @@ public class AuthController { private final BusinessSignupService businessSignupService; private final LoginService loginService; private final FindIdService findIdService; + private final EmailVerificationService emailVerificationService; public AuthController( StudentSignupService studentSignupService, BusinessSignupService businessSignupService, LoginService loginService, - FindIdService findIdService + FindIdService findIdService, + EmailVerificationService emailVerificationService ) { this.studentSignupService = studentSignupService; this.businessSignupService = businessSignupService; this.loginService = loginService; this.findIdService = findIdService; + this.emailVerificationService = emailVerificationService; } @PostMapping("/signup/business") @@ -77,4 +82,11 @@ public ApiResponse refresh(@Valid @RequestBody RefreshTokenReques public ApiResponse findId(@Valid @RequestBody FindIdRequest request) { return ApiResponse.success("계정 정보를 찾았습니다.", findIdService.findId(request)); } + + @PostMapping("/send-email") + @Operation(summary = "이메일 인증번호 발송", description = "입력한 이메일 주소로 5분간 유효한 인증번호를 발송합니다.") + public ApiResponse sendEmail(@Valid @RequestBody SendEmailRequest request) { + emailVerificationService.sendVerificationCode(request); + return ApiResponse.success("인증번호를 이메일로 발송했습니다.", null); + } } diff --git a/src/main/java/com/teamnative/moss/domain/auth/dto/SendEmailRequest.java b/src/main/java/com/teamnative/moss/domain/auth/dto/SendEmailRequest.java new file mode 100644 index 0000000..7f21083 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/dto/SendEmailRequest.java @@ -0,0 +1,11 @@ +package com.teamnative.moss.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SendEmailRequest( + @NotBlank(message = "이메일을 입력해 주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email +) { +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java new file mode 100644 index 0000000..36489ec --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java @@ -0,0 +1,44 @@ +package com.teamnative.moss.domain.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "email_verifications") +public class EmailVerification { + @Id + @Column(nullable = false, length = 254) + private String email; + + @Column(name = "code_hash", nullable = false, length = 100) + private String codeHash; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "sent_at", nullable = false) + private LocalDateTime sentAt; + + protected EmailVerification() { + } + + public EmailVerification(String email, String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) { + this.email = email; + this.codeHash = codeHash; + this.sentAt = sentAt; + this.expiresAt = expiresAt; + } + + public LocalDateTime getSentAt() { + return sentAt; + } + + public void replaceCode(String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) { + this.codeHash = codeHash; + this.sentAt = sentAt; + this.expiresAt = expiresAt; + } +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java new file mode 100644 index 0000000..27e2cea --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java @@ -0,0 +1,7 @@ +package com.teamnative.moss.domain.auth.repository; + +import com.teamnative.moss.domain.auth.entity.EmailVerification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailVerificationRepository extends JpaRepository { +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java new file mode 100644 index 0000000..64dc0bf --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java @@ -0,0 +1,81 @@ +package com.teamnative.moss.domain.auth.service; + +import com.teamnative.moss.domain.auth.dto.SendEmailRequest; +import com.teamnative.moss.domain.auth.entity.EmailVerification; +import com.teamnative.moss.domain.auth.repository.EmailVerificationRepository; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Locale; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class EmailVerificationService { + private static final int CODE_BOUND = 1_000_000; + private static final long CODE_TTL_MINUTES = 2; + private static final long RESEND_INTERVAL_SECONDS = 60; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final EmailVerificationRepository verificationRepository; + private final PasswordEncoder passwordEncoder; + private final ObjectProvider mailSenderProvider; + private final String fromAddress; + + public EmailVerificationService( + EmailVerificationRepository verificationRepository, + PasswordEncoder passwordEncoder, + ObjectProvider mailSenderProvider, + @Value("${app.mail.from:no-reply@example.com}") String fromAddress + ) { + this.verificationRepository = verificationRepository; + this.passwordEncoder = passwordEncoder; + this.mailSenderProvider = mailSenderProvider; + this.fromAddress = fromAddress; + } + + @Transactional + public void sendVerificationCode(SendEmailRequest request) { + JavaMailSender mailSender = mailSenderProvider.getIfAvailable(); + if (mailSender == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "이메일 발송 설정이 완료되지 않았습니다."); + } + + String email = request.email().trim().toLowerCase(Locale.ROOT); + LocalDateTime now = LocalDateTime.now(); + EmailVerification verification = verificationRepository.findById(email).orElse(null); + if (verification != null && verification.getSentAt().plusSeconds(RESEND_INTERVAL_SECONDS).isAfter(now)) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "인증번호는 60초 후에 다시 요청할 수 있습니다."); + } + + String code = String.format("%06d", SECURE_RANDOM.nextInt(CODE_BOUND)); + LocalDateTime expiresAt = now.plusMinutes(CODE_TTL_MINUTES); + if (verification == null) { + verification = new EmailVerification(email, passwordEncoder.encode(code), now, expiresAt); + } else { + verification.replaceCode(passwordEncoder.encode(code), now, expiresAt); + } + verificationRepository.saveAndFlush(verification); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(email); + message.setSubject("MOSS 이메일 인증번호"); + message.setText("이메일 인증번호는 " + code + " 입니다.\n인증번호는 발송 시각부터 2분간 유효합니다."); + try { + mailSender.send(message); + } catch (MailException exception) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "인증 이메일을 발송하지 못했습니다. 잠시 후 다시 시도해 주세요.", exception); + } + } +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/service/TokenService.java b/src/main/java/com/teamnative/moss/domain/auth/service/TokenService.java index fc6b93e..687a8b5 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/service/TokenService.java +++ b/src/main/java/com/teamnative/moss/domain/auth/service/TokenService.java @@ -5,8 +5,8 @@ import java.time.Instant; import java.util.Base64; import java.security.MessageDigest; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.springframework.beans.factory.annotation.Value; @@ -67,9 +67,9 @@ private TokenClaims validateToken(String token, String expectedType) { throw new IllegalArgumentException("Invalid token signature."); } JsonNode payload = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1])); - String email = payload.get("sub").asString(); + String email = payload.get("sub").asText(); long userId = payload.get("userId").asLong(); - String type = payload.get("type").asString(); + String type = payload.get("type").asText(); long expiresAt = payload.get("exp").asLong(); if (!expectedType.equals(type) || expiresAt <= Instant.now().getEpochSecond() || email.isBlank() || userId <= 0) { diff --git a/src/main/java/com/teamnative/moss/global/config/SecurityConfig.java b/src/main/java/com/teamnative/moss/global/config/SecurityConfig.java index a2561d6..052a866 100644 --- a/src/main/java/com/teamnative/moss/global/config/SecurityConfig.java +++ b/src/main/java/com/teamnative/moss/global/config/SecurityConfig.java @@ -16,7 +16,7 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.boot.web.servlet.FilterRegistrationBean; -import tools.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectMapper; @Configuration @EnableWebSecurity diff --git a/src/main/java/com/teamnative/moss/global/exception/GlobalExceptionHandler.java b/src/main/java/com/teamnative/moss/global/exception/GlobalExceptionHandler.java index d1428c8..70134a8 100644 --- a/src/main/java/com/teamnative/moss/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/teamnative/moss/global/exception/GlobalExceptionHandler.java @@ -3,8 +3,12 @@ import com.teamnative.moss.global.dto.ApiResponse; import com.teamnative.moss.domain.auth.dto.LoginFailureData; import com.teamnative.moss.domain.auth.service.LoginFailureException; +import com.teamnative.moss.global.logging.HttpLoggingFilter; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -14,6 +18,7 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(LoginFailureException.class) public ResponseEntity> handleLoginFailure(LoginFailureException exception) { @@ -59,7 +64,9 @@ public ResponseEntity> handleNoResourceFound(NoResourceFoundEx } @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception exception) { + public ResponseEntity> handleException(Exception exception, HttpServletRequest request) { + Object traceId = request.getAttribute(HttpLoggingFilter.TRACE_ID_ATTRIBUTE); + logger.error("Unhandled request exception. traceId={}", traceId, exception); return error(HttpStatus.INTERNAL_SERVER_ERROR, "예기치 못한 서버 오류가 발생했습니다."); } diff --git a/src/main/java/com/teamnative/moss/global/logging/HttpLoggingFilter.java b/src/main/java/com/teamnative/moss/global/logging/HttpLoggingFilter.java index e4008b6..b141aa2 100644 --- a/src/main/java/com/teamnative/moss/global/logging/HttpLoggingFilter.java +++ b/src/main/java/com/teamnative/moss/global/logging/HttpLoggingFilter.java @@ -25,8 +25,8 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.ObjectMapper; @Component public class HttpLoggingFilter extends OncePerRequestFilter { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 96bb2e4..eb89be0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,6 +10,21 @@ spring: open-in-view: false hibernate: ddl-auto: update + mail: + host: ${MAIL_HOST:smtp.gmail.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: ${MAIL_SMTP_AUTH:true} + starttls: + enable: ${MAIL_SMTP_STARTTLS:true} + +app: + mail: + from: "${MAIL_FROM:${MAIL_USERNAME:}}" jwt: secret: ${JWT_SECRET:local-development-secret-change-before-deployment-32-bytes-minimum} diff --git a/src/test/java/com/teamnative/moss/global/config/SecurityConfigTest.java b/src/test/java/com/teamnative/moss/global/config/SecurityConfigTest.java index b41a664..cce75f2 100644 --- a/src/test/java/com/teamnative/moss/global/config/SecurityConfigTest.java +++ b/src/test/java/com/teamnative/moss/global/config/SecurityConfigTest.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3c251ca..653073a 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -2,11 +2,12 @@ spring: datasource: url: jdbc:h2:mem:moss-test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa - password: + password: "" driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create-drop + jwt: secret: test-secret-that-is-at-least-32-bytes-long access-token-expiration-seconds: 900 From 558dcd1768dfab8fda5039b65c9262f728b23948 Mon Sep 17 00:00:00 2001 From: s26049 Date: Fri, 23 Oct 2026 07:58:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 9 ++++ .../domain/auth/dto/VerifyEmailRequest.java | 16 +++++++ .../domain/auth/dto/VerifyEmailResponse.java | 6 +++ .../domain/auth/entity/EmailVerification.java | 48 +++++++++++++++++++ .../EmailVerificationRepository.java | 8 ++++ .../auth/repository/UserRepository.java | 1 + .../service/EmailVerificationService.java | 44 +++++++++++++++++ 7 files changed, 132 insertions(+) create mode 100644 src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailRequest.java create mode 100644 src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailResponse.java diff --git a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java index 43c542c..810ffbe 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java +++ b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java @@ -8,6 +8,8 @@ import com.teamnative.moss.domain.auth.dto.RefreshTokenRequest; import com.teamnative.moss.domain.auth.dto.SignupResponse; import com.teamnative.moss.domain.auth.dto.SendEmailRequest; +import com.teamnative.moss.domain.auth.dto.VerifyEmailRequest; +import com.teamnative.moss.domain.auth.dto.VerifyEmailResponse; import com.teamnative.moss.domain.auth.dto.StudentSignupRequest; import com.teamnative.moss.domain.auth.service.BusinessSignupService; import com.teamnative.moss.domain.auth.service.FindIdService; @@ -89,4 +91,11 @@ public ApiResponse sendEmail(@Valid @RequestBody SendEmailRequest request) emailVerificationService.sendVerificationCode(request); return ApiResponse.success("인증번호를 이메일로 발송했습니다.", null); } + + @PostMapping("/verify-email") + @Operation(summary = "이메일 인증번호 확인", description = "유효한 인증번호를 확인하고 비밀번호 재설정 토큰을 발급합니다.") + public ApiResponse verifyEmail(@Valid @RequestBody VerifyEmailRequest request) { + return ApiResponse.success("이메일 인증에 성공했습니다.", emailVerificationService.verifyEmail(request)); + } + } diff --git a/src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailRequest.java b/src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailRequest.java new file mode 100644 index 0000000..f664d1f --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailRequest.java @@ -0,0 +1,16 @@ +package com.teamnative.moss.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record VerifyEmailRequest( + @NotBlank(message = "이메일을 입력해 주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "인증번호를 입력해 주세요.") + @Pattern(regexp = "^[0-9]{6}$", message = "인증번호는 숫자 6자리여야 합니다.") + String code +) { +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailResponse.java b/src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailResponse.java new file mode 100644 index 0000000..4169fe1 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/dto/VerifyEmailResponse.java @@ -0,0 +1,6 @@ +package com.teamnative.moss.domain.auth.dto; + +import java.time.LocalDateTime; + +public record VerifyEmailResponse(String resetToken, LocalDateTime expiresAt) { +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java index 36489ec..7ad89de 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java +++ b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java @@ -22,6 +22,18 @@ public class EmailVerification { @Column(name = "sent_at", nullable = false) private LocalDateTime sentAt; + @Column(name = "verification_attempts", nullable = false) + private int verificationAttempts; + + @Column(name = "verified_at") + private LocalDateTime verifiedAt; + + @Column(name = "reset_token_hash", length = 100) + private String resetTokenHash; + + @Column(name = "reset_token_expires_at") + private LocalDateTime resetTokenExpiresAt; + protected EmailVerification() { } @@ -30,15 +42,51 @@ public EmailVerification(String email, String codeHash, LocalDateTime sentAt, Lo this.codeHash = codeHash; this.sentAt = sentAt; this.expiresAt = expiresAt; + this.verificationAttempts = 0; } public LocalDateTime getSentAt() { return sentAt; } + public String getCodeHash() { + return codeHash; + } + + public boolean isVerified() { + return verifiedAt != null; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public int getVerificationAttempts() { + return verificationAttempts; + } + + public void recordVerificationFailure() { + verificationAttempts++; + } + + public void markVerified(String resetTokenHash, LocalDateTime verifiedAt, LocalDateTime resetTokenExpiresAt) { + this.verifiedAt = verifiedAt; + this.resetTokenHash = resetTokenHash; + this.resetTokenExpiresAt = resetTokenExpiresAt; + } + + public void consumeResetToken() { + this.resetTokenHash = null; + this.resetTokenExpiresAt = null; + } + public void replaceCode(String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) { this.codeHash = codeHash; this.sentAt = sentAt; this.expiresAt = expiresAt; + this.verificationAttempts = 0; + this.verifiedAt = null; + this.resetTokenHash = null; + this.resetTokenExpiresAt = null; } } diff --git a/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java index 27e2cea..94f1f71 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java @@ -1,7 +1,15 @@ package com.teamnative.moss.domain.auth.repository; import com.teamnative.moss.domain.auth.entity.EmailVerification; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.Optional; public interface EmailVerificationRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select verification from EmailVerification verification where verification.email = :email") + Optional findByEmailForUpdate(@Param("email") String email); } diff --git a/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java b/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java index 5d45e36..813c6ef 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java @@ -20,4 +20,5 @@ public interface UserRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select user from UserAccount user where user.email = :email") Optional findByEmailForUpdate(@Param("email") String email); + } diff --git a/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java index 64dc0bf..6354bf0 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java +++ b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java @@ -1,9 +1,12 @@ package com.teamnative.moss.domain.auth.service; import com.teamnative.moss.domain.auth.dto.SendEmailRequest; +import com.teamnative.moss.domain.auth.dto.VerifyEmailRequest; +import com.teamnative.moss.domain.auth.dto.VerifyEmailResponse; import com.teamnative.moss.domain.auth.entity.EmailVerification; import com.teamnative.moss.domain.auth.repository.EmailVerificationRepository; import java.security.SecureRandom; +import java.util.Base64; import java.time.LocalDateTime; import java.util.Locale; import org.springframework.beans.factory.ObjectProvider; @@ -22,6 +25,8 @@ public class EmailVerificationService { private static final int CODE_BOUND = 1_000_000; private static final long CODE_TTL_MINUTES = 2; private static final long RESEND_INTERVAL_SECONDS = 60; + private static final int MAX_VERIFICATION_ATTEMPTS = 5; + private static final long RESET_TOKEN_TTL_MINUTES = 5; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final EmailVerificationRepository verificationRepository; @@ -78,4 +83,43 @@ public void sendVerificationCode(SendEmailRequest request) { "인증 이메일을 발송하지 못했습니다. 잠시 후 다시 시도해 주세요.", exception); } } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) { + String email = request.email().trim().toLowerCase(Locale.ROOT); + LocalDateTime now = LocalDateTime.now(); + EmailVerification verification = verificationRepository.findByEmailForUpdate(email) + .orElseThrow(() -> invalidVerificationCode()); + + if (verification.isVerified() || !verification.getExpiresAt().isAfter(now)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "인증번호가 유효하지 않거나 만료되었습니다."); + } + if (verification.getVerificationAttempts() >= MAX_VERIFICATION_ATTEMPTS) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "인증번호 입력 횟수를 초과했습니다. 인증번호를 다시 요청해 주세요."); + } + if (!passwordEncoder.matches(request.code(), verification.getCodeHash())) { + verification.recordVerificationFailure(); + verificationRepository.save(verification); + if (verification.getVerificationAttempts() >= MAX_VERIFICATION_ATTEMPTS) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "인증번호 입력 횟수를 초과했습니다. 인증번호를 다시 요청해 주세요."); + } + throw invalidVerificationCode(); + } + + byte[] tokenBytes = new byte[32]; + SECURE_RANDOM.nextBytes(tokenBytes); + String resetToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + LocalDateTime resetTokenExpiresAt = now.plusMinutes(RESET_TOKEN_TTL_MINUTES); + verification.markVerified(passwordEncoder.encode(resetToken), now, resetTokenExpiresAt); + verificationRepository.save(verification); + return new VerifyEmailResponse(resetToken, resetTokenExpiresAt); + } + + private ResponseStatusException invalidVerificationCode() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, + "인증번호가 유효하지 않거나 만료되었습니다."); + } } From 8bbe000efc7c57c059555e35d17fb385e3b4b56c Mon Sep 17 00:00:00 2001 From: s26049 Date: Fri, 23 Oct 2026 08:18:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 8 ++++ .../domain/auth/dto/ResetPasswordRequest.java | 20 ++++++++++ .../domain/auth/entity/EmailVerification.java | 12 ++++++ .../moss/domain/auth/entity/UserAccount.java | 4 ++ .../EmailVerificationRepository.java | 4 ++ .../auth/repository/UserRepository.java | 4 ++ .../service/EmailVerificationService.java | 37 +++++++++++++++++++ 7 files changed, 89 insertions(+) create mode 100644 src/main/java/com/teamnative/moss/domain/auth/dto/ResetPasswordRequest.java diff --git a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java index 810ffbe..b3cc214 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java +++ b/src/main/java/com/teamnative/moss/domain/auth/controller/AuthController.java @@ -6,6 +6,7 @@ import com.teamnative.moss.domain.auth.dto.LoginRequest; import com.teamnative.moss.domain.auth.dto.LoginResponse; import com.teamnative.moss.domain.auth.dto.RefreshTokenRequest; +import com.teamnative.moss.domain.auth.dto.ResetPasswordRequest; import com.teamnative.moss.domain.auth.dto.SignupResponse; import com.teamnative.moss.domain.auth.dto.SendEmailRequest; import com.teamnative.moss.domain.auth.dto.VerifyEmailRequest; @@ -23,6 +24,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -98,4 +100,10 @@ public ApiResponse verifyEmail(@Valid @RequestBody VerifyEm return ApiResponse.success("이메일 인증에 성공했습니다.", emailVerificationService.verifyEmail(request)); } + @PatchMapping("/reset-password") + @Operation(summary = "비밀번호 재설정", description = "이메일 인증으로 발급된 토큰을 사용해 비밀번호를 재설정합니다.") + public ApiResponse resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + emailVerificationService.resetPassword(request); + return ApiResponse.success("비밀번호가 변경되었습니다.", null); + } } diff --git a/src/main/java/com/teamnative/moss/domain/auth/dto/ResetPasswordRequest.java b/src/main/java/com/teamnative/moss/domain/auth/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..a5bff4e --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/dto/ResetPasswordRequest.java @@ -0,0 +1,20 @@ +package com.teamnative.moss.domain.auth.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ResetPasswordRequest( + @NotBlank(message = "재설정 토큰을 입력해주세요.") + String resetToken, + @NotBlank(message = "비밀번호를 입력해주세요.") + @Size(min = 6, max = 15, message = "비밀번호는 6자 이상 15자 이하여야 합니다.") + String password, + @NotBlank(message = "비밀번호 확인을 입력해주세요.") + String passwordConfirm +) { + @AssertTrue(message = "비밀번호가 일치하지 않습니다.") + public boolean isPasswordConfirmed() { + return password != null && password.equals(passwordConfirm); + } +} diff --git a/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java index 7ad89de..1e7add1 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java +++ b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java @@ -80,6 +80,18 @@ public void consumeResetToken() { this.resetTokenExpiresAt = null; } + public String getEmail() { + return email; + } + + public String getResetTokenHash() { + return resetTokenHash; + } + + public LocalDateTime getResetTokenExpiresAt() { + return resetTokenExpiresAt; + } + public void replaceCode(String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) { this.codeHash = codeHash; this.sentAt = sentAt; diff --git a/src/main/java/com/teamnative/moss/domain/auth/entity/UserAccount.java b/src/main/java/com/teamnative/moss/domain/auth/entity/UserAccount.java index dac600f..ad4b63b 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/entity/UserAccount.java +++ b/src/main/java/com/teamnative/moss/domain/auth/entity/UserAccount.java @@ -87,6 +87,10 @@ public String getPasswordHash() { return passwordHash; } + public void changePassword(String passwordHash) { + this.passwordHash = passwordHash; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java index 94f1f71..ae4b935 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java @@ -6,9 +6,13 @@ import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface EmailVerificationRepository extends JpaRepository { + List findAllByVerifiedAtIsNotNullAndResetTokenExpiresAtAfter(LocalDateTime now); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select verification from EmailVerification verification where verification.email = :email") Optional findByEmailForUpdate(@Param("email") String email); diff --git a/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java b/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java index 813c6ef..20989b4 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/UserRepository.java @@ -21,4 +21,8 @@ public interface UserRepository extends JpaRepository { @Query("select user from UserAccount user where user.email = :email") Optional findByEmailForUpdate(@Param("email") String email); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select user from UserAccount user where user.email = :email") + Optional findByEmailForPasswordUpdate(@Param("email") String email); + } diff --git a/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java index 6354bf0..6ccb4d2 100644 --- a/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java +++ b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java @@ -1,10 +1,12 @@ package com.teamnative.moss.domain.auth.service; import com.teamnative.moss.domain.auth.dto.SendEmailRequest; +import com.teamnative.moss.domain.auth.dto.ResetPasswordRequest; import com.teamnative.moss.domain.auth.dto.VerifyEmailRequest; import com.teamnative.moss.domain.auth.dto.VerifyEmailResponse; import com.teamnative.moss.domain.auth.entity.EmailVerification; import com.teamnative.moss.domain.auth.repository.EmailVerificationRepository; +import com.teamnative.moss.domain.auth.repository.UserRepository; import java.security.SecureRandom; import java.util.Base64; import java.time.LocalDateTime; @@ -30,17 +32,20 @@ public class EmailVerificationService { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final EmailVerificationRepository verificationRepository; + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final ObjectProvider mailSenderProvider; private final String fromAddress; public EmailVerificationService( EmailVerificationRepository verificationRepository, + UserRepository userRepository, PasswordEncoder passwordEncoder, ObjectProvider mailSenderProvider, @Value("${app.mail.from:no-reply@example.com}") String fromAddress ) { this.verificationRepository = verificationRepository; + this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.mailSenderProvider = mailSenderProvider; this.fromAddress = fromAddress; @@ -118,6 +123,38 @@ public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) { return new VerifyEmailResponse(resetToken, resetTokenExpiresAt); } + @Transactional + public void resetPassword(ResetPasswordRequest request) { + LocalDateTime now = LocalDateTime.now(); + EmailVerification candidate = verificationRepository.findAllByVerifiedAtIsNotNullAndResetTokenExpiresAtAfter(now).stream() + .filter(row -> row.getResetTokenHash() != null + && passwordEncoder.matches(request.resetToken(), row.getResetTokenHash())) + .findFirst() + .orElseThrow(this::invalidResetToken); + EmailVerification verification = verificationRepository.findByEmailForUpdate(candidate.getEmail()) + .filter(current -> current.isVerified() + && current.getResetTokenExpiresAt() != null + && current.getResetTokenExpiresAt().isAfter(now) + && current.getResetTokenHash() != null + && passwordEncoder.matches(request.resetToken(), current.getResetTokenHash())) + .orElseThrow(this::invalidResetToken); + + var user = userRepository.findByEmailForPasswordUpdate(verification.getEmail()) + .orElseThrow(this::invalidResetToken); + if (passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + } + + user.changePassword(passwordEncoder.encode(request.password())); + verification.consumeResetToken(); + } + + private ResponseStatusException invalidResetToken() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, + "비밀번호 재설정 토큰이 유효하지 않거나 만료되었습니다."); + } + private ResponseStatusException invalidVerificationCode() { return new ResponseStatusException(HttpStatus.BAD_REQUEST, "인증번호가 유효하지 않거나 만료되었습니다.");