ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [게시판 만들기] 5️⃣ 이메일 인증 처리
    Spring Framework 2024. 5. 14. 01:00

    https://github.com/jngsngjn/board-study

     

    GitHub - jngsngjn/board-study: 게시판을 만들어 봅시다!

    게시판을 만들어 봅시다! Contribute to jngsngjn/board-study development by creating an account on GitHub.

    github.com


     

    회원가입 시 이메일 인증 방식은 흔히 사용되는 방식이다. 이메일을 입력하고 버튼을 누르면 입력된 이메일로 인증 메일이 전송되는 방식이다. 인증 코드를 전송하고 입력하게 하는 방식과 링크를 보내고 링크에 접속하면 인증이 되는 방식이 있다. 나는 링크 인증 방식으로 구현해 보았다.

    메일 전송은 스프링부트가 제공하는 라이브러리를 추가하고 약간의 설정을 하면 간단히 구현할 수 있었다. 나의 구글 계정 이메일을 발신자로 하여 설정했다. 구글 계정 이메일을 사용하려면 보안 설정에서 앱 비밀번호를 생성해야 한다. 생성 후 안전한 곳에 보관하여 기억해 두어야 한다.

     

    (1) 이메일 전송 설정

    build.gradle

    dependencies {
        // 메일 관련 라이브러리 추가
        implementation 'org.springframework.boot:spring-boot-starter-mail'
    
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
        implementation 'org.springframework.boot:spring-boot-starter-validation'
        implementation 'org.springframework.boot:spring-boot-starter-web'
        compileOnly 'org.projectlombok:lombok'
        runtimeOnly 'com.mysql:mysql-connector-j'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
    }

     

     

    application.properties

    spring.mail.host=smtp.gmail.com
    spring.mail.port=587
    spring.mail.username=[구글 계정 이메일]
    spring.mail.password=[앱 비밀번호]
    spring.mail.properties.mail.smtp.auth=true
    spring.mail.properties.mail.smtp.starttls.enable=true
    spring.mail.properties.mail.smtp.timeout=5000
    

     

     

    EmailService

    • 아래와 같이 SimpleMailMessage 객체를 생성한 후 메일을 설정하고 주입 받은 JavaMailSender를 사용하여 메일을 전송할 수 있다.
    @Service
    public class EmailService {
    
        private final JavaMailSender mailSender;
        private final EmailTokenRepository emailTokenRepository;
    
        public EmailService(JavaMailSender mailSender, EmailTokenRepository emailTokenRepository) {
            this.mailSender = mailSender;
            this.emailTokenRepository = emailTokenRepository;
        }
    
        @Value("${spring.mail.username}")
        private String fromEmail;
        
         // 인증 메일 전송
        public void sendVerificationEmail(String email, HttpServletRequest request) {
            String token = generateToken();
            saveToken(email, token);
    
            String verificationLink = generateLink(token, request);
    
            SimpleMailMessage message = new SimpleMailMessage();
            
            message.setFrom(fromEmail); // 발신자
            message.setTo(email); // 수신자
            message.setSubject("이메일 인증"); // 메일 제목
            
            // 메일 본문
            message.setText("안녕하세요,\\n\\n아래 링크를 클릭하여 이메일 인증을 완료해주세요.\\n\\n인증 링크: " + verificationLink);
            mailSender.send(message);
        }
    }

     

     

    (2) 이메일 전송

    인증 메일 전송 버튼을 클릭하고 사용자에게 메일이 전송되었음을 즉각적으로 알리기 위해 AJAX를 사용해 보았다.

     

    registerForm

    <input type="email" th:field="*{email}" placeholder="이메일" class="inputs" th:errorclass="error-input">
    <div class="error" th:errors="*{email}"></div> <br>
    <button type="button" id="sendVerificationEmailBtn">인증 메일 전송</button>
    <div id="verificationMessage"></div> <br>

     

     

    email.js

    • "/send-verification-email"와 매핑된 컨트롤러를 만들고 응답을 보내주어야 한다.
    • 스프링 시큐리티를 사용하므로 CSRF 토큰을 포함시켰다.
    $(document).ready(function() {
        $("#sendVerificationEmailBtn").click(function() {
            const email = $("#email").val();
    
            if (email === "") {
                alert("이메일을 입력해 주세요.")
                return;
            }
    
            const csrfToken = $("meta[name='_csrf']").attr("content");
            const csrfHeader = $("meta[name='_csrf_header']").attr("content");
    
            $.ajax({
                type: "POST",
                url: "/send-verification-email",
                data: {
                    email: email
                },
                beforeSend: function(xhr) {
                    xhr.setRequestHeader(csrfHeader, csrfToken);
                }
            })
                .done(function() {
                    $("#verificationMessage").text("인증 메일이 전송되었습니다.");
                })
                .fail(function(xhr) {
                    if (xhr.status === 400) {
                        $("#verificationMessage").text("잘못된 이메일 주소입니다.");
                    } else {
                        $("#verificationMessage").text("인증 메일 전송에 실패했습니다.");
                    }
                });
        });
    });

     

     

    EmailController

    • 메일 전송 기능에만 먼저 초점을 맞추기 위해 예외 처리는 일단 생략했다..^^
    • 스프링 시큐리티를 사용한다면 해당 URL에 대해 permitAll() 설정을 해주어야 한다.
    @Slf4j
    @Controller
    public class EmailController {
    
        private final EmailService emailService;
    
        public EmailController(EmailService emailService) {
            this.emailService = emailService;
        }
    
        @PostMapping("/send-verification-email")
        @ResponseBody
        public ResponseEntity<Void> sendVerificationEmail(@RequestParam String email, HttpServletRequest request) {
            log.info("email={}", email);
    
            emailService.sendVerificationEmail(email, request);
            return ResponseEntity.status(HttpStatus.OK).build();
        }
    }
    

     

     

    (3) 이메일 인증

    메일을 전송하는 기능을 구현했으니 이제 사용자가 인증 링크로 접속했을 때 해당 이메일을 인증된 이메일로 인식해야 한다. 또한, 인증된 이메일이라고 하더라도 시간이 지나면 만료되어야 한다. 이 기능을 구현하기 위해 토큰 방식을 사용했다.

     

    EmailToken

    @Data
    @Entity
    public class EmailToken {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Integer id;
    
        private String token;
    
        private String email;
    
        @Column(name = "expiry_date")
        private LocalDateTime expiryDate;
    
        // 0은 인증되지 않은 상태, 1은 인증된 상태
        private boolean verified;
    
    }

     

     

    EmailTokenRepository

    @Repository
    public interface EmailTokenRepository extends JpaRepository<EmailToken, Integer> {
    
        EmailToken findByToken(String token);
    
        EmailToken findByEmail(String email);
        
        // 만료 기간이 지난 토큰 삭제
        @Transactional
        void deleteByExpiryDateBefore(LocalDateTime now);
    
        // 인증 상태를 1로 업데이트
        @Modifying
        @Transactional
        @Query("UPDATE EmailToken e SET e.verified = true WHERE e.token = :token")
        void updateVerifiedByToken(String token);
    
        // 해당 이메일의 토큰 삭제
        @Transactional
        void deleteByEmail(String email);
    }
    

     

     

    EmailService

    • sendVerificationEmail() : UUID를 사용한 임의의 랜덤 토큰을 만들고 이메일과 함께 데이터베이스에 저장한다. 그리고 “/verify/토큰”식으로 URL을 만들고 메일 본문에 담아 메일을 전송한다.
    • deleteExpiredTokens() : @Scheduled 어노테이션을 사용하여 1분마다 만료 기한이 지난 토큰을 확인하고, 있다면 삭제하는 작업을 수행한다. 이 어노테이션을 활성화하려면 스프링부트 애플리케이션(메인 클래스)에 @EnableScheduling 어노테이션을 붙여주어야 한다.
    • isTokenExpired() : 전달 받은 토큰이 유효한 토큰인지 확인한다.
    • isVerifiedEmail() : 전달 받은 이메일이 인증된 이메일인지 확인한다.
    @Service
    public class EmailService {
    
        private final JavaMailSender mailSender;
        private final EmailTokenRepository emailTokenRepository;
    
        public EmailService(JavaMailSender mailSender, EmailTokenRepository emailTokenRepository) {
            this.mailSender = mailSender;
            this.emailTokenRepository = emailTokenRepository;
        }
    
        @Value("${spring.mail.username}")
        private String fromEmail;
    
        // 인증 메일 전송
        public void sendVerificationEmail(String email, HttpServletRequest request) {
            String token = generateToken();
            saveToken(email, token);
    
            String verificationLink = generateLink(token, request);
    
            SimpleMailMessage message = new SimpleMailMessage();
    
            message.setFrom(fromEmail); // 발신자
            message.setTo(email); // 수신자
            message.setSubject("이메일 인증"); // 메일 제목
    
            // 메일 본문
            message.setText("안녕하세요,\\n\\n아래 링크를 클릭하여 이메일 인증을 완료해주세요.\\n\\n인증 링크: " + verificationLink);
            mailSender.send(message);
        }
    
        // 토큰 생성
        private String generateToken() {
            return UUID.randomUUID().toString();
        }
    
        // 토큰 저장
        private void saveToken(String email, String token) {
            EmailToken emailToken = new EmailToken();
            emailToken.setToken(token);
            emailToken.setEmail(email);
    
            // 만료 기간을 10분으로 설정
            emailToken.setExpiryDate(LocalDateTime.now().plusMinutes(10));
    
            emailTokenRepository.save(emailToken);
        }
    
        // 인증 링크 생성
        private String generateLink(String token, HttpServletRequest request) {
            return getBaseUrl(request) + "/verify/" + token;
        }
    
        private static String getBaseUrl(HttpServletRequest request) {
            return request.getRequestURL().toString().replace(request.getRequestURI(), "");
        }
    
        public boolean isTokenExpired(String token) {
            EmailToken emailToken = emailTokenRepository.findByToken(token);
            if (emailToken == null) {
                return true; // 토큰이 존재하지 않으면 만료된 것으로 간주
            }
    
            LocalDateTime expiryDate = emailToken.getExpiryDate();
            return expiryDate.isBefore(LocalDateTime.now());
        }
    
        public boolean isVerifiedEmail(String email) {
            EmailToken emailToken = emailTokenRepository.findByEmail(email);
    
            if (emailToken == null || !emailToken.isVerified()) {
                return false;
            }
    
            return true;
        }
    
        // 이메일의 인증된 상태로 변경
        public void verifySuccess(String token) {
            emailTokenRepository.updateVerifiedByToken(token);
        }
    
        // 1분마다 실행
        @Scheduled(cron = "0 * * * * ?")
        public void deleteExpiredTokens() {
            LocalDateTime now = LocalDateTime.now();
            emailTokenRepository.deleteByExpiryDateBefore(now);
        }
    }

     

     

    EmailController

    • 사용자가 인증 링크로 접속했을 때 토큰을 확인하여 만료된 토큰이면 에러 페이지를 보여주고, 유효한 토큰이면 성공 페이지를 보여준다.
    @Slf4j
    @Controller
    public class EmailController {
    
        private final EmailService emailService;
    
        public EmailController(EmailService emailService) {
            this.emailService = emailService;
        }
    
        @PostMapping("/send-verification-email")
        @ResponseBody
        public ResponseEntity<Void> sendVerificationEmail(@RequestParam String email, HttpServletRequest request) {
            log.info("email={}", email);
    
            emailService.sendVerificationEmail(email, request);
            return ResponseEntity.status(HttpStatus.OK).build();
        }
    
        @GetMapping("/verify/{token}")
        public String verifyEmail(@PathVariable String token) {
    
            // 토큰 만료
            if (emailService.isTokenExpired(token)) {
                return "verifyError";
            }
    
            emailService.verifySuccess(token);
    
            return "verifySuccess";
        }
    }
    

     

     

    verifyError.html

    <html lang="ko">
    <head>
        <meta charset="UTF-8">
        <title>Error</title>
    </head>
    <body>
    인증 메일이 만료되었습니다. <br>
    <a href="/boards">메인 화면으로 이동</a>
    </body>
    </html>

     

     

    verifySuccess.html

    <html lang="ko">
    <head>
        <meta charset="UTF-8">
        <title>verifySuccess</title>
    </head>
    <body>
    인증 성공! <br>
    10분 내로 회원가입을 완료하지 않으면 인증 토큰이 만료되니 주의하세요.
    </body>
    </html>

     

     

    (4) 회원가입 처리

    토큰을 사용하여 이메일 인증 기능을 구현했으니 사용자가 회원가입 버튼을 눌렀을 때 사용자가 입력한 이메일이 인증된 이메일인지 확인해야 한다. 인증을 완료했더라도 시간이 지났을 경우 토큰이 사라지기 때문에 회원가입에 실패한다.

     

    RegisterController

    @Slf4j
    @Controller
    @RequestMapping("/register")
    public class RegisterController {
    
        private final UserService userService;
        private final EmailService emailService;
    
        public RegisterController(UserService userService, EmailService emailService) {
            this.userService = userService;
            this.emailService = emailService;
        }
    
        @GetMapping
        public String registerForm(Model model) {
            model.addAttribute("registerForm", new RegisterForm());
            return "registerForm";
        }
    
        @PostMapping
        public String register(@Validated @ModelAttribute RegisterForm registerForm, BindingResult bindingResult) {
    
            if (bindingResult.hasErrors()) {
                log.info("필드 에러");
                return "registerForm";
            }
    
            if (!emailService.isVerifiedEmail(registerForm.getEmail())) {
                log.info("인증되지 않은 이메일={}", registerForm.getEmail());
                return "registerForm";
            }
    
            if (!userService.register(registerForm)) {
                log.info("아이디 또는 이메일 중복");
                return "registerForm";
            }
    
            return "redirect:/boards";
        }
    }
    

     

     

    UserService

    • 회원가입을 성공하면 해당 이메일에 대한 토큰을 삭제한다.
    @Slf4j
    @Service
    @Transactional
    public class UserService {
    
        private final UserRepository userRepository;
        private final EmailTokenRepository emailTokenRepository;
        private final BCryptPasswordEncoder passwordEncoder;
    
        public UserService(UserRepository userRepository, EmailTokenRepository emailTokenRepository, BCryptPasswordEncoder passwordEncoder) {
            this.userRepository = userRepository;
            this.emailTokenRepository = emailTokenRepository;
            this.passwordEncoder = passwordEncoder;
        }
    
        public boolean register(RegisterForm registerForm) {
            log.info("registerForm={}", registerForm);
    
            if (userRepository.existsByLoginId(registerForm.getLoginId()) || userRepository.existsByEmail(registerForm.getEmail())) {
                return false;
            }
    
            User user = new User();
            user.setLoginId(registerForm.getLoginId());
            user.setPassword(passwordEncoder.encode(registerForm.getPassword()));
            user.setName(registerForm.getName());
            user.setEmail(registerForm.getEmail());
            user.setRole(registerForm.getLoginId().equals("admin") ? ROLE_ADMIN : ROLE_USER);
    
            userRepository.save(user);
    	      
            // 회원가입 성공 후 해당 이메일의 토큰 삭제
            emailTokenRepository.deleteByEmail(registerForm.getEmail());
    
            return true;
        }
    
        public boolean isDuplicateId(String loginId) {
            return userRepository.existsByLoginId(loginId);
        }
    }

     

    처음 이메일 전송 기능을 구현해 봤는데 스프링 부트를 사용하면 굉장히 편리하게 사용할 수 있음에 신기하고 감사했다.. 토큰 방식에 익숙하지 않아 어려웠지만 이번에 공부하면서 이해가 된 것 같다. 혼자 해 보면서 분명 더 나은 방법이 있을 것 같은데.. 라는 생각이 많이 들었다. 그래도 우선 처음엔 이렇게 해 봐야 나중에 더 나은 방법을 알았을 때 내가 했던 방법과 무엇이 어떻게 다른지 알 수 있을 것 같다는 생각도 들었다. 개인적으로 이번 실습은 정말 재미있는 실습이었다.