뉴스피드 프로젝트

[뉴스피드 프로젝트] 인증 필터와 인가 필터 동작 알아보기

Ynghan 2024. 4. 24. 17:10

이전 게시글에서 등록한 인증 필터와 인가 필터의 동작이 어떻게 이루어 지는지 알아보자.

일단, 8080포트에 사용자의 요청이 들어오면 Spring Security를 사용하지 않는 경우에는 디스패처 서블릿(Dispatcher Servlet)이 처음 동작한다.

그러나, Spring Security를 사용하는 경우에는 Security Filter를 거친 후에 Dispatcher Servlet에 도착한다.

Dispatcher Servlet은 api 주소를 파싱하여 컨트롤러를 호출하고 컨트롤러는 서비스, 서비스는 레포지토리, 레포지토리는 영속성 컨텍스트를 호출하는 방식으로 동작한다.

이때, Dispatcher Servlet에서부터 영속성 컨텍스트까지의 동작에서 예외가 발생하는 경우에는 @RestControllerAdvice를 사용한 CustomExceptionHandler 클래스를 통해 예외 처리가 가능하지만 Security Filter 내부에서 예외가 발생하는 경우 해당 클래스를 사용할 수 없다.

@RestControllerAdvice
public class CustomExceptionHandler {

    @ExceptionHandler(CustomApiException.class)
    public ResponseEntity<?> apiException(CustomApiException e) {
        return new ResponseEntity<>(new ResponseDto<>(-1, e.getMessage(), null), HttpStatus.BAD_REQUEST);
    }
    
    ...
    
}

 

그렇다면 필터 내부에서 일어나는 인증/인가 과정과 예외는 어떻게 동작하는지 알아보도록 하자.

 

인증 필터(UserPasswordAuthenticationFilter)

 

기본적으로 인증필터는 특정 api 주소에만 동작한다. 저자는 "/api/login" 주소에 동작하도록 설정하였다.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        setFilterProcessesUrl("/api/login");
        this.authenticationManager = authenticationManager;
    }
	...
}

해당 주소로 요청이 들어오면 UserPasswordAuthenticationFilter에서 이를 인터셉트하여 처리한다.

@Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        log.debug("디버그 : JwtAuthenticationFilter attemptAuthentication()");

        try {
            ObjectMapper om = new ObjectMapper();
            UserReqDto.LoginReqDto loginReqDto = om.readValue(request.getInputStream(), UserReqDto.LoginReqDto.class);

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    loginReqDto.getUsername(),
                    loginReqDto.getPassword());

            // loginUserService 호출 코드
            return authenticationManager.authenticate(authenticationToken);
        } catch (Exception e) {
            /*
             * InternalAuthenticationServiceException 해당 Exception으로 보내야
             * authenticationEntryPoint()로 처리됨.
             */
            throw new InternalAuthenticationServiceException(e.getMessage());
        }
    }

이때, attemptAuthentication 메서드가 동작한다. 

- username과 password를 파싱하여 LoginRequestDto로 변환한다.

- UserPasswordAuthenticationToken(인증 토큰)을 만든다.

- 인증 토큰을 사용해 AuthenticationManger 객체에게 authenticate 메서드를 호출한다.

@ControllerAdvice
@Service
public class LoginService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User userPS = userRepository.findByUsername(username).orElseThrow(
                () -> new InternalAuthenticationServiceException("인증 실패") // 나중에 테스트할 때 설명해드림
        );
        return new LoginUser(userPS);
    }
}

authenticate 메서드는 실질적으로 UserDetailsService 구현체의 loadUserByUsername 메서드를 호출하여 DB에서 사용자 정보를 조회한다.

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException failed) throws IOException, ServletException {
        CustomResponseUtil.fail(response, "로그인실패", HttpStatus.UNAUTHORIZED);
    }

만약, 조회가 실패한다면 예외를 터뜨리고 UsernamePasswordAuthenticationFilter의 unsuccessfulAuthentication메서드를 호출한다.

조회가 성공한다면 그대로 loadByUsername 메서드는 UserDatails 객체를 리턴한다.

이때, UserDetails 객체는 SecurityContextHolder에 Authentication 객체를 만들어 SecurityContext로 감싸서 저장하게된다.

attemptAuthentication 메서드는 저장된 Authentication 객체를 리턴하면서 끝난다.

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        log.debug("디버그 : JwtAuthenticationFilter successfulAuthentication()");
        // 1. 세션에 있는 loginUser 가져오기
        LoginUser loginUser = (LoginUser) authResult.getPrincipal();

        // 2. 세션값으로 토큰 생성
        String jwtToken = JwtProcess.create(loginUser);

        // 3. 토큰을 헤더에 담기
        response.addHeader(JwtVO.HEADER, jwtToken);

        // 4. 토큰 담아서 성공 응답하기
        UserRespDto.LoginRespDto loginRespDto = new UserRespDto.LoginRespDto(loginUser.getUser());
        CustomResponseUtil.success(response, loginRespDto);
    }

Authentication 객체가 리턴이되면 successfulAthentication 메서드가 호출되어 JWT 토큰을 생성하여 응답 헤더에 담아 사용자에게 응답한다.


인가 필터(BasicAuthenticationFilter)

 

다음으로 인가 필터의 동작을 알아보자.

인가 필터는 모든 api 주소에 적용할 수 있고, 특정 api 주소에만 적용할 수도 있다. 저자는 아래와 같이 "/api/s/**" 요청에 대해서 인가 기능을 적용하도록 설정해두었다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        ...

        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers("/api/s/**").authenticated()
        );

        return http.build();
    }

 

해당 주소로 요청이 들어오면 인가를 위한 필터인 BasicAuthenticationFilter의 doFilter 메서드가 동작한다.

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    ...
}

 

해당 메서드는 Header의 JWT 정보를 파싱해서 verify 메서드를 호출하여 검증한다.

검증이 실패하면 예외가 발생하여 터지고, 예외없이 처리되면 UserDetails 객체가 생긴다.

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        ...
		
        LoginUser loginUser = JwtProcess.verify(token);
            
        ...
    }

 

 

이때, UserDetails 객체를 사용하여 Authentication 객체를 만들고 해당 객체를 SecurityContextHolder에 담는다.

            
            
@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        ...
		
        Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
            
        ...
    }

 

 

이때, UserDetails가 가지는 상태 값에는 username과 role이 전부다.

SecurityContextHolder에 들어간 객체의 값은 인증된 정보임을 보장한다.

해당 요청은 doFilter()를 통해 여러 필터들을 거치고 나서 Dispatcher Servlet에서 Controller를 파싱해준다.

인증이 필요한 Controller에서는 SecurityContextHolder에 들어 있는 객체의 값을 확인하게 된다.

예를 들어 "/api/admin"과 같은 주소에는 ADMIN 권한을 가진 사용자만이 접근할 수 있다고 가정하면, 해당 주소에 파싱된 컨트롤러에서는 먼저, SecurityContextHolder에 Authentication 객체가 있는지 확인하고, Authentication 객체에 있는 Role 값을 확인한다.

해당 Authentication 객체가 없거나, Authentication 객체의 Role 값이 ADMIN이 아니라면 요청을 거부하고, 이후로 SecurityContextHolder의 Authentication 객체는 사라진다.

 

이상으로 포스팅을 마친다.

출처

스프링부트 - JUnit 테스트