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 @@ -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(
Expand All @@ -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<ApiResponse<Boolean>> checkEmail(@RequestParam String email) {
public ResponseEntity<ApiResponse<Boolean>> 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<ApiResponse<String>> send(@RequestBody EmailRequestDTO dto) {
public ResponseEntity<ApiResponse<String>> 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<ApiResponse<String>> verify(@RequestBody EmailVerifyRequestDTO dto) {
public ResponseEntity<ApiResponse<String>> verify(@RequestBody @Valid EmailVerifyRequestDTO dto) {
service.verifyCode(dto.getEmail(), dto.getCode());
return ResponseEntity.ok(ApiResponse.onSuccess("이메일 인증 성공"));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
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;
import lombok.Getter;

@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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 = """
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Email Verification</title>
</head>
<body style="margin:0;background:#f6f7fb;padding:24px;font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,Apple SD Gothic Neo,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
<tr>
<td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;padding:32px;box-shadow:0 6px 16px rgba(0,0,0,.08);">
<tr><td style="font-size:20px;font-weight:700;color:#111;">이메일 인증</td></tr>
<tr><td style="padding-top:12px;line-height:1.7;color:#333;">
안녕하세요, Indayvidual입니다.<br/>
아래 인증번호를 입력해 인증을 완료해 주세요.
</td></tr>
<tr>
<td align="center" style="padding:24px 0 8px;">
<div style="display:inline-block;letter-spacing:6px;font-size:28px;font-weight:800;background:#eef4ff;color:#1a56db;padding:12px 20px;border-radius:10px;">
%s
</div>
</td>
</tr>
<tr><td style="color:#555;font-size:14px;">유효시간: <strong>%d분</strong></td></tr>
<tr><td style="color:#888;font-size:12px;padding-top:16px;">※ 인증번호는 타인과 공유하지 마세요.</td></tr>
<tr><td style="color:#888;font-size:12px;padding-top:4px;">※ 본 메일은 발신 전용입니다. 문의: support@indayvidual.com</td></tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""".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);
}
}

Original file line number Diff line number Diff line change
@@ -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;


Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", "이미 존재하는 사용자입니다."),
Expand Down