diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java b/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java index f1b23224..4905cf7a 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java @@ -71,7 +71,19 @@ public class AuthController { @Operation( summary = "회원가입 API", - description = "회원 이메일, 비밀번호, 이름, 전화번호 정보를 입력받아 새로운 사용자를 생성합니다.", + description = """ + 회원 이메일, 비밀번호, 이름, 전화번호 정보를 입력받아 새로운 사용자를 생성합니다. + + 비밀번호 정책: + - 길이: 8~20자 + - 최소 1개의 대문자(A-Z) + - 최소 1개의 소문자(a-z) + - 최소 1개의 숫자(0-9) + - 최소 1개의 특수문자(!@#$%^&*()_+=-{};:'",.<>/?) + + 위 조건을 모두 만족하지 않으면 400 (INVALID_INPUT) 예외가 발생합니다. + """, + responses = { @ApiResponse( responseCode = "201", @@ -83,11 +95,20 @@ public class AuthController { "email": "testuser@example.com", "name": "홍길동", "phoneNumber": "01012345678", - "role": "USER" + "role": "TEAM_MEMBER" } """)) ), - @ApiResponse(responseCode = "400", description = "요청 데이터 유효성 검증 실패") + @ApiResponse( + responseCode = "400", + description = "요청 데이터 유효성 검증 실패 (비밀번호 정책 미준수 포함)", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다." + } + """)) + ) } ) @PostMapping("/signup") diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java index af3f4998..699902f2 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java @@ -128,6 +128,45 @@ private String generateCode() { return sb.toString(); } + // 비밀번호 인증 관련 메서드 + public void sendResetEmail(String email) { + if(!userRepository.existsByEmail(email)){ + log.debug("Password reset requested for non-existent email {}", email); + return; + } + + String code = generateCode(); + redisTemplate.opsForValue().set("PASSWORD_RESET_EMAIL:" + email, code, emailProperties.getCodeExpire()); + + try { + MimeMessage message = createResetMessage(email, code); + mailSender.send(message); + } catch (MessagingException e) { + throw new MailSendException("failed to send mail", e); + } + } + + public void verifyResetEmail(String email, String code) { + String stored = redisTemplate.opsForValue().get("PASSWORD_RESET_EMAIL:" + email); + if (stored == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); + if(!stored.equals(code)) throw new CustomException(ErrorCode.EMAIL_CODE_MISMATCH); + redisTemplate.delete("PASSWORD_RESET_EMAIL:" + email); + } + + private MimeMessage createResetMessage(String email, String code) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + message.setFrom(new InternetAddress(from)); + message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(email)); + message.setSubject("비밀번호 재설정 인증코드"); + + Context context = new Context(); + context.setVariable("email", email); + context.setVariable("code", code); + + String body = templateEngine.process("mail/resetEmail", context); + message.setText(body, "UTF-8", "html"); + return message; + } diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 75fa87ca..b3f32fc2 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -12,10 +12,10 @@ import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.auth.dto.SignupRequest; import org.sejongisc.backend.auth.dto.SignupResponse; -import org.sejongisc.backend.user.dto.UserInfoResponse; -import org.sejongisc.backend.user.dto.UserUpdateRequest; +import org.sejongisc.backend.user.dto.*; import org.sejongisc.backend.user.service.UserService; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -147,4 +147,185 @@ public ResponseEntity updateUser( userService.updateUser(userId, request); return ResponseEntity.ok("회원 정보가 수정되었습니다."); } -} + + @Operation( + summary = "아이디 찾기 API", + description = """ + 사용자의 이름과 전화번호를 입력하면 가입된 이메일 주소를 반환합니다. + - 이름(name)과 전화번호(phoneNumber)가 모두 일치하는 회원만 조회됩니다. + - 일치하는 회원이 없을 경우 404 응답을 반환합니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "email": "testuser@example.com" + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "일치하는 회원 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "해당 정보로 가입된 사용자를 찾을 수 없습니다." + } + """) + ) + ) + } + ) + @PostMapping("/id/find") + public ResponseEntity findUserID(@RequestBody @Valid UserIdFindRequest request) { + String name = request.name(); + String phone = request.phoneNumber(); + String email = userService.findEmailByNameAndPhone(name, phone); + + if (email == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", "해당 정보로 가입된 사용자를 찾을 수 없습니다.")); + } + + return ResponseEntity.ok(Map.of("email", email)); + } + + @Operation( + summary = "비밀번호 재설정: 인증코드 발송 API", + description = """ + 가입된 이메일 주소로 비밀번호 재설정을 위한 인증코드를 전송합니다. + - 인증코드는 3분간 유효합니다. + - 존재하지 않는 이메일일 경우 404 에러를 반환합니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "인증코드 발송 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "인증코드를 전송했습니다." + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "이메일 미존재", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "해당 이메일로 가입된 사용자를 찾을 수 없습니다." + } + """) + ) + ) + } + ) + @PostMapping("/password/reset/send") + public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest req){ + String email = req.email().trim(); + log.info("비밀번호 재설정 요청"); // 개인정보 로그 남기지 않기 + userService.passwordReset(email); + return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다.")); + } + + @Operation( + summary = "비밀번호 재설정: 인증코드 검증 API", + description = """ + 이메일과 인증코드를 검증하고, 유효한 경우 비밀번호 재설정용 토큰(`resetToken`)을 발급합니다. + - 인증코드는 3분간만 유효합니다. + - 검증에 성공하면 resetToken(10분 유효)을 반환합니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "검증 성공 및 resetToken 발급", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "resetToken": "c8a2434d-7e11-4f7e-a201-b9fbc9d7d43a" + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 코드 또는 만료된 코드", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "인증코드가 올바르지 않거나 만료되었습니다." + } + """) + ) + ) + } + ) + @PostMapping("/password/reset/verify") + public ResponseEntity verifyReset(@RequestBody @Valid PasswordResetVerifyRequest req){ + String email = req.email().trim(); + String code = req.code().trim(); + + String token = userService.verifyResetCodeAndIssueToken(email, code); + return ResponseEntity.ok(Map.of("resetToken", token)); + } + + @Operation( + summary = "비밀번호 재설정 최종 API", + description = """ + 검증된 resetToken과 새 비밀번호를 전달하면 비밀번호를 최종 변경합니다. + - resetToken은 10분간 유효합니다. + - 비밀번호 정책: + • 길이: 8~20자 + • 최소 1개의 대문자(A-Z) + • 최소 1개의 소문자(a-z) + • 최소 1개의 숫자(0-9) + • 최소 1개의 특수문자(!@#$%^&*()_+=-{};:'",.<>/?) + - 위 조건을 만족하지 않으면 400 응답을 반환합니다. + - 변경 완료 후, 로그인 화면으로 이동하여 새 비밀번호로 로그인할 수 있습니다. + """, + responses = { + @ApiResponse( + responseCode = "200", + description = "비밀번호 변경 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "비밀번호가 변경되었습니다. 다시 로그인해 주세요." + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "비밀번호 정책 위반 또는 잘못된/만료된 토큰", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "message": "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다." + } + """) + ) + ) + } + ) + @PostMapping("/password/reset/commit") + public ResponseEntity commitReset(@RequestBody @Valid PasswordResetCommitRequest req){ + userService.resetPasswordByToken(req.resetToken(), req.newPassword()); + return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다. 다시 로그인해 주세요.")); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java b/backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java index dea467bf..d4cef550 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java @@ -15,4 +15,6 @@ public interface UserRepository extends JpaRepository { Optional findUserByEmail(String email); List findAllByOrderByPointDesc(); + + Optional findByNameAndPhoneNumber(String name, String phoneNumber); } diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java new file mode 100644 index 00000000..1755fae9 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java @@ -0,0 +1,16 @@ +package org.sejongisc.backend.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetCommitRequest( + @NotBlank(message = "resetToken은 필수입니다.") + String resetToken, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\];:'\",.<>/?]).{8,20}$", + message = "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다." + ) + String newPassword +) { } diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java new file mode 100644 index 00000000..ef0fee16 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java @@ -0,0 +1,10 @@ +package org.sejongisc.backend.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record PasswordResetSendRequest( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email +) { } diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java new file mode 100644 index 00000000..aaf5005e --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java @@ -0,0 +1,15 @@ +package org.sejongisc.backend.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordResetVerifyRequest( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "인증코드는 필수입니다.") + @Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다.") + String code +) {} diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java new file mode 100644 index 00000000..69a19219 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java @@ -0,0 +1,12 @@ +package org.sejongisc.backend.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UserIdFindRequest(@NotBlank(message = "이름은 필수입니다.") + String name, + + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^010\\d{8}$", message = "전화번호 형식이 올바르지 않습니다.") + String phoneNumber) { +} diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index 8c145273..eede6c02 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -6,6 +6,7 @@ import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.auth.oauth.OauthUserInfo; +import java.util.Optional; import java.util.UUID; public interface UserService { @@ -18,4 +19,12 @@ public interface UserService { User getUserById(UUID userId); void deleteUserWithOauth(UUID userId); + + String findEmailByNameAndPhone(String name, String phoneNumber); + + void passwordReset(String email); + + String verifyResetCodeAndIssueToken(String email, String code); + + void resetPasswordByToken(String resetToken, String newPassword); } diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java index 6028492e..625519a5 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java @@ -1,8 +1,12 @@ package org.sejongisc.backend.user.service; +import org.sejongisc.backend.auth.service.EmailService; import org.sejongisc.backend.auth.service.OauthUnlinkService; +import org.sejongisc.backend.auth.service.RefreshTokenService; import org.sejongisc.backend.common.auth.jwt.TokenEncryptor; +import org.sejongisc.backend.user.util.PasswordPolicyValidator; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,8 +25,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.Optional; import java.util.UUID; + @Slf4j @Service @RequiredArgsConstructor @@ -34,7 +41,9 @@ public class UserServiceImpl implements UserService { private final OauthUnlinkService oauthUnlinkService; private final PasswordEncoder passwordEncoder; private final TokenEncryptor tokenEncryptor; - + private final EmailService emailService; + private final RedisTemplate redisTemplate; + private final RefreshTokenService refreshTokenService; @Override @@ -49,8 +58,20 @@ public SignupResponse signUp(SignupRequest dto) { throw new CustomException(ErrorCode.DUPLICATE_PHONE); } - // 패스워드 인코딩 - String encodedPw = passwordEncoder.encode(dto.getPassword()); + // trim 적용 후 검증 및 저장 + String rawPassword = dto.getPassword(); + String trimmedPassword = rawPassword == null ? null : rawPassword.trim(); + + // null / 공백 검사 + if (trimmedPassword == null || trimmedPassword.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + // 비밀번호 정책 검증 (trim된 값으로) + PasswordPolicyValidator.validate(trimmedPassword); + + // 패스워드 인코딩 (trim된 값 사용) + String encodedPw = passwordEncoder.encode(trimmedPassword); Role role = dto.getRole(); if (role == null) { @@ -179,5 +200,118 @@ public void deleteUserWithOauth(UUID userId) { log.info("회원 탈퇴 완료: userId={}", userId); } + @Override + public String findEmailByNameAndPhone(String name, String phone){ + String normalizedName = name == null ? null : name.trim(); + String normalizedPhone = phone == null ? null : phone.trim(); + + if (normalizedName == null || normalizedName.isEmpty() || + normalizedPhone == null || normalizedPhone.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + return userRepository.findByNameAndPhoneNumber(normalizedName, normalizedPhone) + .map(User::getEmail) + .orElse(null); + } + + @Override + public void passwordReset(String email) { + if (email == null) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + String normalizedEmail = email.trim(); + if (normalizedEmail.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + if (!userRepository.existsByEmail(normalizedEmail)) { + log.debug("Password reset requested for non-existent email: {}", normalizedEmail); + return; + } + + // 정상적인 이메일일 경우만 발송 + emailService.sendResetEmail(normalizedEmail); + } + + @Override + public String verifyResetCodeAndIssueToken(String email, String code) { + if (email == null || code == null) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + String normalizedEmail = email.trim(); + String normalizedCode = code.trim(); + + if (normalizedEmail.isEmpty() || normalizedCode.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + // 정규화된 값으로 검증 + emailService.verifyResetEmail(normalizedEmail, normalizedCode); + + // 토큰 발급 + String token = UUID.randomUUID().toString(); + + try { + redisTemplate.opsForValue().set( + "PASSWORD_RESET:" + token, + normalizedEmail, + Duration.ofMinutes(10) + ); + } catch (Exception e) { + log.error("Redis 연결 실패: 비밀번호 재설정 토큰 저장 불가", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + return token; + } + + @Override + @Transactional + public void resetPasswordByToken(String resetToken, String newPassword) { +// String email = (String) redisTemplate.opsForValue().get("PASSWORD_RESET:" + resetToken); + String email = null; + + try { + email = (String) redisTemplate.opsForValue().get("PASSWORD_RESET:" + resetToken); + } catch (Exception e) { + log.error("Redis 연결 실패 - 비밀번호 재설정 토큰 조회 불가", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + if(email == null) { + throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); + } + + User user = userRepository.findUserByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (newPassword == null) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + String trimmedPassword = newPassword.trim(); + if (trimmedPassword.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + // 반드시 trim된 값으로 정책 검증 + PasswordPolicyValidator.validate(trimmedPassword); + + // trim된 값을 인코딩하여 저장 + user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); + userRepository.save(user); + + try { + redisTemplate.delete("PASSWORD_RESET:" + resetToken); + } catch (Exception e) { + log.error("Redis 연결 실패 - 비밀번호 재설정 토큰 삭제 불가", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + refreshTokenService.deleteByUserId(user.getUserId()); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java b/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java new file mode 100644 index 00000000..4809eb2c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java @@ -0,0 +1,22 @@ +package org.sejongisc.backend.user.util; + + +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; + +import java.util.regex.Pattern; + +public class PasswordPolicyValidator { + + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{};:'\",.<>/?]).{8,20}$"); + + public static void validate(String password) { + if (password == null || password.trim().isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + } +} diff --git a/backend/src/main/resources/templates/mail/resetEmail.html b/backend/src/main/resources/templates/mail/resetEmail.html new file mode 100644 index 00000000..59f245c6 --- /dev/null +++ b/backend/src/main/resources/templates/mail/resetEmail.html @@ -0,0 +1,23 @@ + + + + + 비밀번호 재설정 인증코드 + + + +
+

비밀번호 재설정 요청

+

안녕하세요, user@example.com 님.

+

아래 인증 코드를 입력해 비밀번호를 재설정하세요.

+

123456

+

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

+
+

감사합니다.
Sejong ISC 드림.

+
+ + diff --git a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java index e4de8d56..a18224ba 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import java.time.Duration; import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; @@ -15,6 +16,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sejongisc.backend.auth.service.EmailService; +import org.sejongisc.backend.auth.service.RefreshTokenService; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; @@ -27,6 +30,7 @@ import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.auth.entity.UserOauthAccount; import org.sejongisc.backend.auth.oauth.OauthUserInfo; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; @ExtendWith(MockitoExtension.class) @@ -41,6 +45,18 @@ class UserServiceImplTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private EmailService emailService; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private RefreshTokenService refreshTokenService; + + @Mock + private org.sejongisc.backend.common.auth.jwt.TokenEncryptor tokenEncryptor; + @InjectMocks private UserServiceImpl userService; @Test @@ -355,5 +371,115 @@ void updateUser_allFieldsNull_noChanges() { verifyNoInteractions(passwordEncoder); // 비밀번호 인코더 안 씀 } + @Test + @DisplayName("비밀번호 재설정 요청: 유효한 이메일이면 인증 메일 전송") + void passwordReset_success() { + // given + String email = "user@example.com"; + User mockUser = User.builder().email(email).build(); + + when(userRepository.findUserByEmail(email)).thenReturn(Optional.of(mockUser)); + + // when + userService.passwordReset(email); + + // then + verify(emailService, times(1)).sendResetEmail(email); + } + + @Test + @DisplayName("비밀번호 재설정 요청 실패: 존재하지 않는 이메일이면 예외 발생") + void passwordReset_userNotFound() { + // given + String email = "notfound@example.com"; + when(userRepository.findUserByEmail(email)).thenReturn(Optional.empty()); + + // when & then + CustomException ex = assertThrows(CustomException.class, () -> userService.passwordReset(email)); + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND); + verify(emailService, never()).sendResetEmail(anyString()); + } + + @Test + @DisplayName("비밀번호 재설정 코드 검증 성공: Redis에 토큰 저장 후 반환") + void verifyResetCodeAndIssueToken_success() { + // given + String email = "user@example.com"; + String code = "123456"; + + doNothing().when(emailService).verifyResetEmail(email, code); + when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); + + // when + String token = userService.verifyResetCodeAndIssueToken(email, code); + + // then + assertThat(token).isNotNull(); + verify(emailService).verifyResetEmail(email, code); + verify(redisTemplate.opsForValue(), atLeastOnce()) + .set(startsWith("PASSWORD_RESET:"), eq(email), any(Duration.class)); + } + + @Test + @DisplayName("비밀번호 재설정: 토큰 유효 시 비밀번호 변경 및 RefreshToken 삭제") + void resetPasswordByToken_success() { + // given + String resetToken = "abc123"; + String email = "user@example.com"; + String newPassword = "newPassword!"; + + var valueOps = mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.get("PASSWORD_RESET:" + resetToken)).thenReturn(email); + + User user = User.builder() + .userId(UUID.randomUUID()) + .email(email) + .passwordHash("OLD_HASH") + .build(); + + when(userRepository.findUserByEmail(email)).thenReturn(Optional.of(user)); + when(passwordEncoder.encode(newPassword)).thenReturn("ENCODED_NEW_PW"); + + // when + userService.resetPasswordByToken(resetToken, newPassword); + + // then + assertThat(user.getPasswordHash()).isEqualTo("ENCODED_NEW_PW"); + verify(passwordEncoder).encode(newPassword); + verify(userRepository).save(user); + verify(refreshTokenService).deleteByUserId(user.getUserId()); + verify(redisTemplate).delete("PASSWORD_RESET:" + resetToken); + } + + @Test + @DisplayName("비밀번호 재설정 실패: Redis에서 이메일을 찾지 못하면 예외 발생") + void resetPasswordByToken_invalidToken_throws() { + // given + String resetToken = "invalid"; + when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); + when(redisTemplate.opsForValue().get("PASSWORD_RESET:" + resetToken)).thenReturn(null); + + // when + CustomException ex = assertThrows(CustomException.class, + () -> userService.resetPasswordByToken(resetToken, "newPw")); + + // then + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.EMAIL_CODE_NOT_FOUND); + verify(userRepository, never()).save(any()); + } + + @Test + void verifyResetCodeAndIssueToken_RedisFailure_ThrowsException() { + String email = "test@example.com"; + String code = "123456"; + + doThrow(new RuntimeException("Redis down")) + .when(redisTemplate.opsForValue()) + .set(anyString(), any(), any(Duration.class)); + + assertThrows(CustomException.class, + () -> userService.verifyResetCodeAndIssueToken(email, code)); + } }