Skip to content

Commit

Permalink
feat-be: 이메일 인증 기능 구현 (#848)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kwoun Ki Ho <fingercut3822@gmail.com>
  • Loading branch information
2 people authored and Dobby-Kim committed Oct 23, 2024
1 parent a538313 commit 8d1224d
Show file tree
Hide file tree
Showing 17 changed files with 444 additions and 7 deletions.
14 changes: 14 additions & 0 deletions backend/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ services:
platform: linux/arm64
depends_on:
- database-mysql
- redis
restart: always
image: ${DOCKER_REPO_NAME}/cruru:${DOCKER_IMAGE_VERSION_TAG}
ports:
Expand All @@ -39,6 +40,19 @@ services:
cruru_network:
ipv4_address: ${APP_IP_ADDRESS}

redis:
container_name: redis-container
image: redis:latest
environment:
TZ: Asia/Seoul
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports:
- ${REDIS_PORT}:6379
command: [ "redis-server", "--requirepass", "${REDIS_PASSWORD}" ]
networks:
cruru_network:
ipv4_address: ${REDIS_IP_ADDRESS}

promtail:
environment:
TZ: Asia/Seoul
Expand Down
28 changes: 28 additions & 0 deletions backend/src/docs/asciidoc/email.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,31 @@ operation::email/send-fail/invalid-email[snippets="http-request,request-cookies,
==== 실패: 존재하지 않는 동아리

operation::email/send-fail/club-not-found[snippets="http-request,request-cookies,request-parts,http-response"]

=== 이메일 인증 번호 발송

==== 성공

operation::email/verification-code[snippets="http-request,request-fields,http-response"]

==== 실패: 이메일 형식이 올바르지 않은 이메일 형식

operation::email/verification-code-fail/invalid-email[snippets="http-request,request-fields,http-response"]

=== 이메일 인증 확인

==== 성공

operation::email/verify-code[snippets="http-request,request-fields,http-response"]

==== 실패: 이메일 형식이 올바르지 않은 이메일 형식

operation::email/verify-code-fail/invalid-email[snippets="http-request,request-fields,http-response"]

==== 실패: 인증 번호가 없는 이메일

operation::email/verify-code-fail/code-not-found[snippets="http-request,request-fields,http-response"]

==== 실패: 인증 번호가 다른 이메일

operation::email/verify-code-fail/code-mismatch[snippets="http-request,request-fields,http-response"]
2 changes: 2 additions & 0 deletions backend/src/main/java/com/cruru/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public void addInterceptors(InterceptorRegistry registry) {
.excludePathPatterns("/**/signup")
.excludePathPatterns("/**/login")
.excludePathPatterns("/**/applyform/*/submit")
.excludePathPatterns("/**/emails/verification-code")
.excludePathPatterns("/**/emails/verify-code")
.excludePathPatterns("/");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.cruru.email.controller;

import com.cruru.auth.annotation.ValidAuth;
import com.cruru.email.controller.dto.EmailRequest;
import com.cruru.email.controller.request.EmailRequest;
import com.cruru.email.controller.request.SendVerificationCodeRequest;
import com.cruru.email.controller.request.VerifyCodeRequest;
import com.cruru.email.facade.EmailFacade;
import com.cruru.global.LoginProfile;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
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;

Expand All @@ -28,4 +31,18 @@ public ResponseEntity<Void> send(
emailFacade.send(request);
return ResponseEntity.ok().build();
}

@PostMapping("/verification-code")
public ResponseEntity<Void> sendVerificationCode(
@RequestBody @Valid SendVerificationCodeRequest request
) {
emailFacade.sendVerificationCode(request);
return ResponseEntity.ok().build();
}

@PostMapping("/verify-code")
public ResponseEntity<Void> verifyCode(@Valid @RequestBody VerifyCodeRequest request) {
emailFacade.verifyCode(request);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.cruru.email.controller.dto;
package com.cruru.email.controller.request;

import com.cruru.applicant.domain.Applicant;
import com.cruru.auth.annotation.RequireAuth;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.cruru.email.controller.request;

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

public record SendVerificationCodeRequest(
@NotBlank
@Email
String email
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.cruru.email.controller.request;

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

public record VerifyCodeRequest(
@NotBlank
@Email
String email,

@NotBlank
String verificationCode
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.cruru.email.exception.badrequest;

import com.cruru.advice.badrequest.BadRequestException;

public class VerificationCodeMismatchException extends BadRequestException {

private static final String MESSAGE = "인증 코드가 일치하지 않습니다.";

public VerificationCodeMismatchException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.cruru.email.exception.badrequest;

import com.cruru.advice.badrequest.BadRequestException;

public class VerificationCodeNotFoundException extends BadRequestException {

private static final String MESSAGE = "인증 코드가 존재하지 않거나 만료되었습니다.";

public VerificationCodeNotFoundException() {
super(MESSAGE);
}
}
23 changes: 22 additions & 1 deletion backend/src/main/java/com/cruru/email/facade/EmailFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
import com.cruru.applicant.service.ApplicantService;
import com.cruru.club.domain.Club;
import com.cruru.club.service.ClubService;
import com.cruru.email.controller.dto.EmailRequest;
import com.cruru.email.controller.request.EmailRequest;
import com.cruru.email.controller.request.SendVerificationCodeRequest;
import com.cruru.email.controller.request.VerifyCodeRequest;
import com.cruru.email.exception.EmailAttachmentsException;
import com.cruru.email.service.EmailRedisClient;
import com.cruru.email.service.EmailService;
import com.cruru.email.util.FileUtil;
import com.cruru.email.util.VerificationCodeUtil;
import java.io.File;
import java.io.IOException;
import java.util.List;
Expand All @@ -23,6 +27,7 @@ public class EmailFacade {
private final EmailService emailService;
private final ClubService clubService;
private final ApplicantService applicantService;
private final EmailRedisClient emailRedisClient;

public void send(EmailRequest request) {
Club from = clubService.findById(request.clubId());
Expand Down Expand Up @@ -52,4 +57,20 @@ private List<File> saveTempFiles(Club from, String subject, List<MultipartFile>
throw new EmailAttachmentsException(from.getId(), subject);
}
}

public void sendVerificationCode(SendVerificationCodeRequest request) {
String email = request.email();
String verificationCode = VerificationCodeUtil.generateVerificationCode();

emailRedisClient.saveVerificationCode(email, verificationCode);
emailService.sendVerificationCode(email, verificationCode);
}

public void verifyCode(VerifyCodeRequest request) {
String email = request.email();
String inputVerificationCode = request.verificationCode();
String storedVerificationCode = emailRedisClient.getVerificationCode(email);

VerificationCodeUtil.verify(storedVerificationCode, inputVerificationCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.cruru.email.service;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class EmailRedisClient {

private static final String REDIS_PREFIX = "email_verification:";
private static final long VERIFICATION_CODE_EXPIRATION = 10;

private final RedisTemplate<String, String> redisTemplate;

public void saveVerificationCode(String email, String verificationCode) {
redisTemplate.opsForValue()
.set(REDIS_PREFIX + email, verificationCode, VERIFICATION_CODE_EXPIRATION, TimeUnit.MINUTES);
}

public String getVerificationCode(String email) {
return redisTemplate.opsForValue().get(REDIS_PREFIX + email);
}
}
18 changes: 18 additions & 0 deletions backend/src/main/java/com/cruru/email/service/EmailService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.cruru.club.domain.Club;
import com.cruru.email.domain.Email;
import com.cruru.email.domain.repository.EmailRepository;
import com.cruru.email.util.EmailTemplate;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.io.File;
Expand Down Expand Up @@ -79,4 +80,21 @@ public void save(Email email) {
public void deleteAllByTos(List<Applicant> applicants) {
emailRepository.deleteAllByTos(applicants);
}

@Async
public void sendVerificationCode(String to, String verificationCode) {
try {
String subject = "[크루루] 인증 코드 안내";
String content = EmailTemplate.generateVerificationEmailContent(verificationCode);

MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, true);
mailSender.send(message);
} catch (MessagingException | MailException e) {
log.error("이메일 전송 실패: to={}, subject={}", to, e.getMessage());
}
}
}
28 changes: 28 additions & 0 deletions backend/src/main/java/com/cruru/email/util/EmailTemplate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.cruru.email.util;

public class EmailTemplate {

public static String generateVerificationEmailContent(String verificationCode) {
return """
<div style='font-family: Arial, sans-serif; padding: 20px; background-color: #ffffff; border: 1px solid #e0e0e0; max-width: 600px; margin: 0 auto;'>
<div style='text-align: center; padding: 20px 0; background-color: #AA2298; color: white;'>
<h1 style='margin: 0;'>크루루</h1>
</div>
<div style='padding: 30px; text-align: center;'>
<h2 style='color: #333;'>[크루루] 인증 코드 안내</h2>
<p style='font-size: 16px; color: #555;'>안녕하세요,</p>
<p style='font-size: 16px; color: #555;'>아래 인증 코드를 입력해 주세요:</p>
<div style='padding: 20px; background-color: #f0f0f0; border-radius: 10px; display: inline-block; margin: 20px 0;'>
<span id='verificationCode' style='font-size: 32px; font-weight: bold; color: #333;'>%s</span>
</div>
<p style='font-size: 14px; color: #888;'>이 코드는 10분 후에 만료됩니다.</p>
</div>
<hr style='border: none; border-top: 1px solid #ddd; margin: 20px 0;'>
<div style='padding: 20px; text-align: center; font-size: 12px; color: #888;'>
<p>본 메일은 크루루 시스템에 의해 자동 발송되었습니다.</p>
<p>&copy; 2024 크루루. All Rights Reserved.</p>
</div>
</div>
""".formatted(verificationCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.cruru.email.util;

import com.cruru.email.exception.badrequest.VerificationCodeMismatchException;
import com.cruru.email.exception.badrequest.VerificationCodeNotFoundException;
import java.util.Random;

public class VerificationCodeUtil {

private static final Random random = new Random();
private static final int CODE_LENGTH = 6;

private VerificationCodeUtil() {
}

public static String generateVerificationCode() {
return String.format("%0" + CODE_LENGTH + "d", random.nextInt((int) Math.pow(10, CODE_LENGTH)));
}

public static void verify(String storedVerificationCode, String inputVerificationCode) {
if (storedVerificationCode == null) {
throw new VerificationCodeNotFoundException();
}

if (!storedVerificationCode.equals(inputVerificationCode)) {
throw new VerificationCodeMismatchException();
}
}
}
6 changes: 3 additions & 3 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ spring:
max-request-size: 50MB
data:
redis:
port: ${REDIS_PORT}
host: ${REDIS_HOST}
password: ${REDIS_PASSWORD}
port: 6379
host: localhost
password: password

security:
jwt:
Expand Down
Loading

0 comments on commit 8d1224d

Please sign in to comment.