Skip to content

Commit

Permalink
[feat #20] : 공무원 이메일 인증 API (#25)
Browse files Browse the repository at this point in the history
* [feat] : MailConfig 작성

* [fix] : AuthError code 오탈자 수정

* [feat] : 인증코드 메일 발송 구현
- 난수 6자리 인증번호 생성 구현
- 인증코드 메일 발송 구현

* [feat] : 이메일 발송 후 반환타입 변경

* [feat] : 이메일 인증코드 발송 컨트롤러 추가

* [feat] : 이메일 발송 request/response DTO 추가(+ Mapper)

* [feat] : 인증코드 검증 request DTO 이메일 필드 추가

* [feat] : 공무원 이메일 인증 요청 DTO Email validate 추가

* [feat] : 공무원 이메일 인증코드 검증 API 추가

* [feat] : 공무원 이메일 인증코드 검증 응답 DTO 추가(+Mapper)

* [feat] : 공무원 이메일 비즈니스 로직
- 인증코드 생성 후 redis 저장 로직 추가
- 이메일 인증코드 검증 로직 추가

* [feat] : Redis 에러 코드 추가

* [feat] : Redis Util 메서드 추가

* [feat] : officalEmail를 통한 회원 찾기 추가

* [feat] : 메일 발송 전 중복 회원 검증 추가

* [fix] : 오탈자 수정

* [feat] : 만료일 검증 메서드 추가

* [test] : MailService 단위 테스트 추가

* [rename] : 인증코드 요청 API 메서드명 변경

* [fix] : 인증코드 요청 API HTTP Method 누락 추가

* [fix] : redis key PREFIX 변경

* [test] : 인증번호 검증 단위테스트 수정

* [test] : MailController 통합테스트 추가

* [feat] : 만료 키 관련 에러코드 추가

* [chore] : mail 의존추가

* [fix] : 인증 코드 검증 API 수정
- GetMapping -> PostMapping 변경

* [rename] : PR Review에 따른 변수명, 메서드명 수정

* [test] : PR Review에 따른 클래스명, 변수명 수정

* [test] : API 오류 수정

* [feat] : MailController 재추가

* [test] : MailContorllerTest 네이밍 변경

* [test] : 컨트롤러 통합테스트 인증을 위한 setUp 설정

* [fix] : redis 트랜잭션 제거

* [feat] : 인증코드 검증 로직 수정

* [feat] : redis data 검증 로직 변경

* [test] : MailService 단위 테스트 수정
- Mock 주입 문제로 인한 테스트 오류 해결
- Mockito -> BDDMockito 변경
  • Loading branch information
dudxo authored Aug 9, 2024
1 parent 5c096d6 commit 25b766f
Show file tree
Hide file tree
Showing 19 changed files with 611 additions and 4 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ dependencies {
//spring bean validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

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

//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@RequiredArgsConstructor
public enum AuthErrorCode implements ErrorCode {

UNSUPPORTED_SOCIAL_LOGIN("해당 소셜 로그인은 지원되지 않습니다.", "AUHT_001"),
UNSUPPORTED_SOCIAL_LOGIN("해당 소셜 로그인은 지원되지 않습니다.", "AUTH_001"),
NOT_FOUND_PROVIDER("알맞은 Provider를 찾을 수 없습니다.", "AUTH_002"),
NOT_FOUND_AUTH("회원의 AUTH를 찾을 수 없습니다.", "AUTH_003");

Expand Down
68 changes: 68 additions & 0 deletions src/main/java/com/dnd/gongmuin/common/config/MailConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.dnd.gongmuin.common.config;

import java.util.Properties;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;

@Configuration
public class MailConfig {

@Value("${spring.mail.host}")
private String host;

@Value("${spring.mail.port}")
private int port;

@Value("${spring.mail.username}")
private String username;

@Value("${spring.mail.password}")
private String password;

@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;

@Value("${spring.mail.properties.mail.smtp.starttls.enable}")
private boolean starttlsEnable;

@Value("${spring.mail.properties.mail.smtp.starttls.required}")
private boolean starttlsRequired;

@Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
private int connectionTimeout;

@Value("${spring.mail.properties.mail.smtp.timeout}")
private int timeout;

@Value("${spring.mail.properties.mail.smtp.writetimeout}")
private int writeTimeout;

@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);
mailSender.setDefaultEncoding("UTF-8");
mailSender.setJavaMailProperties(getMailProperties());

return mailSender;
}

private Properties getMailProperties() {
Properties properties = new Properties();
properties.put("mail.smtp.auth", auth);
properties.put("mail.smtp.starttls.enable", starttlsEnable);
properties.put("mail.smtp.starttls.required", starttlsRequired);
properties.put("mail.smtp.connectiontimeout", connectionTimeout);
properties.put("mail.smtp.timeout", timeout);
properties.put("mail.smtp.writetimeout", writeTimeout);

return properties;
}
}
38 changes: 38 additions & 0 deletions src/main/java/com/dnd/gongmuin/mail/controller/MailController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.dnd.gongmuin.mail.controller;

import org.springframework.http.ResponseEntity;
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 com.dnd.gongmuin.mail.dto.request.AuthCodeRequest;
import com.dnd.gongmuin.mail.dto.request.SendMailRequest;
import com.dnd.gongmuin.mail.dto.response.AuthCodeResponse;
import com.dnd.gongmuin.mail.dto.response.SendMailResponse;
import com.dnd.gongmuin.mail.service.MailService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth/check-email")
public class MailController {

private final MailService mailService;

@PostMapping
public ResponseEntity<SendMailResponse> sendAuthCodeToMail(
@RequestBody @Valid SendMailRequest sendMailRequest) {
SendMailResponse targetEmail = mailService.sendEmail(sendMailRequest);
return ResponseEntity.ok(targetEmail);
}

@PostMapping("/authCode")
public ResponseEntity<AuthCodeResponse> verifyMailAuthCode(
@RequestBody @Valid AuthCodeRequest authCodeRequest) {
AuthCodeResponse authCodeResponse = mailService.verifyMailAuthCode(authCodeRequest);
return ResponseEntity.ok(authCodeResponse);
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/dnd/gongmuin/mail/dto/MailMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dnd.gongmuin.mail.dto;

import com.dnd.gongmuin.mail.dto.response.AuthCodeResponse;
import com.dnd.gongmuin.mail.dto.response.SendMailResponse;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

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

public static SendMailResponse toSendMailResponse(String targetEmail) {
return new SendMailResponse(targetEmail);
}

public static AuthCodeResponse toAuthCodeResponse(boolean result) {
return new AuthCodeResponse(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dnd.gongmuin.mail.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public record AuthCodeRequest(
@NotBlank(message = "인증 코드를 입력해주세요.")
@Pattern(regexp = "\\d{6}", message = "인증 코드는 6자리 숫자여야 합니다.")
String authCode,

@NotBlank(message = "공무원 이메일을 입력해주세요.")
@Email
String targetEmail
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.dnd.gongmuin.mail.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record SendMailRequest(
@NotBlank(message = "공무원 이메일을 입력해주세요.")
@Email
String targetEmail
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dnd.gongmuin.mail.dto.response;

public record AuthCodeResponse(
boolean result
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dnd.gongmuin.mail.dto.response;

public record SendMailResponse(
String targetEmail
) {
}
18 changes: 18 additions & 0 deletions src/main/java/com/dnd/gongmuin/mail/exception/MailErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.dnd.gongmuin.mail.exception;

import com.dnd.gongmuin.common.exception.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum MailErrorCode implements ErrorCode {

CONFIGURATION_ERROR("메일 설정 오류입니다.", "MAIL_001"),
CONTENT_ERROR("인증 코드 생성에 실패했습니다.", "MAIL_002"),
DUPLICATED_ERROR("이미 존재하는 공무원 이메일입니다.", "MAIL_003");

private final String message;
private final String code;
}
94 changes: 94 additions & 0 deletions src/main/java/com/dnd/gongmuin/mail/service/MailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.dnd.gongmuin.mail.service;

import java.time.Duration;
import java.util.Objects;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import com.dnd.gongmuin.common.exception.runtime.NotFoundException;
import com.dnd.gongmuin.mail.dto.MailMapper;
import com.dnd.gongmuin.mail.dto.request.AuthCodeRequest;
import com.dnd.gongmuin.mail.dto.request.SendMailRequest;
import com.dnd.gongmuin.mail.dto.response.AuthCodeResponse;
import com.dnd.gongmuin.mail.dto.response.SendMailResponse;
import com.dnd.gongmuin.mail.exception.MailErrorCode;
import com.dnd.gongmuin.mail.util.AuthCodeGenerator;
import com.dnd.gongmuin.member.service.MemberService;
import com.dnd.gongmuin.redis.util.RedisUtil;

import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class MailService {

@Value("${spring.mail.auth-code-expiration-millis}")
private long authCodeExpirationMillis;
private final String SUBJECT = "[공무인] 공무원 인증 메일입니다.";
private static final String AUTH_CODE_PREFIX = "AuthCode ";

private final JavaMailSender mailSender;
private final AuthCodeGenerator authCodeGenerator;
private final RedisUtil redisUtil;
private final MemberService memberService;

public SendMailResponse sendEmail(SendMailRequest request) {
String targetEmail = request.targetEmail();

checkDuplicatedOfficialEmail(targetEmail);
MimeMessage email = createMail(targetEmail);
mailSender.send(email);

return MailMapper.toSendMailResponse(targetEmail);
}

public AuthCodeResponse verifyMailAuthCode(AuthCodeRequest authCodeRequest) {
String targetEmail = AUTH_CODE_PREFIX + authCodeRequest.targetEmail();
String authCode = authCodeRequest.authCode();

redisUtil.validateExpiredFromKey(targetEmail);

boolean result = redisUtil.validateData(targetEmail, authCode);

return MailMapper.toAuthCodeResponse(result);
}

private MimeMessage createMail(String targetEmail) {
try {
String authCode = authCodeGenerator.createAuthCode();

if (Objects.isNull(authCode) || authCode.isBlank()) {
throw new IllegalArgumentException();
}

saveAuthCodeToRedis(targetEmail, authCode, authCodeExpirationMillis);

MimeMessage mimeMessage = mailSender.createMimeMessage();
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
messageHelper.setTo(targetEmail);
messageHelper.setSubject(SUBJECT);
messageHelper.setText("인증 코드는 다음과 같습니다.\n" + authCode);

return mimeMessage;
} catch (IllegalArgumentException e) {
throw new NotFoundException(MailErrorCode.CONTENT_ERROR);
} catch (Exception e) {
throw new NotFoundException(MailErrorCode.CONFIGURATION_ERROR);
}
}

private void saveAuthCodeToRedis(String targetEmail, String authCode, long authCodeExpirationMillis) {
String key = AUTH_CODE_PREFIX + targetEmail;
redisUtil.setValues(key, authCode, Duration.ofMillis(authCodeExpirationMillis));
}

private void checkDuplicatedOfficialEmail(String officialEmail) {
if (memberService.isOfficialEmailExists(officialEmail)) {
throw new NotFoundException(MailErrorCode.DUPLICATED_ERROR);
}
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/dnd/gongmuin/mail/util/AuthCodeGenerator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dnd.gongmuin.mail.util;

import java.security.SecureRandom;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springframework.stereotype.Component;

@Component
public class AuthCodeGenerator {

private static final SecureRandom RANDOM = new SecureRandom();
private static final int CODE_LENGTH = 6;

public String createAuthCode() {
return Stream.generate(() -> RANDOM.nextInt(10))
.limit(CODE_LENGTH)
.map(String::valueOf)
.collect(Collectors.joining());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findBySocialEmail(String socialEmail);

boolean existsByNickname(String nickname);

boolean existsByOfficialEmail(String officialEmail);
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,11 @@ private Member updateAdditionalInfo(AdditionalInfoRequest request, Member findMe

return memberRepository.save(findMember);
}

@Transactional(readOnly = true)
public boolean isOfficialEmailExists(String officialEmail) {
boolean result = memberRepository.existsByOfficialEmail(officialEmail);

return result;
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/dnd/gongmuin/redis/exception/RedisErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.dnd.gongmuin.redis.exception;

import com.dnd.gongmuin.common.exception.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum RedisErrorCode implements ErrorCode {

REDIS_SAVE_ERROR("저장하지 못했습니다.", "REDIS_001"),
REDIS_FIND_ERROR("값을 찾는 도중 오류가 발생했습니다.", "REDIS_002"),
REDIS_DELETE_ERROR("삭제하지 못했습니다.", "REDIS_003"),
REDIS_EXPIRE_ERROR("만료시키지하지 못했습니다.", "REDIS_004"),
REDIS_EXPIRED_ERROR("만료된 키 입니다.", "REDIS_005");

private final String message;
private final String code;
}
Loading

0 comments on commit 25b766f

Please sign in to comment.