본문 바로가기
카테고리 없음

JWT + Spring Security 를 이용한 로그인 고찰 - 2

by onejunu 2022. 3. 1.

https://onejunu.tistory.com/137

 

JWT + Spring Security 를 이용한 로그인 고찰 - 1

https://spring.io/guides/topicals/spring-security-architecture Spring Security Architecture this topical is designed to be read and comprehended in under an hour, it provides broad coverage of a top..

onejunu.tistory.com

 

1편에 이어서 이어가도록 하겠습니다.

 

인증과 관련된 부품들을 다 만들었기때문에 동작하도록 하는 service 와 controller를 만들어보겠습니다. 여기서 헷갈리면 안되는 부분중 하나가 토큰을 통해 Authentication 정보를 가져오는 부분입니다. Token의 파싱로직이라던지 해싱알고리즘이라던지 디테일한 부분은 관심이 없고 중요한건 Token은 유저의 정보를 가지고 있고 만료여부를 알 수 있다는 것입니다. Token을 통해 유저의 정보를 꺼내온다음 Authentication의 구현체인 CustomEmailPasswordAuthToken을 리턴하는 것입니다. 이때 password가 비어있는 이유는 토큰이 유효하다면 이미 인증을 마친 토큰이므로 email정보만 있으면 되기 때문에 password는 필요가 없습니다.

 

AuthService.java 
package com.memo.backend.service.auth;

import com.memo.backend.domain.Authority.Authority;
import com.memo.backend.domain.Authority.AuthorityRepository;
import com.memo.backend.domain.Authority.MemberAuth;
import com.memo.backend.domain.jwt.RefreshToken;
import com.memo.backend.domain.jwt.RefreshTokenRepository;
import com.memo.backend.domain.member.Member;
import com.memo.backend.domain.member.MemberRepository;
import com.memo.backend.dto.jwt.TokenDTO;
import com.memo.backend.dto.jwt.TokenReqDTO;
import com.memo.backend.dto.login.LoginReqDTO;
import com.memo.backend.dto.member.MemberReqDTO;
import com.memo.backend.dto.member.MemberRespDTO;
import com.memo.backend.exceptionhandler.AuthorityExceptionType;
import com.memo.backend.exceptionhandler.BizException;
import com.memo.backend.exceptionhandler.JwtExceptionType;
import com.memo.backend.exceptionhandler.MemberExceptionType;
import com.memo.backend.jwt.CustomEmailPasswordAuthToken;
import com.memo.backend.jwt.TokenProvider;
import com.memo.backend.service.member.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManager authenticationManager;
    private final MemberRepository memberRepository;
    private final AuthorityRepository authorityRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final CustomUserDetailsService customUserDetailsService;



    @Transactional
    public MemberRespDTO signup(MemberReqDTO memberRequestDto) {
        if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
            throw new BizException(MemberExceptionType.DUPLICATE_USER);
        }

        // DB 에서 ROLE_USER를 찾아서 권한으로 추가한다.
        Authority authority = authorityRepository
                .findByAuthorityName(MemberAuth.ROLE_USER).orElseThrow(()->new BizException(AuthorityExceptionType.NOT_FOUND_AUTHORITY));

        Set<Authority> set = new HashSet<>();
        set.add(authority);


        Member member = memberRequestDto.toMember(passwordEncoder,set);
        log.debug("member = {}",member);
        return MemberRespDTO.of(memberRepository.save(member));
    }

    @Transactional
    public TokenDTO login(LoginReqDTO loginReqDTO) {
        CustomEmailPasswordAuthToken customEmailPasswordAuthToken = new CustomEmailPasswordAuthToken(loginReqDTO.getEmail(),loginReqDTO.getPassword());
        Authentication authenticate = authenticationManager.authenticate(customEmailPasswordAuthToken);
        String email = authenticate.getName();
        Member member = customUserDetailsService.getMember(email);

        String accessToken = tokenProvider.createAccessToken(email, member.getAuthorities());
        String refreshToken = tokenProvider.createRefreshToken(email, member.getAuthorities());

        //refresh Token 저장
        refreshTokenRepository.save(
                RefreshToken.builder()
                        .key(email)
                        .value(refreshToken)
                        .build()
        );

        return tokenProvider.createTokenDTO(accessToken,refreshToken);

    }

    @Transactional
    public TokenDTO reissue(TokenReqDTO tokenRequestDto) {
        /*
        *  accessToken 은 JWT Filter 에서 검증되고 옴
        * */
        String originAccessToken = tokenRequestDto.getAccessToken();
        String originRefreshToken = tokenRequestDto.getRefreshToken();

        // refreshToken 검증
        int refreshTokenFlag = tokenProvider.validateToken(originRefreshToken);

        log.debug("refreshTokenFlag = {}", refreshTokenFlag);

        //refreshToken 검증하고 상황에 맞는 오류를 내보낸다.
        if (refreshTokenFlag == -1) {
            throw new BizException(JwtExceptionType.BAD_TOKEN); // 잘못된 리프레시 토큰
        } else if (refreshTokenFlag == 2) {
            throw new BizException(JwtExceptionType.REFRESH_TOKEN_EXPIRED); // 유효기간 끝난 토큰
        }

        // 2. Access Token 에서 Member Email 가져오기
        Authentication authentication = tokenProvider.getAuthentication(originAccessToken);

        log.debug("Authentication = {}",authentication);

        // 3. 저장소에서 Member Email 를 기반으로 Refresh Token 값 가져옴
        RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                .orElseThrow(() -> new BizException(MemberExceptionType.LOGOUT_MEMBER)); // 로그 아웃된 사용자


        // 4. Refresh Token 일치하는지 검사
        if (!refreshToken.getValue().equals(originRefreshToken)) {
            throw new BizException(JwtExceptionType.BAD_TOKEN); // 토큰이 일치하지 않습니다.
        }

        // 5. 새로운 토큰 생성
        String email = tokenProvider.getMemberEmailByToken(originAccessToken);
        Member member = customUserDetailsService.getMember(email);

        String newAccessToken = tokenProvider.createAccessToken(email, member.getAuthorities());
        String newRefreshToken = tokenProvider.createRefreshToken(email, member.getAuthorities());
        TokenDTO tokenDto = tokenProvider.createTokenDTO(newAccessToken, newRefreshToken);

        log.debug("refresh Origin = {}",originRefreshToken);
        log.debug("refresh New = {} ",newRefreshToken);
        // 6. 저장소 정보 업데이트 (dirtyChecking으로 업데이트)
        refreshToken.updateValue(newRefreshToken);

        // 토큰 발급
        return tokenDto;
    }
}
AuthController.java
package com.memo.backend.restcontroller.auth;

import com.memo.backend.dto.jwt.TokenDTO;
import com.memo.backend.dto.jwt.TokenReqDTO;
import com.memo.backend.dto.login.LoginReqDTO;
import com.memo.backend.dto.member.MemberReqDTO;
import com.memo.backend.dto.member.MemberRespDTO;
import com.memo.backend.service.auth.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
/**
 * AuthController 설명 : auth controller
 * @author jowonjun
 * @version 1.0.0
 * 작성일 : 2022/02/14
**/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

        private final AuthService authService;

        @PostMapping("/signup")
        public MemberRespDTO signup(@RequestBody MemberReqDTO memberRequestDto) {
            log.debug("memberRequestDto = {}",memberRequestDto);
            return authService.signup(memberRequestDto);
        }

        @PostMapping("/login")
        public TokenDTO login(@RequestBody LoginReqDTO loginReqDTO) {
            return authService.login(loginReqDTO);
        }

        @PostMapping("/reissue")
        public TokenDTO reissue(@RequestBody TokenReqDTO tokenRequestDto) {
            return authService.reissue(tokenRequestDto);
        }
}

 

1편에서 설명드렸던 JWT인증과정과 똑같습니다. 다음 차례는 JwtFilter 설정입니다. 로그인과 재발행 요청에 대해서는 토큰에 대해 검사하면 안되고 그외 모든 요청에 대해서는 검사를 해야합니다. 그리고 filter 에서 한번 검사하고 난 뒤 requestDispatcher에 의해 다른 요청으로 forward 된다면 또 다시 filter에서 검사할 수 있기 때문에 한 번 들어온 요청에 대해서는 한번만 인증을 거치도록 하는 필터를 구현해야 합니다. 이를 위해서는 OncePerRequestFilter 를 확장하여 구현하면 됩니다. 제가 만들 필터의 이름은 JwtFilter 입니다.

 

JwtFilter.java
package com.memo.backend.jwt;

import com.memo.backend.exceptionhandler.BizException;
import com.memo.backend.exceptionhandler.JwtExceptionType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Enumeration;

/**
 * JwtFilter 설명 : 디스패처 포워딩이 되어도 단 한번만 실행되는 필터
 * @author jowonjun
 * @version 1.0.0
 * 작성일 : 2022/02/20
**/
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        if(request.getServletPath().startsWith("/auth")) {
            filterChain.doFilter(request,response);
        }else {
            String token = resolveToken(request);

            log.debug("token  = {}",token);
            if(StringUtils.hasText(token)) {
                int flag = tokenProvider.validateToken(token);

                log.debug("flag = {}",flag);
                // 토큰 유효함
                if(flag == 1) {
                    this.setAuthentication(token);
                }else if(flag == 2) { // 토큰 만료
                    response.setContentType("application/json");
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.setCharacterEncoding("UTF-8");
                    PrintWriter out = response.getWriter();
                    log.debug("doFilterInternal Exception CALL!");
                    out.println("{\"error\": \"ACCESS_TOKEN_EXPIRED\", \"message\" : \"엑세스토큰이 만료되었습니다.\"}");
                }else { //잘못된 토큰
                    response.setContentType("application/json");
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.setCharacterEncoding("UTF-8");
                    PrintWriter out = response.getWriter();
                    log.debug("doFilterInternal Exception CALL!");
                    out.println("{\"error\": \"BAD_TOKEN\", \"message\" : \"잘못된 토큰 값입니다.\"}");
                }
            }
            else {
                response.setContentType("application/json");
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.setCharacterEncoding("UTF-8");
                PrintWriter out = response.getWriter();
                out.println("{\"error\": \"EMPTY_TOKEN\", \"message\" : \"토큰 값이 비어있습니다.\"}");
            }
        }
    }

    /**
     *
     * @param token
     * 토큰이 유효한 경우 SecurityContext에 저장
     */
    private void setAuthentication(String token) {
        Authentication authentication = tokenProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        // bearer : 123123123123123 -> return 123123123123123123
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

/auth 로 시작하는 모든 요청은 그냥 통과합니다. 그외에는 토큰의 값을 검사합니다. reponse에 직접 에러의 정보를 하드 코딩했는데 이는 Filter에서는 @ExceptionHandler 가 먹히지 않기 때문입니다. 좀 더 좋은 방안이 있을 거 같습니다. 

 

JwtFilter를 SpringSecurity 설정에 추가해봅시다. 커스터마이징한 CustomEmailPasswordAuthProvider 는 AuthenticationManagerBuilder 를 통해서 추가 할 수 있습니다.

 

UsernamePasswordAuthenticationFilter 앞에 추가한 이유는 딱히 없지만 SecurityContext를 쓰기 위해서 앞단의 필터들을 지나야 하므로 UsernamePasswordAuthenticationFilter 에서도 SecurityContext를 사용하는 것으로 보아 (정확히는 AbstractAuthenticationProcessingFilter 의 successfulAuthentication 메서드) UsernamePasswordAuthenticationFilter 이전에 추가하는 사람들이 많은 거 같습니다.

SecurityConfig.java
package com.memo.backend.config;

import com.memo.backend.jwt.*;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@EnableWebSecurity // 기본적인 웹보안을 사용하겠다는 것
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true) // @PreAuthorize 사용을 위함
public class SecurityConfig extends WebSecurityConfigurerAdapter { // WebSecurityConfigurerAdapter 를 확장하면 보안 관련된 설정을 커스터마이징 할 수 있음
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final CustomEmailPasswordAuthProvider customEmailPasswordAuthProvider;


    /*
    * AuthenticationManager를 주입받기 위해서 빈으로 등록한다.
    * */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customEmailPasswordAuthProvider);
    }
    // h2 database 테스트가 원활하도록 관련 API 들은 전부 무시
    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers("/h2-console/**", "/favicon.ico");
    }



    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // CSRF 설정 Disable
        http.csrf().disable()

                // exception handling 할 때 우리가 만든 클래스를 추가
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                /* iframe 관련 설정이고 X-frame-Options Click Jaking 공격을 기본적으로 막는걸로 설정되어있는데
                 이를 풀기위한 설정을 하려면 아래의 설정을 추가하면 됨 */
               /* .and()
                .headers()
                .frameOptions()
                .sameOrigin() */

                // 시큐리티는 기본적으로 세션을 사용
                // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
                .and()
                .authorizeRequests() // http servletRequest 를 사용하는 요청들에 대한 접근제한을 설정
                .antMatchers("/auth/**").permitAll()
                .antMatchers("/v3/api-docs", "/configuration/**", "/swagger*/**", "/webjars/**").permitAll() // swagger3

                .anyRequest().authenticated()   // 나머지 API 는 전부 인증 필요

                // JwtFilter 를 등록한다.
                // UsernamePasswordAuthenticationFilter 앞에 등록하는 이유는 딱히 없지만
                // SecurityContext를 사용하기 때문에 앞단의 필터에서 SecurityContext가 설정되고 난뒤 필터를 둔다.
                .and()
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);

    }
}

 

 

원래 SecurityConfig 파일안에 PasswordEncoder도 있었으나 빈 순환참조 에러 때문에 따로 빼서 빈으로 설정하였습니다. 그리고 스프링5 부터 PasswordEncoder 의 다양한 암호화 알고리즘의 변경성에 대응하기위해 빈으로 생성하는 방식이 변경되었습니다. 그리고 ExceptionHandling 에서 401 과 403 에러에 대해서 response.sendError 로 처리할 수 있으나 이 역시 하드코딩으로 json 형식을 작성했습니다.

 

ExceptionHandling 부분에 보면 entrypoint 와 accessDeniedHandler 가 있는데 각각 401과 403 에러입니다.

.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
package com.memo.backend.jwt;

import com.memo.backend.exceptionhandler.BizException;
import com.memo.backend.exceptionhandler.MemberExceptionType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.HandlerExceptionResolver;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException , ServletException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        out.println("{\"error\": \"NO_AUTHORIZATION\", \"message\" : \"인증정보가 없습니다.\"}");

    }
}
package com.memo.backend.jwt;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        log.debug("JwtAccessDeniedHandler CALL!");
        out.println("{\"error\": \"AUTH_FORBIDDEN\", \"message\" : \"권한이 없습니다.\"}");
    }
}
package com.memo.backend.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class AppConfig {
    /**
     *
     * 2022-02-26
     *
     * @return PasswordEncoder - Spring5 부터 인코더 생성 방법이 변경되었다.
     *  다양한 암호화 알고리즘을 변경가능하도록 한것으로 보인다.
     *   처음에는 SecurityConfig 에 빈으로 생성하였으나 SecurityConfig 에서 CustomEmailPasswordAuthProvider 를 의존하고
     *   CustomEmailPasswordAuthProvider 는 passwordEncoder(SecurityConfig)에 의존하여 순환참조가 일어나
     *   따로 빈을 만드는 AppConfig를 생성하였다.
     *
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // Default 사용
    }
}

 

1편과 2편에서 작성한 내용은 주로 스웨거로 테스트 하였으며 전체 코드는 아래에서 볼 수 있습니다.

https://github.com/hangeulisbest/memo

 

GitHub - hangeulisbest/memo

Contribute to hangeulisbest/memo development by creating an account on GitHub.

github.com

 

정확한 테스트를 위해서 Controller 테스트의 깊은 이해가 필요함을 느꼈습니다. 테스트코드에 대해 좀더 공부하고 업데이트를 해야겠다는 아쉬움이 듭니다. 아래 코드는 Service를 테스트한 테스트 코드이며 첨부하고 마무리 하겠습니다.

 

package com.memo.backend.service.auth;

import com.memo.backend.domain.Authority.Authority;
import com.memo.backend.domain.Authority.AuthorityRepository;
import com.memo.backend.domain.Authority.MemberAuth;
import com.memo.backend.domain.jwt.RefreshToken;
import com.memo.backend.domain.jwt.RefreshTokenRepository;
import com.memo.backend.domain.member.Member;
import com.memo.backend.domain.member.MemberRepository;
import com.memo.backend.dto.jwt.TokenDTO;
import com.memo.backend.dto.jwt.TokenReqDTO;
import com.memo.backend.dto.login.LoginReqDTO;
import com.memo.backend.dto.member.MemberReqDTO;
import com.memo.backend.exceptionhandler.BizException;
import com.memo.backend.exceptionhandler.MemberExceptionType;
import com.memo.backend.jwt.TokenProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class AuthServiceTest {
    @Autowired
    AuthService authService;

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    AuthorityRepository authorityRepository;

    @Autowired
    RefreshTokenRepository refreshTokenRepository;

    @Autowired
    EntityManager em;

    /**
     * 각 테스트 실행전에 실행
     */
    @BeforeEach
    public void beforeEach() {
        authorityRepository.save(new Authority(MemberAuth.ROLE_USER));
        authorityRepository.save(new Authority(MemberAuth.ROLE_ADMIN));

        MemberReqDTO dto = new MemberReqDTO();
        dto.setUsername("normalUser");
        dto.setEmail("normalUser@normalUser.com");
        dto.setPassword("1234");

        authService.signup(dto);
    }

    @DisplayName("회원가입이 정상적으로 된다")
    @Test
    @Transactional
    public void signup(){
        MemberReqDTO dto = new MemberReqDTO();
        dto.setUsername("user1");
        dto.setEmail("test@test.com");
        dto.setPassword("54321");

        authService.signup(dto);

        // 영속성 컨텍스트 플러쉬
        em.flush();
        em.clear();

        Optional<Member> ret = memberRepository.findByEmail("test@test.com");
        assertEquals(ret.get().getUsername(),"user1");
    }

    @DisplayName("이미 존재하는 회원인 경우 회원가입 불가")
    @Test
    @Transactional
    public void signupDuplicateMember() {
        MemberReqDTO dto = new MemberReqDTO();
        dto.setUsername("normalUser");
        dto.setEmail("normalUser@normalUser.com");
        dto.setPassword("1234");

        BizException bizException = assertThrows(BizException.class, () -> {
            authService.signup(dto);
        });

        // 이미 존재하는 사용자 입니다.
        assertEquals(bizException.getBaseExceptionType().getErrorCode(),"DUPLICATE_USER");
    }

    @DisplayName("이메일이 존재하지 않아 로그인에 실패한다.")
    @Test
    @Transactional
    public void loginFailBecauseNotFoundEmail() {
        //given
        LoginReqDTO dto = new LoginReqDTO();
        dto.setEmail("abc@abc.com");

        //when
        BizException bizException = assertThrows(BizException.class, () -> {
            authService.login(dto);
        });

        //then
        // 사용자를 찾을 수 없습니다.
        assertEquals(bizException.getBaseExceptionType().getErrorCode(), MemberExceptionType.NOT_FOUND_USER.getErrorCode());
    }

    @DisplayName("비밀번호를 입력하지 않아 로그인 실패")
    @Test
    @Transactional
    public void loginFailBecauseEmptyPassword() {
        // given
        LoginReqDTO dto = new LoginReqDTO();
        dto.setEmail("normalUser@normalUser.com");

        //when
        BizException bizException = assertThrows(BizException.class, () -> {
            authService.login(dto);
        });

        //then
        // 비밀번호를 입력해주세요.
        assertEquals(bizException.getBaseExceptionType().getErrorCode()
                ,MemberExceptionType.NOT_FOUND_PASSWORD.getErrorCode());

    }

    @DisplayName("비밀번호가 틀려서 로그인 실패")
    @Test
    @Transactional
    public void loginFailBecauseWrongPassword() {
        // given
        LoginReqDTO dto = new LoginReqDTO();
        dto.setEmail("normalUser@normalUser.com");
        dto.setPassword("12345"); // right password : 1234

        //when
        BizException bizException = assertThrows(BizException.class, () -> {
            authService.login(dto);
        });

        //then
        // 비밀번호를 잘못 입력하였습니다.
        assertEquals(bizException.getBaseExceptionType().getErrorCode()
                ,MemberExceptionType.WRONG_PASSWORD.getErrorCode());

    }

    @DisplayName("로그인에 성공한다")
    @Test
    @Transactional
    public void loginSuccess() {
        // given
        LoginReqDTO dto = new LoginReqDTO();
        dto.setEmail("normalUser@normalUser.com");
        dto.setPassword("1234"); // right password : 1234

        TokenDTO login = authService.login(dto);
        assertEquals(login.getGrantType(),"Bearer");
    }

}

 

댓글