Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 회원가입 이메일 인증 기능 구현 #92

Merged
merged 8 commits into from
May 23, 2024
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
6 changes: 6 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ jobs:
openai.api.key: ${{secrets.OPENAI_API_KEY}}
openai.api.url: ${{secrets.OPENAI_API_URL}}
openai.model: ${{secrets.OPENAI_MODEL}}
mail.username: ${{secrets.MAIL_USERNAME}}
mail.password: ${{secrets.MAIL_PASSWORD}}
mail.templates.path: ${{secrets.MAIL_TEMPLATE_PATH}}
mail.templates.img.logo: ${{secrets.MAIL_LOGO_PATH}}
mail.templates.img.title: ${{secrets.MAIL_TITLE_PATH}}
mail.templates.img.text: ${{secrets.MAIL_TEXT_PATH}}

# Configure Gradle for optimal use in GiHub Actions, including caching of downloaded dependencies.
# See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
Expand Down
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ dependencies {
//욕설 필터링 라이브러리
implementation 'io.github.vaneproject:badwordfiltering:1.0.0'

//회원가입 이메일 인증
implementation 'org.springframework.boot:spring-boot-starter-mail:3.2.2'

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

}

tasks.named('test') {
Expand Down
63 changes: 63 additions & 0 deletions src/main/java/org/dallili/secretfriends/config/MailConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.dallili.secretfriends.config;

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;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;

import javax.swing.*;
import java.util.Properties;

@Configuration
public class MailConfig {
@Value("${mail.host}")
private String mailServerHost;
@Value("${mail.port}")
private int mailServerPort;
@Value("${mail.username}")
private String mailServerUsername;
@Value("${mail.password}")
private String mailServerPassword;
@Value("${mail.templates.path}")
private String mailTemplatesPath;


//이메일 발송에 사용되는 JavaMailSender 빈으로 등록
@Bean
public JavaMailSender javaMailSender(){
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost(mailServerHost);
javaMailSender.setPort(mailServerPort);
javaMailSender.setUsername(mailServerUsername);
javaMailSender.setPassword(mailServerPassword);

Properties properties = javaMailSender.getJavaMailProperties();
properties.put("mail.transport.protocol","smtp");
properties.put("mail.smtp.auth","true");
properties.put("mail.smtp.starttls.enable","true");

return javaMailSender;
}

@Bean
public ITemplateResolver thymeleafTemplateResolver(){
ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
templateResolver.setPrefix(mailTemplatesPath);
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode("HTML");
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}

@Bean
public SpringTemplateEngine thymeleafTemplateEngine(){
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(thymeleafTemplateResolver());
return templateEngine;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class MySecurityConfig{
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

private static final String[] AUTH_WHITELIST = {
"/swagger-ui/**", "/swagger-ui.html", "/members/signup", "/members/login",
"/swagger-ui/**", "/swagger-ui.html", "/members/signup/**", "/members/login",
"/v3/api-docs","/api-docs/**","/swagger-resources/**","api-docs/",
"/v3/api-docs/**"
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.servlet.http.HttpServletRequest;
import org.dallili.secretfriends.dto.HttpErrorResponse;
import org.dallili.secretfriends.exception.CustomException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
Expand All @@ -20,6 +21,14 @@ public HttpErrorResponse exceptionHandler(Exception ex, HttpServletRequest reque
.message(ex.getMessage())
.path(request.getRequestURI())
.build();
}

@ExceptionHandler(CustomException.class)
public HttpErrorResponse exceptionHandler(CustomException e, HttpServletRequest request){
return HttpErrorResponse.builder()
.status(e.getErrorCode().getStatus())
.message(e.getErrorCode().getMessage())
.path(request.getRequestURI())
.build();
}
}
4 changes: 2 additions & 2 deletions src/main/java/org/dallili/secretfriends/dto/MemberDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class MemberDTO {
public static class SignUpRequest{

@NotBlank(message = "비밀번호를 입력해주세요.")
@Size(min=5, message = "비밀번호가 너무 짧습니다. (최소 5글자)")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[!@#$%^*])(?=.*[0-9]).{8,24}$" , message = "영어 대/소문자, 숫자, 특수문자(!@#$%^*) 를 혼합한 8자 이상 25자 미만의 비밀번호만 허용됩니다.")
private String password;

@NotBlank(message = "닉네임을 입력해주세요.")
Expand Down Expand Up @@ -68,7 +68,7 @@ public static class PasswordRequest{
@NotBlank(message = "'현재 비밀번호' 항목을 입력해주세요.")
private String oldPassword;
@NotBlank(message = "'새 비밀번호' 항목을 입력해주세요.")
@Size(min=5, message = "비밀번호가 너무 짧습니다. (최소 5글자)")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[!@#$%^*])(?=.*[0-9]).{8,24}$" , message = "영어 대/소문자, 숫자, 특수문자(!@#$%^*) 를 혼합한 8자 이상 25자 미만의 비밀번호만 허용됩니다.")
private String newPassword;
@NotBlank(message = "'새 비밀번호 확인' 항목을 입력해주세요.")
private String confirmPassword;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.dallili.secretfriends.email.controller;

import io.swagger.v3.oas.annotations.Operation;
import jakarta.mail.MessagingException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dallili.secretfriends.email.dto.EmailDTO;
import org.dallili.secretfriends.email.service.EmailService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;

@RestController
@RequiredArgsConstructor
public class EmailController {
private final EmailService emailService;

@PostMapping("/members/signup/email")
@Operation(summary = "회원가입 이메일 인증 코드 발송", description = "이메일을 받아 해당 메일로 인증 코드 발송")
public void sendVerificationEmail(@RequestBody @Valid EmailDTO.SendRequest request) throws MessagingException {
emailService.sendVerificationEmail(request.getEmail());
}

@PostMapping("/members/signup/email/verification")
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "회원가입 이메일 인증 코드 발송", description = "이메일을 받아 해당 메일로 인증 코드 발송")
public void verifyEmailByCode(@RequestBody @Valid EmailDTO.VerifyRequest request){
LocalDateTime now = LocalDateTime.now();
emailService.verifyCode(request.getCode(), now);
}

}
20 changes: 20 additions & 0 deletions src/main/java/org/dallili/secretfriends/email/dto/EmailDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.dallili.secretfriends.email.dto;

import jakarta.validation.constraints.Email;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

public class EmailDTO {

@Getter
public static class SendRequest{
@Email
private String email;
}

@Getter
public static class VerifyRequest{
private String code;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.dallili.secretfriends.email.dto;

import lombok.*;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class VerificationCode {
private String code;
private LocalDateTime createdAt;
private Integer expirationTimeMinutes;

//만료되었는지 확인
public boolean isExpired(LocalDateTime now){
LocalDateTime expiredAt = this.createdAt.plusMinutes(this.expirationTimeMinutes);
return now.isAfter(expiredAt);
}

public String getExpirationTime(){
String expiredAt = this.createdAt
.plusMinutes(this.expirationTimeMinutes)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

return expiredAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.dallili.secretfriends.email.repository;

import org.dallili.secretfriends.email.dto.VerificationCode;

import java.util.Optional;

public interface VerificationCodeRepository {
public VerificationCode save(VerificationCode verificationCode);
public Optional<VerificationCode> findByCode(String code);
public void remove(VerificationCode verificationCode);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.dallili.secretfriends.email.repository;

import org.dallili.secretfriends.email.dto.VerificationCode;
import org.springframework.stereotype.Repository;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class VerificationCodeRepositoryImpl implements VerificationCodeRepository{
private final Map<String, VerificationCode> repository = new ConcurrentHashMap<>();

@Override
public VerificationCode save(VerificationCode verificationCode) {
return repository.put(verificationCode.getCode(), verificationCode);
}

@Override
public Optional<VerificationCode> findByCode(String code) {
return Optional.ofNullable(repository.get(code));
}

@Override
public void remove(VerificationCode verificationCode) {
repository.remove(verificationCode.getCode());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.dallili.secretfriends.email.service;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.dallili.secretfriends.email.dto.VerificationCode;
import org.dallili.secretfriends.email.repository.VerificationCodeRepository;
import org.dallili.secretfriends.exception.CustomException;
import org.dallili.secretfriends.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class EmailService {
@Value("${mail.username}")
private String serviceEmail;
@Value("${mail.templates.img.logo}")
private String logoPath;
@Value("${mail.templates.img.title}")
private String titlePath;
@Value("${mail.templates.img.text}")
private String textPath;

private final Integer EXPIRATION_TIME_MINUTES = 3;

private final JavaMailSender javaMailSender;
private final VerificationCodeRepository verificationCodeRepository;
private final SpringTemplateEngine templateEngine;

public void sendVerificationEmail(String to) throws MessagingException {

VerificationCode verificationCode = generateVerificationCode();
verificationCodeRepository.save(verificationCode);

HashMap<String, Object> map = new HashMap<>();
map.put("code", verificationCode.getCode());
map.put("expirationTime", String.format("위 코드의 만료 시간은 %s 입니다.",verificationCode.getExpirationTime()));

Context context = new Context();
context.setVariables(map); //템플릿에 전달할 데이터
String html = templateEngine.process("verificationCode.html",context);


MimeMessage mailMessage = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mailMessage,true,"UTF-8");
helper.setFrom(serviceEmail); //이메일 발신 주소
helper.setTo(to); //이메일 송신 주소
helper.setSubject("[비밀친구] 회원가입 정보 인증을 위한 메일입니다."); //이메일 제목
helper.setText(html, true);
helper.addInline("logo",new ClassPathResource(logoPath));
helper.addInline("title",new ClassPathResource(titlePath));
helper.addInline("text",new ClassPathResource(textPath));
javaMailSender.send(mailMessage);
}

private VerificationCode generateVerificationCode(){
String code = UUID.randomUUID().toString();
LocalDateTime now = LocalDateTime.now();
return VerificationCode.builder()
.code(code)
.createdAt(now)
.expirationTimeMinutes(EXPIRATION_TIME_MINUTES)
.build();
}

public void verifyCode(String code, LocalDateTime verifiedAt){
VerificationCode verificationCode = verificationCodeRepository.findByCode(code).orElseThrow(()->{
throw new CustomException(ErrorCode.EMAIL_VERIFICATION_FAILED);
});

if(verificationCode.isExpired(verifiedAt)){
throw new CustomException(ErrorCode.EMAIL_VERIFICATION_EXPIRED);
}

verificationCodeRepository.remove(verificationCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.dallili.secretfriends.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException{
private final ErrorCode errorCode;
}
14 changes: 14 additions & 0 deletions src/main/java/org/dallili/secretfriends/exception/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.dallili.secretfriends.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ErrorCode {
EMAIL_VERIFICATION_FAILED(420,"인증 코드가 틀렸습니다."),
EMAIL_VERIFICATION_EXPIRED(421,"인증 코드가 만료되었습니다.");

private final int status;
private final String message;
}
Loading
Loading