[Spring Security] 예외 처리 및 요청 캐시 필터

식빵·2022년 8월 14일
0
post-thumbnail

이 시리즈에 나오는 모든 내용은 인프런 인터넷 강의 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 에서 기반된 것입니다. 그리고 여기서 인용되는 PPT 이미지 또한 모두 해당 강의에서 가져왔음을 알립니다.


Spring Security 의 주요 예외인 AuthenticationExceptionAccessDeniedException을 처리해주는 ExceptionTranslationFilter, 그리고 기존 Request 를 잠시 캐싱해주는 RequestCacheAwareFilter 를 알아보자.



🥝 필터 설명

1. ExceptionTranslationFilter

AuthenticationException, AccessDeniedException 를 처리해주는 필터이다.
이 예외를 던지는 것은 FilterSecurityInterceptor 필터이다.

FilterSecurityInterceptor는 Spring Security 가 관리하는 필터들 중에서도
가장 마지막에 존재하며 이 필터 바로 앞에 있는 것이 ExceptionTranslationFilter이다.

요약하면...

  • FilterSecurityInterceptor : 예외 터뜨림
  • ExceptionTranslationFilter : 예외 처리

2. RequestCacheAwareFilter

AuthenticationException 가 터지면 대부분 로그인 페이지로 이동시킨다.
그런데 로그인 페이지에서 로그인 이후에 본래에 가려고 하던 곳으로 갈 때 필요한 필터다.



🥝 예외 처리 흐름


앞서 AuthenticationException, AccessDeniedException 예외를 처리해준다고 했다.

그 처리 흐름이 어떻게 이루어지는지 큰 흐름을 파악해보자.

AuthenticationException(인증예외) 처리

  1. AuthenticationEntryPoint 를 호출한다.
    기본적으로는 로그인 페이지로 이동 + HTTP 상태코드 401 세팅을 한다.

  2. 인증 예외가 발생하기 전의 요청 정보를 세션에 저장한다. 개발자는 RequestCache를 통해서 SavedRequest 를 추출하여 이전 요청 정보를 꺼내올 수 있다.


AccessDeniedException(인가예외) 처리

  1. AccessDeniedHandler 에 의한 예외 처리를 위임한다.



참고: AuthenticationEntryPoint를 추가 여부에 따른 디폴트 로그인 페이지 제공

커스텀 AuthenticationEntryPoint 적용 여부에 따라 스프링 시큐리티가
제공하는 기본 로그인 페이지는 활성화/비활성화된다는 점 주의하길 바란다.

이러는 이유는 디폴트 로그인 페이지 필터를 제공할지 말지를 결정하는
DefaultLoginPageConfigurer 의 코드를 보면 알 수 있다.

분기문을 자세히 보면 exceptionConf, 즉 예외처리 관련 설정에서 authenticationEntryPoint 에 대한 설정을 읽어오고, 만약 null 이라면 default 로그인 페이지 필터를 시큐리티에 제공하고, 아니면 넣지 않는 것을 확인할 수 있다.

만약 우리가 스프링 시큐리티에 설정을 해주면 null 이 아님을 확인할 수 있다.




🥝 예외 처리 코드 관찰


전반적인 프로세스를 봤으니 코드로 이 동작을 확인해보자.

1. 코드 세팅

일단 Spring Security 설정을 해주자.

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/favicon.ico");
    }
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        
        auth.inMemoryAuthentication().withUser("user")
        							.password("{noop}1111").roles("USER");
        
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
            .authorizeRequests()
            .antMatchers("/login").permitAll()
            .antMatchers("/user").hasRole("USER")
            .anyRequest().authenticated();
    
        http.formLogin()
            .successHandler((request, response, authentication) -> {
                HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
                
                // 세션에서 이미 저장되어 있는 이전 요청 정보를 추출!
                SavedRequest savedRequest = requestCache.getRequest(request, response);
                String redirectUrl = savedRequest.getRedirectUrl();
                System.out.println("redirectUrl = " + redirectUrl);
                
                // 그 이전 요청 위치로 이동!
                response.sendRedirect(redirectUrl);
            });
        
        http.exceptionHandling()
            .authenticationEntryPoint((request, response, authException) -> {
                // 주의!!! AuthenticationEntryPoint를 직접 구현하게 되면
                // 우리가 만든 로그인 페이지로 이동하게 된다. 
                // 스프링 시큐리티가 제공하는 로그인 페이지가 아니다!
                response.sendRedirect("/login");
            })
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.sendRedirect("/denied");
            });
        
    }
    
}

그리고 이런 URL 을 지원하는 Controller 를 하나 생성한다.

@RestController
public class SecurityController {
    
    @GetMapping("/")
    public String index() {
        return "home";
    }
    
    @GetMapping("/user")
    public String user() {
        return "user";
    }
    
    @GetMapping("/login")
    public String login() {
        return "login";
    }
    
    
    @GetMapping("/denied")
    public String denied() {
        return "denied";
    }
}



2. 디버깅 포인트 잡기

ExceptionTranslationFilter

  • handleAccessDeniedException 메소드
  • sendStartAuthentication 메소드

HttpSessionRequestCache

  • saveRequest 메소드



3. 서버 실행 및 테스트

3-1. ExceptionTranslationFilter 내부

먼저 서버를 켜고 루트 경로에 접급해보자.
ExceptionTranslationFilter.handleAccessDeniedException 메소드에서 디버깅
포인트가 활성화되는 것을 볼 수 있다.
if (isAnonymous || ~~) 에서 true 가 나와서 분기문 내용으로 들어간다.
익명사용자라는 것을 의미한다.

아무튼 익명사용자로 판단이 되어서 sendStartAuthentication 메소드가 호출된다.


sendStartAuthentication 메소드가 하는 일은 크게 2가지임을 알 수 있다.

  • requestCache.saveRequest : 이전 요청 저장
  • authenticationEntryPoint.commence : AuthenitcationEntryPoint 호출

3-2. HttpSessionRequestCache

sendStartAuthentication 메소드 내부에서 requestCache.saveRequest 호출하면
위 그림의 메소드가 수행된다. 내부적으로 세션에 이전 요청을 저장하는 것을 확인할 수 있다.


3-3. 자신이 작성한 Spring Security 설정 클래스

sendStartAuthentication 메소드 내부에서 authenticationEntryPoint.commence 호출하면 위 그림의 메소드가 수행된다. 그러면 자신이 작성했던 스프링 시큐리티 클래스의
AuthenticationEntryPoint 내용이 수행된다.


3-4. 우리가 만든 로그인 화면 표출

최종적으로 우리가 작성한 로그인 페이지(?)가 표출된다.




4. 스프링 시큐리티 설정 수정

@Override
protected void configure(HttpSecurity http) throws Exception {

    http
        .authorizeRequests()
        .antMatchers("/login").permitAll()
        .antMatchers("/user").hasRole("USER")
        .antMatchers("/admin").hasRole("ADMIN") // 추가!!!!!!
        .anyRequest().authenticated();

    http.formLogin()
        .successHandler((request, response, authentication) -> {
            HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
            SavedRequest savedRequest = requestCache.getRequest(request, response);
            String redirectUrl = savedRequest.getRedirectUrl();
            System.out.println("redirectUrl = " + redirectUrl);
            response.sendRedirect(redirectUrl);
        });
    
    http.exceptionHandling()
    	// 주석 처리!!!!!!!!!! 
        // .authenticationEntryPoint((request, response, authException) -> {
        //     // 우리가 만든 로그인 페이지로 이동하게 된다.
        //     response.sendRedirect("/login");
        // })
        .accessDeniedHandler((request, response, accessDeniedException) -> {
            response.sendRedirect("/denied");
        });
    
}
  • .antMatchers("/admin").hasRole("ADMIN") 추가
  • .authenticationEntryPoint 부분 전체 주석

기존과 달리 authenticationEntryPoint 부분을 전체 주석하고, 스프링 시큐리티가
기본으로 제공하는 로그인 페이지에 접근하고, user 사용자로 로그인 하고나서 인가 예외에
대한 처리를 관찰해보자.



5. 서버 재실행 및 관찰

5-1. 루트 페이지 접근

루트 페이지 접근하려고 하면 막힌다. 이 과정은 이전과 마찬가지다.
다만 이번에는 AuthenticationEntryPoint 를 직접 설정하지 않았다.
그러면 어떤 AuthenticationEntryPoint를 사용할까?

LoginUrlAuthenticationEntryPoint 라는 클래스의 commence 메소드를 호출한다.
로그인 페이지로 리다이렉트 시키는 간단한 작업을 수행한다.


5-2. 로그인 페이지 표출 및 로그인

지금은 authenticationEntryPoint 를 커스텀하게 만들지 않아서 스프링 시큐리티
기본 로그인 페이지가 나오는 것이다.

로그인을 해주자.


5-3. 이전요청에 따라 루트 페이지로 이동확인

정상적으로 접근이 됐다.



5-4. 권한 없는 페이지 접근(/admin) ==> ExceptionTranslationFilter

user 사용자는 ADMIN 이라는 권한이 없어서 ExceptionTranslationFilter 에서
예외처리 하는 구문의 디버깅 포인트가 잡힌다.

하지만 이전의 인증예외와 달리 인가예외라서 this.accessDeniedHandler.handle 메소드가
실행된다. 이 메소드의 내용을 관찰해보자.


5-4. 개인 스프링 시큐리티 설정 클래스

이전에 설정했던 AccessDeniedHandler 가 동작한다.



5-5. redirect 결과 페이지 표출

참고: 만약 AccessDeniedHandler 구현을 안했다면 default 로 아래 클래스가 호출된다.


결과적으로 아래 페이지가 나온다.




참고) 전체 코드 프로세스

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글