From c5b7666b12a89f699492363686b3acca888526af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Tue, 7 Jan 2025 21:55:27 +0900 Subject: [PATCH 01/17] =?UTF-8?q?:sparkles:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 35b709a..55c06dd 100644 --- a/build.gradle +++ b/build.gradle @@ -59,9 +59,11 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + } test { From 8a35e5ba400d651948bff7b5e83e23e1b91b61f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Tue, 7 Jan 2025 21:55:47 +0900 Subject: [PATCH 02/17] =?UTF-8?q?:sparkles:=20Email=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EB=B2=88=ED=98=B8=20=EC=A0=84=EC=86=A1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 24 +++++++-- .../socket/auth/service/AuthService.java | 4 -- .../socket/auth/service/MailService.java | 49 +++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/mycom/socket/auth/service/MailService.java diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index 37e3618..dc26e34 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -4,13 +4,11 @@ import com.mycom.socket.auth.dto.request.RegisterRequestDto; import com.mycom.socket.auth.dto.response.LoginResponseDto; import com.mycom.socket.auth.service.AuthService; +import com.mycom.socket.auth.service.MailService; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -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.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/auth") @@ -18,6 +16,8 @@ public class AuthController { private final AuthService authService; + private final MailService mailService; + private int number; @PostMapping("/login") public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request, @@ -34,4 +34,20 @@ public void logout(HttpServletResponse response) { public Long register(@Valid @RequestBody RegisterRequestDto request) { return authService.register(request); } + + @PostMapping("/mail-send") + public Integer mailSend(@RequestParam(name = "mail") String mail) { + try { + number = mailService.sendMail(mail); + return number; + } catch (Exception e) { + throw new RuntimeException("Failed to send mail: " + e.getMessage()); + } + } + + @GetMapping("/mail-check") + public Boolean mailCheck(@RequestParam(name = "userNumber") String userNumber) { + return userNumber.equals(String.valueOf(number)); + } + } diff --git a/src/main/java/com/mycom/socket/auth/service/AuthService.java b/src/main/java/com/mycom/socket/auth/service/AuthService.java index 184f3b0..1c4e7f2 100644 --- a/src/main/java/com/mycom/socket/auth/service/AuthService.java +++ b/src/main/java/com/mycom/socket/auth/service/AuthService.java @@ -49,10 +49,6 @@ public LoginResponseDto login(LoginRequestDto request, HttpServletResponse respo ); } - // 이메일 인증 코드 전송 - - // 이메일 인증 코드 만료 - @Transactional public Long register(RegisterRequestDto request) { // 이메일 중복 검사 diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java new file mode 100644 index 0000000..50f9d6d --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -0,0 +1,49 @@ +package com.mycom.socket.auth.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender javaMailSender; + private static final String senderEmail= "oyuchan50@gmail.com"; + private static int number; + + // 랜덤으로 숫자 생성 + public static void createNumber() { + number = (int)(Math.random() * (90000)) + 100000; //(int) Math.random() * (최댓값-최소값+1) + 최소값 + } + + public MimeMessage CreateMail(String mail) { + createNumber(); + MimeMessage message = javaMailSender.createMimeMessage(); + + try { + message.setFrom(senderEmail); + message.setRecipients(MimeMessage.RecipientType.TO, mail); + message.setSubject("이메일 인증"); + String body = ""; + body += "

" + "요청하신 인증 번호입니다." + "

"; + body += "

" + number + "

"; + body += "

" + "감사합니다." + "

"; + message.setText(body,"UTF-8", "html"); + } catch (MessagingException e) { + e.printStackTrace(); + } + + return message; + } + + public int sendMail(String mail) { + MimeMessage message = CreateMail(mail); + javaMailSender.send(message); + + return number; + } +} + From b4a56571ee19ccf58c606093dca04c57b6e8ea38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 22:05:26 +0900 Subject: [PATCH 03/17] =?UTF-8?q?:recycle:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=B2=88=ED=98=B8=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81(?= =?UTF-8?q?=EC=A1=B0=EA=B8=88=EB=8D=94=20=EB=B3=B4=EC=95=88=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 24 ++++++--- .../socket/auth/service/MailService.java | 50 +++++++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index dc26e34..76ca55d 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -5,9 +5,12 @@ import com.mycom.socket.auth.dto.response.LoginResponseDto; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.auth.service.MailService; +import com.mycom.socket.auth.service.RateLimiter; +import com.mycom.socket.global.exception.BaseException; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @@ -17,7 +20,7 @@ public class AuthController { private final AuthService authService; private final MailService mailService; - private int number; + private final RateLimiter rateLimiter; @PostMapping("/login") public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request, @@ -35,19 +38,24 @@ public Long register(@Valid @RequestBody RegisterRequestDto request) { return authService.register(request); } - @PostMapping("/mail-send") + @PostMapping("/email/verification") public Integer mailSend(@RequestParam(name = "mail") String mail) { try { - number = mailService.sendMail(mail); - return number; + rateLimiter.checkRateLimit(mail); // 요청 제한 체크 + return mailService.sendMail(mail); } catch (Exception e) { - throw new RuntimeException("Failed to send mail: " + e.getMessage()); + throw new BaseException("이메일 전송에 실패했습니다.", HttpStatus.BAD_REQUEST); } } - @GetMapping("/mail-check") - public Boolean mailCheck(@RequestParam(name = "userNumber") String userNumber) { - return userNumber.equals(String.valueOf(number)); + @GetMapping("/email/verify") + public Boolean mailCheck(@RequestParam(name = "mail") String mail, + @RequestParam(name = "code") String code) { + try { + return mailService.verifyCode(mail, code); + } catch (Exception e) { + throw new BaseException("인증코드 검증에 실패했습니다.", HttpStatus.BAD_REQUEST); + } } } diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 50f9d6d..12bb4bd 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -1,49 +1,67 @@ package com.mycom.socket.auth.service; +import com.mycom.socket.global.exception.BaseException; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +import java.security.SecureRandom; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + @Service @RequiredArgsConstructor public class MailService { private final JavaMailSender javaMailSender; - private static final String senderEmail= "oyuchan50@gmail.com"; - private static int number; + private final Map verificationCodes = new ConcurrentHashMap<>(); + + @Value("${spring.mail.username}") + private String senderEmail; + // 랜덤으로 숫자 생성 - public static void createNumber() { - number = (int)(Math.random() * (90000)) + 100000; //(int) Math.random() * (최댓값-최소값+1) + 최소값 + private int createVerificationCode() { + // Math.random()은 예측 가능한 난수를 생성할 수 있어 보안에 취약 + // SecureRandom은 암호학적으로 안전한 난수를 생성하므로 인증번호 생성에 더 적합 + SecureRandom secureRandom = new SecureRandom(); + return 100000 + secureRandom.nextInt(900000); } - public MimeMessage CreateMail(String mail) { - createNumber(); - MimeMessage message = javaMailSender.createMimeMessage(); + public MimeMessage createMail(String mail) { + int verificationCode = createVerificationCode(); + verificationCodes.put(mail, verificationCode); + MimeMessage message = javaMailSender.createMimeMessage(); try { message.setFrom(senderEmail); message.setRecipients(MimeMessage.RecipientType.TO, mail); message.setSubject("이메일 인증"); - String body = ""; - body += "

" + "요청하신 인증 번호입니다." + "

"; - body += "

" + number + "

"; - body += "

" + "감사합니다." + "

"; - message.setText(body,"UTF-8", "html"); + String body = String.format(""" +

요청하신 인증 번호입니다.

+

%d

+

감사합니다.

+ """, verificationCode); + message.setText(body, "UTF-8", "html"); } catch (MessagingException e) { - e.printStackTrace(); + throw new BaseException("이메일 생성 중 오류가 발생했습니다.", HttpStatus.BAD_REQUEST); } - return message; } public int sendMail(String mail) { - MimeMessage message = CreateMail(mail); + MimeMessage message = createMail(mail); javaMailSender.send(message); + return verificationCodes.get(mail); + } - return number; + public boolean verifyCode(String email, String code) { + Integer savedCode = verificationCodes.get(email); + return savedCode != null && String.valueOf(savedCode).equals(code); } } From 92944df5e24dd42cd243a50ffd1a3a9d88b98046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 22:05:52 +0900 Subject: [PATCH 04/17] =?UTF-8?q?:sparkles:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=B2=88=ED=98=B8=20=EC=97=AC=EB=9F=AC?= =?UTF-8?q?=EB=B2=88=20=EC=9A=94=EC=B2=AD=20=EC=A0=9C=ED=95=9C=20(1?= =?UTF-8?q?=EB=B6=84=EB=8B=B9=20=EC=B5=9C=EB=8C=80=203=EB=B2=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../socket/auth/service/RateLimiter.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/mycom/socket/auth/service/RateLimiter.java diff --git a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java new file mode 100644 index 0000000..73a9922 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java @@ -0,0 +1,35 @@ +package com.mycom.socket.auth.service; + +import com.mycom.socket.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@RequiredArgsConstructor +public class RateLimiter { + private final Map> requestMap = new ConcurrentHashMap<>(); + private static final int MAX_REQUESTS = 3; // 1분당 최대 3번 + private static final Duration WINDOW_SIZE = Duration.ofMinutes(1); // 1분의 시간 간격 + + public void checkRateLimit(String email) { + List requests = requestMap.computeIfAbsent(email, k -> new ArrayList<>()); + LocalDateTime now = LocalDateTime.now(); + + requests.removeIf(requestTime -> + requestTime.plus(WINDOW_SIZE).isBefore(now)); + + if (requests.size() >= MAX_REQUESTS) { + throw new BaseException("너무 많은 요청입니다. 잠시 후 다시 시도해주세요.", HttpStatus.TOO_MANY_REQUESTS); + } + + requests.add(now); + } +} From 3915305984bcc4bf5034da095c95184be4590e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 22:15:29 +0900 Subject: [PATCH 05/17] =?UTF-8?q?:recycle:=20=EC=A7=80=EA=B8=88=EC=9D=80?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=B4=EC=84=9C=20=EC=9D=B8=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=20=EC=B6=94?= =?UTF-8?q?=ED=9B=84=20Redis=EB=A5=BC=20=ED=86=B5=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mycom/socket/GoSocketBeApplication.java | 2 ++ .../java/com/mycom/socket/auth/service/RateLimiter.java | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/com/mycom/socket/GoSocketBeApplication.java b/src/main/java/com/mycom/socket/GoSocketBeApplication.java index cd45bef..a2d5dde 100644 --- a/src/main/java/com/mycom/socket/GoSocketBeApplication.java +++ b/src/main/java/com/mycom/socket/GoSocketBeApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class GoSocketBeApplication { diff --git a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java index 73a9922..fb212df 100644 --- a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java +++ b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java @@ -3,6 +3,7 @@ import com.mycom.socket.global.exception.BaseException; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.Duration; @@ -19,6 +20,13 @@ public class RateLimiter { private static final int MAX_REQUESTS = 3; // 1분당 최대 3번 private static final Duration WINDOW_SIZE = Duration.ofMinutes(1); // 1분의 시간 간격 + @Scheduled(fixedRate = 3600000) // 1시간마다 실행 + public void cleanup() { + LocalDateTime threshold = LocalDateTime.now().minus(WINDOW_SIZE); + requestMap.entrySet().removeIf(entry -> + entry.getValue().stream().allMatch(time -> time.isBefore(threshold))); + } + public void checkRateLimit(String email) { List requests = requestMap.computeIfAbsent(email, k -> new ArrayList<>()); LocalDateTime now = LocalDateTime.now(); From d6f20ad4eb75cadf41a13805d5513bbc5f076840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 23:10:14 +0900 Subject: [PATCH 06/17] =?UTF-8?q?:recycle:=20=EC=9D=B8=EC=A6=9D=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=EA=B0=84=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../socket/auth/service/MailService.java | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 12bb4bd..422e3cf 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -18,13 +20,20 @@ public class MailService { private final JavaMailSender javaMailSender; - private final Map verificationCodes = new ConcurrentHashMap<>(); + private final RateLimiter rateLimiter; // 인증 번호 요청 제한 + private final Map verificationCodes = new ConcurrentHashMap<>(); // 이메일별 인증번호 저장 + private final Map expiryTimes = new ConcurrentHashMap<>(); // 이메일별 인증번호 만료 시간 저장 + + private static final Duration CODE_VALID_DURATION = Duration.ofMinutes(5); @Value("${spring.mail.username}") private String senderEmail; - - // 랜덤으로 숫자 생성 + /** + * 6자리 난수 인증번호 생성 + * SecureRandom 사용하여 보안성 향상 + * @return 100000~999999 범위의 인증번호 + */ private int createVerificationCode() { // Math.random()은 예측 가능한 난수를 생성할 수 있어 보안에 취약 // SecureRandom은 암호학적으로 안전한 난수를 생성하므로 인증번호 생성에 더 적합 @@ -32,6 +41,11 @@ private int createVerificationCode() { return 100000 + secureRandom.nextInt(900000); } + /** + * 인증메일 생성 + * @param mail 수신자 이메일 주소 + * @return 생성된 인증메일 + */ public MimeMessage createMail(String mail) { int verificationCode = createVerificationCode(); verificationCodes.put(mail, verificationCode); @@ -53,15 +67,49 @@ public MimeMessage createMail(String mail) { return message; } + /** + * 인증메일 발송 및 인증번호 반환 + * @param mail 수신자 이메일 주소 + * @return 생성된 인증번호 + */ public int sendMail(String mail) { + rateLimiter.checkRateLimit(mail); MimeMessage message = createMail(mail); javaMailSender.send(message); + + // 만료 시간 설정 + expiryTimes.put(mail, LocalDateTime.now().plus(CODE_VALID_DURATION)); + return verificationCodes.get(mail); } + /** + * 인증번호 검증 + * @param email 수신자 이메일 주소 + * @param code 사용자가 입력한 인증번호 + * @return 인증번호 일치 여부 + */ public boolean verifyCode(String email, String code) { Integer savedCode = verificationCodes.get(email); - return savedCode != null && String.valueOf(savedCode).equals(code); + + + LocalDateTime expiryTime = expiryTimes.get(email); + + // 코드가 없거나 만료된 경우 + if (savedCode == null || + expiryTime == null || + LocalDateTime.now().isAfter(expiryTime)) { + return false; + } + + boolean isValid = String.valueOf(savedCode).equals(code); + if (isValid) { + // 검증 성공시 데이터 삭제 + verificationCodes.remove(email); + expiryTimes.remove(email); + } + return isValid; + } } From 5fca75a75a13e709b4747de30bdb0392d0b22db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 23:20:33 +0900 Subject: [PATCH 07/17] =?UTF-8?q?:recycle:=20=EC=9D=B8=EC=A6=9D=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9A=94=EC=B2=AD=20=EB=A7=8E=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=83=81=EC=84=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mycom/socket/auth/service/RateLimiter.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java index fb212df..9864a48 100644 --- a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java +++ b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java @@ -35,7 +35,11 @@ public void checkRateLimit(String email) { requestTime.plus(WINDOW_SIZE).isBefore(now)); if (requests.size() >= MAX_REQUESTS) { - throw new BaseException("너무 많은 요청입니다. 잠시 후 다시 시도해주세요.", HttpStatus.TOO_MANY_REQUESTS); + LocalDateTime oldestRequest = requests.get(0); + Duration waitTime = WINDOW_SIZE.minus(Duration.between(oldestRequest, now)); + throw new BaseException( + String.format("너무 많은 요청입니다. %d초 후에 다시 시도해주세요.",waitTime.getSeconds()), + HttpStatus.TOO_MANY_REQUESTS); } requests.add(now); From b40a9e5dff38f1e4894a2d85589bd7b93c7d679d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 23:21:04 +0900 Subject: [PATCH 08/17] =?UTF-8?q?:recycle:=20VerificationData=20Record?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=EC=84=9C=20=EB=8D=94=20=ED=9A=A8?= =?UTF-8?q?=EC=9C=A8=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../socket/auth/service/MailService.java | 33 ++++++++----------- .../auth/service/data/VerificationData.java | 8 +++++ 2 files changed, 21 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/mycom/socket/auth/service/data/VerificationData.java diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 422e3cf..2caffa5 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -1,5 +1,6 @@ package com.mycom.socket.auth.service; +import com.mycom.socket.auth.service.data.VerificationData; import com.mycom.socket.global.exception.BaseException; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; @@ -21,9 +22,8 @@ public class MailService { private final JavaMailSender javaMailSender; private final RateLimiter rateLimiter; // 인증 번호 요청 제한 - private final Map verificationCodes = new ConcurrentHashMap<>(); // 이메일별 인증번호 저장 - private final Map expiryTimes = new ConcurrentHashMap<>(); // 이메일별 인증번호 만료 시간 저장 + private final Map verificationDataMap = new ConcurrentHashMap<>(); private static final Duration CODE_VALID_DURATION = Duration.ofMinutes(5); @Value("${spring.mail.username}") @@ -48,7 +48,10 @@ private int createVerificationCode() { */ public MimeMessage createMail(String mail) { int verificationCode = createVerificationCode(); - verificationCodes.put(mail, verificationCode); + verificationDataMap.put(mail, new VerificationData( + verificationCode, + LocalDateTime.now().plus(CODE_VALID_DURATION) + )); MimeMessage message = javaMailSender.createMimeMessage(); try { @@ -62,7 +65,8 @@ public MimeMessage createMail(String mail) { """, verificationCode); message.setText(body, "UTF-8", "html"); } catch (MessagingException e) { - throw new BaseException("이메일 생성 중 오류가 발생했습니다.", HttpStatus.BAD_REQUEST); + throw new BaseException("이메일 생성 중 오류가 발생했습니다: " + e.getMessage(), + HttpStatus.BAD_REQUEST); } return message; } @@ -77,10 +81,7 @@ public int sendMail(String mail) { MimeMessage message = createMail(mail); javaMailSender.send(message); - // 만료 시간 설정 - expiryTimes.put(mail, LocalDateTime.now().plus(CODE_VALID_DURATION)); - - return verificationCodes.get(mail); + return verificationDataMap.get(mail).code(); } /** @@ -90,23 +91,15 @@ public int sendMail(String mail) { * @return 인증번호 일치 여부 */ public boolean verifyCode(String email, String code) { - Integer savedCode = verificationCodes.get(email); - - - LocalDateTime expiryTime = expiryTimes.get(email); + VerificationData data = verificationDataMap.get(email); - // 코드가 없거나 만료된 경우 - if (savedCode == null || - expiryTime == null || - LocalDateTime.now().isAfter(expiryTime)) { + if (data == null || LocalDateTime.now().isAfter(data.expiryTime())) { return false; } - boolean isValid = String.valueOf(savedCode).equals(code); + boolean isValid = String.valueOf(data.code()).equals(code); if (isValid) { - // 검증 성공시 데이터 삭제 - verificationCodes.remove(email); - expiryTimes.remove(email); + verificationDataMap.remove(email); } return isValid; diff --git a/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java new file mode 100644 index 0000000..3c00db3 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java @@ -0,0 +1,8 @@ +package com.mycom.socket.auth.service.data; + +import java.time.LocalDateTime; + +public record VerificationData( + int code, + LocalDateTime expiryTime +) {} From 0bae0deaf505ff635b199bafba333b5784579272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 23:36:02 +0900 Subject: [PATCH 09/17] =?UTF-8?q?:recycle:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 2 +- .../socket/auth/service/MailService.java | 71 +++++++++++++------ .../auth/service/data/VerificationData.java | 18 +++-- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index 76ca55d..81eef53 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -39,7 +39,7 @@ public Long register(@Valid @RequestBody RegisterRequestDto request) { } @PostMapping("/email/verification") - public Integer mailSend(@RequestParam(name = "mail") String mail) { + public Boolean mailSend(@RequestParam(name = "mail") String mail) { try { rateLimiter.checkRateLimit(mail); // 요청 제한 체크 return mailService.sendMail(mail); diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 2caffa5..7c7fdbc 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -9,12 +9,14 @@ import org.springframework.http.HttpStatus; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.security.SecureRandom; import java.time.Duration; import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; @Service @RequiredArgsConstructor @@ -24,7 +26,7 @@ public class MailService { private final RateLimiter rateLimiter; // 인증 번호 요청 제한 private final Map verificationDataMap = new ConcurrentHashMap<>(); - private static final Duration CODE_VALID_DURATION = Duration.ofMinutes(5); + private static final String EMAIL_REGEX = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; @Value("${spring.mail.username}") private String senderEmail; @@ -34,11 +36,11 @@ public class MailService { * SecureRandom 사용하여 보안성 향상 * @return 100000~999999 범위의 인증번호 */ - private int createVerificationCode() { + private String createVerificationCode() { // Math.random()은 예측 가능한 난수를 생성할 수 있어 보안에 취약 // SecureRandom은 암호학적으로 안전한 난수를 생성하므로 인증번호 생성에 더 적합 SecureRandom secureRandom = new SecureRandom(); - return 100000 + secureRandom.nextInt(900000); + return String.format("%06d", secureRandom.nextInt(1000000)); } /** @@ -46,23 +48,17 @@ private int createVerificationCode() { * @param mail 수신자 이메일 주소 * @return 생성된 인증메일 */ - public MimeMessage createMail(String mail) { - int verificationCode = createVerificationCode(); - verificationDataMap.put(mail, new VerificationData( - verificationCode, - LocalDateTime.now().plus(CODE_VALID_DURATION) - )); - + public MimeMessage createMail(String mail, String verificationCode) { MimeMessage message = javaMailSender.createMimeMessage(); try { message.setFrom(senderEmail); message.setRecipients(MimeMessage.RecipientType.TO, mail); message.setSubject("이메일 인증"); String body = String.format(""" -

요청하신 인증 번호입니다.

-

%d

-

감사합니다.

- """, verificationCode); +

요청하신 인증 번호입니다.

+

%s

+

감사합니다.

+ """, verificationCode); message.setText(body, "UTF-8", "html"); } catch (MessagingException e) { throw new BaseException("이메일 생성 중 오류가 발생했습니다: " + e.getMessage(), @@ -71,17 +67,37 @@ public MimeMessage createMail(String mail) { return message; } + /** + * 이메일 유효성 검사 + * + * @param email 검사할 이메일 주소 + * @return 유효한 이메일 주소인지 여부 + */ + private boolean isValidEmail(String email) { + return StringUtils.hasText(email) && Pattern.matches(EMAIL_REGEX, email); + } + /** * 인증메일 발송 및 인증번호 반환 * @param mail 수신자 이메일 주소 * @return 생성된 인증번호 */ - public int sendMail(String mail) { - rateLimiter.checkRateLimit(mail); - MimeMessage message = createMail(mail); - javaMailSender.send(message); + public boolean sendMail(String mail) { + if (!isValidEmail(mail)) { + throw new BaseException("유효하지 않은 이메일 형식입니다.", HttpStatus.BAD_REQUEST); + } - return verificationDataMap.get(mail).code(); + rateLimiter.checkRateLimit(mail); + String verificationCode = createVerificationCode(); + verificationDataMap.put(mail, new VerificationData(verificationCode)); + + MimeMessage message = createMail(mail, verificationCode); + try{ + javaMailSender.send(message); + return true; + }catch (Exception e) { + throw new BaseException("이메일 발송 중 오류가 발생했습니다: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } } /** @@ -91,18 +107,27 @@ public int sendMail(String mail) { * @return 인증번호 일치 여부 */ public boolean verifyCode(String email, String code) { + if (!isValidEmail(email)){ + throw new BaseException("유효하지 않은 이메일 형식입니다.", HttpStatus.BAD_REQUEST); + } + + if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) { + return false; + } + VerificationData data = verificationDataMap.get(email); - if (data == null || LocalDateTime.now().isAfter(data.expiryTime())) { + if (data == null || data.isExpired()) { return false; } - boolean isValid = String.valueOf(data.code()).equals(code); - if (isValid) { + boolean isVerified = data.code().equals(code); + + if (isVerified){ verificationDataMap.remove(email); } - return isValid; + return isVerified; } } diff --git a/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java index 3c00db3..f941f16 100644 --- a/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java +++ b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java @@ -2,7 +2,17 @@ import java.time.LocalDateTime; -public record VerificationData( - int code, - LocalDateTime expiryTime -) {} +import java.time.Duration; + +public record VerificationData(String code, LocalDateTime expiryTime) { + + private static final Duration CODE_VALID_DURATION = Duration.ofMinutes(5); + + public VerificationData(String code) { + this(code, LocalDateTime.now().plus(CODE_VALID_DURATION)); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiryTime); + } +} From 0bb17dc7a9899e2eb40b381d8fd4e6b01b0e0970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Wed, 8 Jan 2025 23:36:39 +0900 Subject: [PATCH 10/17] =?UTF-8?q?:fire:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mycom/socket/auth/service/MailService.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 7c7fdbc..e817e01 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -12,8 +12,6 @@ import org.springframework.util.StringUtils; import java.security.SecureRandom; -import java.time.Duration; -import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; From 13cd90f1143049c47524ec5b8775c4b9d5c5b43a Mon Sep 17 00:00:00 2001 From: Ohyuchan Date: Wed, 8 Jan 2025 23:13:00 +0900 Subject: [PATCH 11/17] =?UTF-8?q?:fire:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20gitkeep=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index e69de29..0000000 From 1da4aefe1712316bba5bd12f07f87421efc2b2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Thu, 9 Jan 2025 00:22:46 +0900 Subject: [PATCH 12/17] =?UTF-8?q?:recycle:=20ApiResponse=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=B0=A9=EC=8B=9D=20=EC=96=91=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mycom/socket/global/dto/ApiResponse.java | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mycom/socket/global/dto/ApiResponse.java b/src/main/java/com/mycom/socket/global/dto/ApiResponse.java index d3e618b..eb1a1b1 100644 --- a/src/main/java/com/mycom/socket/global/dto/ApiResponse.java +++ b/src/main/java/com/mycom/socket/global/dto/ApiResponse.java @@ -2,29 +2,53 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; import java.time.LocalDateTime; @JsonInclude(JsonInclude.Include.NON_NULL) public record ApiResponse( + boolean success, String message, T data, @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime timestamp ) { + @Builder + public ApiResponse { + } public static ApiResponse success(String message) { - return new ApiResponse<>(message, null, LocalDateTime.now()); + return ApiResponse.builder() + .success(true) + .message(message) + .timestamp(LocalDateTime.now()) + .build(); } public static ApiResponse success(String message, T data) { - return new ApiResponse<>(message, data, LocalDateTime.now()); + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .timestamp(LocalDateTime.now()) + .build(); } public static ApiResponse error(String message) { - return new ApiResponse<>(message, null, LocalDateTime.now()); + return ApiResponse.builder() + .success(false) + .message(message) + .timestamp(LocalDateTime.now()) + .build(); } public static ApiResponse error(String message, T data) { - return new ApiResponse<>(message, data, LocalDateTime.now()); + return ApiResponse.builder() + .success(false) + .message(message) + .data(data) + .timestamp(LocalDateTime.now()) + .build(); } -} + +} \ No newline at end of file From 579d964337a946f61296491998b0985ad969bdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Thu, 9 Jan 2025 00:23:06 +0900 Subject: [PATCH 13/17] =?UTF-8?q?:recycle:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=96=91=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GlobalExceptionHandler.java | 19 ++++++++++++++++--- .../global/handler/ResponseHandler.java | 12 +----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java b/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java index 4dd5a35..e151fc5 100644 --- a/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java @@ -8,13 +8,17 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import java.util.NoSuchElementException; import java.util.stream.Collectors; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + + // 비지니스 예외 처리 @ExceptionHandler(BaseException.class) protected ResponseEntity> handleBaseException(BaseException e) { @@ -40,12 +44,21 @@ protected ResponseEntity> handleMethodArgumentNotValidException( .body(ApiResponse.error(errorMessage)); } - // 그 밖의 예외 처리 + // 일반적인 예외 처리 (IllegalArgumentException, NoSuchElementException 등) + @ExceptionHandler({IllegalArgumentException.class, NoSuchElementException.class, NoResourceFoundException.class}) + protected ResponseEntity> handleCommonException(Exception e) { + HttpStatus status = (e instanceof NoResourceFoundException) ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST; + log.error("Common Exception : {}", e.getMessage()); + return ResponseEntity + .status(status) + .body(ApiResponse.error(e.getMessage())); + } + // 모든 예외 처리 (최후의 보루) @ExceptionHandler(Exception.class) - protected ResponseEntity> handleException(Exception e) { + protected ResponseEntity> handleAllException(Exception e) { log.error("Internal Server Error", e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error("서버 내부 오류가 발생했습니다")); + .body(ApiResponse.error(e.getMessage() != null ? e.getMessage() : "서버 내부 오류가 발생했습니다.")); } } diff --git a/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java b/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java index ae52a47..21ca6f4 100644 --- a/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java +++ b/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java @@ -24,16 +24,6 @@ public Object beforeBodyWrite(Object body, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { - // null 처리 - if (body == null) { - return ApiResponse.success("Success"); - } - - // String 타입 처리 - if (body instanceof String) { - return ApiResponse.success("Success", body); - } - - return ApiResponse.success("Success", body); + return body; } } From ed0d68974c779ecec52288e82e88acba5ec90b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Thu, 9 Jan 2025 00:23:28 +0900 Subject: [PATCH 14/17] =?UTF-8?q?:recycle:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=B0=A9=EC=8B=9D=20Request=20?= =?UTF-8?q?=EC=99=80=20Response=20Dto=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 30 ++++++++++--------- .../auth/dto/request/EmailRequestDto.java | 11 +++++++ .../request/EmailVerificationRequestDto.java | 15 ++++++++++ .../EmailVerificationCheckResponseDto.java | 14 +++++++++ .../EmailVerificationResponseDto.java | 14 +++++++++ .../socket/auth/service/MailService.java | 20 ------------- 6 files changed, 70 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java create mode 100644 src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java create mode 100644 src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java create mode 100644 src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index 81eef53..6c0aa77 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -1,16 +1,18 @@ package com.mycom.socket.auth.controller; +import com.mycom.socket.auth.dto.request.EmailRequestDto; +import com.mycom.socket.auth.dto.request.EmailVerificationRequestDto; import com.mycom.socket.auth.dto.request.LoginRequestDto; import com.mycom.socket.auth.dto.request.RegisterRequestDto; +import com.mycom.socket.auth.dto.response.EmailVerificationCheckResponseDto; +import com.mycom.socket.auth.dto.response.EmailVerificationResponseDto; import com.mycom.socket.auth.dto.response.LoginResponseDto; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.auth.service.MailService; -import com.mycom.socket.auth.service.RateLimiter; import com.mycom.socket.global.exception.BaseException; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @@ -20,7 +22,6 @@ public class AuthController { private final AuthService authService; private final MailService mailService; - private final RateLimiter rateLimiter; @PostMapping("/login") public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request, @@ -38,23 +39,24 @@ public Long register(@Valid @RequestBody RegisterRequestDto request) { return authService.register(request); } - @PostMapping("/email/verification") - public Boolean mailSend(@RequestParam(name = "mail") String mail) { + @PostMapping("/verification") + public EmailVerificationResponseDto mailSend(@Valid @RequestBody EmailRequestDto emailRequestDto) { try { - rateLimiter.checkRateLimit(mail); // 요청 제한 체크 - return mailService.sendMail(mail); - } catch (Exception e) { - throw new BaseException("이메일 전송에 실패했습니다.", HttpStatus.BAD_REQUEST); + boolean isSuccess = mailService.sendMail(emailRequestDto.email()); + return isSuccess ? EmailVerificationResponseDto.createSuccessResponse() : EmailVerificationResponseDto.createFailureResponse("이메일 전송에 실패했습니다."); + } catch (BaseException e) { + return EmailVerificationResponseDto.createFailureResponse(e.getMessage()); } } @GetMapping("/email/verify") - public Boolean mailCheck(@RequestParam(name = "mail") String mail, - @RequestParam(name = "code") String code) { + public EmailVerificationCheckResponseDto mailCheck(@Valid @RequestBody EmailVerificationRequestDto emailRequestDto) { try { - return mailService.verifyCode(mail, code); - } catch (Exception e) { - throw new BaseException("인증코드 검증에 실패했습니다.", HttpStatus.BAD_REQUEST); + boolean isVerified = mailService.verifyCode(emailRequestDto.email(), emailRequestDto.code()); + return isVerified ? EmailVerificationCheckResponseDto.createSuccessResponse() : + EmailVerificationCheckResponseDto.createFailureResponse("이메일 인증에 실패했습니다."); + } catch (BaseException e) { + return EmailVerificationCheckResponseDto.createFailureResponse(e.getMessage()); } } diff --git a/src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java new file mode 100644 index 0000000..abf4411 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java @@ -0,0 +1,11 @@ +package com.mycom.socket.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; + +public record EmailRequestDto( + @NotEmpty(message = "이메일 주소를 입력해주세요.") + @Email(message = "유효하지 않은 이메일 형식입니다.") + String email +) { +} diff --git a/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java new file mode 100644 index 0000000..ee1ac44 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java @@ -0,0 +1,15 @@ +package com.mycom.socket.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; + +public record EmailVerificationRequestDto( + @NotEmpty(message = "이메일 주소를 입력해주세요.") + @Email(message = "유효하지 않은 이메일 형식입니다.") + String email, + @NotEmpty(message = "인증 코드를 입력해주세요.") + @Pattern(regexp = "\\d{6}", message = "인증 코드는 6자리 숫자여야 합니다.") + String code +) { +} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java new file mode 100644 index 0000000..fcd6f59 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java @@ -0,0 +1,14 @@ +package com.mycom.socket.auth.dto.response; + +import com.mycom.socket.global.dto.ApiResponse; + +public record EmailVerificationCheckResponseDto(ApiResponse apiResponse) { + + public static EmailVerificationCheckResponseDto createSuccessResponse() { + return new EmailVerificationCheckResponseDto(ApiResponse.success("이메일 인증 성공", true)); + } + + public static EmailVerificationCheckResponseDto createFailureResponse(String errorMessage) { + return new EmailVerificationCheckResponseDto(ApiResponse.error(errorMessage)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java new file mode 100644 index 0000000..4e78e02 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java @@ -0,0 +1,14 @@ +package com.mycom.socket.auth.dto.response; + +import com.mycom.socket.global.dto.ApiResponse; + +public record EmailVerificationResponseDto(ApiResponse apiResponse) { + + public static EmailVerificationResponseDto createSuccessResponse() { + return new EmailVerificationResponseDto(ApiResponse.success("이메일 전송 성공")); + } + + public static EmailVerificationResponseDto createFailureResponse(String errorMessage) { + return new EmailVerificationResponseDto(ApiResponse.error(errorMessage)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index e817e01..9a3dacd 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -14,7 +14,6 @@ import java.security.SecureRandom; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Pattern; @Service @RequiredArgsConstructor @@ -24,7 +23,6 @@ public class MailService { private final RateLimiter rateLimiter; // 인증 번호 요청 제한 private final Map verificationDataMap = new ConcurrentHashMap<>(); - private static final String EMAIL_REGEX = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; @Value("${spring.mail.username}") private String senderEmail; @@ -65,26 +63,12 @@ public MimeMessage createMail(String mail, String verificationCode) { return message; } - /** - * 이메일 유효성 검사 - * - * @param email 검사할 이메일 주소 - * @return 유효한 이메일 주소인지 여부 - */ - private boolean isValidEmail(String email) { - return StringUtils.hasText(email) && Pattern.matches(EMAIL_REGEX, email); - } - /** * 인증메일 발송 및 인증번호 반환 * @param mail 수신자 이메일 주소 * @return 생성된 인증번호 */ public boolean sendMail(String mail) { - if (!isValidEmail(mail)) { - throw new BaseException("유효하지 않은 이메일 형식입니다.", HttpStatus.BAD_REQUEST); - } - rateLimiter.checkRateLimit(mail); String verificationCode = createVerificationCode(); verificationDataMap.put(mail, new VerificationData(verificationCode)); @@ -105,10 +89,6 @@ public boolean sendMail(String mail) { * @return 인증번호 일치 여부 */ public boolean verifyCode(String email, String code) { - if (!isValidEmail(email)){ - throw new BaseException("유효하지 않은 이메일 형식입니다.", HttpStatus.BAD_REQUEST); - } - if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) { return false; } From 8aa701dd90a398bee09e1f16641f1f508a9635d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Thu, 9 Jan 2025 00:24:01 +0900 Subject: [PATCH 15/17] =?UTF-8?q?:fire:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mycom/socket/global/handler/ResponseHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java b/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java index 21ca6f4..1821527 100644 --- a/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java +++ b/src/main/java/com/mycom/socket/global/handler/ResponseHandler.java @@ -1,6 +1,5 @@ package com.mycom.socket.global.handler; -import com.mycom.socket.global.dto.ApiResponse; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; From 9991a3d30101798b02db357eb98fecfeb36f3183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Thu, 9 Jan 2025 00:37:25 +0900 Subject: [PATCH 16/17] =?UTF-8?q?:recycle:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mycom/socket/global/dto/ApiResponse.java | 18 +++++++++++---- .../handler/GlobalExceptionHandler.java | 23 +++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/mycom/socket/global/dto/ApiResponse.java b/src/main/java/com/mycom/socket/global/dto/ApiResponse.java index eb1a1b1..4f3a96c 100644 --- a/src/main/java/com/mycom/socket/global/dto/ApiResponse.java +++ b/src/main/java/com/mycom/socket/global/dto/ApiResponse.java @@ -5,6 +5,7 @@ import lombok.Builder; import java.time.LocalDateTime; +import java.util.Objects; @JsonInclude(JsonInclude.Include.NON_NULL) public record ApiResponse( @@ -17,38 +18,45 @@ public record ApiResponse( @Builder public ApiResponse { } + + private static LocalDateTime getCurrentTimestamp() { + return LocalDateTime.now(); + } public static ApiResponse success(String message) { + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); return ApiResponse.builder() .success(true) .message(message) - .timestamp(LocalDateTime.now()) + .timestamp(getCurrentTimestamp()) .build(); } public static ApiResponse success(String message, T data) { + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); return ApiResponse.builder() .success(true) .message(message) .data(data) - .timestamp(LocalDateTime.now()) + .timestamp(getCurrentTimestamp()) .build(); } public static ApiResponse error(String message) { + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); return ApiResponse.builder() .success(false) .message(message) - .timestamp(LocalDateTime.now()) + .timestamp(getCurrentTimestamp()) .build(); } public static ApiResponse error(String message, T data) { + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); return ApiResponse.builder() .success(false) .message(message) .data(data) - .timestamp(LocalDateTime.now()) + .timestamp(getCurrentTimestamp()) .build(); } - } \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java b/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java index e151fc5..fd0f900 100644 --- a/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/mycom/socket/global/handler/GlobalExceptionHandler.java @@ -44,21 +44,30 @@ protected ResponseEntity> handleMethodArgumentNotValidException( .body(ApiResponse.error(errorMessage)); } - // 일반적인 예외 처리 (IllegalArgumentException, NoSuchElementException 등) - @ExceptionHandler({IllegalArgumentException.class, NoSuchElementException.class, NoResourceFoundException.class}) - protected ResponseEntity> handleCommonException(Exception e) { - HttpStatus status = (e instanceof NoResourceFoundException) ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST; - log.error("Common Exception : {}", e.getMessage()); + // IllegalArgumentException 처리 + @ExceptionHandler(IllegalArgumentException.class) + protected ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("IllegalArgumentException: {}", e.getMessage()); return ResponseEntity - .status(status) + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(e.getMessage())); + } + + // NoSuchElementException 처리 + @ExceptionHandler(NoSuchElementException.class) + protected ResponseEntity> handleNoSuchElementException(NoSuchElementException e) { + log.warn("NoSuchElementException : {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.NOT_FOUND) .body(ApiResponse.error(e.getMessage())); } + // 모든 예외 처리 (최후의 보루) @ExceptionHandler(Exception.class) protected ResponseEntity> handleAllException(Exception e) { log.error("Internal Server Error", e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(e.getMessage() != null ? e.getMessage() : "서버 내부 오류가 발생했습니다.")); + .body(ApiResponse.error("서버 내부 오류가 발생했습니다.")); } } From 89a7f63554bdab846f5ff93d9af156e488464378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=A9=E1=84=8B=E1=85=B2=E1=84=8E=E1=85=A1?= =?UTF-8?q?=E1=86=AB?= Date: Thu, 9 Jan 2025 00:37:43 +0900 Subject: [PATCH 17/17] =?UTF-8?q?:recycle:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20Get=20->=20Post?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mycom/socket/auth/controller/AuthController.java | 9 ++++++--- .../auth/dto/request/EmailVerificationRequestDto.java | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index 6c0aa77..a733f77 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -9,6 +9,7 @@ import com.mycom.socket.auth.dto.response.LoginResponseDto; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.auth.service.MailService; +import com.mycom.socket.auth.service.RateLimiter; import com.mycom.socket.global.exception.BaseException; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -22,6 +23,7 @@ public class AuthController { private final AuthService authService; private final MailService mailService; + private final RateLimiter rateLimiter; @PostMapping("/login") public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request, @@ -49,13 +51,14 @@ public EmailVerificationResponseDto mailSend(@Valid @RequestBody EmailRequestDto } } - @GetMapping("/email/verify") + @PostMapping("/email/verify") public EmailVerificationCheckResponseDto mailCheck(@Valid @RequestBody EmailVerificationRequestDto emailRequestDto) { - try { + try{ + rateLimiter.checkRateLimit(emailRequestDto.email());// 시도 횟수 제한 boolean isVerified = mailService.verifyCode(emailRequestDto.email(), emailRequestDto.code()); return isVerified ? EmailVerificationCheckResponseDto.createSuccessResponse() : EmailVerificationCheckResponseDto.createFailureResponse("이메일 인증에 실패했습니다."); - } catch (BaseException e) { + }catch (BaseException e){ return EmailVerificationCheckResponseDto.createFailureResponse(e.getMessage()); } } diff --git a/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java index ee1ac44..fbb3e32 100644 --- a/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java +++ b/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java @@ -9,7 +9,7 @@ public record EmailVerificationRequestDto( @Email(message = "유효하지 않은 이메일 형식입니다.") String email, @NotEmpty(message = "인증 코드를 입력해주세요.") - @Pattern(regexp = "\\d{6}", message = "인증 코드는 6자리 숫자여야 합니다.") + @Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.") String code ) { } \ No newline at end of file