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

스르륵·2024년 3월 24일
0

포텐데이라는 해커톤에 참여하게 됐다.
당연히 백엔드 개발자로 참여했고 앱의 백엔드 서버를 만들게 되었는데 그 중에서 회원 가입 시 진행한는 이메일 인증을 만들어보려고 한다. 회사에서 쓰지 않는 스프링을 써보려고 한다.

기능 자체는 복잡하지 않다. 서버는 6자리의 랜덤 숫자를 생성하고 해당 숫자를 사용자의 메일로 전송한다. 그리고 사용자는 메일로 받은 숫자를 다시 서버로 보내주면, 서버가 가지고 있는 번호와 사용자가 전송한 번호를 비교하여 일치하면 통과시키는 기능이다.

랜덤 숫자 생성

난수 생성은 진짜 랜덤할까?

난수를 생성하는 것은 어렵지 않다. 하지만 그 난수가 진짜 랜덤한 수일까?

위 영상에서 말하는 것 처럼 컴퓨터가 생성하는 난수는 실제로 랜덤하다고 하기 어려울 수 있다. 그래서 자바의 `SecureRandom`을 사용했다. `Random`의 경우 현재 시간을 시드로 사용하여 시드를 알아내면 재현이 가능하지만 `SecureRandom`의 경우 OS의 무작위 데이터를 시드로 사용하기 때문에 (이 무작위 값이 무엇인지는 사실 아직 이해 못했다) 훨씬 더 안전한 난수를 만들 수 있다.

인증 번호 생성

메일 인증 번호로 사용할 6자리 숫자를 만들어야 하기 때문에 여러 방법이 있겠지만 항상 6자리가 나오도록 간단하게 코드를 작성했다.

SecureRandom secureRandom = new SecureRandom();
int randomInt = secureRandom.nextInt(1000000 - 100000) + 100000

secureRandom.nextInt(max - min) + min의 형태로 900,000 미만의 숫자에서 랜덤한 정수를 생성한 뒤 100,000을 더하면 항상 6자리의 정수가 된다

난수 생성에 대해 알아보면서 난수 생성이 진짜 랜덤한지에 대해 의심해볼 수 있다는 것도 처음 생각해보는 계기가 됐다. 예전에 tensorflow나 pytorch로 모델 학습을 할 때 항상 같은 결과를 얻기 위해 seed를 설정하는 것이 랜덤값 생성시 설정하는 시드였을 거라는 점이 생각이 났다.

메일 전송하기

따로 메일서버가 없기 때문에 gmail을 통해 메일 보내도록 했다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class EmailService {

    private final JavaMailSender emailSender;

    public void sendEmail(String toEmail, String subject, String content) {
        SimpleMailMessage emailForm = createEmailForm(toEmail, subject, content);

        try {
            emailSender.send(emailForm);
        } catch (RuntimeException e) {
            log.debug("MailService.sendEmail exception - toEmail: {}", toEmail);
        }
    }

    private SimpleMailMessage createEmailForm(String toEmail, String subject, String content) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(toEmail);
        message.setSubject(subject);
        message.setText(content);

        return message;
    }
}

메일 전송은 이미 있는 라이브러리를 사용했다. toEmailsubject, content만 만들어주면 손쉽게 메일을 보낼 수 있다.

메일 전송을 위한 설정값은 EmailConfig 클래스에서 설정했다.

package com.potential.hackathon.email;

import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
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 JavaMailSender javaMailService() {
        JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();

        javaMailSender.setHost(host);
        javaMailSender.setPort(port);
        javaMailSender.setUsername(username);
        javaMailSender.setPassword(password);
        javaMailSender.setDefaultEncoding("UTF-8");
        javaMailSender.setJavaMailProperties(getEmailProperties());

        return javaMailSender;
    }

    private Properties getEmailProperties() {
        Properties properties = new Properties();
        properties.put("mail.smpt.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;
    }
}

각각 값들은 application.yaml에 작성한 것을 불러와서 사용한다.

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: gmail@gmail.com
    password: app-password
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000
    auth-code-expiration-millis: 1800000  

이때 password는 로그인 시 사용하는 비밀번호가 아니라 구글 계정 설정에서 앱 비밀번호 설정을 한 후 해당 값을 사용해야 한다.

랜덤 숫자 저장

위에서 말했듯이 랜덤으로 생성한 숫자를 서버가 저장하고 있어야 사용자의 입력값과 비교해 일치하는지를 판단할 수 있다. DB에 값을 저장하고 지우고 하는 과정이 반복될 것이기 때문에 속도 면에서나, 데이터의 특징적으로나 Redis를 사용하기에 적합한 상황이라고 판단했다.

Redis에 사용자의 email을 key로, 인증번호를 value로 저장하고 일정 시간이 지나면 해당 내용이 휘발하도록 설정하여 따로 삭제 명령을 하지 않더라도 인증번호가 삭제되도록 설정했다.

@Slf4j
@Service
@RequiredArgsConstructor
public class VerificationService {

    private final EmailService emailService;
    private final RedisService redisService;

    private static final String AUTH_CODE_PREFIX = "AUTH_CODE ";

    public String createCode() throws NoSuchAlgorithmException {
        try {
            SecureRandom secureRandom = SecureRandom.getInstanceStrong();
            int randomInt = secureRandom.nextInt(900000) + 100000;
            return String.valueOf(randomInt);
        } catch (NoSuchAlgorithmException e) {
            log.debug("RandomNumberGenerator.createCode() exception");
            throw new NoSuchAlgorithmException();
        }
    }

    public void sendAndSaveCode(String toEmail) throws NoSuchAlgorithmException {
        String subject = "인증번호";
        String authCode = this.createCode();

        emailService.sendEmail(toEmail, subject, authCode);
        redisService.setNumber(AUTH_CODE_PREFIX + toEmail, authCode);
    }

    public boolean verifyCode(String email, String authCode) {
        String redisCode = redisService.getNumber(AUTH_CODE_PREFIX + email);
        return authCode.equals(redisCode);
    }

}

혹시나 Redis에서 key로 사용한 이메일이 중복될 수도 있을까 싶어서 prefix를 넣어주었다. 따라서 사용자의 입력값과 비교할 때도 prefix를 붙여서 Redis에서 읽어와야 한다.

Controller

@RestController
@RequiredArgsConstructor
public class EmailController {

    private final VerificationService verificationService;

    @PostMapping("/mails")
    public ResponseEntity sendVerificationMail(@RequestBody EmailDto body) throws NoSuchAlgorithmException {
        verificationService.sendAndSaveCode(body.getEmail());

        return new ResponseEntity<>(HttpStatus.OK);
    }

    @GetMapping("/mails/verification")
    public ResponseEntity verification(@RequestParam String email, @RequestParam String authCode) {
        boolean result = verificationService.verifyCode(email, authCode);

        return new ResponseEntity<>(result, HttpStatus.OK);
    }
}

/mails를 호출하면 사용자의 메일로 인증번호를 전송하고, /mails/verification을 호출하면 파라미터로 전송한 인증번호와 Redis의 인증번호를 비교해 일치하면 true 일치하지 않으면 false를 반환하도록 했다.


기능 구현이 가능한지 테스트 하기 위해 간단하게 작업해보았다. 이 기능을 이번 해커톤에서 만들 앱에 넣게 될지는 모르겠지만 넣는다면 코드를 가다듬을 필요는 있다. 리턴 값에 대해 정의한 것도 없고, 예외나 에러가 발생할 경우 전혀 대처가 안되어있기 때문이다. 특히 현재 Redis에 저장한 후 3분이 지나 인증코드가 만료된 경우 만료됐다는 응답을 해주어야 하는데 데이터를 읽어오지 못해 에러가 발생하는 상태이다.

profile
기록하는 블로그

0개의 댓글