Restful API 구현을 위한 Spring Security 설정해보기 #4 (AuthenticationEntryPoint, AccessDeniedHandler 구현)

안녕하세요.

지난 시간에는 /auth Path에 대하여 무조건 인증을 해보고 성공적인 Response를 반환받아보는 과정을 겪어보았습니다. 

https://sas-study.tistory.com/360

 

Restful API 구현을 위한 Spring Security 설정해보기 #3 (인증 Filter)

안녕하세요.! 지난 시간에 UserDetails 와 UserDetailsService 인터페이스를 구현하여 인증대상객체와 인증 서비스 로직을 구현해보았습니다. 지난 포스팅을 먼저 확인해주시고 이번 내용을 따라와주시�

sas-study.tistory.com

 

이제 /permit-all Path에 대해서는 사실 따로 적용해볼 설정은 없습니다. -> 완전 퍼블릭한 API이기 때문입니다. 누구나 접근 가능하지요.

 

근데 /auth는 여러가지 상황에 의해서 처리되어야할 단계가 있습니다.

 

그 단계에 따라서 추가적인 Spring Security 설정작업을 해보겠습니다.

 

AuthenticationEntryPoint

/auth 라는 Path는 스프링 시큐리티 컨텍스트 내에 존재하는 인증절차를 거쳐 통과해야합니다.

 

그러나 인증과정에서 실패하거나 인증헤더(Authorization)를 보내지 않게되는 경우 401(UnAuthorized) 라는 응답값을 받게되는데요.

 

이를 처리해주는 로직이 바로 AuthenticationEntryPoint라는 인터페이스입니다.

 

Response에 401이 떨어질만한 에러가 발생할 경우 해당로직을 타게되어, commence라는 메소드를 실행하게됩니다.

 

인터페이스를 까봐서 안의 주석을 살펴보면

 

제가 맞게 해석한건지 모르겠지만 Response 의 헤더를 수정하라고 합니다. 

 

뿐만 아니라 제가 설계한 API로는 직접적인 에러 내용에 대해서는 Response Body에 내려주도록 설계하였으므로 그 과정을 수행하는 소스를 짜보았습니다.

 

CustomAuthenticationEntryPoint 구현 클래스

@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private static ExceptionResponse exceptionResponse = 
           new ExceptionResponse(HttpStatus.UNAUTHORIZED.value(), "UnAuthorized!!!", null);

    @Override
    public void commence(HttpServletRequest httpServletRequest, 
                         HttpServletResponse httpServletResponse, 
                         AuthenticationException e) throws IOException, ServletException {
        log.error("UnAuthorizaed!!! message : " + e.getMessage());
        //response에 넣기
        httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        try (OutputStream os = httpServletResponse.getOutputStream()) {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.writeValue(os, exceptionResponse);
            os.flush();
        }
    }
}

Response의 헤더를 수정해야하기 때문에 contentType과 status를 수정하였고 추가적으로 항상 이로직을 타게되면 401(UnAuthorized) 상태를 반환하기때문에 고정적인 Response Body를 내려주려고 합니다. 

 

따라서 변수 exceptionResponse를 선언하여 고정적인 body 형태를 objectMapper를 이용하여 response body에 담아줍니다.

 

이는 또한 Spring Security 컨텍스트 내에서 관리되어야 하므로 @Component로 빈이 되어야하며 SecurityConfig 설정에도 HttpSecurity 정보에 exceptionHandler에 대한 정보로 추가해주어야 합니다.

 

SecurityConfig.java configure(HttpSecurity) 메소드 수정.

... 이전 코드들

http
                .cors().and() //cors Filter 사용
                .csrf().disable()
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                .exceptionHandling()
                    .authenticationEntryPoint(authenticationEntryPoint)
                    .and()
                .authorizeRequests()
                    .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .antMatchers("/api/v1/test/permit-all").permitAll()
                .antMatchers("/api/v1/test/auth").authenticated()
                
.. 이후 코드들

 

그리고 인증을 실패하기 위해 인증로직을 담당하고있는 SecurityAuthenticationFilter의  내용을 잠깐 수정해보겠습니다. loadUserByUsername의 파라미터를 인증되지 않게끔 넘길 생각입니다.

public class SecurityAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
                                    
        // 이부분을 추가하여 /auth로 들어오는 경우는 username이 "test1"이기 때문에 인증을 실패합니다.
        String username = request.getRequestURI().contains("/auth") ? "test1" : "test";
        
        UserDetails authentication = customUserDetailsService.loadUserByUsername(username);
        
        //여기있는 super.setAuthenticated(true); 를 타야함.
        UsernamePasswordAuthenticationToken auth = 
                  new UsernamePasswordAuthenticationToken(authentication.getUsername(), null, null);
        SecurityContextHolder.getContext().setAuthentication(auth);
        filterChain.doFilter(request, response);
    }
}

 

 

결과 확인

 

 


 

AccessDeniedHandler

다음은 401(UnAuthorized)과 가장 헷갈리는 403(Forbidden) 에 대한 처리입니다.

 

이는 권한에 대한 처리인데요. 인증에서 한단계 더 진보적인 보안수단입니다.

 

예컨데 A는 슈퍼유저, B는 일반유저라 했을 때. 프로그램 전반적인 설정을 수행하는 API를 호출하게 되었습니다. 이 때 슈퍼유저인 A유저만 이 API를 수행할 권한을 가져야 할텐데요. 

 

이렇듯 인증과는 별개로 추가적으로 확인되어야하는 권한입니다. 이것이 충족되지 않으면 403(Forbidden) 접근금지 타입을 반환합니다.

 

이는 AccessDeniedHandler라는 인터페이스를 구현한 클래스를 통해서 처리가 가능합니다.

 

/auth는 이제 권한이 없어서 403을 리턴받는 예제를 보여드리도록 하겠습니다.

 

AccessDeniedHandler.java

@Component
@Slf4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private static ExceptionResponse exceptionResponse = 
                new ExceptionResponse(HttpStatus.FORBIDDEN.value(), "Forbidden!!!", null);

    @Override
    public void handle(HttpServletRequest httpServletRequest, 
                       HttpServletResponse httpServletResponse, 
                       AccessDeniedException e) throws IOException, ServletException {
                       
        log.error("Forbidden!!! message : " + e.getMessage());

        //response에 넣기
        httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
        httpServletResponse.setStatus(HttpStatus.FORBIDDEN.value());
        try (OutputStream os = httpServletResponse.getOutputStream()) {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.writeValue(os, exceptionResponse);
            os.flush();
        }
    }
}

AuthenticationEntryPoint 때의 로직과는 크게 변하지 않습니다. 인터페이스 이름 또한 AccessDenied되었을 때 처리되는 handler라는 명칭으로 아주 명시적입니다.

 

그에 대한 처리를 하였습니다. 마찬가지로 고정적인 Response Body 형태가 필요하여 exceptionResponse 변수를 선언하고 Body에 담아주었습니다.

 

이번에는 인증은 성공하고 권한은 없어야하기 때문에 위에서 설정했던 SecurityAuthenticationFilter 필터의 내용중 username을 결정하는 부분을 다음과같이 수정해서 /auth 도 인증은 통과하도록 할 것입니다.

 

SecurityAuthenticationFilter 수정

public class SecurityAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        String username = "test";
        
        UserDetails authentication = customUserDetailsService.loadUserByUsername(username);
        
        //여기있는 super.setAuthenticated(true); 를 타야함.
        UsernamePasswordAuthenticationToken auth = 
                  new UsernamePasswordAuthenticationToken(authentication.getUsername(), null, null);
        SecurityContextHolder.getContext().setAuthentication(auth);
        filterChain.doFilter(request, response);
    }
}

이렇게 하면 당장 인증은 통과할 것입니다. 

 

하지만 스프링 시큐리티 컨텍스트가 이 403(Forbidden)이 발생할 것을 인지해야하므로 SecurityConfig에 등록을 해주어야합니다.

 

또한, 권한 체크는 아직 진행되지 않아서 이대로는 그냥 200(ok) 코드가 떨어질 것입니다.

 

CustomAccessDeniedHandler를 등록하고 권한 체크하는 설정을 SecurityConfig.java에 추가해보도록 하겠습니다.

 

SecurityConfig.java

... 이전 코드들

http
                .cors().and()
                .csrf().disable()
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                .exceptionHandling()
                    .authenticationEntryPoint(authenticationEntryPoint)
                    //추가해줍니다.
                    .accessDeniedHandler(accessDeniedHandler)
                    .and()
                .authorizeRequests()
                    .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .antMatchers("/api/v1/test/permit-all").permitAll()
                .antMatchers("/api/v1/test/auth").hasRole("AUTH") // 수정해줍니다.
                
.. 이후 코드들

이로써 /auth 의 url에는 AUTH 라는 Role이 들어있어야 통과가 될것입니다.

 

확인해보겠습니다.

 

Forbidden이 떴습니다.!!

 

그럼 이번에는 Role을 추가해보고 200코드를 받아보겠습니다.

 

먼저 SecurityUser에 Role 관련한 필드를 추가해야합니다.

public class SecurityUser implements UserDetails {

    private String username;
    private Collection<? extends GrantedAuthority> authorities;

    public SecurityUser(String username, List<String> roles) {
        this.username = username;
        this.authorities = Optional.ofNullable(roles)
                .orElse(Collections.emptyList())
                .stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }
    
    .. 이후 코드 동일

생성자로 인스턴스를 authorities 필드를 초기화해줍니다. 이 필드는 loadUserByUsername에서 SecurityUser를 반환할 때 Role값을 넣어줄 것입니다.

 

 

CustomUserDetailsService 클래스의 loadUserByUsername 메소드를 다음과 같이 수정해줍니다.

@Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        if (!s.equals("test")) throw new UsernameNotFoundException("해당 유저가 존재하지 않습니다.");
        return new SecurityUser(s, Arrays.asList("ROLE_AUTH"));
    }

위에서 hasRole("AUTH") 라고 주게되었을 경우에 Spring Security에는 Role_라는 prefix가 자동으로 붙게됩니다. 따라서 ROLE_AUTH로 지정해줍니다.

 

그렇게 된다면 SecurityUser 타입의 UserDetails가 ROLE_AUTH라는 권한을 갖게 되므로 SecurityConfig에서 설정한 스펙을 모두 반영해보았습니다.

 

결과확인하시죠!!

 

 

/auth 인증성공! 권한부여 성공!

 

감사합니다!!

댓글

Designed by JB FACTORY