caffeine-library / pro-spring-5

🌱 전문가를 위한 스프링5를 읽는 스터디
5 stars 0 forks source link

[additional] CSRF 토큰을 통한 보호 #100

Closed binchoo closed 2 years ago

binchoo commented 2 years ago

CSRF 토큰을 통한 보호

Q1. 클라이언트는 어떻게 CSRF 토큰을 얻나요

방법1. 서버가 HTML 렌더링 시 meta 태그에 토큰 집어 넣어 주기

<meta name="csrf-token" content="{{#_csrf}}token{{/_csrf}}">

방법2. 서버가 HTML 렌더링 시 form 태그에 hidden _csrf 필드 집어 넣어 주기

<html>
<body>
    <form method="POST" enctype="multipart/form-data" action="/넘길페이지">
        <div>
            <input type="hidden" name="_csrf" value="{{#_csrf}}token{{/_csrf}}" />
            <input type="submit" value="Upload" />
        </div>
    </form>
</body>
</html>

방법3. 서버의 API 호출하기

RESTful 서버는 뷰 렌더링을 하지 않으므로, CSRF 토큰을 획득할 수 있는 별도 API를 클라이언트에게 제공합니다.

Q2. 서버는 토큰을 어떻게 생성하나요?

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                ...
                .csrf()
                .csrfTokenRepository(cookieCsrfRepository())
                .and();
    }

    @Bean
    HttpSessionCsrfTokenRepository sessionCsrfRepository() {
        HttpSessionCsrfTokenRepository csrfRepository = new HttpSessionCsrfTokenRepository();

        // HTTP 헤더에서 토큰을 인덱싱하는 문자열 설정
        csrfRepository.setHeaderName("X-CSRF-TOKEN");
        // URL 파라미터에서 토큰에 대응되는 변수 설정
        csrfRepository.setParameterName("_csrf");
        // 세션에서 토큰을 인덱싱 하는 문자열을 설정. 기본값이 무척 길어서 오버라이딩 하는 게 좋아요.
        // 기본값: "org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN"
        csrfRepository.setSessionAttributeName("CSRF_TOKEN");

        return csrfRepository;
    }

    @Bean
    CookieCsrfTokenRepository cookieCsrfRepository() {
        CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();

        csrfRepository.setCookieHttpOnly(false);
        csrfRepository.setHeaderName("X-CSRF-TOKEN");
        csrfRepository.setParameterName("_csrf");
        csrfRepository.setCookieName("XSRF-TOKEN");
        //csrfRepository.setCookiePath("..."); // 기본값: request.getContextPath()

        return csrfRepository;
    }
}

스프링 시큐리티 설정

방법1. 토큰을 만들면 유저의 세션에 저장할래요

시큐리티 설정에서 csrf().csrfTokenRepository(sessionCsrfRepository()) 처럼 HttpSessionCsrfTokenRepository를 주입합니다.

이제 클라이언트가 임의의 요청을 보내면 CsrfFilter가 끼어들어 CSRF 토큰을 생성하고 HttpSessionHttpServletRequest에 토큰을 저장합니다.

클라이언트 측이 해당 토큰을 얻어야하므로 다음과 같이 컨트롤러를 작성해 봅니다. 클라이언트는 /csrf 주소로 접근하여 응답 헤더의 X-CSRF-TOKEN를 보고 CSRF 토큰을 얻을 수 있습니다.

@RequestMapping("/csrf")
@Controller
public class CsrfController {

    private static final Logger logger = LoggerFactory.getLogger(CsrfController.class);

    @RequestMapping(method = RequestMethod.GET)
    public ResponseEntity<String> getOrCreateCsrfToken(HttpSession session, HttpServletRequest request) {
        final DefaultCsrfToken csrfToken = (DefaultCsrfToken) session.getAttribute(
                "CSRF_TOKEN");

        assert(csrfToken == request.getAttribute(csrfToken.getParameterName()));

        return ResponseEntity.ok()
                .header(csrfToken.getHeaderName(), csrfToken.getToken()).body("Check your response header!");
    }
}

image

방법2. 토큰을 만들면 브라우저 쿠키에 저장할래요

시큐리티 설정에서 csrf().csrfTokenRepository(cookieCsrfRepository()) 처럼 CookieCsrfTokenRepository를 주입합니다.

이제 클라이언트가 임의의 요청을 보내면 CsrfFilter가 끼어들어 CSRF 토큰을 생성하고 HttpServletRequest에 토큰을 저장합니다. 최초에 한하여 응답 헤더에 Set-Cookie로 CSRF 토큰을 내려 보냅니다.

최초 접근 이후 클라이언트는 CSRF 토큰을 쿠키로 보유하는 상태입니다. 서버는 매 (요청 헤더 or 파라미터) vs 쿠키를 비교하여 유효한 CSRF가 담겼는지 검증합니다.

다음 절차로 송수신 되는 패킷을 확인해 봅시다.

  1. 브라우저 쿠키 비우기
  2. 서비스의 임의 페이지 접근 후 응답 헤더 Set-Cookie 확인 (XSRF-TOKEN) image

CsrfFilter가 하는 일을 알고 싶어요

protected void doFilterInternal(HttpServletRequest request,
    HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
  request.setAttribute(HttpServletResponse.class.getName(), response);

  CsrfToken csrfToken = this.tokenRepository.loadToken(request);
  final boolean missingToken = csrfToken == null;
  if (missingToken) {
      csrfToken = this.tokenRepository.generateToken(request);
      this.tokenRepository.saveToken(csrfToken, request, response);
  }
  request.setAttribute(CsrfToken.class.getName(), csrfToken);
  request.setAttribute(csrfToken.getParameterName(), csrfToken);

  if (!this.requireCsrfProtectionMatcher.matches(request)) {
      filterChain.doFilter(request, response);
      return;
  }

  String actualToken = request.getHeader(csrfToken.getHeaderName());
  if (actualToken == null) {
      actualToken = request.getParameter(csrfToken.getParameterName());
  }
  if (!csrfToken.getToken().equals(actualToken)) {
      if (this.logger.isDebugEnabled()) {
          this.logger.debug("Invalid CSRF token found for "
                  + UrlUtils.buildFullRequestUrl(request));
      }
      if (missingToken) {
          this.accessDeniedHandler.handle(request, response,
                  new MissingCsrfTokenException(actualToken));
      }
      else {
          this.accessDeniedHandler.handle(request, response,
                  new InvalidCsrfTokenException(csrfToken, actualToken));
      }
      return;
  }

  filterChain.doFilter(request, response);
}

Q3. 클라이언트는 어떻게 리퀘스트를 전송해야 하나요

방법1. 헤더에 CSRF 토큰을 넣어 보내세요

jQuery를 활용 중이라면 모든 요청 헤더에 CSRF 토큰을 포함하도록 할 수 있습니다. 서버와 합의된 헤더 형식을 지켜 CSRF 토큰을 적재합시다. 기본적으로 기대되는 헤더명은 X-CSRF-TOKEN or X-XSRF-TOKEN 입니다.

$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});

방법2. 파라미터에 CSRF 토큰을 넣어 보내세요

form에 CSRF 토큰을 위한 hidden 필드가 존재한다면 이 방식으로 동작합니다. 기본적으로 서버가 기대하는 파라미터명은 _csrf 입니다.

<html>
<body>
    <form method="POST" enctype="multipart/form-data" action="/넘길페이지">
        <div>
            <input type="hidden" name="_csrf" value="a60159ae-9b7f-45dc-9c97-3a5f14a39cbd" />
            <input type="submit" value="Upload" />
        </div>
    </form>
</body>
</html>

연관 챕터

93 #97 Spring Security

@caffeine-library/readers-pro-spring-5