Spring Security 사용 시 CORs & CSRF

김가빈·2023년 8월 10일
1

springsecurity

목록 보기
10/23
post-thumbnail

CORs

  • 웹브라우저는 기본적으로 origin이 다를 경우 보안 상의 이유로 통신이 불가능하도록 설정해 놓았고, 이를 CORs라고함.
    • origin이란 웹페이지를 불러오는 도메인을 의미하며, 프로토콜(http, https) + 호스트(www.example.com) + 포트번호로 구성된다.

CORs를 해결하는 법

@CrossOrigin 어노테이션

  • @CrossOrigin(origins = "http://localhost:200") - 특정 도메인만 허용한다.
  • @CrossOrigin(origins = "*") - 모든 도메인을 허용한다.
  • 모든 어노테이션을 명시하거나, 접근 가능한 특정 도메인을 전부 입력할 수 없으니 spring security에서 제공하는 기능을 사용한다.

Spring Security conrsConfigurer 사용

package com.eazybytes.config;

import java.util.Collections;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;


@Configuration
@EnableWebSecurity
public class ProjectSecurityConfig {

	@Bean
	SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
		http.cors(cors -> {
			cors.configurationSource(request -> {
				CorsConfiguration config = new CorsConfiguration();
				config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
				config.setAllowedMethods(Collections.singletonList("*"));
				config.setAllowCredentials(true);
				config.setAllowedHeaders(Collections.singletonList("*"));
				config.setMaxAge(3600L);
				return config;
			});
		}).csrf(csrf -> {
			csrf.disable();
		}).authorizeHttpRequests((requests) -> requests
				.requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards", "/user").authenticated()
				.requestMatchers("/notices", "/contact", "/register").permitAll())
				.formLogin(Customizer.withDefaults())
				.httpBasic(Customizer.withDefaults());
				
		return http.build();
	}
	

	@Bean
	public PasswordEncoder passEncoder() {
		return new BCryptPasswordEncoder();
	}
}
  • config.setMaxAge(3600L)는 1시간 동안 캐시를 유지하겠다는 것을 의미한다.
  • 실제로 이런 방식으로 header와 method들을 전부 허용하는 것은 위험하기 때문에 다음과 같이 필요 헤더와 메소드만 허용하는 것이 좋다.
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); // 필요한 HTTP 메서드만 포함
config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); // 필요한 헤더만 포함

CSRF

  • 위의 로직에서 아래의 내용을 삭제하면 CORs에러가 발생한다.
			csrf.disable();
  • 이는 교차 사이트 요청 위조(csrf)를 무시했다가, 다시 서버가 거르도록 했기 때문이다.
  • 참고로 다음과 같은 방식으로 특정 경로에 대한 csrf 보호 요청을 무시할 수 있다.
csrf.ignoringRequestMatchers("/contact", "/register")

spring security에서의 CSRF보호

  • spring security에서는 csrf를 보호하고, 허용된 사이트에 대해 교차 사이트 요청을 가능하게 하기 위해서 csrf token을 생성해 세션에 저장하고, 이를 쿠키에 심어서 브라우저로 보낸다.
    • csrf 토큰 생성은 로그인 시, 폼 페이지 렌더링 시, 세션 생성 시 등 이루어지며 주로 사용자의 세션과 연관지어 생성된다.
  • 사용자가 POST요청을 할 때, CSRF token이 포함된 쿠키를 서버에 함께보내게 된다.
    • csrf().requireCsrfProtectionMatcher() 등을 통해 다른 요청에 대해서도 CSRF 보호를 추가 설정할 수 있다.
  • 서버에서는 쿠키에 포함된 CSRF token의 값과 세션에 저장된 CSRF의 값이 일치하는지 확인한 후 해당 요청을 허락하게 된다.

  • 위의 로직에 다음과 같은 방식으로 CsrfTokenRequestAttributeHandler 객체를 생성한다.
  • 해당 로직은 클라이언트에서 넘어온 CSRF token 정보를 setCsrfRequestAttributeName이라는 메소드를 통해 _csrf라는 이름으로 저장한다.
    • 사실 명시하지 않아도 default로 _csrf라는 이름으로 저장된다.
  • 해당 토큰의 정보가 세션에 넘어온 정보와 일치하는지는 서버에서 특정 로직을 수행할 떄 사용하는데, 그 전에 저장해 놓는 공간으로 생각하면 된다.
  • 그 후 해당 requestHandler를 통해 특정 경로에 대한 csrf 보호를 무시하고, cookie에 csrf token을 저장하는데, withHttpOnlyFalse메소드를 이용해 자바스크립트에서도 접근할 수 있도록 허용한다.
    • 보안상의 위험때문에 withHttpOnlyFalse는 특수 상황에서만 사용한다.

package com.eazybytes.filter;

import java.io.IOException;

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class CsrfCookieFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
		if(null != csrfToken.getHeaderName()) {
			response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
		}
		filterChain.doFilter(request, response);
	}

}
  • 해당 CSRF token정보를 클라이언트 측에서 편리하게 다루기 위해 header에 포함시키는 로직을 작성한다.
  • 앞의 requestHandler에서 저장한 토큰 정보를 들고와서 토큰이 있을 경우 header에 저장한다.

  • 방금 생성한 filter를 적용하기 위한 로직을 추가한다.(순서는 BasicAuthenticationFilter -> csrfCookieFilter 순서)
  • 참고로 BasicAuthenticationFilter는 request를 UsernamePasswordAuthenticationToken으로 바꾸는 역할을 한다.

  • 세션을 관리하기 위한 로직을 추가한다.
    • 참고로 다음과 같이 SessionCreationPolicy.ALWAYS는 요청마다 새로운 세션을 생성한다는 것으로 서버에 부하를 줄 수있으니 특정 상황에서만 사용하도록 한다.

클라이언트에서의 CSRF token처리

  • 서버는 앞의 로직을 통해서 header를 통해 CSRF token정보를 cookie에 심어서 클라이언트에 response할 수 있다.
  • 클라이언트는 해당 쿠키를 받아서, 각 요청 시 마다 해당 쿠키를 전달할 수 있도록 설정하는 것이 필요하다.

  • 앞의 로직을 통해 클라이언트에 전달한 CookieCsrfTokenRepository에서는 기본적으로 COOKIE이름을 XSRF-TOKEN으로 설정해 놓고 있다.
  • 해당 쿠키를 사용하는 방법은 여러가지가 있지만 대표적으로
    • 쿠키를 서버에서 가지고와서 클라이언트 측에 저장하여 사용하는 방법
    • 쿠키를 서버에 저장해 놓고 클라이언트의 요청 시 마다 사전에 비동기 통신을 통해 쿠키를 받아오는 방법
    • 보안상의 장점 때문에 후자의 방법을 더 추천한다.

  • 예를들어서 유저가 로그인을 시도할 때 다음과 같은 코드를 사용할 수 있다.
// 서버코드
@RestController
public class CsrfController {

    @GetMapping("/getCsrfToken")
    public ResponseEntity<String> getCsrfToken(HttpServletRequest request, HttpServletResponse response) {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());

        // 쿠키 생성 및 설정
        Cookie cookie = new Cookie("X-CSRF-TOKEN", csrfToken.getToken());
        cookie.setHttpOnly(true); // HttpOnly 설정
        cookie.setSecure(true); // Secure 설정 (HTTPS 연결에서만 전송)
        response.addCookie(cookie);

        return ResponseEntity.ok("CSRF Token generated and stored in cookie");
    }

    // 요청 처리
    @PostMapping("/processRequest")
    public ResponseEntity<String> processRequest(@RequestHeader("X-CSRF-TOKEN") String csrfToken) {
        // CSRF 토큰 유효성 검사 및 요청 처리
        if (validateCsrfToken(csrfToken)) {
            return ResponseEntity.ok("Request processed successfully");
        } else {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid CSRF Token");
        }
    }

    // CSRF 토큰 유효성 검사 메서드
    private boolean validateCsrfToken(String token) {
        // 실제 유효성 검사 로직 구현
        // 여기서는 단순 예시로 'token'과 일치하면 유효하다고 가정
        return token.equals("token");
    }
}
// 클라이언트 코드
// 서버로부터 CSRF 토큰 쿠키를 가져와서 요청 헤더에 포함시켜 요청 보내기
fetch("http://localhost:8080/getCsrfToken")
  .then(response => response.text())
  .then(data => {
    const csrfTokenCookie = document.cookie
      .split("; ")
      .find(row => row.startsWith("X-CSRF-TOKEN"))
      .split("=")[1];
      
    fetch("http://localhost:8080/processRequest", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-TOKEN": csrfTokenCookie,
      },
      body: JSON.stringify({ /* 요청 본문 데이터 */ }),
    })
      .then(response => response.text())
      .then(data => {
        // 응답 처리
        console.log(data);
      })
      .catch(error => {
        // 에러 처리
        console.error(error);
      });
  })
  .catch(error => {
    // 에러 처리
    console.error(error);
  });
profile
신입 웹개발자입니다.

0개의 댓글