Search
📧

메일 서비스 만들기 with Google SMTP

태그
spring boot 3
SMTP
vue.js
분류
Spring Boot

Google SMTP 설정

2022 년 5 월 부터 변경이 되었기에, 아래 블로그를 참고하여 smtp 설정을 마쳐주세요!

build.gradle

아래와 같이 dependency 를 추가해줍니다.
implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
Java
복사

메일 서비스의 흐름

메일 서비스의 흐름은 각자의 기능 요구사항에 따라 다르겠지만, 저의 경우는 아래와 같은 흐름으로 동작되도록 만들었습니다. 이는 회원가입시에 사용한 로직입니다.
프론트에서의 이메일 입력 후 인증메일 전송 요청
서버에서는 요청으로 받은 이메일로 메일 전송
해당 유저가 이미 존재하는지 확인
존재하지 않는다면 이메일을 키로 하고, 인증번호를 생성하여 이를 값으로 redis 에 저장
유효시간을 적절하게 설정합니다.
존재한다면 이미 존재하는 유저임을 알리는 에러코드를 반환합니다.
시큐리티와 JWT 필터를 무시하도록 설정을 해줍니다.
유저는 프론트에 인증번호를 입력후, 인증번호 검증 요청
프론트에서의 요청값과 Redis 에 저장된 값을 토대로 검증 수행
Redis 에 값이 없다면 유효기간이 지났기 때문에 다시 인증번호를 받아야 합니다.
값이 일치한다면 200 을 반환합니다.

프론트에서의 메일 서비스

1. AuthenticationDto.ts

export interface authenticationRequest { email: string; } export interface authenticationValidateRequest { email: string; tempCode: string; } export interface signupRequest { email: string; password: string; nickname: string; birthday: Date } ...
TypeScript
복사

2. AuthenticationApi.ts

import {ref } from "vue"; import { authenticationRequest, authenticationValidateRequest, loginRequest, signupRequest } from "../dto/AuthenticationDto.ts"; import axios from "axios"; import axiosInstance from "./AxiosInstance.ts"; import {useUserStore} from "../store/userStore.ts"; export const error = ref<String | null>(null); const API_BASE_URL = 'http://localhost:8080/api/v1/auth'; export const authenticationMailSend = async (data: authenticationRequest) => { try { const response = await axios.post(`${API_BASE_URL}/signup/authentication/send`, data); return response; } catch (e: any) { return e.response; } }; export const authenticationValidate = async (data: authenticationValidateRequest) => { try { const response = await axios.post(`${API_BASE_URL}/signup/authentication/check`, data); return response.status; } catch (e: any) { return e.response.status; } }; export const signup = async (data: signupRequest)=> { try { const response = await axios.post(`${API_BASE_URL}/signup`, data); return response.status; } catch (e: any) { return e.response.status; } }; ...
TypeScript
복사

3. SignupPage.vue

<template> <div class="background"> <div class="white-box"> <div class="signup-content"> <div class="align-text"> <div class="title"> 회원가입을 위해 <br> 정보를 입력해주세요. </div> </div> <form @submit.prevent="handleSignup"> <div class="form-item"> <label for="email">이메일</label> <div class="align-contents"> <input id="email" type="email" v-model="formValue.email" placeholder="이메일을 적어주세요." class="custom-input" /> <button type="button" class="authentication-button" @click="handleEmailSend">이메일 인증</button> </div> </div> <div class="form-item"> <label for="authCode">인증번호</label> <div class="align-contents"> <input id=authCode type="password" v-model="code" placeholder="인증번호를 적어주세요." class="custom-input" :disabled="isCodeInputDisabled"/> <button type="button" class="authentication-button" @click="handleAuthValidate">인증 하기</button> </div> </div> ... </template> <script setup lang="ts"> import { authenticationMailSend, authenticationValidate, signup } from '../../api/AuthenticationApi.ts'; import { authenticationRequest, authenticationValidateRequest, signupRequest } from '../../dto/AuthenticationDto.ts'; import { ref, computed } from 'vue'; import { useMessage } from 'naive-ui'; import DatePicker from "../../components/DatePicker.vue"; import router from "@/router"; const formValue = ref({ email: '', password: '', nickname: '', birthday: new Date() }); const message = useMessage(); const code = ref(''); const isCodeInputDisabled = ref(true); const isInputDisabled = ref(true); const passwordPattern = /^[A-Za-z\d~!@#$%^&*()_\-+=\[\]{}|\\;:'",.<>?/]{8,20}$/; const nicknamePattern = /^[a-zA-Z가-힣\d]+$/; const passwordError = computed(() => { return passwordPattern.test(formValue.value.password) ? '' : '영문, 숫자, 특수문자를 사용하여 8-20 자로 만들어주세요.'; }); const nicknameError = computed(() => { return nicknamePattern.test(formValue.value.nickname) && formValue.value.nickname.length >= 2 && formValue.value.nickname.length <= 12 ? '' : '닉네임은 2-12 글자로 만들수 있어요.'; }); const handleEmailSend = async () => { const data: authenticationRequest = { email: formValue.value.email, }; const response = await authenticationMailSend (data); if (response.status === 200) { message.success("인증 번호가 담긴 메일을 발송 했어요.", { keepAliveOnHover: true }); isCodeInputDisabled.value = false; } else if (response.status === 400) { if (response.data.errorCode === 'MAIL_01') { message.error("잘못된 이메일 형식이에요.", { keepAliveOnHover: true }); } else if (response.data.errorCode === 'INVALID_VALUE_02') { message.warning("이미 존재하는 이메일이에요. 로그인 페이지로 이동할게요.", { keepAliveOnHover: true }); await router.push('/login'); } } }; const handleAuthValidate = async () => { const data: authenticationValidateRequest = { email: formValue.value.email, tempCode: code.value, }; const response = await authenticationValidate (data); if (response === 200) { message.success("이메일 인증이 완료 되었어요.", { keepAliveOnHover: true }); isInputDisabled.value = false; } else { message.error("인증 번호를 다시 입력해주세요.", { keepAliveOnHover: true }); } }; ... </script> <style scoped lang="scss"> ... </style>
HTML
복사

서버에서의 메일 서비스

1. application.yml

spring: ... mail: host: smtp.gmail.com port: 587 username: ${GOOGLE_MAIL_USERNAME} password: ${GOOGLE_MAIL_PASSWORD} properties: mail: smtp: auth: true starttls: enable: true transport: protocol: smtp debug: true ...
Java
복사

2. RandomNumberUtil.java

import com.planner.travel.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.Random; @Component @RequiredArgsConstructor public class RandomNumberUtil { ... public Long setTempCode() { Random random = new Random(); long randomNumber = random.nextLong(900000) + 100000; return randomNumber; } }
Java
복사

3. RedisUtil

import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Component; import java.time.Duration; @Component @RequiredArgsConstructor public class RedisUtil { private final StringRedisTemplate stringRedisTemplate; private ValueOperations<String, String> valueOperations; public String getData(String key) { valueOperations = stringRedisTemplate.opsForValue(); return valueOperations.get(key); } public void setData(String key, String value) { valueOperations = stringRedisTemplate.opsForValue(); valueOperations.set(key, value); } public void setDataWithExpire(String key, String value, Duration duration) { valueOperations = stringRedisTemplate.opsForValue(); valueOperations.set(key, value, duration); } public void deleteData(String key) { stringRedisTemplate.delete(key); } }
Java
복사

4. MailAuthenticationRequest.java

public record MailAuthenticationRequest (String email) {}
Java
복사

5. MailAuthenticationMessage.java

public record MailAuthenticaionMessage ( String to, String subject ) { }
Java
복사

6. emailAuthentication.html

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div style="margin:100px;"> <h1> 안녕하세요.</h1> <h1> Travel planner 입니다. </h1> <br> <p> 아래 코드를 인증번호란에 입력해주세요.</p> <br> <div align="center" style="border:1px solid black; font-family:verdana,serif;"> <h3 style="color:blue"> 회원가입 이메일 인증 코드 입니다. </h3> <div style="font-size:130%" th:text="${code}"> </div> </div> <br/> </div> </body> </html>
Java
복사

7. MailService.java

import com.planner.travel.domain.user.repository.UserRepository; import com.planner.travel.global.util.RandomNumberUtil; import com.planner.travel.global.util.RedisUtil; import com.planner.travel.global.util.mail.dto.MailAuthenticaionMessage; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; import java.time.Duration; @Service @RequiredArgsConstructor public class MailService { private final JavaMailSender javaMailSender; private final RandomNumberUtil randomNumberUtil; private final RedisUtil redisUtil; private final UserRepository userRepository; private final SpringTemplateEngine templateEngine; public String sendMailAuthenticationCode(MailAuthenticaionMessage message) throws MessagingException { validateUser(message.to()); MimeMessage mimeMessage = javaMailSender.createMimeMessage(); MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); Long tempCode = randomNumberUtil.setTempCode(); redisUtil.setDataWithExpire(message.to(), String.valueOf(tempCode), Duration.ofMinutes(5)); messageHelper.setTo(message.to()); messageHelper.setSubject(message.subject()); messageHelper.setText(setContext(String.valueOf(tempCode)), true); javaMailSender.send(mimeMessage); return String.valueOf(tempCode); } public String setContext(String tempCode) { Context context = new Context(); context.setVariable("code", tempCode); return templateEngine.process("emailAuthentication", context); } private void validateUser(String email) { userRepository.findByEmailAndProvider(email, "basic") .ifPresent(u -> { throw new IllegalArgumentException(); }); } }
Java
복사

7. SpringConfiguration.java

import com.planner.travel.global.auth.oauth.handler.OAuth2AuthenticationSuccessHandler; import com.planner.travel.global.auth.oauth.service.CustomOAuth2UserService; import com.planner.travel.global.jwt.JWTAuthenticationFilter; import com.planner.travel.global.jwt.token.TokenAuthenticator; import com.planner.travel.global.jwt.token.TokenExtractor; import com.planner.travel.global.jwt.token.TokenValidator; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.filter.CorsFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfiguration { private final CustomUserDetailsService customUserDetailsService; private final AuthenticationConfiguration authenticationConfiguration; private final TokenExtractor tokenExtractor; private final TokenValidator tokenValidator; private final TokenAuthenticator tokenAuthenticator; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { httpSecurity .csrf(AbstractHttpConfigurer::disable) .cors(cors -> {}) .authorizeHttpRequests((authorizeRequest) -> authorizeRequest .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/v1/auth/**").permitAll() // 여기서 pass 하도록 설정 .requestMatchers("/oauth/**").permitAll() .requestMatchers("/api/v1/oauth/**").permitAll() .requestMatchers("/api/v1/auth/token/**").permitAll() .requestMatchers("/docs/**").permitAll() .requestMatchers("/ws/**").permitAll() .requestMatchers("/favicon.ico/**").permitAll() .requestMatchers("/error").permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement((sessionManagement) -> sessionManagement .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .exceptionHandling((exception) -> exception .authenticationEntryPoint(customAuthenticationEntryPoint) ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(customAuthenticationFilter(), JWTAuthenticationFilter.class); httpSecurity .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization .baseUri("/api/v1/oauth/authorize") ) .redirectionEndpoint(redirection -> redirection .baseUri("/oauth/callback") ) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) ) .successHandler(oAuth2AuthenticationSuccessHandler) ); return httpSecurity.build(); } // CORS 설정 @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern("*"); config.addExposedHeader("Authorization"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } // Custom Bean @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } @Bean public CustomAuthenticationFilter customAuthenticationFilter() throws Exception { CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager()); customAuthenticationFilter.afterPropertiesSet(); return customAuthenticationFilter; } @Bean public AuthenticationManager authenticationManager() throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public CustomAuthenticationProvider customAuthenticationProvider() { return new CustomAuthenticationProvider(customUserDetailsService, bCryptPasswordEncoder()); } @Bean public JWTAuthenticationFilter jwtAuthenticationFilter() { return new JWTAuthenticationFilter(tokenExtractor, tokenValidator, tokenAuthenticator); } }
Java
복사

8. JWTAuthenticationFilter.java

import com.planner.travel.global.jwt.token.TokenAuthenticator; import com.planner.travel.global.jwt.token.TokenExtractor; import com.planner.travel.global.jwt.token.TokenValidator; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.http.HttpStatus; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @RequiredArgsConstructor public class JWTAuthenticationFilter extends OncePerRequestFilter { private final TokenExtractor tokenExtractor; private final TokenValidator tokenValidator; private final TokenAuthenticator tokenAuthenticator; @Override protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException { String requestURI = request.getRequestURI(); if (requestURI.startsWith("/api/v1/auth/signup") || // 여기서 pass 하도록 설정 requestURI.equals("/api/v1/auth/login") || requestURI.equals("/api/v1/auth/logout") || requestURI.startsWith("/api/v1/auth/token") || requestURI.startsWith("/api/v1/oauth") || requestURI.startsWith("/ws") || requestURI.startsWith("/docs") || requestURI.startsWith("/oauth") || requestURI.startsWith("/favicon.ico") ) { filterChain.doFilter(request, response); return; } String accessToken = tokenExtractor.getAccessTokenFromHeader(request); if (accessToken != null) { try { tokenValidator.validateAccessToken(accessToken); tokenAuthenticator.getAuthenticationUsingToken(accessToken); } catch (ExpiredJwtException e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); setResponse(response, "TOKEN_01"); return; } } filterChain.doFilter(request, response); } private void setResponse(HttpServletResponse response, String errorCode) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.getWriter().println( "{\"errorCode\" : \"" + errorCode + "\"}" ); } }
Java
복사

9. MailController.java

import com.planner.travel.global.util.mail.dto.MailAuthenticaionMessage; import com.planner.travel.global.util.mail.dto.request.MailAuthenticationRequest; import com.planner.travel.global.util.mail.service.MailService; import jakarta.mail.MessagingException; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/auth/signup") public class MailController { private final MailService mailService; @PostMapping("/authentication/send") public ResponseEntity<?> sendAuthenticationEmail(@RequestBody MailAuthenticationRequest request) throws MessagingException { MailAuthenticaionMessage mailAuthenticaionMessage = new MailAuthenticaionMessage( request.email(), "[travel-planner] 이메일 인증 코드 입니다." ); String tempCode = mailService.sendMailAuthenticationCode(mailAuthenticaionMessage); return ResponseEntity.ok(tempCode); } }
Java
복사

결과

위와같이 메일 서비스를 작성하면 아래와 같이 메일이 옵니다.