CORs
- 웹브라우저는 기본적으로 origin이 다를 경우 보안 상의 이유로 통신이 불가능하도록 설정해 놓았고, 이를 CORs라고함.
- origin이란 웹페이지를 불러오는 도메인을 의미하며, 프로토콜(http, https) + 호스트(www.example.com) + 포트번호로 구성된다.
CORs를 해결하는 법
@CrossOrigin 어노테이션
- @CrossOrigin(origins = "http://localhost:200") - 특정 도메인만 허용한다.
- @CrossOrigin(origins = "*") - 모든 도메인을 허용한다.
- 모든 어노테이션을 명시하거나, 접근 가능한 특정 도메인을 전부 입력할 수 없으니 spring security에서 제공하는 기능을 사용한다.
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);
});