m1ndy5's coding blog

[SpringBoot 3.0] 이메일 인증을 통한 회원가입 기능 만들기 본문

Toy Projects/Ditto - Discuss Today's Topic

[SpringBoot 3.0] 이메일 인증을 통한 회원가입 기능 만들기

정민됴 2024. 1. 24. 23:43

build.gradle

// email
implementation 'org.springframework.boot:spring-boot-starter-mail'
// string auto generate
implementation 'org.apache.commons:commons-lang3:3.12.0'
//thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

위와 같은 디펜던시를 추가해주었다.
순서대로 이메일을 보내기 위해, 랜덤생성코드를 위해, 이메일을 좀 더 예쁘게 보내기 위해 필요한 디펜던시들이다.
일단 이메일을 보내려면 보내는 사람의 아이디와 앱 비밀번호를 설정하는 것이 필요한데, 이 방법은.. 인터넷에 검색하면 잘나와있다..ㅎㅎ

application.yml

  mail:
    host: smtp.gmail.com
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}

나는 구글을 사용해서 했는데 username과 password는 따로 빼주었다!

MemberAuthenticationCodeEntity

인증번호를 저장해야 비교할 수 있기 때문에 인증번호를 저장해줄 엔티티를 만들었다.

package hanghae99.ditto.auth.domain;

import hanghae99.ditto.global.entity.BaseEntity;
import hanghae99.ditto.global.entity.UsageStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Getter
@Entity
@NoArgsConstructor
public class MemberAuthenticationCodeEntity extends BaseEntity {
    @Column(name = "email", nullable = false)
    private String email;

    @Column(name = "code", unique = true, nullable = false)
    private String code;

    @Column(name = "end_date", nullable = false)
    private LocalDateTime endDate;

    public MemberAuthenticationCodeEntity(String email, String code){
        this.email = email;
        this.code = code;
        this.endDate = LocalDateTime.now().plus(5, ChronoUnit.MINUTES);
        this.status = UsageStatus.ACTIVE;
    }

    public void deleteCode(){
        this.status = UsageStatus.DELETED;
    }
}

이메일, 인증코드, 만료시점을 담았고 status는 생성됐을 때는 ACTIVE 지워졌을 때는 DELETED가 들어올 수 있게 했다.

MemberAuthenticationCodeRepository

package hanghae99.ditto.auth.domain;

import hanghae99.ditto.global.entity.UsageStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDateTime;
import java.util.Optional;

public interface MemberAuthenticationCodeRepository extends JpaRepository<MemberAuthenticationCodeEntity, Long> {

    // 이메일로 end_date가 지금 이후고, status가 active인거 찾아오기
    // 즉, 아직 유효한 인증코드 불러오기
    Optional<MemberAuthenticationCodeEntity> findByEmailAndEndDateAfterAndStatusEquals(String email, LocalDateTime currentDateTime, UsageStatus usageStatus);
}

이메일로 코드를 찾을 건데 만료가 되기 전인 코드만 찾을 것이다! 비교할 때 사용할 것이다.

AuthController

package hanghae99.ditto.auth.controller;

import hanghae99.ditto.auth.dto.request.AuthenticateCodeRequest;
import hanghae99.ditto.auth.dto.request.LoginRequest;
import hanghae99.ditto.auth.dto.request.LogoutRequest;
import hanghae99.ditto.auth.dto.request.SendEmailAuthenticationRequest;
import hanghae99.ditto.auth.service.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    @PostMapping("/email-authentication")
    public HttpEntity<?> sendEmailAuthentication(@RequestBody SendEmailAuthenticationRequest sendEmailAuthenticationRequest){
        return authService.sendEmailAuthentication(sendEmailAuthenticationRequest);
    }

    @PostMapping("/authentication-code")
    public HttpEntity<?> authenticateCode(@RequestBody AuthenticateCodeRequest authenticateCodeRequest){
        return authService.authenticateCode(authenticateCodeRequest);
    }
}

SendEmailAuthenticationRequest

package hanghae99.ditto.auth.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class SendEmailAuthenticationRequest {
    @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
    @NotBlank(message = "이메일은 필수 입력 값입니다.")
    private String email;
}

인증코드를 받을 이메일을 보내는 DTO이다.

EmailAuthenticationResponse

package hanghae99.ditto.auth.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class EmailAuthenticationResponse {
    private Integer code;
    private String message;
}

code는 성공여부를 나타내고 message에 성공실패 여부가 들어갈 것이다.

AuthenticateCodeRequest

package hanghae99.ditto.auth.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AuthenticateCodeRequest {
    @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
    @NotBlank(message = "이메일은 필수 입력 값입니다.")
    private String email;

    @NotBlank(message = "인증코드는 필수 입력 값입니다.")
    @Size(max = 10)
    private String code;
}

인증코드를 확인하는 DTO이다.

EmailService

package hanghae99.ditto.auth.service;

import hanghae99.ditto.auth.dto.request.SendEmailAuthenticationRequest;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.io.UnsupportedEncodingException;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class EmailService {
    private final JavaMailSender javaMailSender;

    private final TemplateEngine templateEngine;

    public Boolean sendEmailAuthentication(SendEmailAuthenticationRequest sendEmailAuthenticationRequest,
                                           String authenticationCode){
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            // 이메일 제목 설정
            message.setSubject("사이트 회원가입 인증번호 입니다.");

            // 이메일 수신자 설정
            message.addRecipients(Message.RecipientType.TO, String.valueOf(new InternetAddress(sendEmailAuthenticationRequest.getEmail(), "", "UTF-8")));

            // 이메일 내용 설정
            message.setText(setContext(authenticationCode), "UTF-8", "html");

            // 송신
            javaMailSender.send(message);
        } catch (MessagingException e) {
            return false;
        } catch (UnsupportedEncodingException e) {
            return false;
        }

        return true;
    }

    private String setContext(String authenticationCode){
        Context context = new Context();
        context.setVariable("authenticationCode", authenticationCode);
        return templateEngine.process("email-authentication", context);
    }
}

이메일을 보내는 서비스이다.

AuthService

package hanghae99.ditto.auth.service;

import hanghae99.ditto.auth.domain.MemberAuthenticationCodeEntity;
import hanghae99.ditto.auth.domain.MemberAuthenticationCodeRepository;
import hanghae99.ditto.auth.dto.request.AuthenticateCodeRequest;
import hanghae99.ditto.auth.dto.request.LoginRequest;
import hanghae99.ditto.auth.dto.request.LogoutRequest;
import hanghae99.ditto.auth.dto.request.SendEmailAuthenticationRequest;
import hanghae99.ditto.auth.dto.response.EmailAuthenticationResponse;
import hanghae99.ditto.auth.dto.response.LoginResponse;
import hanghae99.ditto.auth.support.JwtTokenProvider;
import hanghae99.ditto.auth.support.RedisUtil;
import hanghae99.ditto.global.entity.UsageStatus;
import hanghae99.ditto.member.domain.Member;
import hanghae99.ditto.member.domain.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
    private final MemberAuthenticationCodeRepository memberAuthenticationCodeRepository;
    private final MemberRepository memberRepository;
    private final EmailService emailService;

    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisUtil redisUtil;

    @Transactional
    public HttpEntity<?> sendEmailAuthentication(SendEmailAuthenticationRequest sendEmailAuthenticationRequest){
        // 랜덤 인증 코드 생성
        String authenticationCode = createAuthenticationCode();

        // emailSerivce의 인증코드 보내는 메서드의 성공 여부에 따라 응답
        if (!emailService.sendEmailAuthentication(sendEmailAuthenticationRequest, authenticationCode)){
            return new ResponseEntity<>(
                    new EmailAuthenticationResponse(-1, "인증 번호 발송 실패"), HttpStatus.BAD_REQUEST
            );
        }

        // 메일 발송 성공 시
        // 아직 유효한 인증 코드 데이터를 찾아서
        Optional<MemberAuthenticationCodeEntity> beforeMemberAuthenticationCodeEntityOptional = memberAuthenticationCodeRepository
                .findByEmailAndEndDateAfterAndStatusEquals(
                        sendEmailAuthenticationRequest.getEmail(),
                        LocalDateTime.now(),
                        UsageStatus.ACTIVE);

        // 있으면 무효화 (delete_date 설정)
        if (beforeMemberAuthenticationCodeEntityOptional.isPresent()){
            MemberAuthenticationCodeEntity beforeMemberAuthenticationCodeEntity = beforeMemberAuthenticationCodeEntityOptional.get();
            beforeMemberAuthenticationCodeEntity.deleteCode();
        }

        // 인증 코드 데이터를 저장하기 위해 새 엔티티를 작성하여
        MemberAuthenticationCodeEntity memberAuthenticationCodeEntity = new MemberAuthenticationCodeEntity(sendEmailAuthenticationRequest.getEmail(), authenticationCode);

        memberAuthenticationCodeRepository.save(memberAuthenticationCodeEntity);

        return new ResponseEntity<>(
                new EmailAuthenticationResponse(0, "인증 번호 발송 성공"), HttpStatus.OK
        );

    }

    public String createAuthenticationCode(){
        return RandomStringUtils.random(8, true, true);
    }

    @Transactional
    public HttpEntity<?> authenticateCode(AuthenticateCodeRequest authenticateCodeRequest){
        // 유효한 인증 코드 데이터를 찾아서
        Optional<MemberAuthenticationCodeEntity> memberAuthenticationCodeEntityOptional =
                memberAuthenticationCodeRepository.findByEmailAndEndDateAfterAndStatusEquals(authenticateCodeRequest.getEmail(), LocalDateTime.now(), UsageStatus.ACTIVE);

        // 없으면 인증 코드 없음 반환
        if (memberAuthenticationCodeEntityOptional.isEmpty()){
            return new ResponseEntity<>(
                    new EmailAuthenticationResponse(-1, "인증 코드 없음"), HttpStatus.BAD_REQUEST
            );
        }

        // 있으면 찾아서
        MemberAuthenticationCodeEntity memberAuthenticationCodeEntity = memberAuthenticationCodeEntityOptional.get();

        // 입력한 인증 코드가 일치하는 지 검증
        if (memberAuthenticationCodeEntity.getCode().equals(authenticateCodeRequest.getCode())){

            memberAuthenticationCodeEntity.deleteCode();

            // 해당 멤버 status 인증됨으로 변경
            Member member = memberRepository.findByEmail(authenticateCodeRequest.getEmail()).orElseThrow(() -> {
                throw new IllegalArgumentException("유효하지 않은 이메일입니다.");
            });
            member.verifiedWithEmail();

            return new ResponseEntity<>(
                    new EmailAuthenticationResponse(0, "인증 성공"), HttpStatus.OK
            );
        } else {
            return new ResponseEntity<>(
                    new EmailAuthenticationResponse(-1, "인증 실패"), HttpStatus.BAD_REQUEST
            );
        }
    }
}

이메일 인증의 제일 핵심로직이라고 할 수 있다.
로직의 동작 원리는

  1. 랜덤 인증 코드를 생성한다.
  2. 인증코드를 보낸다. -> 실패시 실패 응답 전송
  3. 성공시 같은 메일로 받은 기존의 코드가 있는지 확인한다. -> 있으면 그 코드는 없앤다.
  4. 새로운 인증코드를 저장한다.

이제 가입자 입장에서는 인증코드를 받아서 이거 맞냐?하고 보내면

  1. 이 이메일로 받은 인증코드가 있는지 확인 -> 없으면 인증코드없다고 응답
  2. 있다면 일치하는지 확인 -> 일치하지 않는다면 일치하지 않는다고 응답
  3. 인증 완료되면 멤버의 status를 active로 바꿔준다.

진짜 참고한 블로거분이 진짜 정리를 너무 잘해놓으셔서 아래를 보고 차근차근 따라가시길!

참고 : https://velog.io/@dbtmdgks7897/Spring-Boot-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D-5-%EA%B5%AC%ED%98%84-2