Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.yeoro.twogether.domain.member.dto.request.*;
import com.yeoro.twogether.domain.member.dto.response.LoginResponse;
import com.yeoro.twogether.domain.member.dto.response.MemberInfoResponse;
import com.yeoro.twogether.domain.member.dto.response.PasswordResetVerifyResponse;
import com.yeoro.twogether.domain.member.entity.Member;
import com.yeoro.twogether.domain.member.service.EmailVerificationService;
import com.yeoro.twogether.domain.member.service.MemberService;
Expand All @@ -18,8 +19,8 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;

import java.net.URL;

Expand Down Expand Up @@ -183,4 +184,27 @@ public ResponseEntity<String> deleteMe(@Login Long memberId) {
public LoginResponse refreshToken(HttpServletRequest request, HttpServletResponse response) {
return memberService.refreshTokens(request, response);
}

// 비밀번호 재설정 코드 발송
@PostMapping("/password/forgot")
public ResponseEntity<String> forgotPassword(@RequestBody @Valid PasswordResetCodeRequest req) {
memberService.issuePasswordResetCode(req.email());
return ResponseEntity.ok("비밀번호 재설정 코드가 이메일로 전송되었습니다.");
}

// 코드 검증 → Reset Ticket 발급
@PostMapping("/password/verify")
public ResponseEntity<PasswordResetVerifyResponse> verifyCode(@RequestBody @Valid PasswordResetVerifyRequest req) {
PasswordResetVerifyResponse resp = memberService.verifyPasswordResetCode(req.email(), req.code());
return ResponseEntity.ok(resp);
}

// Reset Ticket + 새 비밀번호로 최종 변경
@PostMapping("/password/reset")
public ResponseEntity<String> resetPassword(@RequestBody @Valid PasswordResetFinalizeRequest req,
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
memberService.resetPasswordWithTicket(req.email(), req.resetTicket(), req.newPassword(), httpRequest, httpResponse);
return ResponseEntity.ok("성공적으로 비밀번호를 변경했습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.yeoro.twogether.domain.member.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record PasswordResetCodeRequest(
@NotBlank @Email String email
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.yeoro.twogether.domain.member.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record PasswordResetFinalizeRequest(
@NotBlank @Email String email,
@NotBlank String resetTicket,
@NotBlank @Size(min = 8, max = 64) String newPassword
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.yeoro.twogether.domain.member.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record PasswordResetVerifyRequest(
@NotBlank @Email String email,
@NotBlank String code
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.yeoro.twogether.domain.member.dto.response;

public record PasswordResetVerifyResponse(
String resetTicket
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,43 @@ public class MailService {
@Value("${spring.mail.username}")
private String senderEmail;

// 이메일 인증 코드 발송
public void sendSimpleMessage(String to, String code) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");

helper.setTo(to);
helper.setFrom(senderEmail);
helper.setSubject("인증을 위한 이메일 인증번호");

String body = "<html>" +
"<body style='font-family: Arial, sans-serif; background-color: #f1f1f1; padding: 20px;'>" +
"<div style='max-width: 600px; margin: 0 auto; padding: 30px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);'>" +
"<h2 style='color: #4CAF50; font-size: 24px; text-align: center;'>인증을 위한 이메일 인증번호</h2>" +
"<p style='font-size: 16px; color: #333;'>안녕하세요, <strong>회원님</strong>.</p>" +
"<p style='font-size: 16px; color: #555;'>요청하신 인증 번호는 아래와 같습니다:</p>" +
"<p style='font-size: 16px; color: #333;'>요청하신 인증 번호는 아래와 같습니다:</p>" +
"<div style='text-align: center; padding: 20px; background-color: #f9f9f9; border-radius: 8px; margin: 20px 0;'>" +
"<h1 style='font-size: 36px; color: #4CAF50; font-weight: bold;'>" + code + "</h1>" +
"<p style='font-size: 16px; color: #555;'>이 코드를 입력하여 이메일 인증을 완료하세요.</p>" +
"</div>" +
"<p style='font-size: 14px; color: #777;'>감사합니다!</p>" +
"<footer style='font-size: 12px; color: #aaa; text-align: center;'>" +
"<p>&copy; 2025 Your Company</p>" +
"</footer>" +
"</div>" +
"</body>" +
"</html>";
"<footer style='font-size: 12px; color: #aaa; text-align: center;'><p>&copy; 2025 Your Company</p></footer>" +
"</div></body></html>";
sendHtml(to, "인증을 위한 이메일 인증번호", body);
}

helper.setText(body, true); // HTML true 설정
// 비밀번호 재설정 코드 발송
public void sendPasswordResetCode(String to, String code) throws MessagingException {
String html = """
<html><body style='font-family:Arial,sans-serif;'>
<h2>비밀번호 재설정 코드</h2>
<p>아래 코드를 입력하면 새 비밀번호를 설정하실 수 있습니다. (유효시간 10분)</p>
<div style='padding:12px;background:#f5f5f5;border-radius:8px;display:inline-block;'>
<span style='font-size:26px;font-weight:bold;'>%s</span>
</div>
</body></html>
""".formatted(code);
sendHtml(to, "비밀번호 재설정 코드", html);
}

private void sendHtml(String to, String subject, String body) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
helper.setTo(to);
helper.setFrom(senderEmail);
helper.setSubject(subject);
helper.setText(body, true);
mailSender.send(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import com.yeoro.twogether.domain.member.dto.request.LoginRequest;
import com.yeoro.twogether.domain.member.dto.request.SignupRequest;
import com.yeoro.twogether.domain.member.dto.response.LoginResponse;
import com.yeoro.twogether.domain.member.dto.response.PasswordResetVerifyResponse;
import com.yeoro.twogether.domain.member.entity.Gender;
import com.yeoro.twogether.domain.member.entity.LoginPlatform;
import com.yeoro.twogether.domain.member.entity.Member;
import com.yeoro.twogether.domain.member.mail.MailService;
import com.yeoro.twogether.domain.member.repository.MemberRepository;
import com.yeoro.twogether.domain.member.service.EmailVerificationService;
import com.yeoro.twogether.domain.member.service.MemberService;
Expand All @@ -20,6 +22,7 @@
import com.yeoro.twogether.global.service.s3.HighlightS3Service;
import com.yeoro.twogether.global.service.s3.ProfileS3Service;
import com.yeoro.twogether.global.store.PartnerCodeStore;
import com.yeoro.twogether.global.store.PasswordResetStore;
import com.yeoro.twogether.global.token.JwtService;
import com.yeoro.twogether.global.token.TokenPair;
import com.yeoro.twogether.global.token.TokenService;
Expand All @@ -43,6 +46,7 @@
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Locale;
import java.util.UUID;

import static com.yeoro.twogether.global.exception.ErrorCode.MEMBER_NOT_FOUND;
Expand All @@ -67,8 +71,8 @@ public class MemberServiceImpl implements MemberService {
private final WaypointItemRepository waypointItemRepository;
private final MemberHardDeleteTx memberHardDeleteTx;
private final ProfileS3Service profileS3Service;


private final PasswordResetStore passwordResetStore;
private final MailService mailService;


/**
Expand Down Expand Up @@ -703,5 +707,108 @@ private static String extractKey(String imageUrlOrKey) {
return imageUrlOrKey;
}
}

// 비밀번호 재설정 코드 발송
// MemberServiceImpl.issuePasswordResetCode
@Override
@Transactional
public void issuePasswordResetCode(String email) {
var normEmail = email == null ? null : email.trim().toLowerCase(Locale.ROOT);
var opt = memberRepository.findByEmail(normEmail);

// 존재하지 않으면 실제 메일 발송/저장은 생략 (응답은 항상 동일 문구로)
if (opt.isEmpty()) {
// 시도 카운트도 증가시키지 않고 조용히 반환
return;
}

// LOCAL 계정만 허용
Member m = opt.get();
if (m.getLoginPlatform() != LoginPlatform.LOCAL) {
// 소셜 계정은 미지원: 조용히 반환
return;
}

// 발송 시도 제한: 과도한 요청 방지
if (passwordResetStore.tooManyAttempts(normEmail)) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS);
}

String code = CodeGenerator.generateNumericCode(6);
passwordResetStore.saveCode(normEmail, code);

try {
mailService.sendPasswordResetCode(normEmail, code);
} catch (Exception e) {
log.error("[pwdreset] 메일 전송 실패: {}", normEmail, e);
throw new ServiceException(ErrorCode.MAIL_SEND_FAILED);
}
}


@Override
@Transactional
public PasswordResetVerifyResponse verifyPasswordResetCode(String email, String code) {
if (passwordResetStore.tooManyAttempts(email)) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS);
}

String saved = passwordResetStore.getCode(email);
if (saved == null) {
throw new ServiceException(ErrorCode.PASSWORD_RESET_CODE_EXPIRED);
}
if (!saved.equalsIgnoreCase(code)) {
throw new ServiceException(ErrorCode.PASSWORD_RESET_CODE_INVALID);
}

passwordResetStore.deleteCode(email);
passwordResetStore.clearAttempts(email);

String ticket = passwordResetStore.issueTicket(email);
return new PasswordResetVerifyResponse(ticket);
}

@Override
@Transactional
public void resetPasswordWithTicket(String email,
String resetTicket,
String newPassword,
HttpServletRequest request,
HttpServletResponse response) {
boolean ticketOk = passwordResetStore.consumeTicket(email, resetTicket);
if (!ticketOk) throw new ServiceException(ErrorCode.PASSWORD_RESET_TICKET_INVALID);

Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new ServiceException(ErrorCode.MEMBER_NOT_FOUND));

if (member.getLoginPlatform() != LoginPlatform.LOCAL) {
throw new ServiceException(ErrorCode.NOT_LOCAL_MEMBER);
}
if (!PasswordValidator.isValid(newPassword)) {
throw new ServiceException(ErrorCode.PASSWORD_NOT_VALID);
}
if (passwordEncoder.matches(newPassword, member.getPassword())) {
throw new ServiceException(ErrorCode.PASSWORD_RESET_SAME_AS_OLD);
}

// 비밀번호 업데이트
member.setPassword(passwordEncoder.encode(newPassword));

// 기존 토큰 무효화(해당 회원만)
jwtService.invalidateRefreshToken(member.getId());
tokenService.removeRefreshTokenFromRedis(member.getId());
jwtService.clearRefreshTokenCookie(response);

// 추가: 현재 요청에 Access Token이 있다면 블랙리스트 처리(선택적)
String currentAccessToken = jwtService.resolveToken(request);
if (currentAccessToken != null && !currentAccessToken.isBlank()) {
tokenService.blacklistAccessToken(currentAccessToken);
}

// 클린업
passwordResetStore.deleteCode(email);
passwordResetStore.clearAttempts(email);
}

}

Loading