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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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'


// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-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