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
복사
결과
위와같이 메일 서비스를 작성하면 아래와 같이 메일이 옵니다.