본문 바로가기

Server Programming/Spring Boot Full-Stack Programming

[스프링 풀스택 클론 코딩 - 회원가입] (1-11) 회원 가입 완료 후 자동 로그인

반응형

회원가입, 이메일 인증 완료시 자동 로그인

-> 컨트롤러에서 회원가입 완료, 토큰 일치 확인 = completeSignUp() 후 자동로그인


AccountService

package com.demo.account;

import java.util.List;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.demo.domain.Account;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class AccountService {

	private final AccountRepository accountRepository;
	// 이메일 발송 관련 빈 의존성 주입
	private final JavaMailSender javaMailSender;
	//패스워드 인코딩
	private final PasswordEncoder passwordEncoder;
	//정석적인 로그인 확인
	//private final AuthenticationManager authenticationManager;
	
	@Transactional
	public Account processNewAccount(SignUpForm signUpForm) {
		// 1. 회원정보 저장
				// 2. 이메일 토큰 만들기
				// 3. 이메일 전송
				Account newAccount = saveNewAccount(signUpForm);

				// 회원가입 이메일 발송 -> starter-mail 이용 -> mailsender // 일단 가짜 객체에 저장해 콘솔에 출력
				// 계정인증을 위한 토큰값 생성 -> 유효 아이디를 이용해 랜덤 생성
				newAccount.generateEmailCheckToken();
				//emailCehckToken=null
				//저장 후 토큰 생성 -> 함수안에서만 트랜잭션이 존재하므로, DB에 싱크오류 ->@Transactional 필요
				//상태 종료시 DB에 싱크를 해주기 위함

				sendSignUpConfirmEmail(newAccount);

				// TODO 회원 가입 처리 valid에 안걸리면 회원가입
				return newAccount;
				//로그인 처리 위해 -> 회원정보를 리턴하도록 변경 후 컨트롤러에서 받는다.
	}


	private Account saveNewAccount(SignUpForm signUpForm) {
		// form을 이용해 account에 저장
		Account account = Account.builder().email(signUpForm.getEmail()).nickname(signUpForm.getNickname())
				//.password(signUpForm.getPassword()) // TODO encoding (해시 암호화) 필요함
				.password(passwordEncoder.encode(signUpForm.getPassword())) 		// 패스워드 인코딩 수행
				// .emailVerified(false) //검증은 아직 이루어지지 않았으므로 null이 들어가 있어서 불필요
				.studyCreatedByWeb(true).studyEnrollmentResultByWeb(true).studyUpdatedByWeb(true)
				// 웹으로 알림을 알리는 옵션은 켜둔 상태로 저장 -> 나머지 기본값들은 모두 false
				.build();

		// 해당 account 생성 -> repository에 저장
		//Account newAccount = accountRepository.save(account);

		//return newAccount;
		
		return accountRepository.save(account);
	}

	private void sendSignUpConfirmEmail(Account newAccount) {
		// 관련 객체 정의 후 -> 빈 정의 후 발송
		SimpleMailMessage mailMessage = new SimpleMailMessage();

		// 받는 주소
		mailMessage.setTo(newAccount.getEmail());
		// 제목 작성
		mailMessage.setSubject("Demo, 회원 가입 인증");
		// 본문 작성 -> 가입인증 링크 만들어 제공 -> 토큰값을 생성해 가입인증 진행
		mailMessage.setText("/check-email-token?token=" + newAccount.getEmailCheckToken() +
                "&email=" + newAccount.getEmail());
		// 메일 발송 ->
		javaMailSender.send(mailMessage);
		// 만약에 발송을 하지않는다면, test에서 오류를 내뿜게된다.
	}


	public void login(Account account) {
		// TODO Auto-generated method stub
		
		//스프링 시큐리티에서 컨텍스트 홀더라는 기능을 이용해 간편하게 로그인
		//토큰을 이용해 로그인 -> 토큰은, 닉네임, 패스워드, 권한으로 이루어져 있다.
		UsernamePasswordAuthenticationToken token =new UsernamePasswordAuthenticationToken(
				account.getNickname(), 
				account.getPassword(), 
				List.of(new SimpleGrantedAuthority("ROLE USER"))); 
		//권한 목록을 받아서 ROLE USER로 설정
		
//		SecurityContext context =SecurityContextHolder.getContext();
//		context.setAuthentication(token);
		SecurityContextHolder.getContext().setAuthentication(token);
		//해당 토큰을 인증에 사용 ->정석적으로는 form인증할때 username, password를 이용해 AuthenticationManger를 통해 인증
		
//		//정석적인 코드 -> 실제 비밀번호를 접근해야 하므로 사용하기 어렵다.
//		UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
//		Authentication authentication = authenticationManager.authenticate(token);
//		SecurityContext context = SecurityContextHolder.getContext();
//		context.setAuthentication(authentication);
		
		
	}


	
}

 

AccountController

package com.demo.account;

import java.time.LocalDateTime;

import javax.validation.Valid;

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 com.demo.domain.Account;

import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
public class AccountController {

	private final SignUpFormValidator signUpFormValidator;
	//계정 리포지토리와 메일 샌더는 서비스로 이전
	private final AccountService accountService; 
	//이메일 인증 메일 발송을 위한, accountrepository 사용 -> 리파지토리를 도메인으로 여겨서 자유롭게 사용
	private final AccountRepository accountRepository;


	@InitBinder("signUpForm")
	public void initBinder(WebDataBinder webDataBinder) {
		webDataBinder.addValidators(signUpFormValidator);
	}

	// sign-up 페이지에 연결된다면
	@GetMapping("/sign-up")
	public String signUpForm(Model model) {
		// model.addAttribute(new SignUpForm()); 생략 가능
		model.addAttribute("signUpForm", new SignUpForm());
		return "account/sign-up";
	}
	// 스프링부트 자동설정에 의해
	// templates에 존재하는 view인 account/sign-up을 리턴한다.

	@PostMapping("/sign-up") // 복합객체는 본디 ModelAttribute로 받지만, 생략가능
	public String signUpSubmit(@Valid @ModelAttribute SignUpForm signUpForm, Errors errors) {
		if (errors.hasErrors()) {
			return "account/sign-up";
		}
		// SignUpForm 도메인에 Valid를 위한 처리 필요

//		@InitBinder를 이용해 대체 
//		signUpFormValidator.validate(signUpForm, errors);
//		if (errors.hasErrors()) {
//			return "account/sign-up";
//		}
		// -> 자동으로 SignUpForm 검증을 한다.

		//이메일 보내는 작업을 컨트롤러가 모르도록 서비스 쪽으로 이전 -> 서비스에서 private으로 수행
		//accountService.processNewAccount(signUpForm);
		
		//로그인 처리를 위한 리팩토링 
		accountService.login(accountService.processNewAccount(signUpForm));
		//서비스에서 processNewAccount메서드가 리턴값이 account로 변경
		
		return "redirect:/";

	}
	
	//이메일 인증 확인 -> 이메일, 토큰 값 둘다 일치해야함 -> 화면에 전달할 모델도 파라미터로 전송
	@GetMapping("/check-email-token")
	public String checkEmailToken(String token, String email, Model model) {
		Account account = accountRepository.findByEmail(email);
		String view = "account/checked-email";
		//같은 링크를 리턴하지만, 담은 정보는 각각 다르다. 
		//해당 뷰에서 error를 담아서 왔을 경우와 setEmailVerified가 true를 담아서 왔을 때를 구분해서 처리
		
		//리파지토리를 도메인 계층으로 생각해, 레이어계층에서의 리파지토리 참조를 자유롭게 한다.
		//반대로, 레이어 계층인, 서비스나 컨트롤러를 도메인 계층에서 참조하는 것은 지양한다.
		//1단계에서 이메일의 존재여부를 판단
		//2단계에서는 해당하는 이메일의 토큰값 동일 여부를 확인한다.
		if(account==null) {
			//해당 이메일이 존재하지 않을 때 오류 발생
			//정보제공을 줄이기 위해 -> 서버단에서만 wrong.email이라는 것을 명시하고, 리턴은 하지않는다.
			model.addAttribute("error", "wrong.email");
			return view;
		}
		
		//토큰이 같지 않다면-> 하나의 메서드로
		if(!account.isValidToken(token)) {
			model.addAttribute("error", "wrong.token");
			return view;
		}
		
		//항상 동시에 수행된다면, 하나의 메서드로 묶는다.
		account.completeSignUp();
		//회원가입이  완료되으므로, 로그인 처리
		accountService.login(account);
		
		
		model.addAttribute("numberOfUser", accountRepository.count());
		model.addAttribute("nickname", account.getNickname());
		return view;
		
	}

}

AccountControllerTest

package com.demo.account;

import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
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.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
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.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

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 com.demo.domain.Account;

@Transactional
//테스트를 위한 어노테이션 -> 스프링부트테스트, 자동설정목업 MVC
@SpringBootTest @AutoConfigureMockMvc
//사용하면 서블릿이 뜨고, @AutoConfigureWebClient  @AutoConfigureTestClient를 이용하면 테스트 가능 
//@SpringBootTest (webEnvironment =WebEnvironment.DEFINED_PORT)
//@SpringBootTest (webEnvironment =WebEnvironment.NONE)
//@SpringBootTest (webEnvironment =WebEnvironment.RANDOM_PORT)

//테스트 코드가 필요한 이유
//코드를 변경한 이후에 작성한 코드가 오류를 발생시키지 않았다는 것을 확인하기 위해
//모놀리틱 아키텍처의 경우, 작은 변화가 코드 전체의 오류에 영향을 끼치기 쉽기 때문에 테스트 코드가 필요하다.
class AccountControllerTest {
	
	
	//의존성 주입
	@Autowired private MockMvc mockMvc;
	
	@Autowired private AccountRepository accountRepository;

	//이메일 보내는지 확인할 목업빈 -> 메일샌더가 메일을 보냈는지 여부 확인
	@MockBean
	JavaMailSender javaMailSender;

	@DisplayName("회원 가입 화면이 보이는지 테스트")
	@Test
	void signUpForm() throws Exception { //아래의 응답이 아닐경우 예외처리
		mockMvc.perform(get("/sign-up")) //회원가입 요청에서
		.andDo(print()) //실제 웹사이트 출력 -> 타임리프이기 때문에
		.andExpect(status().isOk()) //보이는지 -> 즉, 상태가 정상일때 (200일때)
		.andExpect(view().name("account/sign-up")) //뷰의 이름이 account/sign-up이 맞는지
		.andExpect(model().attributeExists("signUpForm"))
		.andExpect(unauthenticated());

	}
	@DisplayName("회원 가입 처리 - 입력값 오류")
	@Test
	void signUpSubmit_with_wrong_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email..")
				.param("password", "12345")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().isOk())
		//status OK 확인 후 -> 다시 회원가입페이지 출력
		.andExpect(view().name("account/sign-up"))
		.andExpect(unauthenticated());

	}
	//403출력하는데 -> 스프링 시큐리티에서 authorize만 확인하는데
	//-> CSRF 토큰에 의해 타사이트에서 폼데이터를 보내는 공격을 방지하기 위해
	//타임리프 탬플릿에서 form을 보낼 경우 토큰값을 확인하는데, 토큰값이 다르다는 것을 확인
	//-> csrf 토큰을 삽입한다.

	@DisplayName("회원 가입 처리 - 입력값 정상")
	@Test
	void signUpSubmit_with_correct_input() throws Exception {
		mockMvc.perform(post ("/sign-up")
				.param("nickname", "jihun")
				 //이메일이나 패스워드 오류 검증
				.param("email", "email@gmail.com")
				.param("password", "12345678")
				.with(csrf()))		//csrf 토큰에 의한 403 오류 방지
		.andExpect(status().is3xxRedirection())		//redirection 응답 확인
		.andExpect(view().name("redirect:/"))
		.andExpect(authenticated().withUsername("jihun"));

		//비밀번호 인코딩 확인 -> id를 가져와서 확인 
		Account account = accountRepository.findByEmail("email@gmail.com");
		
		//null이 아닌지 확인 -> assertTrue와 같은 것이 된다.
		assertNotNull(account);
		//입력한 값과 같지 않은지 확인
		assertNotEquals(account.getPassword(), "12345678");
		//토큰 null 오류 발생  -> 디버깅 토큰이 널인지 아닌지 확인
		assertNotNull(account.getEmailCheckToken());
		
		//유저 조회를 위한 의존성 주입 필요-> AccountRepository
		//junit 의존성 추가
//		assertTrue(accountRepository.existsByEmail("email@gmail.com"));
		//-> 패스워드 인코딩 확인하기 위해 직접 Email가져오기 때문에 주석처리
		
		then(javaMailSender).should().send(any(SimpleMailMessage.class));
	}

	@DisplayName("인증 메일 확인 - 입력값 오류 ")
	@Test
	void checkEmailToken_with_wrong_input() throws Exception{
		mockMvc.perform(get("/check-email-token")
				.param("token", "lssdejelskak")
				.param("email", "email@email.com"))
				.andExpect(status().isOk())
				.andExpect(model().attributeExists("error"))
				//model객체의 attribute에 errors담는지, 해당 뷰 네임이 checked-mail인지 확인
				.andExpect(view().name("account/checked-email"))
				//mockMvc에서 지원하는 authentication기능을 이용해 로그인 여부를 확인
				.andExpect(unauthenticated());
		
	}
	
	@DisplayName("인증 메일 확인 - 입력값 정상")
	@Test
	void checkEmailToken() throws Exception{
		//오류와 달리 정상의 경우 입력값을 저장하는 객체가 필요하다
		 
		//빌더 패턴는 생성자 패턴의 단점을 해결하는 패턴으로
		//		복잡한 객체의 생성 과정 및 표현 방법을 분리해 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있게 하는 패턴
		//		빌더는 생성자 또는 정적 메소드와 비교하여 테스트용 객체를 생성할 때 용이하게 해주고, 불필요한 코드의 양을 줄이는 등의 이점을 안겨준다.
		
		
		Account account = Account.builder()
				.email("test@email.com")
				.password("12345678")
				.nickname("jihun")
				.build();
		 Account newAccount = accountRepository.save(account);
		 //토큰 비교를 위해 토큰 생성
		 newAccount.generateEmailCheckToken();
		// Repository의 내부 구현체를 자동으로 생성시켜 주기 때문에 별도의 구현체를 따로 생성하지 않아도 된다.
		//임시로 사용하기에 편하다 따라서, 존재여부 조회시, 단위 테스트시, 임시로 저장하는데 사용한다.
		
		
		mockMvc.perform(get("/check-email-token")
				//이 경우에는 해당 이메일이 가지고 있는 정보를 가져와야하므로
				.param("token", newAccount.getEmailCheckToken())
				.param("email", newAccount.getEmail()))
				.andExpect(status().isOk())
				.andExpect(model().attributeDoesNotExist("error"))
				//해당 몇번째 회원인지와 닉네임 리턴
				.andExpect(model().attributeExists("nickname"))
				.andExpect(model().attributeExists("numberOfUser"))
				.andExpect(view().name("account/checked-email"))
		//model객체의 attribute에 errors담는지, 해당 뷰 네임이 checked-mail인지 확인
		//마찬가지로 토큰 값을 DB에 싱크하기 위해 @Transactional이 필요하다
				.andExpect(authenticated().withUsername("jihun"));
		
	}
	
}
반응형