
1. SMTP
SMTP란?
SMTP는 Simple Mail Transfer Protocol의 약자이다.
SMTP는 인터넷을 통해 이메일 메시지를 보내고 받는 데 사용되는 통신 프로토콜이다. 메일 서버 및 기타 메시지 전송 에이전트(MTA)는 SMTP를 사용하여 메일 메시지를 보내고, 받고, 중계한다.
여기서 개인이 개발을 진행하면서 SMTP 서버를 구현하여 서비스를 만드는건 굉장히 복잡하다. 따라서 구글이나 네이버에서 SMTP의 기능을 이용할 수 있게 끔 제공해주고 있다. (매우 감사합니다)
이 글은 구글이 제공하는 SMTP를 활용하여 이메일 인증을 구현해 볼 것이다.
(이미지 출처 : https://www.geeksforgeeks.org/simple-mail-transfer-protocol-smtp/)
2. 구글 앱 생성 및 설정 변경
2-1 구글 앱 비밀번호 생성
구글 계정 설정 페이지 로 와서, 검색창에 비밀번호 를 입력하면 앱 비밀번호 메뉴를 확인할 수 있다.
클릭해서 들어가보자.
앱 비밀번호 생성 페이지 로 와서, 원하는 앱 이름 을 입력한다.
그후 만들기를 클릭하자.
그러면 기기용 앱 비밀번호 16자리를 얻을 수 있는데, 한번 제공된 이후 다시 찾을 수 없으니, 반드시 메모장이나 핸드폰에 잘 저장해 두자.
2-2 구글 메일 설정 변경
구글 이메일의 설정 -> 전달 및 POP/IMAP 을 클릭하여 위와 똑같이 세팅을 진행해 준다.
위의 세팅이 모두 완료가 되었다면 이제 Spring에서 이메일 인증을 구현해보도록 하자.
3. 구현
3-1 build.gradle
dependencies {
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5'
}
spring-boot-starter-mail 라이브러리를 추가해준다.
3-2 application.yml / application.properties
1) application.yml
spring:
port: '587'
username: 공통으로 사용할 이메일 입력
properties:
mail:
smtp:
timeout: '5000'
auth: 'true'
starttls:
enable: 'true'
required: 'true'
writetimeout: '5000'
connectiontimeout: '5000'
auth-code-expiration-millis: !!binary |-
MTA4MDAwMDAgIyAxODAgKiA2MCAqIDEwMDAgPT0gMTgww6vCtsKE
host: smtp.gmail.com
mail: 'null'
password: 구글에서 받은 비밀번호입력
2) application.properties
spring.mail=null
spring.host=smtp.gmail.com
spring.port=587
spring.username=이메일 입력
spring.password=구글에서 받은 비밀번호 입력
spring.properties.mail.smtp.auth=true
spring.properties.mail.smtp.starttls.enable=true
spring.properties.mail.smtp.starttls.required=true
spring.properties.mail.smtp.connectiontimeout=5000
spring.properties.mail.smtp.timeout=5000
spring.properties.mail.smtp.writetimeout=5000
spring.auth-code-expiration-millis=10800000 # 180 * 60 * 1000 == 180분
필자는 팀프로젝트를 진행하면서 팀원들이 모두 properties 확장자를 사용하고 있었기 때문에, properties로 코드를 작성하였다.
본인의 스타일에 맞게 yml 혹은 properties 중 선택하여 사용하면 된다.
yml -> properties 혹은 properties -> yml 변환 사이트는 아래를 통해 손쉽게 가능하다.
https://mageddo.com/tools/yaml-converter
3-3 EmailController
package com.sparta.javafeed.controller;
import com.sparta.javafeed.dto.EmailCheckRequestDto;
import com.sparta.javafeed.dto.EmailSendResponseDto;
import com.sparta.javafeed.dto.EmailVerifyCheckRequestDto;
import com.sparta.javafeed.security.UserDetailsImpl;
import com.sparta.javafeed.service.EmailService;
import jakarta.mail.MessagingException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.io.UnsupportedEncodingException;
@RestController
@RequiredArgsConstructor
public class EmailController {
private final EmailService emailService;
/**
* 이메일 발송
* @param details 회원 정보
* @param request 요청 객체
* @return 인증번호
*/
@PostMapping("/email")
public EmailSendResponseDto sendEmail(@RequestBody @Valid EmailCheckRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl details) throws MessagingException, UnsupportedEncodingException {
return emailService.sendEmail(requestDto.getEmail(), details.getUser());
}
/**
* 이메일 인증 (인증번호 확인 후 userStatsus : ACTIVE 처리)
* @param details 회원 정보
* @param request 요청 객체
* @return 인증 성공 여부
*/
@PostMapping("/email/verify")
public String verifyEmail(@RequestBody @Valid EmailVerifyCheckRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl details) {
Boolean isVerified = emailService.verifyCode(requestDto, details.getUser());
if (isVerified) {
return "인증 성공";
} else {
return "인증 코드 불일치";
}
}
}
이메일 전송 을 위한 PostMapping
이메일 검증 을 위한 PostMapping
두개를 추가해 주었다. 필자는 EmailSendResponseDto 를 추가적으로 만들어서, 코드 / 전송된 시간 / 만료 시간 을 추가하였다.
3-4 EmailSendResponseDto
package com.sparta.javafeed.dto;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class EmailSendResponseDto {
private String authCode;
private LocalDateTime sentAt;
private LocalDateTime expiredAt;
public EmailSendResponseDto(String authCode, LocalDateTime sentAt, LocalDateTime expiredAt) {
this.authCode = authCode;
this.sentAt = sentAt;
this.expiredAt = expiredAt;
}
}
이메일이 전송되었을 때,
- 인증 번호
- 발송 시간
- 만료 시간
세가지를 반환시켜 보았다.
3-5 EmailCheckRequestDto
package com.sparta.javafeed.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class EmailCheckRequestDto {
@NotBlank(message = "이메일을 입력해 주세요.")
@Email(message = "올바른 이메일 형식을 입력해 주세요.")
private String email;
}
이메일을 전송할 때 클라이언트에서 이메일 값을 받아와야 하기 때문에 생성한 requestDto 이다.
3-6 EmailVerifyCheckRequestDto
package com.sparta.javafeed.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class EmailVerifyCheckRequestDto {
@NotBlank(message = "이메일을 입력해 주세요.")
@Email(message = "올바른 이메일 형식을 입력해 주세요.")
private String email;
@NotBlank(message = "인증번호를 입력해 주세요.")
private String authNum;
}
이메일 인증 코드를 검증할 때 클라이언트에서 이메일과 인증코드를 받아와야 하기 때문에 생성한 reqeustDto 이다.
3-7 EmailService
package com.sparta.javafeed.service;
import com.sparta.javafeed.dto.EmailSendResponseDto;
import com.sparta.javafeed.dto.EmailVerifyCheckRequestDto;
import com.sparta.javafeed.entity.User;
import com.sparta.javafeed.enums.ErrorType;
import com.sparta.javafeed.enums.UserStatus;
import com.sparta.javafeed.exception.CustomException;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Random;
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender emailSender;
private final UserService userService;
private String authNum;
// 인증번호 랜덤 코드로 8자 생성코드
public void createCode() {
Random random = new Random();
StringBuffer key = new StringBuffer();
for(int i=0; i<8; i++) {
int idx = random.nextInt(3);
switch (idx) {
case 0 :
key.append((char) ((int)random.nextInt(26) + 97));
break;
case 1:
key.append((char) ((int)random.nextInt(26) + 65));
break;
case 2:
key.append(random.nextInt(9));
break;
}
}
authNum = key.toString();
}
// 메일 양식 작성
public MimeMessage createEmailForm(String email) throws MessagingException, UnsupportedEncodingException {
createCode();
String setFrom = "test@gmail.com";
String toEmail = email;
String title = "인증번호";
MimeMessage message = emailSender.createMimeMessage();
message.addRecipients(MimeMessage.RecipientType.TO, toEmail);
message.setSubject(title);
// 메일 내용
String msgOfEmail="";
msgOfEmail += "<div style='margin:20px;text-align:center;width: 495px;margin: 0 auto;'>";
msgOfEmail += "<img src='https://ifh.cc/g/VLQ01c.png' style='border-radius: 60px;'>";
msgOfEmail += "<h1> 안녕하세요 JAVAFEED 입니다. </h1>";
msgOfEmail += "<br>";
msgOfEmail += "<p>아래 코드를 입력해주세요<p>";
msgOfEmail += "<br>";
msgOfEmail += "<p>감사합니다.<p>";
msgOfEmail += "<br>";
msgOfEmail += "<div align='center' style='border:1px solid black;font-family:verdana;border-radius: 30px;'>";
msgOfEmail += "<div style='font-size:130%; margin-top: 20px;'>";
msgOfEmail += "CODE : <strong>";
msgOfEmail += authNum + "</strong><div><br/> ";
msgOfEmail += "</div>";
message.setFrom(setFrom);
message.setText(msgOfEmail, "utf-8", "html");
return message;
}
//메일 발송
public EmailSendResponseDto sendEmail(String email, User loginUser) throws MessagingException, UnsupportedEncodingException {
//같은 이메일을 입력했는지 검증
if (!loginUser.getEmail().equals(email)) {
throw new CustomException(ErrorType.INVALID_EMAIL);
}
//메일전송에 필요한 정보 설정
MimeMessage emailForm = createEmailForm(email);
//실제 메일 전송
emailSender.send(emailForm);
//전송 시간 기록
LocalDateTime sentAt = LocalDateTime.now();
// 회원 테이블에 이메일 전송 시간 저장
userService.updateUserEmailSent(email, sentAt);
// 만료 시간 설정 (전송 시간으로부터 3분 후)
LocalDateTime expiredAt = sentAt.plusMinutes(3);
return new EmailSendResponseDto(authNum, sentAt, expiredAt);
}
//메일 인증번호 확인
public boolean verifyCode(EmailVerifyCheckRequestDto requestDto, User loginUser) {
//탈퇴한 회원인지 검증 로직
if (loginUser.getUserStatus().equals(UserStatus.DEACTIVATE)) {
throw new CustomException(ErrorType.DEACTIVATE_USER);
}
//이미 이메일 확인을 한 회원인지 검증 로직
if (loginUser.getUserStatus().equals(UserStatus.ACTIVE)) {
throw new CustomException(ErrorType.VERIFIED_EMAIL);
}
//같은 이메일을 입력했는지 검증 로직
if (!loginUser.getEmail().equals(requestDto.getEmail())) {
throw new CustomException(ErrorType.INVALID_EMAIL);
}
//올바른 인증번호를 입력했는지 확인하는 로직
if (!authNum.equals(requestDto.getAuthNum())) {
throw new CustomException(ErrorType.WRONG_AUTH_NUM);
}
// 인증 시간 만료 검증 로직
LocalDateTime now = LocalDateTime.now();
LocalDateTime emailSentAt = loginUser.getEmailSentAt();
if (ChronoUnit.SECONDS.between(emailSentAt, now) > 180) {
throw new CustomException(ErrorType.EXPIRED_AUTH_NUM);
}
// 유저 테이블에서 userStatus 업데이트 처리
userService.updateUserStatus(requestDto);
return authNum.equals(requestDto.getAuthNum());
}
}
- createCode() : 인증코드 8자를 랜덤으로 생성해 준다.
- createEmailForm() : 이메일의 포멧을 html로 설정해준다. 본인의 취향에 맞게 설정해주면 된다.
- sendEmail() : JAVA emailSender를 통해 이메일을 전송한다.
- verifyCode() : 인증코드를 검증하는 로직
4. 테스트
포스트맨으로 테스트를 진행해본 결과, 정상적으로 작동하는 것을 볼 수 있다.
한번 이메일로 가보자.
이메일도 정상적으로 도착했고, postman에 찍힌 CODE와 일치하는것을 볼 수 있다.
드디어 해냈다. 너무 기쁘다.
만약 3분이 지나고 인증하게되면 인증이 만료되었다는 예외처리를 통해, 정상적으로 처리되는 것을 볼 수 있다.
'JAVASPRING > study' 카테고리의 다른 글
H2 database, 개념 잡기 (0) | 2024.06.27 |
---|---|
GlobalException (0) | 2024.06.21 |
Controller / Service / Repository로 나누는 이유는 뭘까? (0) | 2024.05.28 |