Spring/Security

SpringSecurity : 웹 애플리케이션 시큐리티

Bong Gu 2021. 10. 9. 17:30
728x90

스프링 시큐리티 : 웹 애플리케이션 시큐리티

Security ignoring 설정

  • WebSecurity의 ignoring() 을 사용해서 시큐리티 필터 적용을 제외할 요청을 할 수 있다.
  @Override
  public void configure(WebSecurity web) throws Exception {
  web.ignoring().mvcMatchers("/favicon.ico");
  }
  • 스프링 부트가 제공하는 PathRequest를 사용해서 정적 자원 요청을 스프링 시큐리티 필터를 적용하지 않도록 설정한다.
    • WebSecurity 설정이 아닌 HttpSecurity에 설정을 할 수 도 있다.
  • @Override public void configure(WebSecurity web) throws Exception { web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations()); }
  @Overide
  public void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests()   
      .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
      .permitAll();
  }
  • 결과적으로 같지만, 필터를 모두 타지만 허용하는 것.
  • WebSecurity ignoring 설정의 경우에는 필터 자체를 거치지 않는다.
  • 따라서 시큐리티를 아예 적용하고 싶지않은 부분은(정적 리소스라던지..) WebSecurity설정이 더 좋다.
  • 동적 리소스는 http.authorizeRequests() 에서 처리하는것을 권장하고, 정적 리소스는 web.ignore() 를 권장한다. (인증이 필요한 정적 자원의 경우, web.ignore() 에서 exclude하고 http.authorizeRequests() 에서 처리하는것을 권장한다.

Async 웹 MVC를 지원하는 필터 : WebAsyncManagerIntegrationFilter

  • 가장 먼저 타는 SecurityFilter
  • 스프링 MVC의 Async 기능 (핸들러에서 Callable을 리턴할 수 있는 기능)을 사용할때에도 SecurityContext를 공유하도록 도와주는 필터
    • SecurityContext는 ThreadLocal 같은 Thread에서만 공유하지만, Async 기능에서는 다른 Thread를 사용하게되는데 이런 경우에도 그 Thread에서도 동일한 SecurityContext를 사용할 수 있도록 지원해주는 필터
    • PreProcess: SecurityContext를 설정한다. (새로운 Thread에)
    • Callable: 비록 다른 쓰레드지만 그 안에서는 동일한 SecurityContext를 참조할 수 있다.
    • PostProcess: SecurityContext를 정리(clean up)한다.
  • image-20210920160530927

스프링 시큐리티와 @Async

  • Async한 핸들러(Callable)를 호출하는게 아니라, Async한 서비스를 호출하는 경우
  • 그냥 @Async 만 붙여서 실행하는 경우 하나의 Thread만 사용한다.
    • @EnableAsync 를 설정해어야한다.
    • ThreadPool을 설정해주는게 좋다.
    • 자세한 내용은 Spring MVC 강의
  • image-20210920161702210
  • Async 설정 후 다시 테스트
    • Thread는 새롭게 생긴것을 확인
    • 새로운 Thread에서는 SecurityContext가 공유 안되는것을 확인하였다.
    • 해결방법
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    }
    • SecurityContextHolder의 전략을 선택할 수 있다.
    • 기본값은 ThreadLocal 로 동일한 Thread에서만 공유가 가능하다.
    • MODE_INHERITABLETHREADLOCAL : 하위 Thread까지 SecurityContext가 공유된다.
    • 해결 후 결과
    • image-20210920162736265
  • image-20210920162130645

SecurityContext 영속화 필터 : SecurityContextPersistenceFilter

  • 보통 두번째 등록된 필터
  • 여러 요청간에 SecurityContext를 공유할 수 있는 기능을 제공한다.
    • 새로 인증을 하지 않아도 인증내용을 공유
  • 기본적으로 SecurityContextRepository에 위임하여, 기존의 SecurityContext를 읽어오거나 비어 있다면 초기화 한다.
    • 기본전략인 SecurityContextRepository 의 기본 구현체가 HttpSessionSecurityContextRepository이다.
    • 즉, HttpSession 에서 읽어온다.
    • 모든 인증관련 필터들 보다 더 위에 선언이 되어 있어야 한다.
    • 이곳에서 읽어왔다면, 인증필터들을 생략해야하기 때문에

시큐리티 관련 헤더 추가 필터 : HeaderWriterFIlter

  • 응답헤더에 특정 시큐리티 관련 헤더들을 추가해주는 필터
  • 우리가 설정할 일이 거의 없는 필터
  • 추가해주는 헤더 정보
    • XContentTypeOptionsHeaderWriter : 마임타입 스니핑 방어
    • 실행할 수 없는 마임타입을 실행하려고 시도하면서 보안상 이슈가 발생하는 공격
    • X-Content-Type-Options: nosniff : contentType에 명시된 마임타입으로만 실행할 수 있게 한다.
    • XXssProtectionHeaderWriter : 브라우저 내장된 XSS 필터 적용
    • 해당 필터로 XSS 공격을 막을 수는 없지만 1차적으로 방어가 가능하다.
    • XXssProtectionHeaderWriter: 1; mode=block
    • CacheControlHEadersWriter : 캐시 히스토리 취약점 방어
    • Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    • HstsHeaderWriter : HTTPS만 소통하도록 강제
    • XFrameOptionsHeaderWriter : clickJacking 방어 헤더
    • click jaking : 보이지 않는 영역에 이상한 사이트의 정보가 들어와 있어 어떤영역을 클릭 시, 나의 민감한 정보가 보내지거나 하는등의 공격

CSRF 어택 방지 필터: CsrfFilter

  • CSRF 어택 방지 필터
    • Cross Site Request Forgery
    • 인증된 유저의 계정을 사용해 악의적인 변경 요청을 만들어 보내는 기법
    • image-20210922162044793
    • CORS를 허용할 때, 특히 주의해야 한다.
      • 타 도메인에서 보내오는 요청을 허용하기 때문에…
  • 의도한 사용자만 리소스를 변경할 수 있도록 허용하는 필터
    • CSRF 토큰을 사용하여 방지
    • 서버쪽에서 토큰을 만들어 요청에 같이 보내도록 한다.
    • 서버에서 제공하는 Form 에만 CSRF를 포함하도록 한다. (_csrf 이름으로 hidden 으로 처리)

사용예제

  • 회원가입 및 뷰 컨트롤러
  @RequiredArgsConstructor
  @RequestMapping("/signup")
  @Controller
  public class SignUpController {

    private final AccountService accountService;

    @GetMapping
    public String signUpForm(Model model) {
      model.addAttribute("account", new Account());
      return "signup";
    }

    @PostMapping
    public String processSignUp(@ModelAttribute Account account) {
      account.setRole("USER");
      accountService.create(account);
      return "redirect:/";
    }

  }
  • signup.html
  <!DOCTYPE html>
  <html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>SignUp</title>
  </head>
  <body>
    <h1>Sign Up</h1>
    <form action="/signup" th:action="@{/signup}" th:object="${account}" method="post">
      <p>Username : <input type="text" th:field="*{username}" /></p>
      <p>Password : <input type="text" th:field="*{password}" /></p>
      <p><input type="submit" value="SignUp" /></p>
    </form>
  </body>
  </html>
  • 뷰페이지를 개발자모드로 확인 시
    • html에 파일에 넣어주지 않은 _csrf 가 들어가 있다.
    • thymeleaf 2.1 버전 이후 버전 or JSP 사용 시, form 태그를 사용 시, 자동으로 넣어준다.
  • image-20210922170831714
  • Post요청의 경우에 토큰값을 체크하도록 되어있다.
    • GET, HEAD, TRACE, OPTIONS 인경우를 제외하고 확인하도록 되어있다. (CsrfFilter)
  • image-20210922172050494
  • Test
  @ExtendWith(SpringExtension.class)
  @AutoConfigureMockMvc
  @SpringBootTest
  class SignUpControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void signUpForm() throws Exception {
      mockMvc.perform(get("/signup"))
          .andDo(print())
          .andExpect(status().isOk())
          .andExpect(content().string(containsString("_csrf")));
    }

    @Test
    void processSignup() throws Exception {
      mockMvc.perform(post("/signup")
              .param("username", "yrchoi")
              .param("password", "123")
              .with(csrf())
          )
          .andDo(print())
          .andExpect(status().is3xxRedirection());
    }
  }

로그아웃 처리 필터 : LogoutFilter

  • LogoutHandler를 사용하여 로그아웃 시, 필요한 처리를 하며 이후에는 LogoutSuccessHandler를 사용하여 로그아웃 후처리를 한다.
    • LougoutHandler는 Composite객체로 여러가지 로그아웃 핸들러를 가지고 있다.
    • 기본적으로 CsrfLogoutHandler, SecurityContextLogoutHandler
    • LogoutSuccessHandler 의 기본은 SimpleUrlLogoutSuccessHandler로 명시된 url로 redirect 시켜준다.
  • Logout 페이지는 LogoutFilter가 만들어주는 것이 아니라, DefaultLogoutPageGeneratingFilter가 만들어주는 것이다.
  • logout으로 가는 post 요청만 걸리게 될 것이다.

설정

@Override
protected void configure(HttpSecurity http) throws Exception {
  http.logout()
      .logoutUrl("/my/logout")
      .logoutSuccessUrl("/")
//        .invalidateHttpSession(true)
//        .deleteCookies()
//        .addLogoutHandler()
//        .logoutSuccessHandler()
  ;
}
  • logoutUrl : logout을 요청할 url을 커스텀할 때 설정 (기본값은 logout)
  • logoutSuccessHandler : 로그아웃 후, 이동할 url
  • addLogoutHandler : 로그아웃 핸들러 추가, 로그아웃 할때 필요한 처리를 추가하고 싶을 때, 설정
  • logoutSuccessHandler : 로그아웃 성공 처리를 별도로 구현해서 하고 싶을때 설정
  • invliadateHttpSession : HttpSession을 invalid 처리할것인가 (기본값 true)
  • deleteCookies : 로그아웃 시, 삭제할 쿠키이름 추가한다.

폼 인증 처리 필터 : UsernamePasswordAuthenticationFilter

  • 폼 로그인을 처리하는 필터
    • Username과 password를 가져와서 AuthenticationToken을 만들고 AuthenticationManager를 사용해서 인증을 시도한다.
    • AuthenticationManager의 구현체인 ProviderManager가 AuthenticationProvider들을 사용해서 인증을 처리한다.
    • 그 중 DaoAuthenticationProvider가 UserDetailsService를 사용하여 loadByUsername을 사용해서 DB에 있는 유저객체와 입력한 pasword가 일치하는지 확인한다.
  • 실패할 경우 다시 로그인 페이지를 보여주는데, 이부분은 UsernamePasswordAuthenticationFilter가 하는것은 아니다.

DefaultLoginPageGeneratingFilter

  • 기본 로그인 폼 페이지를 생성해주는 필터
    • GET /login 요청을 처리하는 필터
  • 로그인 폼 커스터마이징
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.formLogin()
        .usernameParameter("my-username")
        .passwordParameter("my-parameter");
  }
  • 원하는 파라미터 이름을 사용하도록 한다.
    • 커스텀한 로그인 페이지를 사용하고 싶다면
  @Override
  protected void configure(HttpSecurity http) throws Exception {
      http.formLogin()
          .loginPage("/my-login");
  }
  • 커스텀하게 설정하는 경우, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter가 등록되지 않는다.

로그인/로그아웃 폼 커스터마이징

  • 커스텀 로그인 페이지 설정
  @Override
  protected void configure(HttpSecurity http) throws Exception {
      http.formLogin()
          .usernameParameter("my-username")
          .passwordParameter("my-password")
          .loginProcessingUrl("/my-login")
          .loginPage("/signin").permitAll();
  }
  • loginPage는 로그인페이지로 이동시켜 줄때 해당 페이지를 찾는다.
  • loginProcessingUrl을 설정해주지 않으면 로그인 POST 요청 또한 loginPage에서 설정한 url로 요청해야한다.
    • 로그인 페이지 라우팅 컨트롤러
  @Controller
  public class LoginController {

    @GetMapping("/signin")
    public String loginForm() {
      return "sign-in";
    }

  }
  • 뷰 페이지
  <!DOCTYPE html>
  <html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>SignIn</title>
  </head>
  <body>
    <h1>Sign In</h1>
    <div th:if="${param.error}">
      Invalid username or password
    </div>
    <form action="/my-login" method="post" th:action="@{/my-login}">
      <p>Username : <input type="text" name="my-username" /></p>
      <p>Username : <input type="password" name="my-password" /></p>
      <p><input type="submit" value="login"></p>
    </form>
  </body>
  </html>
  • 에러가 발생한 경우 , th:if="${param.error}" 에 의해 에러메세지를 보여줄 수 있다.
    • 커스텀 시, 로그아웃 페이지도 함께 등록이 안되므로 로그아웃 페이지도 필요하다면 만들어 줘야한다.
  • 로그아웃 페이지 라우팅 컨트롤러
  • @GetMapping("logout") public String logoutPage() { return "logout"; }
  • 뷰 페이지
  • <!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Logout</title> </head> <body> <h1>Logout</h1> <form action="/my/logout" method="post" th:action="@{/my/logout}" > <p><input type="submit" value="logout"></p> </form> </body> </html>

Basic 인증 처리 필터 : BasicAuthenticationFilter

  • Http Basic 인증 지원
  • Basic 인증이란?
    • Http 스펙중 하나
    • 요청 헤더에 Header에 username, password를 Base64 로 인코딩하여 실어 보내면 브라우저 또는 서버가 그 값을 읽어서 인증
    • ex ) Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l (keesun:123 을 BASE 64)
    • 보안이 취약하여, 반드시 https를 사용할 것을 권장한다.
      • Base64 로 디코딩 하면 다보이기 때문에
  • curl -u {username:password} {url} 로 요청 가능
    • ex) curl -u keesun:123 http://localhost:8080/dashboard
  • 이 필터도 인증이되면 SecurityContextHodler 에 인증된 Authentication를 저장시켜준다.
    • AuthenticationManager를 사용
    • UsernamePasswordAuthenticationFilter를 사용한다.
    • 읽어오는 방법이 다를뿐 (Header에서 읽어온다.)
    • success 시, successHandler.onAuthenticationSuccess() 에서는 아무것도 하지 않는다.
    • UsernamePasswordAuthenticationFilter는 SessionRepository에 캐싱해둔다.

요청 캐시 필터 : RequestCacheAwareFilter

  • 현재 요청과 관련 있는 캐시된 요청이 있는지 찾아서 적용
    • 캐시된 요청이 있다면, 해당 캐시된 요청 처리
    • 캐시된 요청이 없다면, 현재 요청 처리
  • 예를들어 권한 없는 페이지에 요청 시, 로그인 페이지로 redirect 되고 이후, 로그인 과정을 거치면 원래 요청했던 페이지로 요청을 해준다.
    • 처음 요청한 페이지를 캐싱한다.
    • 로그인
    • 캐싱했던 페이지를 처리한다.
  • 크게 신경쓰지 않아도 되는 필터

시큐리티 관련 서블릿 스펙 구현 필터 : SecurityContextAwareRequestFilter

  • Servlet3 스펙을 지원하는 일을 한다. (특히 시큐리티 관련)
  • 시큐리티 관련 서블릿 API를 구현해주는 필터
    • HttpServletReuqest#authenticate(HttpServletResponse)
    • 응답을 보고 인증이 되지 않으면 로그인 페이지로 보내준다.
    • HttpServletReuqest#login
    • HttpServletReuqest#logout
    • AsyncContext#start
    • SecurityContextHolder를 공유해주는 것.
    • WebAsyncManagerIntegrationFilter 와 비슷한 일을 한다.
  • 크게 신경쓰지 않아도 되는 필터

익명 인증 필터 : AnonymousAuthenticationFilter

  • 현재 SecurityContext 에 Authenticaion이 null 이면 AnonymousAuthentication을 만들어 넣어주고, null이 아니면 아무일도 하지 않는다.
  • 기본으로 만들어 사용할 AnonymousAuthentication 객체를 설정할 수 있다. (아래는 기본값들)
  http.anonymous()
          .principal("anonymousUser")
          .authorities("ROLE_ANONYMOUS")
          .key(?);    
  • 굳이 만들어 쓸 이유는 모르겠다.
    • null object pattern 사용
  • null check가 아닌 null을 대변하는 객체를 만들어 넣어 사용하는 패턴

세션 관리 필터 : SessionManagementFitler

  • 세션 변조 방지 전략 설정
    • 세션 변조
    image-20211002180536113
    • 피해자에게 자신의 세션아이디를 보내고 피해자가 해당 세션아이디를 가지고 로그인을 하면 서버에서는 동일인물로 판단
    • 피해자의 정보를 받을 수 있게 된다.
    • 방지 전략
      • none
      • newSession
      • migrateSession
      • 인증 시, 새로운 세션을 만들고 기존 세션 attribute를 복사해서 가져온다.
      • 서블릿 3.0 이하 컨테이너 사용 시, 기본값
      • chagneSessionId
      • 세션아이디를 변경
      • migrateSession보다는 성능이 더 좋다. (새로운 세션을 만들고 복사해오고 등의 동작이 없어서..)
      • 서블릿 3.1 이상 컨테이너 사용 시, 기본값
      • 우리가 사용하는 서블릿컨테이너는 3.1 이상이다.
      • tomcat 확인
        • tomcat9 -> servlet 4.0
        • tomcat8.5 -> servlet 3.1
      • SpringBoot 2.1 기준 내장 tomcat은 9버전 사용
      • custom
      http.sessionManagement()
              .sessionFixation()
              .none();
  • 유효하지 않은 세션을 리다이렉트 시킬 URL 설정
  http.sessionManagement()
            .sessionFixation()
              .none()
            .invalidSessionUrl("/");
  • 동시성 제어 : maximumSessions
  http.sessionManagement()
            .sessionFixation()
              .none()
            .invalidSessionUrl("/")
            .maximumSessions(1)
                        .maxSessionsPreventsLogin(false)
                        .expiredUrl("/");
  • maxSessionsPreventsLogin(기본값 false) : 로그인은 허용하지만 기존 로그인세션을 만료시킨다.
    • true로 변경 시, 뒤에 사용자 로그인 안됨.
  • expiredUrl : 만료 시, 이동 url
    • 세션 생성 전략 : sessionCreationPolicy
  http.sessionManagement()
            .sessionFixation()
              .none()
            .invalidSessionUrl("/")
            .maximumSessions(1)
              .maxSessionsPreventsLogin(false)
              .expiredUrl("/").and()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  • IF_REQUIRED(기본값) : 필요하면 만든다.
  • NEVER : 스프링 시큐리티에서는 만들지 않겠다. 그러나 있다면 사용한다. (대부분 이미 있다.)
  • STATELESS : session이 있어도 session을 사용하지 않는다. (REST API 서버)
    • RequestCacheAware도 세션에 저장해서 사용 불가하게 된다.
  • ALWAYS
    • Multi Server 환경이라 세션을 여러서버에서 공유해야 한다면 Spring Session 을 고려해보자.

인증/인가 예외 처리 필터 : ExceptionTranslationFilter

  • ExceptionTreanslatorFilter -> FilterSecurityInterceptor
    • FilterSecurityInterceptor 보다 앞에 있어야 한다.
    • FilterSecurityInterceptor를 try/catch 블럭으로 감싸고 실행되야한다.
    • FilterSecurityInterceptor가 AccessDecisionManager, AffirmativeBased 구현체를 사용하여 인가처리를 판단
      • 인증에러 (AuthenticationException) -> AuthenticationEntryPoint
      • 인가(권한)에러 (AccessDeniedException) -> AccessDeniedHandler
       http.exceptionHandling()
      //        .accessDeniedPage("/access-denied") 페이지만 리다이렉트
                     // 로그를 남기고 페이지로 리다이렉트
              .accessDeniedHandler((request, response, accessDeniedException) -> {
                UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
                String username = principal.getUsername();
                System.out.println(username + " is denied to access " + request.getRequestURI());
                response.sendRedirect("/access-denied");
              })
          ;

FilterSecurityInterceptor

  • hasAuthority의 하위로 hasRole
    • hasAuthority를 사용하면 ROLE_ 을 붙여서 사용해야 한다.
  • fullyAuthenticated() rememberMe 대상의 경우, 다시 인증을 요구한다.

RememberMeAuthenticationFilter

  • 세션이 사라지거나 만료가 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 지원하는 필터
  • 명시적으로 로그아웃을 하기 전까지 탭을 닫거나 해도 로그인 유지
  • 세션보다 수명이 긴 쿠키나, 토큰값을 브라우저에 쿠키로 남거나 서버에 DB로 저장되어 인증을 유지 시켜준다.
  • 시큐리티 설정
  http.rememberMe()
    .rememberMeParameter("rememer")
    .tokenValiditySeconds(100000)
    .useSecureCookie(true)
    .userDetails(userDetails)
    .key("rembmer-me-sample");
  • 쿠키에 rememberMe를 저장한다.
  • rememberMeParameter : parameter 이름 설정가능 (true/false)
  • tokenValiditySeconds : 쿠키 유지 시간 기본값은 2weeks
  • useSecureCookie : secure 쿠키를 사용하여 https만 쿠키에 접근이 허용가능하도록 할 수 있다.
  • alwaysRemember : 모두 기억하겠다. form에서 값을 넘기지 않아도 (기본값 false)
    • 인증과정
  • filter의 위치는 SecurityContextHolderAwareRequestFilter 뒤 AnonymousAuthenticationFIlter 앞에 위치한다.
  • SecurityContextHolder에서 인증된 사용자가 있는지 확인
  • 인증된 사용자가 없으면 rememeberMe를 체크한다.
  • rememberMeAuth 설정한 쿠키로 인해 만들어진 토큰으로 인증을 한다.
  • 인증된 Authentication을 SecurityContextHolder에 넣어준다.
    • UsernamePasswordAuthenticationToken이 아닌 RemeberMeAuthenticationFilter

커스텀 필터 추가하기

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.addFilterBefore(new LoggingFilter(), WebAsyncManagerIntegrationFilter.class);
}
public class LoggingFilter extends GenericFilterBean {

  private Logger logger = LoggerFactory.getLogger(this.getClass());

  @Override
  public void doFilter(ServletRequest request, ServletResponse servletResponse, FilterChain filterChain)
      throws IOException, ServletException {

    StopWatch stopWatch = new StopWatch();
    stopWatch.start(((HttpServletRequest) request).getRequestURI());

    filterChain.doFilter(request, servletResponse);

    stopWatch.stop();
    logger.info(stopWatch.prettyPrint());
  }
}
  • 일반적인 서블릿 필터를 사용한다.
    • 일반적인 Filter를 구현하거나, 조금 더 손쉽게 만들려면 GenericFilterBean을 상속받아 만들 수 있다.
    • doFilter() 하나만 구현해도 된다.
728x90