-
[Spring] Spring Security + JWT 로그인 구현하기 - 4Spring 2023. 6. 16. 14:36
📝 지난 포스팅
➡︎ Spring Security + JWT 로그인 구현하기 - 3
지난 포스팅에서 Postman을 사용해서 로그인 기능이 잘 작동하는지 테스트를 해보았다.
이번 포스팅에서는 SpringBootTest와 TestRestTemplate을 이용하여 test code를 직접 작성해보는 방식으로 테스트를 진행해보자!
1️⃣ 회원가입 Service 구현
DB에 회원을 직접 넣는 방식 대신 회원가입 로직을 구현해준다.
- 모든 객체의 생성은 Builder Pattern 사용
- DB에 password 저장한 후 UserDeatils를 생성하며 encoding을 하는 방식 대신, DB 자체에 encoding된 password를 저장
- Entity를 직접 사용하는 것은 매우 좋지 않은 방법 ➡︎ Dto 사용!
💡 회원가입 로직
- SignUpDto로 들어온 정보를 toEntity 메서드를 통해 Member 엔티티로 변환한다.
- 변환한 엔티티를 DB에 저장한다. 이때, 반환값으로 저장된 Member 엔티티를 받는다.
- 반환받은 엔티티를 MemberDto의 static method인 toDto를 호출하여 MemberDto로 변환하여 반환한다.
SignUpDto.java
@Getter @NoArgsConstructor @AllArgsConstructor @Builder public class SignUpDto { private String username; private String password; private String nickname; private String address; private String phone; private String profileImg; private List<String> roles = new ArrayList<>(); public Member toEntity(String encodedPassword, List<String> roles) { return Member.builder() .username(username) .password(encodedPassword) .nickname(nickname) .address(address) .phone(phone) .profileImg(profileImg) .roles(roles) .build(); } }
MemberDto.java
@Getter @ToString @AllArgsConstructor @NoArgsConstructor @Builder public class MemberDto { private Long id; private String username; private String nickname; private String address; private String phone; private String profileImg; static public MemberDto toDto(Member member) { return MemberDto.builder() .id(member.getId()) .username(member.getUsername()) .nickname(member.getNickname()) .address(member.getAddress()) .phone(member.getPhone()) .profileImg(member.getProfileImg()).build(); } public Member toEntity() { return Member.builder() .id(id) .username(username) .nickname(nickname) .address(address) .phone(phone) .profileImg(profileImg).build(); } }
MemberServiceImpl.java
... @Transactional @Override public MemberDto signUp(SignUpDto signUpDto) { if (memberRepository.existsByUsername(signUpDto.getUsername())) { throw new IllegalArgumentException("이미 사용 중인 사용자 이름입니다."); } // Password 암호화 String encodedPassword = passwordEncoder.encode(signUpDto.getPassword()); List<String> roles = new ArrayList<>(); roles.add("USER"); // USER 권한 부여 return MemberDto.toDto(memberRepository.save(signUpDto.toEntity(encodedPassword, roles))); }
2️⃣ SecurityConfig 설정
지난 포스팅에서 구현한 SecurityConfig에 .requestMatchers("/members/sign-up").permitAll() 을 추가함으로써 회원가입 API에 대한 모든 요청을 허가한다.
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity // REST API이므로 basic auth 및 csrf 보안을 사용하지 않음 .httpBasic().disable() .csrf().disable() // JWT를 사용하기 때문에 세션을 사용하지 않음 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() // 해당 API에 대해서는 모든 요청을 허가 .requestMatchers("/members/sign-up").permitAll() // ⭐️ .requestMatchers("/members/sign-in").permitAll() // USER 권한이 있어야 요청할 수 있음 .requestMatchers("/members/test").hasRole("USER") // 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정 .anyRequest().authenticated() .and() // JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행 .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build(); } @Bean public PasswordEncoder passwordEncoder() { // BCrypt Encoder 사용 return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } }
3️⃣ 회원가입 Controller 구현
memberService.signUp()를 호출하여 반환된 MemberDto 객체를 ResponseEntity에 넣어서 반환한다.
MemberController.java
@PostMapping("/sign-up") public ResponseEntity<MemberDto> signUp(@RequestBody SignUpDto signUpDto) { MemberDto savedMemberDto = memberService.signUp(signUpDto); return ResponseEntity.ok(savedMemberDto); }
4️⃣ 회원가입 Controller 테스트
@SpringBootTest와 TestRestTemplate을 사용하여 Controller 테스트를 한다.
💡 @SpringBootTest
- @SpringBootTest는 통합 테스트를 제공하는 기본적인 스프링 부트 테스트 어노테이션
- 이 포스팅에서는 webEnvironment 프로퍼티를 RANDOM_PORT로 지정
➡︎ EmbeddedWebApplicationContex를 로드하여 실제 서블릿 활경 구성, 임의의 port listen
💡 TestRestTemplate
- HTTP 요청 후 JSON, xml, String과 같은 응답을 받을 수 있는 HTTP 통신 템플릿
- ResponseEntity와 Server to Server 통신하는데 자주 사용
- Header, Content-Type 등을 설정하여 외부 API 호출
- webEnvironment 설정시 그에 맞춰 자동으로 설정되어 빈 생성
- RestTemplate의 테스트 처리 가능
MemberControllerTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Slf4j class MemberControllerTest { @Autowired DatabaseCleanUp databaseCleanUp; @Autowired MemberService memberService; @Autowired TestRestTemplate testRestTemplate; @LocalServerPort int randomServerPort; private SignUpDto signUpDto; @BeforeEach void beforeEach() { // Member 회원가입 signUpDto = SignUpDto.builder() .username("member") .password("12345678") .nickname("닉네임") .address("서울시 광진구") .phone("010-1234-5678") .build(); } @AfterEach void afterEach() { databaseCleanUp.truncateAllEntity(); } @Test public void signUpTest() { // API 요청 설정 String url = "http://localhost:" + randomServerPort + "/members/sign-up"; ResponseEntity<MemberDto> responseEntity = testRestTemplate.postForEntity(url, signUpDto, MemberDto.class); // 응답 검증 assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); MemberDto savedMemberDto = responseEntity.getBody(); assertThat(savedMemberDto.getUsername()).isEqualTo(signUpDto.getUsername()); assertThat(savedMemberDto.getNickname()).isEqualTo(signUpDto.getNickname()); } }
🖤 beforeEach()
- 각 테스트 케이스 전에 실행되어 회원 등록을 위한 SignUpDto 객체 생성
🖤 afterEach()
- 각 테스트 케이스 후에 실행되어 DB를 정리하기 위해 모든 엔티티 삭제
🖤 signUpTest()
- 회원을 등록하고 저장된 회원 객체의 username, nickname이 예상값과 일치하는지 확인
DatabaseCleanUp.java
@Service @RequiredArgsConstructor @Slf4j public class DatabaseCleanUp implements InitializingBean { private final EntityManager entityManager; private List<String> tableNames = new ArrayList<>(); @Override public void afterPropertiesSet() throws Exception { tableNames = entityManager.getMetamodel().getEntities().stream() .filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null) .map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName())) .collect(Collectors.toList()); } @Transactional public void truncateAllEntity() { entityManager.flush(); entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); for (String tableName : tableNames) { entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); } entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); } }
5️⃣ 로그인 Controller 테스트
MemberControllerTest.java
@Test public void signInTest() { memberService.signUp(signUpDto); SignInDto signInDto = SignInDto.builder() .username("member") .password("12345678").build(); // 로그인 요청 JwtToken jwtToken = memberService.signIn(signInDto); // HttpHeaders 객체 생성 및 토큰 추가 HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.setBearerAuth(jwtToken.getAccessToken()); httpHeaders.setContentType(MediaType.APPLICATION_JSON); log.info("httpHeaders = {}", httpHeaders); // API 요청 설정 String url = "http://localhost:" + randomServerPort + "/members/test"; ResponseEntity<String> responseEntity = testRestTemplate.postForEntity(url, new HttpEntity<>(httpHeaders), String.class); assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(responseEntity.getBody()).isEqualTo(signInDto.getUsername()); // assertThat(SecurityUtil.getCurrentUsername()).isEqualTo(signInDto.getUsername()); // -> 테스트 코드에서는 인증을 위한 절차를 거치지 X. SecurityContextHolder 에 인증 정보가 존재하지 않는다. }
🖤 signInTest()
- 회원가입 성공한 username, password를 입력하여 로그인 진행
- 로그인 성공하면 JwtToken 정상적으로 발급
- httpHeader에 발급받은 JwtToken 객체의 accessToken 넣어주고 MediaType JSON으로 지정
- HttpEntity에 위에서 설정한 httpHeader 등록한 후, testRestTemplate의 postForEntity 메서드 실행
- responseEntitiy의 상태값과 담겨온 객체 내용이 예상값과 일치하는지 확인
💡 테스트 코드에서의 SecurityUtil 사용
테스트 코드에서는 인증을 위한 절차를 거치지 않는다.
따라서 SecurityContextHolder에 인증 정보가 존재하지 않기 때문에, 직접적으로 SecurityUtil을 사용하는 것이 불가능하다.
📋 참고 자료
728x90'Spring' 카테고리의 다른 글
[Spring] @GeneratedValue | 엔티티의 기본 키 생성 전략 (0) 2023.06.18 [Spring] TDD와 단위 테스트 (0) 2023.06.18 [Spring] Spring Security + JWT 로그인 구현하기 - 3 (0) 2023.06.15 [Spring] Spring Security + JWT 로그인 구현하기 - 2 (17) 2023.06.15 [Spring] Spring Security + JWT 로그인 구현하기 - 1 (4) 2023.06.14