이룸 프로젝트
[이룸] 240306 인증 메일 전송하기 구현
쿠마냥
2024. 3. 7. 15:48
인증 메일 전송 기능 구현의 이유
- 이메일을 잘못 입력했는데, 덜컥 가입이 되어 버리면 어쩌지? 라는 질문에서 시작
- 처음에는 유저가 잘못 입력한 이메일을 수정할 수 있도록, 마이페이지에 이메일 수정 기능이 있었음
- 그러나…
- 이메일을 수정한다 → 에러 발생! → 왜? → 발급해 준 토큰 속 이메일과 변경한 이메일의 불일치 → 그러면 이메일을 바꿀 때마다 새로 토큰 두 개를 만들어줘야 하나? → 토큰을 발급하는 건 로그인하는 과정에서 일어난다. 즉, 사용자를 확인하는 과정에서 일어난다 → 그런데 이렇게 되면 ‘이메일 수정 = 로그인’이 되어 버림
- 그러나222…
- 만약 비밀번호를 탈취당한다면? → 해커는 탈취한 비밀번호로 로그인한 다음, 아이디까지 바꿔 버림 → 유저는 아이디와 비밀번호를 모두 잃어 버림. 내 아이디가 탈취되었는지 어떤지조차 알 수 없을 것
- 따라서…
- 아이디는 서버에게도 유저에게도 해당 유저를 식별하는 식별자값. 그러니 변경되지 않는 것이 좋겠다! → 그렇다면 처음부터 이메일을 잘못 입력하지 않도록 인증 이메일을 전송하자!
레퍼런스 :
https://green-bin.tistory.com/83
Spring - 이메일 인증 구현해보기 (랜덤 인증번호 보내기)
배경 새로 시작하게 된 프로젝트에서 회원가입 중 이메일 인증을 하도록 했다. Spring에서 제공하는 API를 사용하면 생각보다 쉽게 구현할 수 있다. 나는 Google SMTP 서버를 이용해서 이메일 인증을
green-bin.tistory.com
클래스 생성 :
- EmailConfig : JavaMailSender 관련 설정 클래스
- EmailVerification: 인증 메일 객체 설정
- EmailVerificationRepository : 인증 메일 저장소
- MemberController: 인증 메일 관련 요청 처리 (수정)
- MemberService: 인증 메일 관련 비즈니스 로직 (수정)
- EmailService : 인증 메일 전송 관련 설정 클래스
구현 과정
[yml]
# Gmail Service
mail:
host: smtp.gmail.com
port: 587
username: eroom.challenge@gmail.com
password: {16자리 앱 비밀번호}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
auth-code-expiration-millis: 1800000 # 30 * 60 * 1000 == 30분
- host: Gmail의 SMTP 서버 호스트
- port: SMTP 서버의 포트 번호. Gmail SMTP 서버는 587번 포트를 사용
- username: 이메일을 보내는 용으로 사용되는 계정의 이메일 주소 입력
- password: 앱 비밀번호 입력
- properties: 이메일 구성에 대한 추가 속성
- auth: SMTP 서버에 인증 필요한 경우 true로 지정한다. Gmail SMTP 서버는 인증을 요구하기 때문에 true로 설정해야 한다.
- starttls: SMTP 서버가 TLS를 사용하여 안전한 연결을 요구하는 경우 true로 설정한다. TLS는 데이터를 암호화하여 안전한 전송을 보장하는 프로토콜이다.
- connectiontimeout: 클라이언트가 SMTP 서버와의 연결을 설정하는 데 대기해야 하는 시간(Millisecond). 연결이 불안정한 경우 대기 시간이 길어질 수 있기 때문에 너무 크게 설정하면 전송 속도가 느려질 수 있다.
- timeout: 클라이언트가 SMTP 서버로부터 응답을 대기해야 하는 시간(Millisecond). 서버에서 응답이 오지 않는 경우 대기 시간을 제한하기 위해 사용된다.
- writetimeout: 클라이언트가 작업을 완료하는데 대기해야 하는 시간(Millisecond). 이메일을 SMTP 서버로 전송하는 데 걸리는 시간을 제한하는데 사용된다.
- auth-code-expiration-millis: 이메일 인증 코드의 만료 시간(Millisecond)
[EmailConfig]
@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;
@Bean
public JavaMailSender javaMailSender() {
Properties mailProperties = new Properties();
mailProperties.put("mail.transport.protocol", "smtp"); // 구글의 smtp 프로토콜로 전송
mailProperties.put("mail.smtp.auth", "true"); // smtp 서버에 이메일을 보낼 때 인증이 필요함. 무단 액세스로부터 계정 보호
mailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory"); // 메일 전송에 ssl 기반 소켓 사용하는 설정
mailProperties.put("mail.smtp.starttls.enable", "true"); // tls 관련 설정. 메일 클라이언트와 서버 사이 연결 암호화. 데이터 안전 전송
mailProperties.put("mail.smtp.debug", "true"); // 디버깅 가능
mailProperties.put("mail.smtp.ssl.trust", "smtp.gmail.com"); // ssl 인증서 유효함을 인증함
mailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2"); // tls 1.2 버전 사용하여 ssl 보안 프로토콜 지정
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setJavaMailProperties(mailProperties);
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);
mailSender.setDefaultEncoding("UTF-8");
return mailSender;
}
}
[EmailVerification]
@Entity
@Getter
@NoArgsConstructor
public class EmailVerification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String authCode;
@Column(nullable = false)
private LocalDateTime expirationTime;
public EmailVerification(String toEmail, String authCode, LocalDateTime expirationTime) {
this.email = toEmail;
this.authCode = authCode;
this.expirationTime = expirationTime;
}
public void update(String authCode, LocalDateTime expirationTime) {
this.authCode = authCode;
this.expirationTime = expirationTime;
}
}
[EmailVerificationRepository]
public interface EmailVerificationRepository extends JpaRepository<EmailVerification, Long> {
Optional<EmailVerification> findByEmailAndAuthCode(String email, String authCode);
Optional<EmailVerification> findByEmail(String email);
void deleteByEmail(String email);
}
[MemberController 추가 메서드]
@PostMapping("/emails/verification-requests")
public ResponseEntity<BaseDto<String>> sendMessage(@RequestParam("email") @Valid String email) {
String message = memberService.sendCodeToEmail(email);
return ResponseEntity.ok(new BaseDto<>(null, message, HttpStatus.OK));
}
@GetMapping("/emails/verifications")
public ResponseEntity<BaseDto<String>> verificationEmail(@RequestParam("email") @Valid String email,
@RequestParam("code") String authCode) {
String message = memberService.verifiedCode(email, authCode);
return ResponseEntity.ok(new BaseDto<>(null, message, HttpStatus.OK));
[MemberService 추가 메서드]
@Transactional
public String sendCodeToEmail(String toEmail) {
// 이미 가입된 회원인지 확인
boolean memberIsPresent = memberRepository.existsByEmail(toEmail);
if (memberIsPresent) {
return "이미 가입된 아이디입니다.";
}
// 회원이 아니라면 인증 코드 생성
String authCode = this.createCode();
// 이메일 내용 정의
String title = "eroom 이메일 인증 번호";
String content =
"<div style='font-family: Arial, Helvetica, sans-serif; color: #333; background-color: #ffffff; padding: 40px; border-radius: 15px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); text-align: center;'>"
+ "<h2 style='color: #4a7c59; font-size: 22px;'>🎉 안녕하세요, 이룸에 오신 것을 환영합니다! 🎉</h2>"
+ "<p style='font-size: 16px;'>5분 내에 아래 <strong>인증번호</strong>를 복사하여 인증번호 확인란에 입력해주세요.</p>"
+ "<div style='margin: 30px auto; padding: 20px; background-color: #e6f9d4; display: inline-block;'>"
+ "<h3 style='color: #333; font-size: 18px;'>회원가입 인증번호입니다.</h3>"
+ "<p style='background-color: #d4f7c5; color: #4a7c59; font-size: 24px; padding: 10px 20px; border-radius: 10px; display: inline-block; margin: 0;'>" + authCode + "</p>"
+ "</div>"
+ "<p style='font-size: 16px; margin-top: 40px;'>이 코드를 요청하지 않은 경우, 이 이메일을 무시해도 됩니다.<br>다른 사용자가 실수로 이메일 주소를 입력했을 수 있습니다.</p>"
+ "</div>";
// 이메일 보내기
String sendMail = "eroom.challenge@gmail.com";
emailService.sendEmail(sendMail, toEmail, title, content);
// 서버시간 기준 5분 후로 이메일 만료 시간 지정
LocalDateTime expirationTime = LocalDateTime.now().plusMinutes(5);
// 이미 인증 메일을 보낸 사용자라면 인증 코드와 만료 시간 업데이트, 아니라면 새로 생성 후 저장
EmailVerification verification = emailVerificationRepository.findByEmail(toEmail)
.orElse(new EmailVerification(toEmail, authCode, expirationTime));
verification.update(authCode, expirationTime);
emailVerificationRepository.save(verification);
return "인증 메일을 전송하였습니다.";
}
private String createCode() {
int length = 6;
try {
Random random = SecureRandom.getInstanceStrong();
StringBuilder builder = new StringBuilder();
for (int i = 0; i < length; i++) {
builder.append(random.nextInt(10));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("인증번호를 만들던 중 오류가 발생했습니다.");
}
}
public String verifiedCode(String email, String authCode) {
Optional<EmailVerification> verification = emailVerificationRepository.findByEmailAndAuthCode(email, authCode);
// 인증메일이 제대로 가지 않아서 저장이 되지 않은 경우
if (!verification.isPresent()) {
return "인증 메일이 정상적으로 전송되지 않았습니다.";
}
LocalDateTime now = LocalDateTime.now();
// 현재시간보다 만료시간이 뒤인 경우(만료시간 아직 안 지남), 인증 메일 지우고 완료 메시지 전송
if (verification.get().getExpirationTime().isAfter(now)) {
emailVerificationRepository.deleteByEmail(email);
return "인증이 완료되었습니다.";
} else {
return "인증 시간이 초과되었습니다.";
}
}
[EmailService]
@Service
public class EmailService {
private final JavaMailSender emailSender;
public EmailService(JavaMailSender emailSender) {
this.emailSender = emailSender;
}
public void sendEmail(String sendEmail, String toEmail, String title, String content) {
// HTML 형식의 이메일 전송하기 위한 설정
MimeMessage message = emailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8");
helper.setFrom(sendEmail);
helper.setTo(toEmail);
helper.setSubject(title);
// 이메일 형식을 html로 해석되야 함을 의미. false로 설정할 경우 단순 text로 전송됨
helper.setText(content, true);
emailSender.send(message);
} catch (MessagingException | jakarta.mail.MessagingException e) {
e.printStackTrace();
}
}
}
MimeMessageHelper란?
MIME(Multipurpose Internet Mail Extensions) 메시지를 쉽게 생성하고 조작할 수 있도록 해주는 util class이자 자바 메일 관련 라이브러리. 텍스트 외에 이미지, 오디오, 비디오 등 다양한 유형의 데이터를 이메일로 보낼 수 있게 해준다.
new MimeMessageHelper(message, true, "utf-8")에서 true 지정을 하면, 텍스트 + 파일 첨부가 가능한 멀티 파트 3번 메시지로 첨부하게 된다.