diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index e69de29..0000000 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 { 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/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index 37e3618..a733f77 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,20 @@ 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.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 +22,8 @@ public class AuthController { private final AuthService authService; + private final MailService mailService; + private final RateLimiter rateLimiter; @PostMapping("/login") public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request, @@ -34,4 +40,27 @@ public void logout(HttpServletResponse response) { public Long register(@Valid @RequestBody RegisterRequestDto request) { return authService.register(request); } + + @PostMapping("/verification") + public EmailVerificationResponseDto mailSend(@Valid @RequestBody EmailRequestDto emailRequestDto) { + try { + boolean isSuccess = mailService.sendMail(emailRequestDto.email()); + return isSuccess ? EmailVerificationResponseDto.createSuccessResponse() : EmailVerificationResponseDto.createFailureResponse("이메일 전송에 실패했습니다."); + } catch (BaseException e) { + return EmailVerificationResponseDto.createFailureResponse(e.getMessage()); + } + } + + @PostMapping("/email/verify") + public EmailVerificationCheckResponseDto mailCheck(@Valid @RequestBody EmailVerificationRequestDto emailRequestDto) { + try{ + rateLimiter.checkRateLimit(emailRequestDto.email());// 시도 횟수 제한 + 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..fbb3e32 --- /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 = "^[0-9]{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/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..9a3dacd --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -0,0 +1,111 @@ +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; +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 org.springframework.util.StringUtils; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender javaMailSender; + private final RateLimiter rateLimiter; // 인증 번호 요청 제한 + + private final Map verificationDataMap = new ConcurrentHashMap<>(); + + @Value("${spring.mail.username}") + private String senderEmail; + + /** + * 6자리 난수 인증번호 생성 + * SecureRandom 사용하여 보안성 향상 + * @return 100000~999999 범위의 인증번호 + */ + private String createVerificationCode() { + // Math.random()은 예측 가능한 난수를 생성할 수 있어 보안에 취약 + // SecureRandom은 암호학적으로 안전한 난수를 생성하므로 인증번호 생성에 더 적합 + SecureRandom secureRandom = new SecureRandom(); + return String.format("%06d", secureRandom.nextInt(1000000)); + } + + /** + * 인증메일 생성 + * @param mail 수신자 이메일 주소 + * @return 생성된 인증메일 + */ + 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(""" +

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

+

%s

+

감사합니다.

+ """, verificationCode); + message.setText(body, "UTF-8", "html"); + } catch (MessagingException e) { + throw new BaseException("이메일 생성 중 오류가 발생했습니다: " + e.getMessage(), + HttpStatus.BAD_REQUEST); + } + return message; + } + + /** + * 인증메일 발송 및 인증번호 반환 + * @param mail 수신자 이메일 주소 + * @return 생성된 인증번호 + */ + public boolean sendMail(String mail) { + 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); + } + } + + /** + * 인증번호 검증 + * @param email 수신자 이메일 주소 + * @param code 사용자가 입력한 인증번호 + * @return 인증번호 일치 여부 + */ + public boolean verifyCode(String email, String code) { + if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) { + return false; + } + + VerificationData data = verificationDataMap.get(email); + + if (data == null || data.isExpired()) { + return false; + } + + boolean isVerified = data.code().equals(code); + + if (isVerified){ + verificationDataMap.remove(email); + } + + return isVerified; + } +} + 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..9864a48 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java @@ -0,0 +1,47 @@ +package com.mycom.socket.auth.service; + +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; +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분의 시간 간격 + + @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(); + + requests.removeIf(requestTime -> + requestTime.plus(WINDOW_SIZE).isBefore(now)); + + if (requests.size() >= MAX_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); + } +} 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..f941f16 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java @@ -0,0 +1,18 @@ +package com.mycom.socket.auth.service.data; + +import java.time.LocalDateTime; + +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); + } +} 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..4f3a96c 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,61 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; import java.time.LocalDateTime; +import java.util.Objects; @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 { + } + + private static LocalDateTime getCurrentTimestamp() { + return LocalDateTime.now(); + } public static ApiResponse success(String message) { - return new ApiResponse<>(message, null, LocalDateTime.now()); + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); + return ApiResponse.builder() + .success(true) + .message(message) + .timestamp(getCurrentTimestamp()) + .build(); } public static ApiResponse success(String message, T data) { - return new ApiResponse<>(message, data, LocalDateTime.now()); + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .timestamp(getCurrentTimestamp()) + .build(); } public static ApiResponse error(String message) { - return new ApiResponse<>(message, null, LocalDateTime.now()); + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); + return ApiResponse.builder() + .success(false) + .message(message) + .timestamp(getCurrentTimestamp()) + .build(); } public static ApiResponse error(String message, T data) { - return new ApiResponse<>(message, data, LocalDateTime.now()); + Objects.requireNonNull(message, "메시지는 null일 수 없습니다."); + return ApiResponse.builder() + .success(false) + .message(message) + .data(data) + .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 4dd5a35..fd0f900 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,30 @@ protected ResponseEntity> handleMethodArgumentNotValidException( .body(ApiResponse.error(errorMessage)); } - // 그 밖의 예외 처리 + // IllegalArgumentException 처리 + @ExceptionHandler(IllegalArgumentException.class) + protected ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("IllegalArgumentException: {}", e.getMessage()); + return ResponseEntity + .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> 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("서버 내부 오류가 발생했습니다.")); } } 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..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; @@ -24,16 +23,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; } }