본문 바로가기

Dot ./에러 모음

[Spring Security + Thymeleaf] logout시 csrf 토큰 관련 에러

    Spring Security + Thymeleaf 로그아웃시  에러 발생

    Spring MVC + Thymeleaf로 개발하는 프로젝트에서 로그아웃시 다음과 같은 오류가 발생했다. 

    ERROR 35109 --- [nio-8080-exec-3] org.thymeleaf.TemplateEngine : [THYMELEAF][http-nio-8080-exec-3] Exception processing template "index": An error happened during template parsing (template: "class path resource [templates/index.html]")
    org.thymeleaf.exceptions.TemplateInputException: An error happened during template parsing (template: "class path resource [templates/index.html]")

    ...

    Caused by: org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "_csrf.token" (template: "fragments/ajax-csrf-header.html" - line 5, col 29)

    ...


    ERROR 8252 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.thymeleaf.exceptions.TemplateInputException: An error happened during template parsing (template: "class path resource [templates/index.html]")] with root cause

    java.lang.IllegalStateException: Cannot create a session after the response has been committed

     

    해결 방법1. HttpSession 정책을 Always로 설정하기 (권장 X)

    ❗️ java.lang.IllegalStateException: Cannot create a session after the response has been committed

    csrf 응답과 관련하여 실행되는 것으로 간주되는 시간과 http 세션이 생성되는 시간 사이의 불일치가 있다. 해결하는 방법은 security 구성에 다음을 포함하여 항상 세션을 생성하도록 하는 것이다. 이 방법으로 해결되지만 그런데 항상 세션을 인위적으로 생성해준다는 것이 어거지로 해결하는 것 같아서 이 방법을 사용하지 않았다.

    • SessionCreationPolicy.ALWAYS - 스프링시큐리티가 항상 세션을 생성
    • SessionCreationPolicy.IF_REQUIRED - 스프링시큐리티가 필요시 생성(기본)
    • SessionCreationPolicy.NEVER - 스프링시큐리티가 생성하지않지만, 기존에 존재하면 사용
    • SessionCreationPolicy.STATELESS - 스프링시큐리티가 생성하지도않고 기존것을 사용하지도 않음
    http
      .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
    

     

    Spring Securty CSRF 기능

    나는 기존 ajax post요청에만 해당 csrf토큰을 넣어줬었다. 그렇다보니 logout에 숨겨진 hidden 토큰 값을 제대로 처리하는 도중 어떤 오류가 발생한 것은 확실한 데 이유를 잘 몰라서 정확한 원인을 알아봤다.

     

    CSRF 기능은 인증과는 별도로 악의적인 공격자의 침입을 막기 위한 목적으로 클라이언트가 POST, PUT, DELETE 등의 HTTP METHOD 방식으로 요청할 때 반드시 csrf 토큰을 요구하게 되고 토큰이 없을 경우 서버 접근을 막아주는 역할을 한다.

     

    이러한 로직은 CsrfFilter라는 필터 클래스가 처리하고 있다. 거기서 CsrfToken을 저장하는 Repository 구현체(HttpSessionCsrfTokenRepository)를 보면 다음과 같은 saveToken 메서드를 확인할 수 있다.

    • CSRF 토큰이 존재하지 않으면 세션을 불러(getSession(false))와서 해당 세션을 삭제(removeAttribute)한다.
    • 만약 클라이언트에서 요청시 전송한 CSRF 토큰이 존재할 경우 세션을 가져오고(getSession()) 해당 세션에 토큰을 저장한다
      • getSession() 현재 존재하는 세션을 가져온다. 만약 세션이 존재하지 않으면 새롭게 하나를 생성한다.
      • getSession(false): 현재 존재하는 세션을 가져온다. 만약 세션이 존재하지 않는다 해도 세션을 새롭게 생성하지 않는다.
    public void saveToken(CsrfToken token, HttpServletRequest request,
          HttpServletResponse response) {
       if (token == null) {  // csrfToken이 없을 경우 
          HttpSession session = request.getSession(false); // 존재하는 세션을 가져옴 (false: not create)
          if (session != null) { // 세션이 null이 아니면 삭제시켜줌
             session.removeAttribute(this.sessionAttributeName);
          }
       }
       else { // csrfToken이 존재하면 세션을 가져와서 토큰 값을 넣어줌 
          HttpSession session = request.getSession();
          session.setAttribute(this.sessionAttributeName, token);
       }
    }

     

    해결 방법2. Thymeleaf /logout 뷰에 csrf토큰을 불러오는 JS로직 추가하기 (권장)

    이 기능은 인증/인가와 상관없이 항상 처리하고 있다. 위의 내용을 보면 알 수 있는 점은 나에게 발생된 오류는 CSRF 토큰이 Security에 정상적으로 전송이 되지않아 세션을 삭제당했기 때문이다.
    • ❗️ Cannot create a session after the response has been committed

     

    Security를 사용하면 /logout 로직은 security에서 자동적으로 처리를 해주기 때문에 따로 신경을 쓰지 않았다. 그런데 만약 Thymeleaf2.1+ 그리고 @EnableSecurity을 사용한다면 csrftoken이 자동적으로 포함된다는 것을 알아냈다.

    _csrf 토큰

     

    원인을 알아냈으니 해결 방법은 간단했다. 기존 logout 뷰는 navbar로 th:fragment로 따로 코드를 분리시켜놨기 때문에 Ajax Post를 처리하기 위해 넣어줬던 자바스크립트로 csrfMetaTags 태그를 넣어준 로직이 닿지 않았다. 그래서 해당 fragment에 csrf토큰을 불러오는 자바스크립트 코드를 넣어주니 제대로 동작함을 확인할 수 있었다. 해결 방법을 정리하면 다음과 같다.

     

    1. Meta 태그로 csrf 토큰 값 전달하기

    meta태그로 csrf 값을 넣어준다.

    <head>
    	<meta name="_csrf" content="${_csrf.token}"/>
    	<!-- default header name is X-CSRF-TOKEN -->
    	<meta name="_csrf_header" content="${_csrf.headerName}"/>
    	<!-- ... -->
    </head>

     

    그 다음 ajax로 post 요청을 보낼 때 ajax post 요청을 하는 뷰나 /logout을 처리해주는 뷰에 다음과 같이 csrf 토큰을 전송해주는 다음과 같은 스크립트를 추가해준다.

    $(function () {
        var token = $("meta[name='_csrf']").attr("content");
        var header = $("meta[name='_csrf_header']").attr("content");
        $(document).ajaxSend(function(e, xhr, options) {
            xhr.setRequestHeader(header, token);
        });
    });

     

    2. csrfMetaTags로 한 번에 가져오기

    이 방식은 따로 메타 설정없이 thymeleaf에서 바로 값을 불러와서 전송해주는 방식이다. th:fragment로 따로 로직을 분리한 다음 간단하게 원하는 뷰에 해당 스크립트를 등록해주면 된다.

    <script type="application/javascript" th:inline="javascript" th:fragment="ajax-csrf-header">
        $(function () {
            var csrfToken = /*[[${_csrf.token}]]*/ null;
            var csrfHeader = /*[[${_csrf.headerName}]]*/ null;
            $(document).ajaxSend(function (e, xhr, options) {
                xhr.setRequestHeader(csrfHeader, csrfToken);
            });
        });
    </script>