Skip to content

Commit

Permalink
Merge pull request #23 from DEPthes/feat/#22-verify-email
Browse files Browse the repository at this point in the history
[FEAT]: 이메일 인증 과정을 통한 회원가입
  • Loading branch information
phonil authored Aug 3, 2024
2 parents 6cdfa7a + a05ae36 commit d818215
Show file tree
Hide file tree
Showing 19 changed files with 418 additions and 11 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ jobs:
touch ./src/main/resources/application-jwt.yml
echo "${{ secrets.JWT_YML }}" >> src/main/resources/application-jwt.yml
touch ./src/main/resources/application-infra.yml
echo "${{ secrets.INFRA_YML }}" >> src/main/resources/application-infra.yml
# 빌드 권한 부여
- name: Grant execute permission for gradlew
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ out/
### Database
/src/main/resources/application-db.yml
/src/main/resources/application-jwt.yml
/src/main/resources/application-infra.yml
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ dependencies {
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

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

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

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

// commonmark
implementation 'org.commonmark:commonmark:0.22.0'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import mvp.deplog.domain.member.domain.Member;
import mvp.deplog.domain.member.domain.repository.MemberRepository;
import mvp.deplog.global.security.jwt.JwtTokenProvider;
import mvp.deplog.infrastructure.redis.RedisUtil;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
Expand All @@ -26,6 +27,7 @@
@Service
public class AuthServiceImpl implements AuthService{

private final RedisUtil redisUtil;
private final AuthenticationManager authenticationManager;

private final MemberRepository memberRepository;
Expand All @@ -36,8 +38,17 @@ public class AuthServiceImpl implements AuthService{
@Override
@Transactional
public SuccessResponse<Message> join(JoinReq joinReq) {
String email = joinReq.getEmail();
if (memberRepository.existsByEmail(email))
throw new IllegalArgumentException("이미 가입된 이메일입니다.");

String data = redisUtil.getData(email + "_verify");
if (data == null)
throw new IllegalArgumentException("인증이 필요한 이메일입니다.");
redisUtil.deleteData(email + "_verify");

Member member = Member.builder()
.email(joinReq.getEmail())
.email(email)
.password(passwordEncoder.encode(joinReq.getPassword()))
.name(joinReq.getName())
.part(joinReq.getPart())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import mvp.deplog.domain.tagging.Tagging;
import mvp.deplog.domain.tagging.repository.TaggingRepository;
import mvp.deplog.global.common.SuccessResponse;
import mvp.deplog.infrastructure.MarkdownUtil;
import mvp.deplog.infrastructure.markdown.MarkdownUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/mvp/deplog/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class SecurityConfig {
private final RefreshTokenRepository refreshTokenRepository;

private static final String[] WHITE_LIST = {
"/swagger", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/auth/**", "/test"
"/swagger", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/auth/**", "/test", "/mails/**", "/verification-email"
};

@Bean
Expand Down
6 changes: 5 additions & 1 deletion src/main/java/mvp/deplog/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ public enum ErrorCode {
// Business Error
BUSINESS_EXCEPTION_ERROR(400, "B999", "Business Exception Error"),

NETWORK_AUTHENTICATION_REQUIRED(511, "B998", "Network Authentication Required");
NETWORK_AUTHENTICATION_REQUIRED(511, "B998", "Network Authentication Required"),

ILLEGAL_ARGUMENT_EXCEPTION_ERROR(400, "B997", "Illegal Argument Exception Error"),

ARRAY_INDEX_OUT_OF_BOUNDS_ERROR(400, "B996", "Array Index Out of Bounds Error")

;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,15 @@ protected ResponseEntity<ErrorResponse> handleJsonProcessingException(JsonProces
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException ex) {
log.error("handleIllegalArgumentException", ex);
final ErrorResponse response = ErrorResponse.of(ErrorCode.BAD_REQUEST_ERROR, ex.getMessage());
final ErrorResponse response = ErrorResponse.of(ErrorCode.ILLEGAL_ARGUMENT_EXCEPTION_ERROR, ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

// [Exception] 배열의 잘못된 인덱스에 접근할 경우
@ExceptionHandler(ArrayIndexOutOfBoundsException.class)
protected ResponseEntity<ErrorResponse> handleArrayIndexOutOfBoundsException(ArrayIndexOutOfBoundsException ex) {
log.error("handleArrayIndexOutOfBoundsException", ex);
final ErrorResponse response = ErrorResponse.of(ErrorCode.BAD_REQUEST_ERROR, ex.getMessage());
final ErrorResponse response = ErrorResponse.of(ErrorCode.ARRAY_INDEX_OUT_OF_BOUNDS_ERROR, ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

Expand Down
64 changes: 64 additions & 0 deletions src/main/java/mvp/deplog/infrastructure/mail/MailConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package mvp.deplog.infrastructure.mail;

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 java.util.Properties;

@Configuration
public class MailConfig {

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

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

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

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

@Value("${spring.mail.properties.mail.debug}")
private boolean mailDebug;

@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.ssl.enable}")
private boolean sslEnable;

@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailServerHost);
mailSender.setPort(mailServerPort);
mailSender.setUsername(mailServerUsername);
mailSender.setPassword(mailServerPassword);
mailSender.setJavaMailProperties(getMailProperties());

return mailSender;
}

private Properties getMailProperties() {
Properties properties = new Properties();
properties.put("mail.transport.protocol", "smtp");
properties.put("mail.debug", mailDebug);
properties.put("mail.smtp.auth", auth);
properties.put("mail.smtp.starttls.enable", starttlsEnable);
properties.put("mail.smtp.starttls.required", starttlsRequired);
properties.put("mail.smtp.ssl.enable", sslEnable);

return properties;
}
}
63 changes: 63 additions & 0 deletions src/main/java/mvp/deplog/infrastructure/mail/MailUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package mvp.deplog.infrastructure.mail;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;

import java.security.SecureRandom;

@RequiredArgsConstructor
@Component
public class MailUtil {

private final String SUBJECT = "Deplog 인증 링크입니다.";
private final String SENDER_NAME = "DEPlog";

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

@Value("${example.verify.url}")
private String verificationUrl;

@Value("${example.verify.verification.email}")
private String templateUrl;

private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;

public MimeMessage createMessage(String code, String email) throws MessagingException {
MimeMessage mimeMessage = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8");
helper.setTo(email);
helper.setSubject(SUBJECT);
helper.setFrom(fromEmail);
helper.setText(setContext(code), true); // 'true' indicates HTML content

return mimeMessage;
}

// Description : 코드 생성 함수 (00000000 ~ zzzzzzzz) (8자리)
public String generateCode() {
SecureRandom random = new SecureRandom();
// nextLong(long bound) : 0(포함)부터 입력된 bound(미포함) 사이의 랜덤 정수를 반환
long randomNumber = random.nextLong(2821109907455L + 1);
String code = Long.toString(randomNumber, 36);
code = String.format("%8s", code).replace(' ', '0');

return code;
}

// 타임리프를 이용한 context 설정
public String setContext(String code) {
Context context = new Context();
context.setVariable("code", code);
context.setVariable("verificationUrl", verificationUrl);
return templateEngine.process(templateUrl, context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package mvp.deplog.infrastructure.mail.application;

import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import mvp.deplog.domain.member.domain.repository.MemberRepository;
import mvp.deplog.global.common.SuccessResponse;
import mvp.deplog.infrastructure.mail.dto.MailCodeRes;
import mvp.deplog.infrastructure.mail.MailUtil;
import mvp.deplog.infrastructure.redis.RedisUtil;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.spring6.SpringTemplateEngine;

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MailService {

private final String VERIFY_SUFFIX = "_verify";

private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
private final RedisUtil redisUtil;
private final MailUtil mailUtil;

private final MemberRepository memberRepository;

public SuccessResponse<MailCodeRes> sendMail(String email) throws Exception {
if (memberRepository.existsByEmail(email))
throw new IllegalArgumentException("이미 가입된 이메일입니다.");
String code = mailUtil.generateCode();
redisUtil.setDataExpire(code, email, 60 * 3L);

MimeMessage mimeMessage = mailUtil.createMessage(code, email);
mailSender.send(mimeMessage);

MailCodeRes mailCodeRes = MailCodeRes.builder()
.code(code)
.build();

return SuccessResponse.of(mailCodeRes);
}

public void verifyCode(String code) {
String email = redisUtil.getData(code);
if (email == null)
throw new IllegalArgumentException("유효하지 않은 코드입니다.");
redisUtil.deleteData(code);
redisUtil.setDataExpire(email + VERIFY_SUFFIX, String.valueOf(true), 60 * 60L);

// Message message = Message.builder()
// .message("이메일 인증이 완료되었습니다.")
// .build();
//
// return SuccessResponse.of(message);
}
}
13 changes: 13 additions & 0 deletions src/main/java/mvp/deplog/infrastructure/mail/dto/MailCodeRes.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package mvp.deplog.infrastructure.mail.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class MailCodeRes {

@Schema(type = "string", example = "abcd1234", description = "인증 코드를 출력합니다. 이메일 인증 시 사용자에게 발급되는 임시 키 값입니다.")
private String code;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package mvp.deplog.infrastructure.mail.presentation;

import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import mvp.deplog.global.common.Message;
import mvp.deplog.global.common.SuccessResponse;
import mvp.deplog.global.exception.ErrorResponse;
import mvp.deplog.infrastructure.mail.dto.MailCodeRes;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.view.RedirectView;

import java.io.IOException;

@Tag(name = "Mail API", description = "메일 관련 API입니다.")
public interface MailApi {

@Operation(summary = "메일 발송 API", description = "메일을 발송합니다.")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200", description = "메일 발송 성공",
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = MailCodeRes.class))}
),
@ApiResponse(
responseCode = "400", description = "메일 발송 실패",
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}
)
})
@GetMapping
ResponseEntity<SuccessResponse<MailCodeRes>> sendMail(
@Parameter(description = "이메일을 입력해주세요.", required = true) @RequestParam String email
) throws Exception;

@Hidden
@Operation(summary = "메일 검증 API", description = "메일을 검증합니다. 사용자가 링크 클릭 시 호출됩니다.")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200", description = "메일 검증 성공",
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))}
),
@ApiResponse(
responseCode = "400", description = "메일 검증 실패",
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}
)
})
@GetMapping(value = "/verify")
void verify(
@Parameter(description = "메일 발송 시 응답받은 코드를 사용해주세요.", required = true) @RequestParam String code,
HttpServletResponse response
) throws IOException;
}
Loading

0 comments on commit d818215

Please sign in to comment.