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 c4aa7b1..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 @@ -1,12 +1,20 @@ 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.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; +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; +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; @@ -16,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; @@ -27,15 +36,21 @@ public class AuthController { private final StudentSignupService studentSignupService; private final BusinessSignupService businessSignupService; private final LoginService loginService; + private final FindIdService findIdService; + private final EmailVerificationService emailVerificationService; public AuthController( StudentSignupService studentSignupService, BusinessSignupService businessSignupService, - LoginService loginService + LoginService loginService, + FindIdService findIdService, + EmailVerificationService emailVerificationService ) { this.studentSignupService = studentSignupService; this.businessSignupService = businessSignupService; this.loginService = loginService; + this.findIdService = findIdService; + this.emailVerificationService = emailVerificationService; } @PostMapping("/signup/business") @@ -65,4 +80,30 @@ 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)); + } + + @PostMapping("/send-email") + @Operation(summary = "이메일 인증번호 발송", description = "입력한 이메일 주소로 5분간 유효한 인증번호를 발송합니다.") + 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)); + } + + @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/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/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/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/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 new file mode 100644 index 0000000..1e7add1 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/entity/EmailVerification.java @@ -0,0 +1,104 @@ +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; + + @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() { + } + + public EmailVerification(String email, String codeHash, LocalDateTime sentAt, LocalDateTime expiresAt) { + this.email = email; + 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 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; + 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/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 new file mode 100644 index 0000000..ae4b935 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/repository/EmailVerificationRepository.java @@ -0,0 +1,19 @@ +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.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 194e0e3..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 @@ -15,7 +15,14 @@ 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); + + @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 new file mode 100644 index 0000000..6ccb4d2 --- /dev/null +++ b/src/main/java/com/teamnative/moss/domain/auth/service/EmailVerificationService.java @@ -0,0 +1,162 @@ +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; +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 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; + 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; + } + + @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); + } + } + + @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); + } + + @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, + "인증번호가 유효하지 않거나 만료되었습니다."); + } +} 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()); + } +} 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