diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/EmailVerificationController.java b/src/main/java/com/indayvidual/server/domain/user/controller/EmailVerificationController.java index 7d8c213..3d1c3f5 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/EmailVerificationController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/EmailVerificationController.java @@ -4,9 +4,15 @@ import com.indayvidual.server.domain.user.dto.request.EmailVerifyRequestDTO; import com.indayvidual.server.domain.user.service.MailService.EmailVerificationService; import com.indayvidual.server.global.api.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @Tag( @@ -16,24 +22,55 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/auth/email") +@Validated public class EmailVerificationController { private final EmailVerificationService service; + @Operation( + summary = "이메일 중복 확인", + description = "입력한 이메일이 회원가입에 사용 가능한지 확인합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "사용 가능 여부 반환"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 이메일 형식") + }) @GetMapping("/check") - public ResponseEntity> checkEmail(@RequestParam String email) { + public ResponseEntity> checkEmail( + @Parameter(description = "확인할 이메일", example = "user@example.com") + @RequestParam @Email(message = "올바른 이메일 형식이 아닙니다.") @NotBlank(message = "이메일은 필수입니다.") + String email) { boolean isAvailable = service.isEmailAvailable(email); return ResponseEntity.ok(ApiResponse.onSuccess(isAvailable)); } + @Operation( + summary = "인증 코드 발송", + description = "이메일로 4자리 인증 코드를 발송합니다. 과도한 요청은 제한됩니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "발송 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "재발송 쿨다운"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "502", description = "메일 전송 실패") + }) @PostMapping("/send") - public ResponseEntity> send(@RequestBody EmailRequestDTO dto) { + public ResponseEntity> send(@RequestBody @Valid EmailRequestDTO dto) { service.sendVerificationCode(dto.getEmail()); return ResponseEntity.ok(ApiResponse.onSuccess("인증번호가 전송되었습니다.")); } + @Operation( + summary = "인증 코드 검증", + description = "이메일로 발송된 4자리 인증 코드를 검증합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "인증 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "코드 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "410", description = "코드 만료") + }) @PostMapping("/verify") - public ResponseEntity> verify(@RequestBody EmailVerifyRequestDTO dto) { + public ResponseEntity> verify(@RequestBody @Valid EmailVerifyRequestDTO dto) { service.verifyCode(dto.getEmail(), dto.getCode()); return ResponseEntity.ok(ApiResponse.onSuccess("이메일 인증 성공")); } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailRequestDTO.java index 560c498..5560d54 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailRequestDTO.java @@ -1,11 +1,13 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.Getter; @Getter public class EmailRequestDTO { + @Schema(description = "인증 코드 발송 대상 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED) @Email(message = "올바른 이메일 형식이 아닙니다.") @NotBlank(message = "이메일은 필수입니다.") private String email; diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailVerifyRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailVerifyRequestDTO.java index e0deec5..c4b7547 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailVerifyRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/EmailVerifyRequestDTO.java @@ -1,5 +1,6 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -7,10 +8,12 @@ @Getter public class EmailVerifyRequestDTO { + @Schema(description = "인증 대상 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED) @Email(message = "올바른 이메일 형식이 아닙니다.") @NotBlank(message = "이메일은 필수입니다.") private String email; + @Schema(description = "수신한 인증 코드(4자리 숫자)", example = "1234", requiredMode = Schema.RequiredMode.REQUIRED) @Pattern(regexp = "^[0-9]{4}$", message = "인증번호는 4자리 숫자여야 합니다.") @NotBlank(message = "인증번호는 필수입니다.") private String code; diff --git a/src/main/java/com/indayvidual/server/domain/user/service/MailService/EmailVerificationService.java b/src/main/java/com/indayvidual/server/domain/user/service/MailService/EmailVerificationService.java index 3c5d7ab..5568532 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/MailService/EmailVerificationService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/MailService/EmailVerificationService.java @@ -3,12 +3,14 @@ import com.indayvidual.server.domain.user.entity.EmailVerification; import com.indayvidual.server.domain.user.repository.EmailVerificationRepository; import com.indayvidual.server.domain.user.repository.UserRepository; +import com.indayvidual.server.global.api.code.status.ErrorStatus; +import com.indayvidual.server.global.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.security.SecureRandom; import java.time.Duration; -import java.util.concurrent.ThreadLocalRandom; @Service @RequiredArgsConstructor @@ -21,6 +23,7 @@ public class EmailVerificationService { private final EmailVerificationRepository repo; private final UserRepository userRepository; private final MailService mailService; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); @Transactional(readOnly = true) public boolean isEmailAvailable(String email) { @@ -32,35 +35,74 @@ public void sendVerificationCode(String email) { String code = generateCode(); repo.save(EmailVerification.create(email, code, EXPIRE)); + // 이미 가입된 이메일은 차단 + if (userRepository.existsByEmail(email)) { + throw new GeneralException(ErrorStatus.AUTH_EMAIL_DUPLICATED); + } + // 메일 전송 String subject = "[Indayvidual] 이메일 인증번호 안내"; - String body = String.format(""" - 안녕하세요! - - 요청하신 인증번호는 %s 입니다. - %d분 내에 입력해 주세요. - - 감사합니다. - """, code, EXPIRE.toMinutes()); + String prettyCode = code.replaceAll("(\\d)", "$1 ").trim(); + + String html = """ + + + + + Email Verification + + + + + + +
+ + + + + + + + + +
이메일 인증
+ 안녕하세요, Indayvidual입니다.
+ 아래 인증번호를 입력해 인증을 완료해 주세요. +
+
+ %s +
+
유효시간: %d분
※ 인증번호는 타인과 공유하지 마세요.
※ 본 메일은 발신 전용입니다. 문의: support@indayvidual.com
+
+ + +""".formatted(prettyCode, EXPIRE.toMinutes()); - mailService.sendSimpleMessage(email, subject, body); + try { + //mailService.sendSimpleMessage(email, subject, body); + mailService.sendHtmlMessage(email, subject, html); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.EMAIL_SEND_FAILED); + } } // 사용자가 입력한 인증번호 검증 public void verifyCode(String email, String code) { EmailVerification ev = repo.findByEmailAndCode(email, code) - .orElseThrow(() -> new IllegalArgumentException("인증번호가 일치하지 않습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.EMAIL_CODE_MISMATCH)); if (ev.isExpired()) { - throw new IllegalStateException("인증번호가 만료되었습니다."); + throw new GeneralException(ErrorStatus.EMAIL_CODE_EXPIRED); } ev.markVerified(); // verified = true } //util: n자리 숫자 난수 private String generateCode() { - int bound = (int) Math.pow(10, EmailVerificationService.CODE_LENGTH); - return String.format("%0" + EmailVerificationService.CODE_LENGTH + "d", ThreadLocalRandom.current().nextInt(bound)); + int bound = (int) Math.pow(10, CODE_LENGTH); + int n = SECURE_RANDOM.nextInt(bound); + return String.format("%0" + CODE_LENGTH + "d", n); } } diff --git a/src/main/java/com/indayvidual/server/domain/user/service/MailService/MailService.java b/src/main/java/com/indayvidual/server/domain/user/service/MailService/MailService.java index e100d86..98fd89d 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/MailService/MailService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/MailService/MailService.java @@ -1,9 +1,14 @@ package com.indayvidual.server.domain.user.service.MailService; +import com.indayvidual.server.global.api.code.status.ErrorStatus; +import com.indayvidual.server.global.exception.GeneralException; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; @@ -26,4 +31,17 @@ public void sendSimpleMessage(String to, String subject, String text) { } // HTML 템플릿/FreeMarker/Thymeleaf 등으로 확장 가능 + public void sendHtmlMessage(String to, String subject, String html) { + MimeMessage message = mailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(html, true); // HTML 모드 + mailSender.send(message); + } catch (MessagingException e) { + throw new GeneralException(ErrorStatus.EMAIL_SEND_FAILED); + } + } } \ No newline at end of file diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index 26edf67..a63d8b6 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -54,6 +54,12 @@ public enum ErrorStatus implements BaseErrorCode { AUTH_OAUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH4016", "소셜 액세스 토큰이 유효하지 않습니다."), AUTH_OAUTH_PROVIDER_ERROR(HttpStatus.BAD_GATEWAY, "AUTH5021", "소셜 인증 제공자와의 통신에 실패했습니다."), + // ===== EMAIL VERIFICATION ===== + EMAIL_RESEND_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "EMAIL4290", "요청이 너무 잦습니다. 잠시 후 다시 시도해 주세요."), + EMAIL_SEND_FAILED(HttpStatus.BAD_GATEWAY, "EMAIL5020", "이메일 전송에 실패했습니다."), + EMAIL_CODE_MISMATCH(HttpStatus.BAD_REQUEST, "EMAIL4001", "인증번호가 일치하지 않습니다."), + EMAIL_CODE_EXPIRED(HttpStatus.GONE, "EMAIL4100", "인증번호가 만료되었습니다."), + // 계정 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."), USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER4091", "이미 존재하는 사용자입니다."),