모르지 않다는 것은 아는것과 다르다.

Spring Security

Security Filter

채마스 2022. 2. 28. 20:43

Spring Security Filter

  • 스프링 시큐리티에서 기본적으로 제공하는 15개의 필터에 대해서 알아보려 한다.
  • 아래와 같은 순서로 15개의 필터가 구성되어 있다.



WebAsyncManagerIntegrationFilter

  • SpringMVC Async Hanlder 를 지원하기 위한 필터다.
  • SecurityContextHolder 는 thread local 을 사용하기 때문에 aysnc 한 핸들러(컨트롤러) 를 사용할 경우 같인 thread 를 사용하지 않아도 SecurityContext 를 공유할 수 있게 도와주는 필터이다.
  • 역할을 아래와 같이 정리할 수 있다.
    • preprocessing 과정에서 새로만든 thread 에 SecurityContext를 공유하는 작업이 일어난다.
    • postprocessing 과정에서 SecurityContext 를 비워주는 작업이 일어난다.
  • 아래와 같은 테스트해볼 수 있다.
public class SecurityLogger {

    public static void log(String message){
        System.out.println(message);
        Thread thread = Thread.currentThread();
        System.out.println("Thread : " + thread.getName());
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        System.out.println("Principal : " + principal);
        System.out.println("===================");
    }
}
  • 위와 같이 현재의 thread 와 principal 정보를 보여주는 역할을 하는 클래스를 정의했다.
  • 아래와 같이 2가지 경우로 나눠서 테스트 해볼 수 있다.
    • aysnc 한 controller 일 경우
  @GetMapping("/async-handler")
  @ResponseBody
  public Callable<String> aysncHanlder(){
      SecurityLogger.log("MVC"); //thread 1
      return new Callable<String>() {
          @Override
          public String call() throws Exception {
              SecurityLogger.log("Callable"); //thread 2
              return "Aysnc Handler";
          }
      };
  }
- 로그 결과를 보면 thread1, thread2 가 다른 스레드인 것을 확인할 수 있다.
- 하지만 둘 모두 같은 Principal 가 같다. -> WebAsyncManagerIntegrationFilter 덕분이다.
  • aysnc 한 service 일 경우 (@EnableAsync 를 설정 클래스에 추가해 줘야한다.)
  @GetMapping("/async-service")
  @ResponseBody
  public String asyncService(){
      SecurityLogger.log("MVC, before async service"); //thread1
      sampleSerivce.asyncService();
      SecurityLogger.log("MVC, after async service");
      return "Async Service";
  }
  @Async 
  public void asyncService() {
      SecurityLogger.log("Async Service"); //thread2
      System.out.println("Async Service is called");
  }
  • 위의 경우 처럼 service 가 async 할 경우에는 SecurityContext를 공유할 수 있을까?
  • 공유하지 못해서 Principal 값이 다르다. -> 공유하기 위해서 아래와 같은 설정을 추가해 줘야한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.addFilterBefore(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);

    http.authorizeRequests()
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
            .mvcMatchers("/", "/info", "/account/**", "/signup").permitAll()
            .mvcMatchers("/admin").hasRole("ADMIN")
            .mvcMatchers("/user").hasRole("USER")
            .anyRequest().authenticated()
            .expressionHandler(expressionHandler());

    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
  • 위와 같이 SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); 를 설정해주면 같은 Principal 값을 확인할 수 있다.
  • MODE_INHERITABLETHREADLOCAL 인 이유는 thread1 인 nio 안에서 만들어진 하위 스레드 이기 때문이다.






SecurityContextPersistenceFilter

  • 15개의 필터 중에서 2번째에 위치하는 필터다.
  • SecurityContextRepository 를 사용해서 기존의 SecurityContext 가 있다면 읽어오고, 없다면 비어있는 SecurityContext를 만든다.
  • SecurityContext 는 Http Session 에서 읽어 온다.
  • Spring-Session 과 연동하여 세션 클러스터를 구현할 수 있다.
  • 이 필터는 인증을 처리하기 전에 거치는 것이 맞다. -> 이미 인증된 SecurityContext 를 다시한번 인증 하기위해 다른 필터들을 거칠 필요는 없기 때문이다. -> 그렇기 때문에 2번째에 위치한 것이다.
  • 그렇기 때문에 커스텀한 인증 필터를 만들고 싶다면 이 필터 뒤에 위치시켜야 한다.
  • 정리하면 아래와 같다.
    • 익명사용자의 경우
      • 새로운 SecurityContext 객체를 생성하여 SecurityContextHolder 에 저장한다.
      • AnonymousAuthenticationFilter 에서 AnonymousAuthenticationToken 객체를 SecurityContext 에 저장한디.
    • 인증 시점
      • 새로운 SecurityContext 객체를 생성하여 SecurityContextHolder 에 저장한다.
      • UsernamePasswordAuthenticationFilter 에서 인증 성공 후 SecurityContext 에 UsernamePasswordAuthentication 객체를 SecurityContext 에 저장한다.
      • 인증이 최종 완료되면 Session 에 SecurityContext 를 저장한다.
    • 인증 후
      • Session 에서 SecurityContext 꺼내어 SecurityContextHolder 에서 저장한다.
      • SecurityContext 안에 Authentication 객체가 존재하면 계속 인증을 유지한다.
    • 최종 응답이 완료된 후
      • SecurityContextHolder.clearContext()
  • SecurityContextPersistenceFilter 의 처리흐름은 아래와 같다.

  • 위의 사진처럼 인증 된 사용자와 인증 되지 않은 사용자의 로직이 다른것을 확인할 수 있다.

  • 왼쪽의 SecurityContextHolder 가 인증전이고 오른쪽의 SecurityContextHolder 가 인증 후이다.
  • 인증 전일 경우 Authentication 객체가 null 인 것을 확인 할 수 있다.





HeaderWriterFilter

  • 15개의 필터 중에서 3번째에 위치하는 필터이다.
  • 응답 헤더에 시큐리티 관련 헤더를 추가해주는 필터다.
    • XContentTypeOptionsHeaderWriter: 마임 타입 스니핑 방어.
      • X-Content-Type-Options: nosniff
    • XXssProtectionHeaderWriter: 브라우저에 내장된 XSS 필터 적용.
      • X-XSS-Protection: 1; mode=block
    • CacheControlHeadersWriter: 캐시 히스토리 취약점 방어.
      • Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    • HstsHeaderWriter: HTTPS로만 소통하도록 강제.
    • XFrameOptionsHeaderWriter: clickjacking 방어.
      • X-Frame-Options: DENY
  • 위와 같이 설정되어 있기때문에 헤더에는 아래와 같이 표시된다.
    Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    Content-Language: en-US
    Content-Type: text/html;charset=UTF-8
    Date: Sun, 04 Aug 2019 16:25:10 GMT
    Expires: 0
    Pragma: no-cache
    Transfer-Encoding: chunked
    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    X-XSS-Protection: 1; mode=block





CsrfFilter

  • CSRF 어택 방지 필터이다.

  • CSRF 는 인증된 유저의 계정을 사용해 악의적인 변경 요청을 만들어 보내는 기법을 말한다.
  • CORS를 사용할 때 특히 주의 해야 한다. -> 타 도메인에서 보내오는 요청을 허용하기 때문이다.
  • csrfFilter 는 모든 요청에 랜덤하게 생성된 토큰을 HTTP 파라미터로 요구한다.
  • 요청 시 전달되는 토큰 값과 서버에 저장된 실제 값과 비교한 후 만약 일치하지 않으면 요청은 실패하게 된다.
  • 하지만 폼기반의 애플리케이션이 아니고, rest api 인 경우는 http.csrf().disable(); 로 설정해서 이 필터를 거치지 않게 하는게 낫다. -> 요청을 보낼 때마다 CSRF 토큰을 보내줘야하기 때문이다.
  • 폼 전송일 경우에는 무조건 하는게 좋다.





LogoutFilter

  • 여러 LogoutHandler를 사용하여 로그아웃시 필요한 처리를 하며 이후에는 LogoutSuccessHandler를 사용하여 로그아웃 후처리를 한다.
  • LogoutFilter 는 크게 LogoutHandler 와 LogoutSuccessHandler 로 나뉘어진다.
  • LogoutHandler 여라가지 핸들러를 가지고있는 Composite 객체이다.
  • LogoutHandler
    • CsrfLogoutHandler
    • SecurityContextLogoutHandler
  • LogoutSuccessHandler -> LogoutSuccessHandler 는 로그아웃이 성공했을 때의 처리를 컨트롤 한다.
    • SimplUrlLogoutSuccessHandler
  • 아래와 같이 로그아웃 필터를 설정할 수 있다.
http.logout()
        .logoutUrl("/logout") // 로그아웃 url 설정 -> 커스텀 하려면 바꿀 수 있다.
        .logoutSuccessUrl("/") // 로그아웃되면 루트 페이지로 보내겠다.
        .logoutRequestMatcher() 
        .invalidateHttpSession(true) // 로그아웃한다음 세션을 invalid 처리할 것이냐
        .deleteCookies("") // 해당 쿠키 삭제
        .addLogoutHandler() // 핸들러를 추가해서 부가적인 작업을 할 수 있다.
        .logoutSuccessHandler(); // 로그아웃이 성공적으로 이뤄진다음에 부가적인 작업을 하고싶을때 사용할 수 있다.






UsernamePasswordAuthenticationFilter

  • 폼에서 아이디 비밀번호를 눌렀을때 처리되는 필터이다.

  • AuthenticationManager 를 이용해서 Authenticate(안증) 를 처리한다.
  • AuthenticationManager 는 구현체인 ProviderManager 를 사용하는데, ProviderManager는 여러개의 AuthenticationProvider 를 사용한다. -> AuthenticationProvider 중 DaoAuthenticationProvider 는 UserDetailsService 를 사용한다.
  • UserDetails 정보를 가져와 사용자가 입력한 password 와 비교한다.





DefaultLogin/LogoutPageGeneratingFilter

  • 기본 로그인 폼 페이지를 생성해주는 필터다.
  • /login 요청을 처리하는 필터다.
http.formLogin()
      .usernameParameter("my-username")
      .passwordParameter("my-password");
  • 위와 같이 폼으로 넘어오는 변수값을 바꿔줄 수 있다.
http.formLogin()
      .loginPage("/signin); 
  • 이렇게 설정하면 DefaultLoginPageGeneratingFilter,DefaultLogoutPageGeneratingFilter 가 등록되지 않는다.
  • 위와 같이 설정된 순간 커스텀한 로그인, 로그아웃 페이지를 쓴다고 가정하고, 위의 두 필터를 제공하지 않는다.
  • 그렇다면 커스텀한 로그인 페이지를 아래와 같이 만들 수 있다.





BasicAuthenticationFilter

  • 요청 헤더에 username와 password를 실어 보내면 브라우저 또는 서버가 그 값을 읽어서 인증하는 방식을 처리한다.
  • 보통 브라우저 기반 요청이 클라이언트의 요청을 처리할 때 자주 사용한다.
  • 보안에 취약하기 때문에 반드시 HTTPS를 사용할 것을 권장한다. -> 예를들어 스니핑을 당할 수 있기 때문이다.
  • UsernamePasswordAuthenticationFilter 와 비슷하지만, UsernamePasswordAuthenticationFilter 는 폼에서 읽어온다.-> 이 경우는 정보가 Security Context 에 저장되고, 그값을 SecurityContextPersistenceFilter 가 가져오므로 stateful하다. -> 폼인증인 경우 이렇게 하는게 바람직하다.
  • 하지만, BasicAuthenticationFilter 는 헤더에서 읽어온다. -> Security Context 에 저장되는 로직이 없다. -> stateless하다. -> 그렇기 때문에 매 요청시 헤더에 보내줘야한다. -> 폼인증이 아닌경우 이렇게 쓰는게 바람직하다.





RequestCacheAwareFilter

  • 현재 요청과 관련 있는 캐시된 요청이 있는지 찾아서 적용하는 필터이다.
  • 만약 어떤 페이지에 접근하면 accessDecisionManager 접근가능한지 체크한다. -> 접근이 불가능하다면, 로그인페이지(다른페이지로 설정할 수 있다.) 로 가는게 맞다고 판단한다. -> 캐시에 원래 요청을 저장해두고 -> 로그인 페이지로 이동한다. -> 로그인 처리가 끝난 후 캐시에 저장해둔 원래 요청을 처리한다.
  • 정리하면 아래와 같다.
    • 캐시된 요청이 없다면, 현재 요청 처리한다.
    • 캐시된 요청이 있다면, 해당 캐시된 요청 처리한다.





SecurityContextHolderAwareRequestFilter

  • 시큐리티 관련 서블릿 API를 구현해주는 필터이다
  • 아래와 같은 메서드를 구현한다.
    • HttpServletRequest#authenticate(HttpServletResponse)
    • HttpServletRequest#login(String, String)
    • HttpServletRequest#logout()
    • AsyncContext#start(Runnable)





AnonymousAuthenticationFilter

  • 익명사용자에 대한 인증 필터이다.
  • 익명사용자와 인증 사용자를 구분해서 처리하기 위한 용도로 사용된다.
  • 인증이 안된 사용자를 익명 Authentication 객체를 만들어서 security context holder 에 넣어주는 역할을 한다.
  • 화면에서 인증 여부를 구현할 때 isAnonymous() 와 isAuthenticated() 로 구분해서 사용한다.
  • 인증객체를 세션에 저장하지 않는다.
  • 현재 SecurityContext에 Authentication이 null이면 “익명 Authentication”을 만들어 넣어주고, null이 아니면 아무일도 하지 않는다.
  • 아래와 같이 기본으로 만들어 사용할 “익명 Authentication” 객체를 설정할 수 있다.
http.anonymous()
        .principal()
        .authorities()
        .key()






SessionManagementFilter

  • 세션을 관리해주는 필터이다.
  • 아래와 같이 세션 전략을 설정할 수 있다.
http.sessionManagement()
        .sessionFixation()
        .changeSessionId();

http.sessionManagement()
        .sessionFixation()
        .migrateSession();
  • migrateSession() 의 경우, 인증이 됐을 때 새로운 세션을 만들고, 기존 세션의 정보를 복사해온다.
  • changeSessionId() 의 경우, 세션 ID 만 바꾸기 때문에 migrateSession 보다 빠르다.
http.sessionManagement()
        .sessionFixation()
        .invalidSessionUrl("/login");
  • 위와 같이 유효하지 않은 세션을 리다이렉트 시킬 URL 설정할 수 있다.
http.sessionManagement()
        .sessionFixation()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true);
  • maximumSessions(1) 를 1로 설정함으로써 동시 로그인 유저 수를 1명으로 제한 할 수 있다.
  • maxSessionsPreventsLogin 는 추가 로그인을 막을지 여부 설정
    • default 값은 false 이다. -> 기존 로그인 세션을 만료 시키고 새로운 로그인 인증만 사용한다.
    • maxSessionsPreventsLogin(true); -> 존 로그인 세션만 유지하고, 새로운 로그인 세션을 막아준다.
  • 세션의 생성 전략은 아래와 같다.
    • IF_REQUIRED -> 기본값이다. -> 필요하면 만든다.
    • NEVER -> 시큐리티에서는 생성 X, 기존의 세션을 활용한다.
    • STATELESS -> 세션을 사용하지 않는다. -> stateless 방식이기 때문에 폼인증에는 저정하지 않는다.
    • ALWAYS -> 세션을 항상 사용한다.





ExceptionTranslationFilter

  • FilterSecurityInterceptor 와 밀접한 관계가 있다.
    • ExceptionTranslationFilter 가 FilterSecurityInterceptor 보다 이전에 있어야 한다. -> 그래서 14번째에 위치하는 것이다.

  • ExceptionTranslationFilter 가 FilterSecurityInterceptor 를 감싸고 실행되어야 한다.
  • Authentication 에러 발생시 -> AuthenticationEntryPoint 를 활용해서 처리한다. -> 로그인 페이지로 이동시킨다.
  • AccessDeniedException 에러 발생시 -> AccessDeniedHandler를 활용해서 처리한다. -> 403 Error 로 처리한다.
    • 아래와 같이 에러를 로깅함으로써 백엔드 사이드에 확실하게 에러를 전달할 수 있다.
    • 당연히 Exception에 대한 처리 방식은 변경할 수 있다.
http.exceptionHandling()
  .accessDeniedHandler((request, response, accessDeniedException) -> {
    UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    String username = principal.getUsername();
    System.out.println(username = " 이 " + request.getRequestURI()+ "에 접근하려다 실패했습니다.");
    response.sendRedirect("/access-denied");
  })
  • security context 에서 principal 정보를 가져와서 로그를 남긴다.
  • /access-denied 페이지로 이동시킨다.





FilterSecurityInterceptor

  • 15개중 가장 마지막에 위치한 필터로써, 인증된 사용자에 대하여 특정 요청의 승인/거부 여부를 최종적으로 결정한다.
  • 인증객체 없이 보호자원에 접근을 시도할 경우 AuthenticationException 을 발생한다.
  • 인증 후 자원에 접근 가능한 권한이 존재하지 않을 경우 AccessDeniedException 을 발생한다.
  • HTTP 리소스 시큐리티 처리를 담당하는 필터이다.
  • 권한 처리를 AccessDecisionManager에게 맡기고, AccessDecisionManager를 사용하여 인가를 처리한다.
  • 인증이 된 상태에서 특정 리소스에 접근할 수 있는지 Role을 확인한다.
  • 다시말해서, AccessDecisionManager를 사용해서 Access Control 또는 예외 처리하는 필터다.
  • 아래와 같이 설정할 수 있다.
http.authorizeRequests()
                .mvcMatchers("/", "/info", "/account/**", "/signup").permitAll()
                .mvcMatchers("/admin").hasRole("ADMIN")
                .mvcMatchers("/user").hasRole("USER")
                .anyRequest().authenticated()
                .expressionHandler(expressionHandler());




REFERENCES

  • 백기선님의 스프링 시큐리티
  • 정수원님의 스프링 시큐리티
  • 안성훈님의 스프링 시큐리티