스프링과 JPA 기반 웹 애플리케이션 개발 #12 회원 가입 리팩토링 및 테스트 (+MockBean +CSRF)
해당 내용은 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발의 강의 내용을 바탕으로 작성된 내용입니다.
강의를 학습하며 요약한 내용을 출처를 표기하고 블로깅 또는 문서로 공개하는 것을 허용합니다 라는 원칙 하에 요약 내용을 공개합니다. 출처는 위에 언급되어있듯, 인프런, 스프링과 JPA 기반 웹 애플리케이션 개발입니다.
제가 학습한 소스코드는 https://github.com/n00nietzsche/jakestudy_webapp 에 지속적으로 업로드 됩니다. 매 커밋 메세지에 강의의 어디 부분까지 진행됐는지 기록해놓겠습니다.
@Autowired private AccountRepository accountRepository;
@MockBean JavaMailSender javaMailSender;
위와 같이, JavaMailSender
와 AccountRepository
를 테스트하기 위해 의존성을 주입받았다. JavaMailSender
의 경우에는 내부 기능을 실행해보는 테스트가 아닌, 해당 객체가 특정 메소드를 특정 타입의 인자로 보냈는지에 대해 알아보기 위해 @MockBean
이라는 애노테이션으로 의존성 주입을 하였다.
공식문서에 아래와 같은 설명이 있다.
Annotation that can be used to add mocks to a Spring ApplicationContext. Can be used as a class level annotation or on fields in either @Configuration classes, or test classes that are @RunWith the SpringRunner.
Mocks can be registered by type or by bean name. Any existing single bean of the same type defined in the context will be replaced by the mock. If no existing bean is defined a new one will be added. Dependencies that are known to the application context but are not beans (such as those registered directly) will not be found and a mocked bean will be added to the context alongside the existing dependency.
스프링 애플리케이션 컨텍스트에 목을 추가하는데 이용되는 애노테이션이다.
@Configuration
클래스나@RunWith
테스트 클래스에서 클래스 레벨이나 필드 레벨 애노테이션으로 사용될 수 있다. 타입 이름 또는 빈 이름에 의해 등록된다. 컨텍스트 내부에 정의된 존재하는 동일한 타입의 단일 빈이 Mock으로 대체될 것이다. 만일, 정의된 빈이 존재하지 않는다면, 새로운 빈이 추가될 것이다. (이를테면 직접 등록된 것과 같은) 빈이 아닌 어플리케이션 컨텍스트에 알려진 의존성은 찾을 수 없다. 그리고 Mock이 된 빈은 존재하는 의존성과 함께 컨텍스트에 추가될 것이다.
결국 스프링 애플리케이션 컨텍스트에서 단일 빈을 찾아서
Mock
으로 만들어버린다고 생각된다. 만일 스프링 애플리케이션 컨텍스트에 없으면, 그냥 새로 만든다.
@DisplayName("회원 가입 처리 - 입력값 오류")
@Test
void signUpSubmitWithWrongInput() throws Exception {
// 기본적으로 CSRF 라는 시큐리티 기능이 활성화 되어있다. 그래서 403 에러가 뜬다.
// 타 사이트에서 해당 사이트로 요청을 보낼 때를 방지한 보안이 적용되어 있다.
// 스프링 시큐리티에서는 해당 보안 취약점을 방지하기 위해 CSRF 토큰 기능을 지원해준다.
// 스프링 시큐리티가 적용된 thymeleaf 결과 html 을 잘 보면 input 에 hidden 값으로 CSRF TOKEN 이 들어있다.
// 194b360d-b353-4063-a228-fc41903df06f와 같은 형식이다.
// 이러한 CSRF 토큰이 없는 경우 보안 정책에 위배된다.
mockMvc.perform(post("/sign-up")
.param("nickname", "minsu")
.param("email", "email..")
.param("password", "12345")
.with(csrf())) // CSRF 토큰도 mock 하기
.andExpect(status().isOk())
.andExpect(view().name("account/sign-up"));
}
mockMvc
를 이용하여 테스트한다..csrf()
메소드로 CSRF 토큰을 적용해주어야 한다.200
상태와 함께 다시 account/sign-up
뷰로 이동한다.Forgery는 위조를 뜻한다.
악성 사용자가 악성 스크립트를 통해 서버로 일반 사용자가 원치 않는 리퀘스트를 보내는 것을 말한다. CSRF 토큰이 있다면, 해당 리퀘스트가 올바른 과정을 거쳐서 들어온 것인지 알 수 있다.
악성 스크립트 외에도 기존에 존재하는 사이트 모양과 비슷하게 생긴 사이트를 구성하여, 사용자를 낚는 이른바 피싱 사이트에서도 이러한 공격이 발생할 수 있다.
CSRF 토큰은 해당 사용자에게 고유하게 발급된 UUID 등을 통해 적용할 수 있다. 스프링 시큐리티에서는 아래와 같이 CSRF 토큰을 준다.
아래의 토큰은 서버에서 뷰 페이지를 발행하면서 랜덤으로 생성된 CSRF Token
을 같이 뷰로 전송한 것이다.
서버에서는 이 CSRF Token
을 받아 세션에 저장된 값과 일치하는지 확인하여 해당 요청이 위조된 것이 아니라는 것을 확인한다.
서버는 일치 여부를 확인한 Token은 바로 폐기하고 새로운 뷰 페이지를 발행할 때마다 새로 생성한다.
input
에 type=hidden
으로 고유 value
가 위와 같이 들어있다. CSRF 공격자는 저 고유한 CSRF 토큰값을 생성해낼 수 없기 때문에, 공격에 실패하게 된다.
@DisplayName("회원 가입 처리 - 입력값 정상")
@Test
void signUpSubmitWithCorrectInput() throws Exception {
String email = "minsu@minsu.co.kr";
String nickname = "minsu";
mockMvc.perform(post("/sign-up")
.param("nickname", nickname)
.param("email", email)
.param("password", "12345678")
.with(csrf())) // CSRF 토큰도 mock 하기
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/"));
assertTrue(accountRepository.existsByEmail(email));
assertTrue(accountRepository.existsByNickname(nickname));
// BDD Mockito
// `SimpleMailMessage` 타입의 인스턴스 중 아무거나 가지고 send가 호출됐는지 확인
// `JavaMailSender` 는 결국 소스에서 인터페이스만 관리하고 외부의 이메일 서비스가 메일을 보낸다.
// 실제로 이메일을 보내는 것까지 테스트하기엔 테스트 코드가 너무 작성하기 힘들어서 이정도만...
then(javaMailSender).should().send(any(SimpleMailMessage.class));
}
Redirection
상태가 발생하는지 확인한다.redirect:/
로 가는지 확인한다.JavaMailSender
가 SimpleMailMessage
클래스를 이용하여 send()
메소드를 호출하는지 확인한다.@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final JavaMailSender javaMailSender;
private Account saveNewAccount(SignUpForm signUpForm) {
Account account = Account.builder()
.email(signUpForm.getEmail())
.nickname(signUpForm.getNickname())
.password(signUpForm.getPassword()) // TODO: PASSWORD ENCODING 해야 함 (HASH로)
.studyEnrollmentResultByWeb(true)
.studyCreatedByWeb(true)
.studyUpdatedByWeb(true)
.build();
Account newAccount = accountRepository.save(account);
return newAccount;
}
private void sendSignUpConfirmEmail(Account newAccount) {
// 이메일 보내기, 이메일을 보내기 위한 의존성은 메이븐에
// spring-boot-starter-mail 을 이용한다.
// `MailSenderAutoConfiguration` 클래스에 메일과 관련된 기본 설정이 들어있다.
// 토큰 값은 UUID를 이용해서 랜덤하게 생성한다.
// 인증 이메일을 다시 보내는 경우
// 매번 이메일을 보낼 때마다 토큰이 변경되게 하기 위해 이쪽에 토큰을 만드는 부분을 넣었다.
// 또한 이 부분은 이메일과만 연관 있는 기능이니 여기에 넣어도 될 것 같다.
newAccount.generateEmailCheckToken();
SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
simpleMailMessage.setTo(newAccount.getEmail());
simpleMailMessage.setSubject("제이크 스터디 회원가입 메일 인증");
simpleMailMessage.setText("/check-email-token?token=" + newAccount.getEmailCheckToken() + "&email=" + newAccount.getEmail());
javaMailSender.send(simpleMailMessage);
}
public void processNewAccount(SignUpForm signUpForm) {
Account newAccount = saveNewAccount(signUpForm);
sendSignUpConfirmEmail(newAccount);
}
}
processNewAccount()
라는 메소드를 만들었다.기존의 JavaMailSender
와 AccountRepository
의존성은 필요 없어지게 된다.
// 메소드만 추출해주어도 코드의 가독성이 훨씬 좋아진다.
// 서비스쪽으로 이메일 보내기 등의 로직을 숨겨두었다.
// 컨트롤러는 JSR 303으로 들어온 데이터에 대한 검증만 하고,
// 나머지 회원 가입 과정은 서비스에게 위임한다.
accountService.processNewAccount(signUpForm);
이렇게 하면 AccountController
는 폼을 통해 들어온 값을 검증하고, 처리 과정은 AccountService
에 위임하게 된다.