티스토리 뷰
본 포스팅은 백기선님의 스프링과 JPA 기반 웹 애플리케이션 개발 강의를 참고하여 작성하였습니다.
소스 코드는 여기 있습니다. (branch: feature/8)
Overview
회원 가입시 전송한 이메일을 확인하여 회원을 인증하는 절차를 개발합니다.
회원 가입시 다시 서버로 요청할 수 있게 토큰을 포함한 링크를 전송하고 사용자가 해당 링크를 클릭했을 때 토큰이 일치하면 가입 완료 처리합니다.
이메일 인증을 하는 이유는 무작위로 생성하는 이메일 계정을 허용하지 않기 위함이고, 서비스 내에서의 메일 전송 기능을 제대로 활용할 수 없기 때문입니다.
이메일 인증을 대체할 수 있는 방법은 소셜 인증 등이 있습니다.
인증 링크로 접근했을 때 노출될 화면과 인증 로직을 개발해야 합니다.
이메일 인증 개발
입력 값에 오류가 있는 경우 에러 문구를 출력합니다.
이 때 오류 문구는 모호하게 노출시키는 게 좋습니다.
힌트를 주는 형태의 오류 문구를 노출하게 되면 어뷰징에 도움이 될 수 있는 등 보안 측면으로 좋은 방법이라고 볼 수 없습니다.
인증이 완료된 경우 환영 문구를 출력합니다.
먼저 컨트롤러를 개발해보겠습니다.
src/main/java/io/lcalmsky/server/account/endpoint/controller/AccountController.java
@Controller
@RequiredArgsConstructor
public class AccountController {
// 생략
@GetMapping("/check-email-token")
public String verifyEmail(String token, String email, Model model) { // (1)
Account account = accountService.findAccountByEmail(email); // (2)
if (account == null) { // (3)
model.addAttribute("error", "wrong.email");
return "account/email-verification";
}
if (!token.equals(account.getEmailToken())) { // (4)
model.addAttribute("error", "wrong.token");
return "account/email-verification";
}
account.verified(); // (5)
model.addAttribute("numberOfUsers", accountRepository.count()); // (6)
model.addAttribute("nickname", account.getNickname()); // (6)
return "account/email-verification"; // (7)
}
}
- 이메일 링크를 클릭하면 해당 메서드로 진입하게 되고 그 때
email
과token
을 파라미터로 전달받습니다. AccountService
에게email
을 이용해 계정 정보를 가져오도록 위임합니다. (AccountService
수정 필요)- 계정정보가 없으면 기존에 가입한 사용자가 아니므로 모델 객체에 에러를 전달합니다.
- 계정정보가 있지만 기존에 발급한
token
과 일치하지 않는 경우 모델 객체에 에러를 전달합니다. email
과token
이 모두 유효하므로 인증 완료 처리를 합니다. (Account
수정 필요)- 인증에 성공했으므로 성공시 보여줄 데이터를 모델 객체에 전달합니다.
- 이메일 인증 화면으로 리다이렉트 합니다.
AccountController 전체 소스 코드 보기
package io.lcalmsky.server.account.endpoint.controller;
import io.lcalmsky.server.account.application.AccountService;
import io.lcalmsky.server.account.domain.entity.Account;
import io.lcalmsky.server.account.endpoint.controller.validator.SignUpFormValidator;
import io.lcalmsky.server.account.infra.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.validation.Valid;
@Controller
@RequiredArgsConstructor
public class AccountController {
private final AccountService accountService;
private final SignUpFormValidator signUpFormValidator;
@InitBinder("signUpForm")
public void initBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(signUpFormValidator);
}
@GetMapping("/sign-up")
public String signUpForm(Model model) {
model.addAttribute(new SignUpForm());
return "account/sign-up";
}
@PostMapping("/sign-up")
public String signUpSubmit(@Valid @ModelAttribute SignUpForm signUpForm, Errors errors) {
if (errors.hasErrors()) {
return "account/sign-up";
}
accountService.signUp(signUpForm);
return "redirect:/";
}
private final AccountRepository accountRepository;
@GetMapping("/check-email-token")
public String verifyEmail(String token, String email, Model model) {
Account account = accountService.findAccountByEmail(email);
if (account == null) {
model.addAttribute("error", "wrong.email");
return "account/email-verification";
}
if (token.equals(account.getEmailToken())) {
model.addAttribute("error", "wrong.token");
return "account/email-verification";
}
account.verified();
model.addAttribute("numberOfUsers", accountRepository.count());
model.addAttribute("nickname", account.getNickname());
return "account/email-verification";
}
}
2, 5번에 설명된대로 AccountService
와 Account
클래스를 수정하도록 하겠습니다.
먼저 AccountService
에 메서드를 하나 추가합니다.
src/main/java/io/lcalmsky/server/account/application/AccountService.java
// 생략
@Service
@RequiredArgsConstructor
public class AccountService {
// 생략
private final AccountRepository accountRepository;
@Transactional
public void signUp(SignUpForm signUpForm) {
Account newAccount = saveNewAccount(signUpForm);
newAccount.generateToken();
sendVerificationEmail(newAccount);
}
// 생략
public Account findAccountByEmail(String email) {
return accountRepository.findByEmail(email); // (1)
}
}
AccountRepository
에서mail을
이용해Account Entity
를 가져옵니다.
이전 포스팅에서 빼먹은 부분인데 signUp 메서드에 @Transactional 애너테이션을 추가해줘야 토큰을 발급한 내용이 DB에 저장됩니다.
Account Service 전체 소스 코드 보기
package io.lcalmsky.server.account.application;
import io.lcalmsky.server.account.domain.entity.Account;
import io.lcalmsky.server.account.endpoint.controller.SignUpForm;
import io.lcalmsky.server.account.infra.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder;
@Transactional
public void signUp(SignUpForm signUpForm) {
Account newAccount = saveNewAccount(signUpForm);
newAccount.generateToken();
sendVerificationEmail(newAccount);
}
private Account saveNewAccount(SignUpForm signUpForm) {
Account account = Account.builder()
.email(signUpForm.getEmail())
.nickname(signUpForm.getNickname())
.password(passwordEncoder.encode(signUpForm.getPassword()))
.notificationSetting(Account.NotificationSetting.builder()
.studyCreatedByWeb(true)
.studyUpdatedByWeb(true)
.studyRegistrationResultByWeb(true)
.build())
.build();
return accountRepository.save(account);
}
private void sendVerificationEmail(Account newAccount) {
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(newAccount.getEmail());
mailMessage.setSubject("Webluxible 회원 가입 인증");
mailMessage.setText(String.format("/check-email-token?token=%s&email=%s", newAccount.getEmailToken(),
newAccount.getEmail()));
mailSender.send(mailMessage);
}
public Account findAccountByEmail(String email) {
return accountRepository.findByEmail(email);
}
}
다음은 Account
클래스에도 가입 일시를 나타내는 필드와 메서드를 추가해줍니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder @Getter @ToString
public class Account extends AuditingEntity {
// 생략
private LocalDateTime joinedAt;
public void verified() { // (1)
this.isValid = true;
joinedAt = LocalDateTime.now();
}
}
- 계정이 유효함을 알 수 있게 isValid 항목을 true로, 가입 일시를 현재 시간으로 업데이트합니다.
Account 전체 소스 코드 보기
package io.lcalmsky.server.account.domain.entity;
import io.lcalmsky.server.account.domain.support.ListStringConverter;
import io.lcalmsky.server.domain.entity.AuditingEntity;
import lombok.*;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder @Getter @ToString
public class Account extends AuditingEntity {
@Id @GeneratedValue
@Column(name = "account_id")
private Long id;
@Column(unique = true)
private String email;
@Column(unique = true)
private String nickname;
private String password;
private boolean isValid;
private String emailToken;
private LocalDateTime joinedAt;
@Embedded
private Profile profile;
@Embedded
private NotificationSetting notificationSetting;
public void generateToken() {
this.emailToken = UUID.randomUUID().toString();
}
public void verified() {
this.isValid = true;
joinedAt = LocalDateTime.now();
}
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder @Getter @ToString
public static class Profile {
private String bio;
@Convert(converter = ListStringConverter.class)
private List<String> url;
private String job;
private String location;
private String company;
@Lob @Basic(fetch = FetchType.EAGER)
private String image;
}
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder @Getter @ToString
public static class NotificationSetting {
private boolean studyCreatedByEmail;
private boolean studyCreatedByWeb;
private boolean studyRegistrationResultByEmail;
private boolean studyRegistrationResultByWeb;
private boolean studyUpdatedByEmail;
private boolean studyUpdatedByWeb;
}
}
여기까지 완료되었다면 로컬에서 애플리케이션을 실행하고 테스트 해볼까요?
http://localhost:8080/sign-up
에 먼저 진입하여 가입을 진행합니다.
- 가입하기를 누른 뒤 로그에 나타나는 이메일 인증 링크를 확인하고 해당 링크로 다시 요청합니다.
ex)http://localhost:8080/check-email-token?token=0b0e52f0-6fd3-4444-b7e3-d04532a3cdee&email=lcalmsky@gmail.com
- 정상적으로 가입된 것을 확인할 수 있습니다.
2번에서 다시 요청할 때 token 값이나 email 값을 수정하면 에러가 노출되는 것을 확인할 수 있습니다.
- 일치하지 않는 이메일로 수정했을 때
- 일치하지 않는 토큰으로 수정했을 때
Test 작성
애플리케이션을 실행해서는 확인해봤지만 앞으로 소스 코드를 잘 유지하기위해 테스트 코드도 추가해보도록 하겠습니다.
src/test/java/io/lcalmsky/server/account/endpoint/controller/AccountControllerTest.java
// 생략
@SpringBootTest
@AutoConfigureMockMvc
class AccountControllerTest {
// 생략
@DisplayName("인증 메일 확인: 잘못된 링크")
@Test
void verifyEmailWithWrongLink() throws Exception {
mockMvc.perform(get("/check-email-token")
.param("token", "token") // (1)
.param("email", "email")) // (1)
.andExpect(status().isOk()) // (2)
.andExpect(view().name("account/email-verification")) // (2)
.andExpect(model().attributeExists("error")); // (3)
}
@DisplayName("인증 메일 확인: 유효한 링크")
@Test
@Transactional // (1)
void verifyEmail() throws Exception {
Account account = Account.builder() // (2)
.email("email@email.com")
.password("1234!@#$")
.nickname("nickname")
.notificationSetting(Account.NotificationSetting.builder()
.studyCreatedByWeb(true)
.studyUpdatedByWeb(true)
.studyRegistrationResultByWeb(true)
.build())
.build();
Account newAccount = accountRepository.save(account); // (3)
newAccount.generateToken(); // (4)
mockMvc.perform(get("/check-email-token")
.param("token", newAccount.getEmailToken()) // (5)
.param("email", newAccount.getEmail())) // (5)
.andExpect(status().isOk()) // (6)
.andExpect(view().name("account/email-verification")) // (6)
.andExpect(model().attributeDoesNotExist("error")) // (7)
.andExpect(model().attributeExists("numberOfUsers", "nickname")); // (8)
}
}
인증 메일 확인: 잘못된 링크
- 유효하지 않은 토큰과 이메일을 입력합니다.
- 상태 자체는 200 OK 에서 변함이 없고
view
도 유지되어야 합니다. error
객체가model
객체를 통해 전달되어야 합니다.
인증 메일 확인: 유효한 링크
- DB 트랜잭션이 발생하기 때문에
@Transactional
애너테이션을 사용합니다. - 토큰을 생성하고 DB와 비교해야 하기 때문에
Account Entity
를 생성합니다. Account Entity
를 저장합니다.- 토큰을 생성합니다.
- 요청시 전달할 토큰과 이메일을 계정 생성시 사용한 것과 동일한 것으로 넣어줍니다.
- 상태와
view
는 변함이 없어야 합니다. error
객체가 포함되면 안 됩니다.numberOfUsers
와nickname
이model
을 통해 전달되어야 합니다.
AccountControllerTest 전체 소스 코드 보기
package io.lcalmsky.server.account.endpoint.controller;
import io.lcalmsky.server.account.domain.entity.Account;
import io.lcalmsky.server.account.infra.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class AccountControllerTest {
@Autowired MockMvc mockMvc;
@Autowired AccountRepository accountRepository;
@MockBean JavaMailSender mailSender;
@Test
@DisplayName("회원 가입 화면 진입 확인")
void signUpForm() throws Exception {
mockMvc.perform(get("/sign-up"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(view().name("account/sign-up"))
.andExpect(model().attributeExists("signUpForm"));
}
@Test
@DisplayName("회원 가입 처리: 입력값 오류")
void signUpSubmitWithError() throws Exception {
mockMvc.perform(post("/sign-up")
.param("nickname", "nickname")
.param("email", "email@gmail")
.param("password", "1234!")
.with(csrf()))
.andDo(print())
.andExpect(status().isOk())
.andExpect(view().name("account/sign-up"));
}
@Test
@DisplayName("회원 가입 처리: 입력값 정상")
void signUpSubmit() throws Exception {
mockMvc.perform(post("/sign-up")
.param("nickname", "nickname")
.param("email", "email@email.com")
.param("password", "1234!@#$")
.with(csrf()))
.andDo(print())
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/"));
assertTrue(accountRepository.existsByEmail("email@email.com"));
Account account = accountRepository.findByEmail("email@email.com");
assertNotEquals(account.getPassword(), "1234!@#$");
assertNotNull(account.getEmailToken());
then(mailSender)
.should()
.send(any(SimpleMailMessage.class));
}
@DisplayName("인증 메일 확인: 잘못된 링크")
@Test
void verifyEmailWithWrongLink() throws Exception {
mockMvc.perform(get("/check-email-token")
.param("token", "token")
.param("email", "email"))
.andExpect(status().isOk())
.andExpect(view().name("account/email-verification"))
.andExpect(model().attributeExists("error"));
}
@DisplayName("인증 메일 확인: 유효한 링크")
@Test
@Transactional
void verifyEmail() throws Exception {
Account account = Account.builder()
.email("email@email.com")
.password("1234!@#$")
.nickname("nickname")
.notificationSetting(Account.NotificationSetting.builder()
.studyCreatedByWeb(true)
.studyUpdatedByWeb(true)
.studyRegistrationResultByWeb(true)
.build())
.build();
Account newAccount = accountRepository.save(account);
newAccount.generateToken();
mockMvc.perform(get("/check-email-token")
.param("token", newAccount.getEmailToken())
.param("email", newAccount.getEmail()))
.andExpect(status().isOk())
.andExpect(view().name("account/email-verification"))
.andExpect(model().attributeDoesNotExist("error"))
.andExpect(model().attributeExists("numberOfUsers", "nickname"));
}
}
기존 테스트를 포함해 모두 정상적으로 수행된 것을 확인할 수 있습니다.
다음 포스팅에서는 자동 로그인 기능을 다룰 예정입니다.
'SpringBoot > Web Application 만들기' 카테고리의 다른 글
스프링 부트 웹 애플리케이션 제작(9): 프론트엔드 라이브러리 및 빌드 설정 (2) | 2021.10.30 |
---|---|
스프링 부트 웹 애플리케이션 제작(8): 회원 가입 후 자동 로그인 처리 (0) | 2021.10.19 |
스프링 부트 웹 애플리케이션 제작(6): PasswordEncoder (0) | 2021.10.12 |
스프링 부트 웹 애플리케이션 제작(5): 회원 가입 리팩터링 (0) | 2021.10.11 |
스프링 부트 웹 애플리케이션 제작(4): 회원 가입 폼 검증 및 기능 구현 (8) | 2021.10.08 |
- Total
- Today
- Yesterday
- 함께 자라기
- Spring Boot Tutorial
- 함께 자라기 후기
- 스프링 부트 회원 가입
- JPA
- Spring Boot
- Spring Data JPA
- 스프링 부트
- QueryDSL
- 스프링부트
- intellij
- Java
- JSON
- 스프링 부트 튜토리얼
- @ManyToOne
- r
- Spring Boot JPA
- 알고리즘
- spring boot jwt
- 스프링 데이터 jpa
- 헥사고날 아키텍처
- 클린 아키텍처
- proto3
- Jackson
- 스프링 부트 애플리케이션
- spring boot application
- Linux
- leetcode
- gRPC
- spring boot app
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |