diff --git a/.gitignore b/.gitignore index 96fc7f4..9ee5203 100644 --- a/.gitignore +++ b/.gitignore @@ -756,4 +756,8 @@ FodyWeavers.xsd # Local Profile /src/main/resources/application-local.yml -# End of https://www.toptal.com/developers/gitignore/api/java,intellij+all,intellij+iml,netbeans,macos,windows,visualstudiocode,visualstudio,gradle,groovy,linux,sonarqube,sonar,dotenv,redis,git \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/java,intellij+all,intellij+iml,netbeans,macos,windows,visualstudiocode,visualstudio,gradle,groovy,linux,sonarqube,sonar,dotenv,redis,git + +# Personal (jeonghyeon) +/pull_request_description.md +/.claude/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index f9ef164..ee7538c 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,9 @@ dependencies { implementation 'com.twelvemonkeys.imageio:imageio-core:3.12.0' implementation 'org.sejda.imageio:webp-imageio:0.1.6' + // Email + implementation 'org.springframework.boot:spring-boot-starter-mail' + // Auth implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/team/wego/wegobackend/auth/application/PasswordResetEmailService.java b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetEmailService.java new file mode 100644 index 0000000..b3b9076 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetEmailService.java @@ -0,0 +1,67 @@ +package team.wego.wegobackend.auth.application; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import team.wego.wegobackend.common.exception.AppErrorCode; +import team.wego.wegobackend.common.exception.AppException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PasswordResetEmailService { + + private final JavaMailSender mailSender; + + /** + * 비밀번호 재설정 이메일을 발송합니다. + * + * @param toEmail 수신자 이메일 + * @param resetUrl 비밀번호 재설정 페이지 전체 URL (검증값 포함) + */ + @Async("mailExecutor") + public void sendPasswordResetEmail(String toEmail, String resetUrl) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); + + helper.setTo(toEmail); + helper.setSubject("[WeGo] 비밀번호 재설정 안내"); + helper.setText(buildEmailBody(resetUrl), true); + + mailSender.send(message); + log.info("비밀번호 재설정 이메일 발송 완료 -> {}", toEmail); + + } catch (MessagingException | MailException e) { + log.error("비밀번호 재설정 이메일 발송 실패 -> {}", toEmail, e); + throw new AppException(AppErrorCode.DEPENDENCY_FAILURE); + } + } + + private String buildEmailBody(String resetUrl) { + return """ +
+

비밀번호 재설정

+

안녕하세요. WeGo입니다.

+

아래 버튼을 클릭하여 비밀번호를 재설정하세요.

+

+ + 비밀번호 재설정 + +

+

+ 본 링크는 30분 동안만 유효합니다.
+ 본인이 요청하지 않은 경우 이 이메일을 무시하셔도 됩니다. +

+
+ """.formatted(resetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/application/PasswordResetService.java b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetService.java new file mode 100644 index 0000000..96e372f --- /dev/null +++ b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetService.java @@ -0,0 +1,75 @@ +package team.wego.wegobackend.auth.application; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.auth.exception.InvalidResetTokenException; +import team.wego.wegobackend.auth.infrastructure.redis.PasswordResetRedisRepository; +import team.wego.wegobackend.user.domain.User; +import team.wego.wegobackend.user.repository.UserRepository; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PasswordResetService { + + @Value("${password-reset.frontend-url}") + private String frontendUrl; + + private final UserRepository userRepository; + private final BCryptPasswordEncoder passwordEncoder; + private final PasswordResetRedisRepository redisRepository; + private final PasswordResetEmailService emailService; + + /** + * 비밀번호 재설정 요청. + * 미가입 이메일이어도 200 OK를 반환하여 이메일 열거 공격을 방지합니다. + */ + public void requestPasswordReset(String email) { + boolean exists = userRepository.existsByEmail(email); + if (!exists) { + log.debug("비밀번호 재설정 요청: 미가입 이메일 -> {}", email); + return; + } + + String token = UUID.randomUUID().toString(); + redisRepository.save(email, token); + + String resetUrl = frontendUrl + "/password-reset?validationValue=" + token; + emailService.sendPasswordResetEmail(email, resetUrl); + + log.info("비밀번호 재설정 토큰 발급 -> email={}", email); + } + + /** + * 검증값 유효성 검사. 토큰을 소비하지 않습니다. (프론트 2단계 검증 1단계) + */ + public void verifyToken(String token) { + redisRepository.findEmailByToken(token) + .orElseThrow(InvalidResetTokenException::new); + } + + /** + * 비밀번호 변경. 토큰 유효성 재검사 후 비밀번호 변경 및 토큰·세션 폐기. + */ + @Transactional + public void resetPassword(String token, String newPassword) { + String email = redisRepository.findEmailByToken(token) + .orElseThrow(InvalidResetTokenException::new); + + User user = userRepository.findByEmail(email) + .orElseThrow(InvalidResetTokenException::new); + + user.updatePassword(passwordEncoder.encode(newPassword)); + user.updateCurrentSessionid(null); + + redisRepository.deleteByToken(token); + + log.info("비밀번호 재설정 완료 -> email={}", email); + } +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetConfirmRequest.java b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetConfirmRequest.java new file mode 100644 index 0000000..d814609 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetConfirmRequest.java @@ -0,0 +1,14 @@ +package team.wego.wegobackend.auth.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordResetConfirmRequest( + @NotBlank(message = "토큰은 필수입니다.") + String token, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") + String newPassword +) { +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetRequest.java b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetRequest.java new file mode 100644 index 0000000..fd595b0 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetRequest.java @@ -0,0 +1,11 @@ +package team.wego.wegobackend.auth.application.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record PasswordResetRequest( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email +) { +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/exception/InvalidResetTokenException.java b/src/main/java/team/wego/wegobackend/auth/exception/InvalidResetTokenException.java new file mode 100644 index 0000000..26a942d --- /dev/null +++ b/src/main/java/team/wego/wegobackend/auth/exception/InvalidResetTokenException.java @@ -0,0 +1,11 @@ +package team.wego.wegobackend.auth.exception; + +import team.wego.wegobackend.common.exception.AppErrorCode; +import team.wego.wegobackend.common.exception.AppException; + +public class InvalidResetTokenException extends AppException { + + public InvalidResetTokenException() { + super(AppErrorCode.INVALID_RESET_TOKEN); + } +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.java b/src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.java new file mode 100644 index 0000000..14eba46 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.java @@ -0,0 +1,71 @@ +package team.wego.wegobackend.auth.infrastructure.redis; + +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +/** + * 비밀번호 재설정 토큰 Redis 저장소. + * + *

이중 키 구조로 "계정당 최신 토큰 1개만 유효" 정책을 구현합니다. + *

+ */ +@Repository +@RequiredArgsConstructor +public class PasswordResetRedisRepository { + + private static final String TOKEN_PREFIX = "pwreset:token:"; + private static final String EMAIL_PREFIX = "pwreset:email:"; + + @Value("${password-reset.token-ttl-minutes:30}") + private long tokenTtlMinutes; + + private final StringRedisTemplate stringRedisTemplate; + + /** + * 토큰을 저장합니다. 기존 토큰이 있으면 먼저 폐기합니다. + */ + public void save(String email, String token) { + // 기존 토큰 폐기 + String oldToken = stringRedisTemplate.opsForValue().get(emailKey(email)); + if (oldToken != null) { + stringRedisTemplate.delete(tokenKey(oldToken)); + } + + Duration ttl = Duration.ofMinutes(tokenTtlMinutes); + stringRedisTemplate.opsForValue().set(tokenKey(token), email, ttl); + stringRedisTemplate.opsForValue().set(emailKey(email), token, ttl); + } + + /** + * 토큰으로 이메일을 조회합니다. + */ + public Optional findEmailByToken(String token) { + return Optional.ofNullable(stringRedisTemplate.opsForValue().get(tokenKey(token))); + } + + /** + * 사용 완료된 토큰을 폐기합니다. (토큰 키, 이메일 역방향 키 모두 삭제) + */ + public void deleteByToken(String token) { + String email = stringRedisTemplate.opsForValue().get(tokenKey(token)); + stringRedisTemplate.delete(tokenKey(token)); + if (email != null) { + stringRedisTemplate.delete(emailKey(email)); + } + } + + private String tokenKey(String token) { + return TOKEN_PREFIX + token; + } + + private String emailKey(String email) { + return EMAIL_PREFIX + email; + } +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java b/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java index 7374594..2d5dc2d 100644 --- a/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java +++ b/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java @@ -10,13 +10,18 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import team.wego.wegobackend.auth.application.AuthService; +import team.wego.wegobackend.auth.application.PasswordResetService; import team.wego.wegobackend.auth.application.dto.request.GoogleLoginRequest; import team.wego.wegobackend.auth.application.dto.request.LoginRequest; +import team.wego.wegobackend.auth.application.dto.request.PasswordResetConfirmRequest; +import team.wego.wegobackend.auth.application.dto.request.PasswordResetRequest; import team.wego.wegobackend.auth.application.dto.request.SignupRequest; import team.wego.wegobackend.auth.application.dto.response.LoginResponse; import team.wego.wegobackend.auth.application.dto.response.RefreshResponse; @@ -34,6 +39,8 @@ public class AuthController implements AuthControllerDocs { private final AuthService authService; + private final PasswordResetService passwordResetService; + private final JwtTokenProvider jwtTokenProvider; /** @@ -155,6 +162,49 @@ public ResponseEntity> refresh( )); } + /** + * 비밀번호 재설정 요청 — 이메일 발송 + * 미가입 이메일이어도 200 OK 반환 (이메일 열거 공격 방지) + */ + @PostMapping("/password-reset/request") + public ResponseEntity> requestPasswordReset( + @Valid @RequestBody PasswordResetRequest request) { + + passwordResetService.requestPasswordReset(request.email()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(200, true)); + } + + /** + * 검증값(validationValue) 유효성 검사 + */ + @GetMapping("/reset-verify") + public ResponseEntity> verifyResetToken( + @RequestParam String validationValue) { + + passwordResetService.verifyToken(validationValue); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(200, true)); + } + + /** + * 비밀번호 변경 + */ + @PostMapping("/password-reset") + public ResponseEntity> resetPassword( + @Valid @RequestBody PasswordResetConfirmRequest request) { + + passwordResetService.resetPassword(request.token(), request.newPassword()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(200, true)); + } + /** * Refresh Token HttpOnly 쿠키 생성 */ diff --git a/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java b/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java index d081029..30327c0 100644 --- a/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java +++ b/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java @@ -8,8 +8,11 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import team.wego.wegobackend.auth.application.dto.request.GoogleLoginRequest; import team.wego.wegobackend.auth.application.dto.request.LoginRequest; +import team.wego.wegobackend.auth.application.dto.request.PasswordResetConfirmRequest; +import team.wego.wegobackend.auth.application.dto.request.PasswordResetRequest; import team.wego.wegobackend.auth.application.dto.request.SignupRequest; import team.wego.wegobackend.auth.application.dto.response.LoginResponse; import team.wego.wegobackend.auth.application.dto.response.RefreshResponse; @@ -51,4 +54,28 @@ ResponseEntity> withDraw( HttpServletResponse response ); + @Operation( + summary = "비밀번호 재설정 요청", + description = "이메일로 비밀번호 재설정 링크를 발송합니다. 가입 여부와 관계없이 200 OK를 반환합니다." + ) + ResponseEntity> requestPasswordReset( + @Valid @RequestBody PasswordResetRequest request + ); + + @Operation( + summary = "비밀번호 재설정 토큰 유효성 검사", + description = "이메일 링크의 검증값(validationValue)이 유효한지 확인합니다. 토큰을 소비하지 않습니다." + ) + ResponseEntity> verifyResetToken( + @RequestParam String validationValue + ); + + @Operation( + summary = "비밀번호 변경", + description = "검증값과 새 비밀번호를 전송하여 비밀번호를 변경합니다. 성공 시 토큰 및 세션이 폐기됩니다." + ) + ResponseEntity> resetPassword( + @Valid @RequestBody PasswordResetConfirmRequest request + ); + } diff --git a/src/main/java/team/wego/wegobackend/common/config/AsyncConfig.java b/src/main/java/team/wego/wegobackend/common/config/AsyncConfig.java new file mode 100644 index 0000000..d34339b --- /dev/null +++ b/src/main/java/team/wego/wegobackend/common/config/AsyncConfig.java @@ -0,0 +1,23 @@ +package team.wego.wegobackend.common.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "mailExecutor") + public Executor mailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("mail-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java index 9ef0286..cdc1ad8 100644 --- a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java +++ b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java @@ -42,7 +42,9 @@ public enum AppErrorCode implements ErrorCode { DUPLICATE_LOGIN(HttpStatus.UNAUTHORIZED, "인증 : 다른 기기에서 로그인되었습니다."), USER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "인증 : 해당 리소스에 접근할 권한이 없습니다."), - NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "알림 : 알림을 찾을 수 없습니다.") + NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "알림 : 알림을 찾을 수 없습니다."), + + INVALID_RESET_TOKEN(HttpStatus.BAD_REQUEST, "인증 : 유효하지 않거나 만료된 비밀번호 재설정 토큰입니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java b/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java index a0d7957..6b42a18 100644 --- a/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java +++ b/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java @@ -7,6 +7,9 @@ public class SecurityEndpoints { "/api/v*/auth/login", "/api/v*/auth/google", "/api/v*/auth/refresh", + "/api/v*/auth/password-reset/request", + "/api/v*/auth/reset-verify", + "/api/v*/auth/password-reset", "/api/v*/health", "/h2-console/**", "/error", diff --git a/src/main/java/team/wego/wegobackend/user/domain/User.java b/src/main/java/team/wego/wegobackend/user/domain/User.java index 09a86c7..a81e79c 100644 --- a/src/main/java/team/wego/wegobackend/user/domain/User.java +++ b/src/main/java/team/wego/wegobackend/user/domain/User.java @@ -195,4 +195,8 @@ public void updateCurrentSessionid(String sessionId) { this.currentSessionid = sessionId; } + public void updatePassword(String encodedPassword) { + this.password = encodedPassword; + } + } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index efc5950..f2139a1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -63,6 +63,18 @@ spring: min-idle: 1 max-wait: 2s + mail: + host: ${MAIL_HOST:smtp.gmail.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true aws: s3: @@ -111,6 +123,11 @@ chat: delete-expired-chat: cron: "0 0 * * * *" # 매 시간 정각 +# 비밀번호 재설정 +password-reset: + frontend-url: ${PASSWORD_RESET_FRONTEND_URL:https://wego.monster} + token-ttl-minutes: 30 + # OAuth-Google google: oauth: diff --git a/src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.java b/src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.java new file mode 100644 index 0000000..f173e53 --- /dev/null +++ b/src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.java @@ -0,0 +1,48 @@ +package team.wego.wegobackend.auth; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration; +import org.springframework.boot.autoconfigure.mail.MailSenderValidatorAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import team.wego.wegobackend.auth.application.PasswordResetEmailService; +import team.wego.wegobackend.common.config.AsyncConfig; + +/** + * 실제 SMTP 서버로 이메일 발송을 검증하는 통합 테스트. + * + *

실행 전 .env 파일에 MAIL_USERNAME, MAIL_PASSWORD가 설정되어 있어야 합니다. + * 테스트 이메일은 MAIL_USERNAME 주소 본인에게 발송됩니다. + */ +@SpringBootTest( + classes = {PasswordResetEmailService.class, AsyncConfig.class} +) +@ImportAutoConfiguration({ + MailSenderAutoConfiguration.class, + MailSenderValidatorAutoConfiguration.class +}) +class PasswordResetEmailServiceTest { + + @Autowired + private PasswordResetEmailService emailService; + + @Value("${spring.mail.username}") + private String mailUsername; + + @Test + @DisplayName("실제 SMTP 서버로 비밀번호 재설정 이메일 발송 검증") + void sendPasswordResetEmail_실제_발송() { + String testResetUrl = "https://wego.monster/password-reset?validationValue=test-token-1234"; + + assertThatCode(() -> + emailService.sendPasswordResetEmail(mailUsername, testResetUrl) + ).doesNotThrowAnyException(); + + System.out.println("✅ 이메일 발송 완료. 수신함 확인: " + mailUsername); + } +} \ No newline at end of file