Skip to content
Merged
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ dependencies {

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// Email
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.withtime.be.withtimebe.domain.auth.constants;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.time.Duration;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class EmailVerificationStorageConstants {

public static final String VERIFICATION_CODE_PREFIX = "EMAIL-VERIFICATION-CODE:";
public static final String EMAIL_VERIFICATION_PREFIX = "EMAIL-VERIFICATION:";
public static final Duration VERIFICATION_CODE_DURATION = Duration.ofMinutes(3);
public static final Duration EMAIL_VERIFICATION_DURATION = Duration.ofHours(1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import org.namul.api.payload.response.DefaultResponse;
import org.springframework.web.bind.annotation.*;
import org.withtime.be.withtimebe.domain.auth.dto.request.AuthRequestDTO;
import org.withtime.be.withtimebe.domain.auth.dto.request.EmailRequestDTO;
import org.withtime.be.withtimebe.domain.auth.service.command.AuthCommandService;
import org.withtime.be.withtimebe.domain.auth.service.command.EmailCommandService;

@RestController
@RequiredArgsConstructor
Expand All @@ -20,6 +22,7 @@
public class AuthController {

private final AuthCommandService authCommandService;
private final EmailCommandService emailCommandService;

@Operation(summary = "회원가입 API by 요시", description = "최초 회원가입 시 필요한 정보를 포함하여 회원가입 진행")
@ApiResponses({
Expand Down Expand Up @@ -86,4 +89,32 @@ public DefaultResponse<String> logout(HttpServletRequest request, HttpServletRes
authCommandService.logout(request, response);
return DefaultResponse.noContent();
}

@Operation(summary = "이메일 인증 번호 전송 API by 요시", description = "이메일로 인증 번호를 전송하는 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "이메일 전송 성공, 인증 코드는 3분 동안 유효"),
@ApiResponse(
responseCode = "500",
description = "EMAIL500_1: 이메일 전송 실패"
)
})
@PostMapping("/email-verifications")
public DefaultResponse<String> sendVerificationCodeToEmail(@Valid @RequestBody EmailRequestDTO.Send request) {
emailCommandService.sendEmail(request);
return DefaultResponse.noContent();
}

@Operation(summary = "이메일 인증 번호 확인 API by 요시" ,description = "이메일 인증 번호 확인 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "이메일 인증 성공, 성공 시 1시간 동안 유효"),
@ApiResponse(
responseCode = "401",
description = "EMAIL401_1: 이메일 인증 실패"
)
})
@PostMapping("/check-email-verifications")
public DefaultResponse<String> checkVerificationCode(@Valid @RequestBody EmailRequestDTO.Check request) {
emailCommandService.checkEmail(request);
return DefaultResponse.noContent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.withtime.be.withtimebe.domain.auth.dto.request;


import jakarta.validation.constraints.Email;

public record EmailRequestDTO() {

public record Send(
@Email
String email
) {
}

public record Check(
@Email
String email,
String code
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.withtime.be.withtimebe.domain.auth.generator;

public interface RandomGenerator<T> {

T generateRandom();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.withtime.be.withtimebe.domain.auth.generator;

import org.springframework.stereotype.Component;

import java.security.SecureRandom;
import java.util.Random;

@Component
public class RandomSixDigitGenerator implements RandomGenerator<String> {

private static final Random RANDOM = new SecureRandom();

@Override
public String generateRandom() {
return String.format("%06d", RANDOM.nextInt(1000000));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.withtime.be.withtimebe.domain.auth.converter.AuthConverter;
import org.withtime.be.withtimebe.domain.auth.dto.request.AuthRequestDTO;
import org.withtime.be.withtimebe.domain.auth.service.query.EmailVerificationCodeStorageQueryService;
import org.withtime.be.withtimebe.domain.auth.service.query.TokenQueryService;
import org.withtime.be.withtimebe.domain.auth.service.query.TokenStorageQueryService;
import org.withtime.be.withtimebe.domain.member.entity.Member;
import org.withtime.be.withtimebe.domain.member.repository.MemberRepository;
import org.withtime.be.withtimebe.global.error.code.AuthErrorCode;
import org.withtime.be.withtimebe.global.error.code.EmailErrorCode;
import org.withtime.be.withtimebe.global.error.code.MemberErrorCode;
import org.withtime.be.withtimebe.global.error.code.TokenErrorCode;
import org.withtime.be.withtimebe.global.error.exception.AuthException;
import org.withtime.be.withtimebe.global.error.exception.EmailException;
import org.withtime.be.withtimebe.global.error.exception.MemberException;
import org.withtime.be.withtimebe.global.error.exception.TokenException;
import org.withtime.be.withtimebe.global.security.constants.AuthenticationConstants;
Expand All @@ -23,6 +27,7 @@

@Service
@RequiredArgsConstructor
@Transactional
public class AuthCommandServiceImpl implements AuthCommandService {

private final PasswordEncoder passwordEncoder;
Expand All @@ -31,6 +36,7 @@ public class AuthCommandServiceImpl implements AuthCommandService {
private final TokenStorageCommandService tokenStorageCommandService;
private final TokenQueryService tokenQueryService;
private final TokenStorageQueryService tokenStorageQueryService;
private final EmailVerificationCodeStorageQueryService emailVerificationCodeStorageQueryService;

@Override
public void signUp(AuthRequestDTO.SignUp request) {
Expand Down Expand Up @@ -87,6 +93,9 @@ private void validateSignUp(AuthRequestDTO.SignUp request) throws AuthException
if (memberRepository.existsByEmail(request.email())) {
throw new AuthException(AuthErrorCode.ALREADY_EXIST_EMAIL);
}
if (!emailVerificationCodeStorageQueryService.isVerified(request.email())) {
throw new EmailException(EmailErrorCode.UNVERIFIED_EMAIL);
}
}

private Long getUserId(String token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.withtime.be.withtimebe.domain.auth.service.command;

import org.withtime.be.withtimebe.domain.auth.dto.request.EmailRequestDTO;

public interface EmailCommandService {
void sendEmail(EmailRequestDTO.Send request);
void checkEmail(EmailRequestDTO.Check request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.withtime.be.withtimebe.domain.auth.service.command;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.withtime.be.withtimebe.domain.auth.dto.request.EmailRequestDTO;
import org.withtime.be.withtimebe.domain.auth.generator.RandomGenerator;
import org.withtime.be.withtimebe.domain.auth.service.query.EmailVerificationCodeStorageQueryService;
import org.withtime.be.withtimebe.domain.auth.util.MailVerificationCodeSender;
import org.withtime.be.withtimebe.global.error.code.EmailErrorCode;
import org.withtime.be.withtimebe.global.error.exception.EmailException;

@Service
@RequiredArgsConstructor
@Transactional
public class EmailCommandServiceImpl implements EmailCommandService {

private final RandomGenerator<String> randomSixDigitGenerator;
private final MailVerificationCodeSender mailVerificationCodeSender;
private final EmailVerificationCodeStorageCommandService emailVerificationCodeStorageCommandService;
private final EmailVerificationCodeStorageQueryService emailVerificationCodeStorageQueryService;

@Override
public void sendEmail(EmailRequestDTO.Send request) {
String email = request.email();
String code = randomSixDigitGenerator.generateRandom();

emailVerificationCodeStorageCommandService.saveVerificationCode(email, code);
try {
mailVerificationCodeSender.sendMail(email, code);
} catch (Exception e) {
emailVerificationCodeStorageCommandService.deleteVerificationCode(email);
throw e;
}

}

@Override
public void checkEmail(EmailRequestDTO.Check request) {
String email = request.email();
if (emailVerificationCodeStorageQueryService.checkVerificationCode(email, request.code())) {
emailVerificationCodeStorageCommandService.saveVerifiedEmail(email);
}
else {
throw new EmailException(EmailErrorCode.INCORRECT_EMAIL_VERIFICATION_CODE);
}
emailVerificationCodeStorageCommandService.deleteVerificationCode(email);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.withtime.be.withtimebe.domain.auth.service.command;

public interface EmailVerificationCodeStorageCommandService {
/**
* 인증 코드 저장
* @param email 인증 코드를 확인할 이메일
* @param verificationCode 이메일에 대한 인증 코드
*/
void saveVerificationCode(String email, String verificationCode);

/**
* 이메일에 대한 인증이 완료됨을 저장
* @param email 인증이 완료됨을 저장할 이메일
*/
void saveVerifiedEmail(String email);

/**
* 이메일에 대한 인증 코드 삭제
* @param email 인증 코드를 삭제할 이메일
*/
void deleteVerificationCode(String email);

/**
* 인증 정보 삭제
* @param email 인증 정보를 삭제할 이메일
*/
void deleteVerifiedEmail(String email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.withtime.be.withtimebe.domain.auth.service.command;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.withtime.be.withtimebe.domain.auth.constants.EmailVerificationStorageConstants;
import org.withtime.be.withtimebe.global.util.RedisUtil;

@Service
@RequiredArgsConstructor
public class RedisEmailVerificationCodeStorageCommandService implements EmailVerificationCodeStorageCommandService {

private final RedisUtil redisUtil;

@Override
public void saveVerificationCode(String email, String verificationCode) {
redisUtil.set(EmailVerificationStorageConstants.VERIFICATION_CODE_PREFIX + email, verificationCode, EmailVerificationStorageConstants.VERIFICATION_CODE_DURATION);
}

@Override
public void saveVerifiedEmail(String email) {
redisUtil.set(EmailVerificationStorageConstants.EMAIL_VERIFICATION_PREFIX + email, true, EmailVerificationStorageConstants.EMAIL_VERIFICATION_DURATION);
}

@Override
public void deleteVerificationCode(String email) {
redisUtil.delete(EmailVerificationStorageConstants.VERIFICATION_CODE_PREFIX + email);
}

@Override
public void deleteVerifiedEmail(String email) {
redisUtil.delete(EmailVerificationStorageConstants.EMAIL_VERIFICATION_PREFIX + email);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.withtime.be.withtimebe.domain.auth.service.query;

public interface EmailVerificationCodeStorageQueryService {
boolean checkVerificationCode(String email, String verificationCode);
boolean isVerified(String email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.withtime.be.withtimebe.domain.auth.service.query;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.withtime.be.withtimebe.domain.auth.constants.EmailVerificationStorageConstants;
import org.withtime.be.withtimebe.global.util.RedisUtil;

@Service
@RequiredArgsConstructor
public class RedisEmailVerificationCodeStorageQueryService implements EmailVerificationCodeStorageQueryService {

private final RedisUtil redisUtil;

@Override
public boolean checkVerificationCode(String email, String verificationCode) {
return verificationCode.equals(redisUtil.get(EmailVerificationStorageConstants.VERIFICATION_CODE_PREFIX + email, String.class));
}

@Override
public boolean isVerified(String email) {
return redisUtil.has(EmailVerificationStorageConstants.EMAIL_VERIFICATION_PREFIX + email);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.withtime.be.withtimebe.domain.auth.util;

public interface MailVerificationCodeSender {
void sendMail(String toEmail, String code);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.withtime.be.withtimebe.domain.auth.util;

import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.withtime.be.withtimebe.global.error.code.EmailErrorCode;
import org.withtime.be.withtimebe.global.error.exception.EmailException;

@Component
@RequiredArgsConstructor
public class SMTPMailVerificationCodeSender implements MailVerificationCodeSender {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine;

@Override
public void sendMail(String toEmail, String code) {

// 1. 템플릿 처리
Context context = new Context();
context.setVariable("code", code);
String html = templateEngine.process("email-verification", context);

// 2. 메일 작성 및 전송
MimeMessage mimeMessage = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "UTF-8");
helper.setTo(toEmail);
helper.setSubject("[WithTime] 이메일 인증 코드입니다.");
helper.setText(html, true); // true → HTML 형식

mailSender.send(mimeMessage);
} catch (Exception e) {
throw new EmailException(EmailErrorCode.FAIL_EMAIL_SEND);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.withtime.be.withtimebe.global.error.code;

import lombok.AllArgsConstructor;
import org.namul.api.payload.code.BaseErrorCode;
import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO;
import org.springframework.http.HttpStatus;

@AllArgsConstructor
public enum EmailErrorCode implements BaseErrorCode {

FAIL_EMAIL_SEND(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL500_1", "이메일 전송에 실패했습니다."),
INCORRECT_EMAIL_VERIFICATION_CODE(HttpStatus.UNAUTHORIZED, "EMAIL401_1", "이메일 인증에 실패했습니다."),
UNVERIFIED_EMAIL(HttpStatus.UNAUTHORIZED, "EMAIL401_2", "인증되지 않은 이메일이거나 인증 유효기간이 지났습니다."),
;
private final HttpStatus httpStatus;
private final String code;
private final String message;

@Override
public DefaultResponseErrorReasonDTO getReason() {
return DefaultResponseErrorReasonDTO.builder()
.httpStatus(this.httpStatus)
.code(this.code)
.message(this.message)
.build();
}
}
Loading