배경
프로젝트 고도화를 진행하며 회원가입 중 이메일 인증을 하도록 했다.
[이메일 인증 추가 시 기대효과]
사용자 신원 확인
- 확인 사용자가 제공한 이메일 주소가 실제로 존재하며, 사용자가 그 주소에 접근할 수 있음을 확인함
- 이는 실제 사용자와 가짜 계정을 구분하는 데 도움이 됨
보안 강화
- 이메일 인증 과정을 통해 비밀번호 재설정, 중요한 계정 변경 사항 알림 등의 보안 관련 통신을 해당 이메일 주소로 보내 알릴 수 있음
- 이를 통해 계정 탈취나 무단 접근을 방지할 수 있음
흐름
- 사용자는 회원가입 화면에서 가입하려는 이메일 입력 후 이메일 인증 버튼 클릭
- 서버에게 사용자의 이메일로 인증 번호 전송 요청
- 서버는 랜덤 인증 번호 생성, 인증 번호를 DB에 저장 후 사용자의 이메일로 인증 번호 전송
- 사용자는 인증 번호 확인 후 인증 번호 입력 후 확인 버튼 클릭
- 클라이언트는 서버에게 인증 번호 검증 요청 보냄
- 서버는 전달받은 인증 번호가 DB에 저장된 인증번호와 동일한지 확인 후 true/false 반환
- 매일 정오마다 만료된 인증코드들 지움
SMTP 계정 설정
우선 SMTP를 사용할 구글 계정 설정을 해준다.
구글 로그인 → 구글 계정 관리 → 앱 비밀번호
만약 앱 비밀번호가 나오지 않는다면 2단계 인증을 하지 않은 확률이 높다.
앱 비밀번호는 2단계 인증을 활성화 해야 사용할 수 있다.
비밀번호 생성 후 생성된 앱 비밀번호를 따로 저장해두자.
구글 Gmail → 설정 → 전달 및 POP/IMAP → 아래 이미지처럼 설정 → 변경사항 저장 클릭
필자는 이미 모든 메일에 POP가 설정되어 있기 때문에 안뜨는 것이고,
이미 다운로드 된 메일을 포함하여 모든 메일에 POP글 활성화 하기 선택하면 된다.
필자는 여기서 꽤 애를 먹었다. 원래 있던 구글 계정에서 새로 계정을 추가해서 원래 있던 계정에 해야하는줄 알았다.
하지만 꼭 사용할 이메일로 설정해야한다. 아니면 이메일 전송 자체를 못한다.
구현
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-mail'
application.yml
mail:
host: smtp.gmail.com # Gmail의 SMTP 서버 호스트
port: 587 # Gmail SMTP 서버는 587번 포트를 사용
username: imgforestmail@gmail.com
password: 앱 비밀번호
properties:
mail:
smtp:
auth: true # SMTP 서버에 인증 필요한 경우 true로 지정 Gmail은 요구함
starttls:
enable: true # SMTP 서버가 TLS를 사용하여 안전한 연결을 요구하는 경우 true로 설정
required: true
connectiontimeout: 5000 # 클라이언트가 SMTP 서버와의 연결을 설정하는 데 대기해야 하는 시간
timeout: 5000 # 클라이언트가 SMTP 서버로부터 응답을 대기해야 하는 시간
writetimeout: 5000 # 클라이언트가 작업을 완료하는데 대기해야 하는 시간
auth-code-expiration-millis: 1800000 # 30 * 60 * 1000 == 30분 이메일 인증 코드의 만료 시간(Millisecond)
EmailConfig
JavaMailSender 인터페이스를 구현하는 클래스
JavaMail API를 사용하여 이메일을 전송하는데 사용된다.
package com.ll.demo.global.config;
import com.ll.demo.member.service.EmailService;
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 EmailConfig {
@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 EmailService emailService() {
return new EmailService(javaMailSender());
}
@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;
}
}
EmailService
이메일 발송 담당 클래스
package com.ll.demo.member.service;
import com.ll.demo.member.entity.VerificationCode;
import com.ll.demo.member.repository.EmailRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
@Slf4j
@Transactional
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender emailSender;
public void sendEmail(String toEmail,String title, String content) throws MessagingException {
MimeMessage message = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(toEmail);
helper.setSubject(title);
helper.setText(content, true); // true를 설정해서 HTML을 사용 가능하게 함
helper.setReplyTo("imgforestmail@gmail.com"); // 회신 불가능한 주소 설정
try {
emailSender.send(message);
} catch (RuntimeException e) {
e.printStackTrace(); // 또는 로거를 사용하여 상세한 예외 정보 로깅
throw new RuntimeException("Unable to send email in sendEmail", e); // 원인 예외를 포함시키기
}
}
public SimpleMailMessage createEmailForm(String toEmail, String title, String text) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(toEmail);
message.setSubject(title);
message.setText(text);
return message;
}
}
MemberController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {
private final MemberService memberService;
private final EmailService emailService;
...
//인증 번호 전송
@PostMapping("/sendEmail")
public GlobalResponse<MemberEmailRequestDto> sendEmail(@RequestBody MemberEmailRequestDto requestDto) {
memberService.sendCodeToEmail(requestDto.getEmail());
return GlobalResponse.of("200", "이메일 전송 성공");
}
//이메일 인증
@PostMapping("/verifyEmail")
public GlobalResponse<MemberEmailVerifyResponseDto> verifyEmail(@RequestBody MemberEmailVerifyRequestDto requestDto) {
boolean isVerified = memberService.verifyCode(requestDto.getEmail(), requestDto.getVerificationCode());
MemberEmailVerifyResponseDto responseDto = new MemberEmailVerifyResponseDto();
responseDto.setVerified(isVerified);
responseDto.setMessage(isVerified ? "Email verified successfully." : "Invalid or expired verification code.");
if(isVerified) return GlobalResponse.of("200", "인증 완료", responseDto);
else return GlobalResponse.of("200", "인증 실패", responseDto);
}
}
MemberService
public void sendCodeToEmail(String email) {
VerificationCode createdCode = createVerificationCode(email);
String title = "Img Forest 이메일 인증 번호";
String content = "<html>"
+ "<body>"
+ "<h1>ImgForest 인증 코드: " + createdCode.getCode() + "</h1>"
+ "<p>해당 코드를 홈페이지에 입력하세요.</p>"
+ "<footer style='color: grey; font-size: small;'>"
+ "<p>※본 메일은 자동응답 메일이므로 본 메일에 회신하지 마시기 바랍니다.</p>"
+ "</footer>"
+ "</body>"
+ "</html>";
try {
emailService.sendEmail(email, title, content);
} catch (RuntimeException | MessagingException e) {
e.printStackTrace(); // 또는 로거를 사용하여 상세한 예외 정보 로깅
throw new RuntimeException("Unable to send email in sendCodeToEmail", e); // 원인 예외를 포함시키기
}
}
// 인증 코드 생성 및 저장
public VerificationCode createVerificationCode(String email) {
String randomCode = generateRandomCode(6);
VerificationCode code = VerificationCode.builder()
.email(email)
.code(randomCode) // 랜덤 코드 생성
.expiresTime(LocalDateTime.now().plusDays(1)) // 1일 후 만료
.build();
return emailRepository.save(code);
}
public String generateRandomCode(int length) {
// 숫자 + 대문자 + 소문자
String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder();
ThreadLocalRandom random = ThreadLocalRandom.current();
for (int i = 0; i < length; i++) {
int index = random.nextInt(characters.length());
sb.append(characters.charAt(index));
}
return sb.toString();
}
// 인증 코드 유효성 검사
public boolean verifyCode(String email, String code) {
return emailRepository.findByEmailAndCode(email, code)
.map(vc -> vc.getExpiresTime().isAfter(LocalDateTime.now()))
.orElse(false);
}
@Transactional
@Scheduled(cron = "0 0 12 * * ?") // 매일 정오에 해당 만료 코드 삭제
public void deleteExpiredVerificationCodes() {
emailRepository.deleteByExpiresTimeBefore(LocalDateTime.now());
}
sendCodeToEmail
- 인증 코드 생성 후 수신자 이메일로 발송하는 메서드
createVerificationCode
- 6자리 랜덤한 인증 코드를 생성하여 DB에 저장 후 반환하는 메서드
generateRandomCode
- 6자리 랜덤한 인증 코드를 생성
verifyCode
- DB에 저장된 인증 코드 유효성 검사 확인 메서드
deleteExpiredVerificationCodes
- 스케쥴링을 통해 만료 인증 코드 삭제 메서드
테스트
이메일 인증 구현을 하며 참고한 사이트
https://green-bin.tistory.com/83
Spring - 이메일 인증 구현해보기 (랜덤 인증번호 보내기)
배경 새로 시작하게 된 프로젝트에서 회원가입 중 이메일 인증을 하도록 했다. Spring에서 제공하는 API를 사용하면 생각보다 쉽게 구현할 수 있다. 나는 Google SMTP 서버를 이용해서 이메일 인증을
green-bin.tistory.com
스프링으로 이메일 인증 구현하기
아까 말했듯 우리가 직접 구현하는 것보다는 외부 서버가 나을 것이다!3)우리는 앱비밀번호라는게 필요한데 앱 비밀번호를 만드려면 우리의 구글 계정에 2단계 인증이 필요하다. 보안탭에서 2단
velog.io
[Gmail] 앱 비밀번호 생성방법
앱 비밀번호란 보안 수준이 낮은 앱 또는 기기에 Google 계정에 대한 액세스 권한을 부여하는 16자리 비밀번호입니다. 앱 비밀번호는 2단계 인증이 사용 설정된 계정에서만 이용할 수 있습니다. *
support.bespinglobal.com
Random 대신 ThreadLocalRandom을 써야 하는 이유
java.util.Random은 멀티 쓰레드 환경에서 하나의 인스턴스에서 전역적으로 의사 난수(pseudo random)를 반환한다. 따라서 같은 시간에 동시 요청이 들어올 경우 경합 상태에서 성능에 문제가 생길 수 있
velog.io
'[for Rest 프로젝트]' 카테고리의 다른 글
Spring - redis 적용하기 (1) | 2024.03.26 |
---|---|
Spring - Swagger UI 적용하기 (0) | 2024.03.10 |
react - [useState] (0) | 2024.02.04 |
LazyLoading (1) | 2024.01.30 |
Spring Security + JWT (0) | 2024.01.26 |