프로젝트/LinkBook

회원가입 이메일 인증 구현

걸어가는 신사 2022. 8. 21. 19:24

기획단계

대부분의 사이트에서 회원가입 시 이메일 인증을 요구하고 있다.

인증 없이 회원가입이 가능하다면 존재하지 않는 이메일을 통해서도 회원가입이 가능하고 공격에 취약해지기 때문에 이메일 인증은 필수라고 생각된다.

이메일 인증을 구현하기 앞서 두 가지 방법을 고려하였다.

  1. 이메일로 인증번호를 보내서 다시 그 인증번호를 입력하는 방식
  2. 해당 이메일로 보낸 링크를 클릭하여 회원가입을 완료하는 방식

프론트와의 회의 끝에 이번 프로젝트에서는 1번째 방법 채택하여 구현하기로 하였다.

 

Email 준비

Gmail SMTP

  • 구글 SMTP 서버를 사용해서 이메일 발송하기로 결정하였다.
  • 보안 수준이 낮은 앱의 액세스를 활성화한 계정에서는 사용자 이름과 비밀번호를 사용해서 Gmail SMTP와 같은 서드 파티 앱에 인증할 수 있었지만 이제는 사용자 이름과 비밀번호를 사용해서 서드 파티 앱과 기기에 로그인 요청하는 것을 지원하지 않습니다.

  • 보안 수준이 높은 Gmail 계정을 만들고 다른 방식으로 인증을 통해 SMTP을 사용하는 방식을 사용하면 되었습니다.

해결

  • 2단계 인증 활성화
  • 앱 비밀번호 추가 (Gmail SMTP)

더 자세한 방법을 하고 싶다면 https://kdevkr.github.io/gmail-smtp/ 위 링크를 통해 도움을 얻을 수 있습니다.

 

설정 정보

(1) Build.gradle 파일에 dependencies 추가

dependencies{
   implementation 'org.springframework.boot:spring-boot-starter-mail'
}

(2) application-mail.yaml

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    protocol: smtp
    default-encoding: UTF-8
    username: devcourse.linkbook@gmail.com
    password: 
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true

(3) EmailConfig.class

@Configuration
@PropertySource("classpath:application-mail.yaml")
public class EmailConfig {

    @Value("${spring.mail.host}")
    private String host;

    @Value("${spring.mail.port}")
    private int port;

    @Value("${spring.mail.protocol}")
    private String protocol;

    @Value("${spring.mail.default-encoding}")
    private String defaultEncoding;

    @Value("${spring.mail.username}")
    private String username;

    @Value("${spring.mail.password}")
    private String password;

    @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
    private boolean starttls;

    @Value("${spring.mail.properties.mail.smtp.auth}")
    private boolean auth;

    @Bean
    public JavaMailSenderImpl mailSender() {
        JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
        Properties properties = new Properties();
        properties.put("mail.smtp.starttls.enable", starttls);
        properties.put("mail.smtp.auth", auth);

        javaMailSender.setProtocol(protocol);
        javaMailSender.setHost(host);
        javaMailSender.setPort(port);
        javaMailSender.setDefaultEncoding(defaultEncoding);
        javaMailSender.setUsername(username);
        javaMailSender.setPassword(password);
        javaMailSender.setJavaMailProperties(properties);

        return javaMailSender;
    }

}

 

구현 로직

(1) 이메일을 Client로부터 입력받는다.

EmailController.class

@PostMapping("/api/emails")
public ResponseEntity<Void> sendEmail(HttpServletRequest request, @RequestBody EmailRequestDto requestDto) {
    emailService.sendEmail(request.getSession(), requestDto);
    return ResponseEntity.ok().build();
}

(2) 입력 받은 email을 Gmail SMTP 이용해서 이메일을 전송한다.

EmailService.class

public void sendEmail(HttpSession httpSession, EmailRequestDto requestDto) {
    try {

        MimeMessage mimeMessage = emailSender.createMimeMessage();
        String userEmail = requestDto.getEmail();
        String key = createKey();
        String message = createMessage(userEmail, key);

        httpSession.setAttribute(userEmail, Map.of(CERTIFICATION_KEY, key,IS_CERTIFICATION, false));
        httpSession.setMaxInactiveInterval(60 * 30);

        MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
        helper.setTo(userEmail);
        helper.setSubject("회원가입 이메일 인증");
        helper.setText(message, true);
        emailSender.send(mimeMessage);

    } catch (Exception e) {
        throw new EmailSendFailureException();
    }
}

// mail 생성
private String createMessage(String userEmail, String key) {
		log.info("userEmail : {}", userEmail);
		log.info("certificationNumber : {}", key);

    String message="";
    message+= "<div style='margin:100px;'>";
    message+= "<h1> 안녕하세요 LinkBook입니다. </h1>";
    message+= "<br>";
    message+= "<p>아래 코드를 회원가입 창으로 돌아가 입력해주세요<p>";
    message+= "<br>";
    message+= "<div align='center' style='border:1px solid black; font-family:verdana';>";
    message+= "<h3 style='color:blue;'>회원가입 인증 코드입니다.</h3>";
    message+= "<div style='font-size:130%'>";
    message+= "CODE : <strong>";
    message+= key+"</strong><div><br/> ";
    message+= "</div>";

    return message;
}

// 암호키 생성
private String createKey() {
    StringBuilder key = new StringBuilder();
    Random random = new Random();

    for (int i = 0; i < 8; i++) {
        int index = random.nextInt(3);

        switch (index) {
            case 0: //  a~z
                key.append((char) ((int) (random.nextInt(26)) + 97));
                break;
            case 1: //  A~Z
                key.append((char) ((int) (random.nextInt(26)) + 65));

                break;
            case 2: // 0~9
                key.append((random.nextInt(10)));
                break;
        }
    }

    return key.toString();
}
  • 암호키 생성한다.
  • html 파일 형식을 String 형식으로 만들고 위에서 생성한 암호키를 포함한 메세지를 생성한다.
  • 해당 메세지를 emailSender를 통해서 메일을 전송한다.
  • 전송한 이메일(Key), 암호키(Value)를 Session에 저장해 둔다.
    • value에는 두가지 정보를 저장해둔다.
      • CERITIFICATION_KEY : 암호키
      • IS_CERTIFICATION : 인증된 이메일 인지 판단
        • 이메일과 암호키를 입력받아서 검증에 통과한다면 true로 바뀐다.

(3) Client로 부터 이메일과 암호키(이메일로 전송된)를 입력받는다.

EmailController.class

@PostMapping("/api/emails/certification")
public ResponseEntity<Void> emailCertification(HttpServletRequest request, @RequestBody EmailCertificationRequestDto requestDto) {
    emailService.emailCertification(request.getSession(), requestDto);
    return ResponseEntity.ok().build();
}

(4) 세션에 있는 값을 통해서 입력받은 이메일과 암호키가 일치하는지 검증한다.

EmailService.class

public void emailCertification(HttpSession httpSession, EmailCertificationRequestDto requestDto) {
    String userEmail = requestDto.getEmail();
    String userKey = requestDto.getKey();

    Map value = (Map) httpSession.getAttribute(userEmail);
    String key = (String) value.get(CERTIFICATION_KEY);
    if(!key.equals(userKey)) {
        throw new EmailCertificationFailureException();
    }
    httpSession.setAttribute(userEmail, Map.of(CERTIFICATION_KEY, key,IS_CERTIFICATION, true));
log.info("이메일 인증 성공");
}
  • httpSession에 이메일(key)을 통해서 암호키를 가져와서 일치하는지 검증한다.
  • 이메일 검증에 통과한다면 IS_CERTIFICATION value 값으로 true로 바꾸고 다시 session에 저장한다.

(5) 회원가입

UserController

@PostMapping("/api/users/register")
public ResponseEntity<Void> register(HttpServletRequest request, @RequestBody RegisterRequestDto requestDto) {
    HttpSession httpSession = request.getSession();
    userService.register(httpSession, requestDto);
    return ResponseEntity.ok().build();
}

UserService

@Transactional
public void register(HttpSession httpSession, RegisterRequestDto requestDto) {
    if (userRepository.existsByEmail(requestDto.getEmail())) {
        throw new DuplicatedEmailException();
    }
    User user = requestDto.toEntity();

    emailService.IsCertificatedEmail(httpSession, user.getEmail());
    user.encodePassword(passwordEncoder.encode(requestDto.getPassword()));
    User saveUser = userRepository.save(user);
}

EmailService

public void IsCertificatedEmail(HttpSession httpSession, String userEmail) {
    Map value = (Map) httpSession.getAttribute(userEmail);
    if(value == null) {
        throw new EmailIsNotCertificatedException();
    }
    String key = (String) value.get(CERTIFICATION_KEY);
    Boolean isCertification = (Boolean) value.get(IS_CERTIFICATION);
    if(!isCertification) {
        throw new EmailIsNotCertificatedException();
    }
}
  • 회원 가입 요청 시 해당 이메일이 인증된 이메일인지를 검증한다.
    • httpSession을 통해서 IS_CERTIFICATION 값이 true일 때만 회원가입에 성공한다.

 

Reference

https://kdevkr.github.io/gmail-smtp/

https://javaju.tistory.com/100

https://csy7792.tistory.com/209

반응형