젬니
Jemin IT블로그
젬니
전체 방문자
오늘
어제
  • 분류 전체보기 (189)
    • [Engineering] (3)
    • [PGS] (8)
    • [BOJ] (20)
    • [백엔드] (3)
    • [DevOps] (14)
    • [Django] (2)
    • [ Algorithm] (33)
    • [SqL] (12)
    • [Techit] (6)
    • [InteliJ 설정] (0)
    • [CS 공부] (42)
    • [DB] (22)
    • [TDD] (1)
    • [NCP] (4)
    • [for Rest 프로젝트] (11)
    • [Kotlin] (3)
    • [비공개 공부] (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 햣

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
젬니

Jemin IT블로그

Spring - 이메일 인증 구현하기
[for Rest 프로젝트]

Spring - 이메일 인증 구현하기

2024. 3. 7. 16:29

배경

프로젝트 고도화를 진행하며 회원가입 중 이메일 인증을 하도록 했다.

 

[이메일 인증 추가 시 기대효과] 

사용자 신원 확인

  • 확인 사용자가 제공한 이메일 주소가 실제로 존재하며, 사용자가 그 주소에 접근할 수 있음을 확인함
  • 이는 실제 사용자와 가짜 계정을 구분하는 데 도움이 됨 

보안 강화 

  • 이메일 인증 과정을 통해 비밀번호 재설정, 중요한 계정 변경 사항 알림 등의 보안 관련 통신을 해당 이메일 주소로 보내 알릴 수 있음 
  • 이를 통해 계정 탈취나 무단 접근을 방지할 수 있음

흐름

  1. 사용자는 회원가입 화면에서 가입하려는 이메일 입력 후 이메일 인증 버튼 클릭
  2. 서버에게 사용자의 이메일로 인증 번호 전송 요청
  3. 서버는 랜덤 인증 번호 생성, 인증 번호를 DB에 저장 후 사용자의 이메일로 인증 번호 전송
  4. 사용자는 인증 번호 확인 후 인증 번호 입력 후 확인 버튼 클릭
  5. 클라이언트는 서버에게 인증 번호 검증 요청 보냄
  6. 서버는 전달받은 인증 번호가 DB에 저장된 인증번호와 동일한지 확인 후 true/false 반환
  7. 매일 정오마다 만료된 인증코드들 지움

 

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


https://velog.io/@dionisos198/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9C%BC%EB%A1%9C-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

스프링으로 이메일 인증 구현하기

아까 말했듯 우리가 직접 구현하는 것보다는 외부 서버가 나을 것이다!3)우리는 앱비밀번호라는게 필요한데 앱 비밀번호를 만드려면 우리의 구글 계정에 2단계 인증이 필요하다. 보안탭에서 2단

velog.io

https://support.bespinglobal.com/ko/support/solutions/articles/73000545275--gmail-%EC%95%B1-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%83%9D%EC%84%B1%EB%B0%A9%EB%B2%95

 

[Gmail] 앱 비밀번호 생성방법

앱 비밀번호란 보안 수준이 낮은 앱 또는 기기에 Google 계정에 대한 액세스 권한을 부여하는 16자리 비밀번호입니다. 앱 비밀번호는 2단계 인증이 사용 설정된 계정에서만 이용할 수 있습니다. *

support.bespinglobal.com

https://velog.io/@sojukang/Random-%EB%8C%80%EC%8B%A0-ThreadLocalRandom%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0

 

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
    '[for Rest 프로젝트]' 카테고리의 다른 글
    • Spring - redis 적용하기
    • Spring - Swagger UI 적용하기
    • react - [useState]
    • LazyLoading
    젬니
    젬니

    티스토리툴바