2024. 3. 3. 10:30ㆍSpring Security
이 장에서 다룰 내용:
- CSRF 공격 이해하기
- 사이트 간 요청 위조 보호 구현하기
- CSRF 보호 커스터마이징
Spring Security 아키텍처에서 필터 체인과 그 목적에 대해 배웠습니다. 5장에서 여러 예제를 통해 필터 체인을 커스터마이징하는 방법을 작업했습니다. 하지만 Spring Security는 필터 체인에 자체 필터도 추가합니다. 이 장에서는 CSRF(사이트 간 요청 위조) 보호를 구성하는 필터에 대해 논의할 것입니다. 이러한 필터를 커스텀마이징하여 시나리오에 완벽하게 맞추는 방법을 배울 것입니다.
지금까지의 대부분의 예제에서 HTTP GET만 구현했고, HTTP POST를 구성해야 할 때는 CSRF 보호를 비활성화하는 추가 지시사항을 추가해야 했음을 아마도 관찰했을 것입니다. HTTP POST 엔드포인트를 직접 호출할 수 없는 이유는 Spring Security에서 기본적으로 활성화되어 있는 CSRF 보호 때문입니다.
이제 CSRF 보호에 대해 논의하고 언제 애플리케이션에서 이를 사용해야 하는지 알아보겠습니다. CSRF는 널리 퍼진 공격 유형으로, CSRF에 취약한 애플리케이션은 사용자가 인증 후 웹 애플리케이션에서 원치 않는 행동을 수행하도록 강요할 수 있습니다. 개발하는 애플리케이션이 CSRF에 취약하여 공격자가 사용자를 속여 원치 않는 행동을 하게 만드는 것을 원하지 않을 것입니다.
이러한 취약점을 완화하는 방법을 이해하는 것이 중요하기 때문에, 먼저 CSRF가 무엇인지와 그 작동 방식을 살펴보겠습니다. 그런 다음 CSRF 취약성을 완화하기 위해 Spring Security가 사용하는 CSRF 토큰 메커니즘에 대해 논의합니다. 토큰을 얻고 이를 사용하여 HTTP POST 방식으로 엔드포인트를 호출하는 방법을 계속해서 살펴볼 것입니다. REST 엔드포인트를 사용하는 작은 애플리케이션으로 이를 증명합니다. Spring Security가 CSRF 토큰 메커니즘을 어떻게 구현하는지 배우면, 실제 애플리케이션 시나리오에서 이를 어떻게 사용하는지에 대해 논의합니다.
마지막으로, Spring Security에서 CSRF 토큰 메커니즘의 가능한 사용자화에 대해 배우게 됩니다.
9.1 How CSRF protection works in Spring Security
이 섹션에서는 Spring Security가 CSRF 보호를 어떻게 구현하는지에 대해 논의합니다. CSRF 보호의 기본 메커니즘을 먼저 이해하는 것이 필수적이라고 생각합니다. 개발자들이 CSRF 보호의 작동 방식을 잘못 이해함으로써, 활성화되어야 할 시나리오에서 비활성화하거나 그 반대의 경우로 잘못 사용하는 상황을 많이 접합니다. 프레임워크의 다른 기능과 마찬가지로, 애플리케이션에 가치를 추가하기 위해서는 올바르게 사용해야 합니다.
예를 들어, 다음 시나리오(그림 9.1)를 고려해보세요:
당신은 직장에서 파일을 저장하고 관리하는 웹 도구를 사용합니다. 이 도구를 통해 웹 인터페이스에서 새 파일을 추가하고, 레코드에 새 버전을 추가하며, 심지어 파일을 삭제할 수도 있습니다. 당신은 이메일을 받아 특정 이유(예를 들어, 좋아하는 상점의 프로모션)로 페이지를 열어보라는 요청을 받습니다. 당신은 페이지를 열었지만, 페이지는 비어있거나 당신이 아는 웹사이트(좋아하는 상점의 온라인 샵)로 리다이렉트됩니다. 당신은 작업으로 돌아가지만 모든 파일이 사라진 것을 발견합니다!
무슨 일이 일어난 걸까요? 당신은 파일을 관리할 수 있도록 직장 애플리케이션에 로그인했습니다. 파일을 추가, 변경, 또는 삭제할 때, 당신이 상호 작용하는 웹 페이지는 이러한 작업을 실행하기 위해 서버의 일부 엔드포인트를 호출합니다. 이메일에서 알 수 없는 링크를 클릭하여 외부 페이지를 열었을 때, 그 페이지는 당신의 앱 백엔드를 호출하고 당신을 대신해 행동(파일 삭제)을 수행했습니다.
당신이 이전에 로그인했기 때문에 서버는 이러한 행동이 당신으로부터 왔다고 믿었습니다. 누군가가 당신을 속여 외부 이메일이나 메시지에서 링크를 클릭하게 만드는 것이 그렇게 쉽지 않다고 생각할 수 있지만, 실제로 많은 사람들이 이런 상황에 빠집니다. 대부분의 웹 앱 사용자는 보안 위험을 인식하지 못합니다. 그러므로 보안에 대해 잘 알고 있는 당신이 사용자를 보호하고 안전한 앱을 구축하는 것이, 앱 사용자들이 스스로를 보호하기를 기대하는 것보다 현명합니다.
CSRF 공격은 사용자가 웹 애플리케이션에 로그인한 상태라고 가정합니다. 사용자는 공격자에 의해 속아서 자신이 작업 중이던 같은 애플리케이션에서 행동을 실행하는 스크립트가 포함된 페이지를 열게 됩니다. 사용자가 이미 로그인했다고 (우리가 처음부터 가정했던 대로) 가정하면, 위조 코드는 이제 사용자를 가장하고 그들을 대신하여 행동을 취할 수 있습니다.
이러한 공격은 웹 애플리케이션에서 사용자의 세션이나 인증 토큰이 여전히 활성 상태인 경우 특히 위험합니다. 공격자는 이러한 상태를 이용하여 악의적인 명령을 애플리케이션에 전송하고, 애플리케이션은 이러한 요청이 신뢰할 수 있는 사용자로부터 온 것으로 잘못 판단하여 실행할 수 있습니다. 결과적으로, 공격자는 사용자가 인지하지 못하는 사이에 중요한 작업(예: 비밀번호 변경, 민감한 정보 삭제 등)을 수행할 수 있습니다.
이를 방지하기 위해, CSRF 보호 메커니즘은 중요한 작업을 수행하기 위한 모든 요청에 대해 사용자 또는 세션을 식별할 수 있는 고유한 토큰을 요구합니다. 이 토큰은 서버에 의해 생성되고, 폼이나 AJAX 요청을 통해 클라이언트로 전송됩니다. 사용자가 작업을 수행할 때, 이 토큰은 요청과 함께 서버로 다시 전송되어야 합니다. 서버는 요청이 유효한 CSRF 토큰을 포함하고 있는지 검증함으로써, 요청이 실제 사용자에 의해 의도적으로 만들어진 것인지를 확인할 수 있습니다. 이 방식으로, CSRF 공격을 효과적으로 방지할 수 있습니다.
그림 9.1 사용자가 자신의 계정에 로그인한 후, 위조[forgery] 코드가 포함된 페이지에 접근합니다. 이 코드는 사용자를 가장하고 사용자를 대신해 원치 않는 행동을 실행할 수 있습니다.
- Carlos는 회계사입니다. 그는 회사 계정을 관리하기 위해 애플리케이션에 로그인합니다.
- 하지만 그는 일할 때 음악을 듣는 것도 좋아합니다. 그래서 무료 음악이 있는 사이트에 접속합니다: 사용자는 위조[forgery] 코드가 포함된 페이지에 접속합니다.
- 그 사이트는 악의적인 의도를 가진 사람이 운영합니다. 음악 외에도 그 페이지에는 위조 코드가 포함되어 있습니다. Carlos는 이 사실을 모른 채 그저 사이트에서 음악을 제공한다고 생각합니다: 사용자가 페이지에 접속할 때 브라우저가 위조 스크립트를 가져옵니다.
- 나중에 Carlos는 자신이 작업 중이던 일부 계정이 변경되거나 삭제된 것을 발견합니다: 스크립트는 로그인된 사용자를 대신해 작동하여 애플리케이션에서 원치 않는 변경을 일으킬 가능성이 있습니다.
사용자를 이러한 시나리오로부터 어떻게 보호할까요? CSRF 보호가 보장하고자 하는 것은 웹 애플리케이션의 프론트엔드만이 변형 작업을 수행할 수 있도록 하는 것입니다(관례적으로 GET, HEAD, TRACE, OPTIONS를 제외한 HTTP 메소드). 그러면 우리 예제에서와 같은 외부 페이지는 사용자를 대신해 행동할 수 없습니다.
이를 어떻게 달성할 수 있을까요? 확실히 알 수 있는 것은, 데이터를 변경할 수 있는 어떤 행동도 하기 전에, 사용자는 적어도 한 번은 HTTP GET을 사용해 웹 페이지를 보기 위한 요청을 보내야 한다는 것입니다. 이런 일이 발생하면, 애플리케이션은 고유한 토큰을 생성합니다. 이제 애플리케이션은 헤더에 이 고유값을 포함하는 변형 작업(POST, PUT, DELETE 등)에 대한 요청만을 수락합니다.
애플리케이션은 토큰의 값을 아는 것이 변형 요청을 하는 것이 앱 자체이고 다른 시스템이 아님을 증명하는 것으로 간주합니다. POST, PUT, DELETE 등과 같은 변형 호출을 포함하는 모든 페이지는 응답을 통해 CSRF 토큰을 받아야 하며, 변형 호출을 할 때 이 토큰을 사용해야 합니다.
CSRF 보호의 출발점은 필터 체인에 있는 CsrfFilter라는 필터입니다. CsrfFilter는 요청을 가로채고 GET, HEAD, TRACE, OPTIONS와 같은 HTTP method를 사용하는 모든 요청을 허용합니다. 다른 모든 요청에 대해서는, 필터는 토큰을 포함한 헤더를 받기를 기대합니다. 이 헤더가 존재하지 않거나 토큰 값이 잘못되었을 경우, 애플리케이션은 요청을 거부하고 응답의 상태를 HTTP 403 Forbidden으로 설정합니다.
이 토큰은 무엇이며, 어디에서 오는 건가요? 이 토큰들은 문자열 값에 불과합니다. GET, HEAD, TRACE, OPTIONS를 제외한 다른 method를 사용할 때 요청의 헤더에 토큰을 추가해야 합니다. 토큰을 포함한 헤더를 추가하지 않으면, 애플리케이션은 요청을 수락하지 않으며, 이는 그림 9.2에서 제시된 것과 같습니다.
그림 9.2 POST 요청을 하기 위해서, 클라이언트는 CSRF 토큰을 포함한 헤더를 추가해야 합니다. 애플리케이션은 페이지가 로드될 때( GET 요청을 통해) CSRF 토큰을 생성하고, 로드된 페이지에서 만들어질 수 있는 모든 요청에 이 토큰을 추가합니다. 이 방법으로, 로드된 페이지만이 변형 요청을 할 수 있습니다.
CsrfFilter(그림 9.3)는 CsrfTokenRepository라는 컴포넌트를 사용하여 CSRF 토큰 값을 관리하며, 새로운 토큰을 생성하고, 토큰을 저장하며, 필요한 경우 이들을 무효화합니다. 기본적으로, CsrfTokenRepository는 HTTP 세션에 토큰을 저장하며, 토큰을 무작위 문자열 값으로 생성합니다. 대부분의 경우 이것으로 충분하지만, 9.3절에서 배우게 될 것처럼, 디폴트 구현이 필요한 요구사항에 적용되지 않는 경우 자신만의 CsrfTokenRepository 구현을 사용할 수 있습니다.
이 섹션에서는 텍스트와 그림을 풍부하게 사용하여 Spring Security에서 CSRF 보호가 어떻게 작동하는지 설명했습니다. 하지만 작은 코드 예제로도 여러분의 이해를 더욱 강화하고 싶습니다. 이 코드는 ssia-ch9-ex1이라는 프로젝트의 일부로 찾을 수 있습니다. HTTP GET으로 호출할 수 있는 엔드포인트와 HTTP POST로 호출할 수 있는 다른 엔드포인트를 노출하는 애플리케이션을 만들어 봅시다.
이제까지 알게 된 바와 같이, CSRF 보호를 비활성화하지 않고는 POST로 직접 엔드포인트를 호출할 수 없습니다. 이 예제에서는 CSRF 보호를 비활성화하지 않고 POST 엔드포인트를 호출하는 방법을 배웁니다. HTTP POST로 호출할 때 헤더에 사용할 수 있도록 CSRF 토큰을 얻어야 합니다.
그림 9.3 CsrfFilter는 필터 체인 중 하나입니다. 이 필터는 request을 받고 결국 체인에서 다음 필터로 전달합니다. CSRF 토큰을 관리하기 위해, CsrfFilter는 CsrfTokenRepository를 사용합니다.
이 예제를 통해 배우게 되는 것처럼, CsrfFilter는 생성된 CSRF 토큰을 _csrf라는 이름의 HTTP request 속성에 추가합니다(그림 9.4). 이것을 알고 있다면, CsrfFilter 이후에 이 속성을 찾아 토큰의 값을 가져올 수 있다는 것을 알 수 있습니다. 이 작은 애플리케이션을 위해, 5장에서 배운 것처럼 CsrfFilter 다음에 사용자 정의 필터를 추가하기로 결정했습니다. 이 사용자 정의 필터를 사용하여 HTTP GET을 사용해 엔드포인트를 호출할 때 앱이 생성하는 CSRF 토큰을 애플리케이션 콘솔에 출력합니다. 그러면 콘솔에서 토큰의 값을 복사하여 HTTP POST로 변형 호출을 할 때 사용할 수 있습니다. 다음 목록에서는 테스트에 사용하는 두 개의 엔드포인트를 가진 컨트롤러 클래스의 정의를 찾을 수 있습니다.
그림 9.4 CsrfFilter 다음에 CsrfTokenLogger(음영 처리됨)를 추가합니다. 이 방식으로, CsrfTokenLogger는 CsrfFilter가 저장하는 요청의 _csrf 속성에서 토큰의 값을 얻을 수 있습니다. CsrfTokenLogger는 애플리케이션 콘솔에 CSRF 토큰을 출력하며, 우리는 그것을 접근하여 HTTP POST 방식으로 엔드포인트를 호출하는 데 사용할 수 있습니다.
Listing 9.1 The controller class with two endpoints
@RestController
public class HelloController {
@GetMapping("/hello")
public String getHello() {
return "Get Hello!";
}
@PostMapping("/hello")
public String postHello() {
return "Post Hello!";
}
}
목록 9.2는 콘솔에 CSRF 토큰의 값을 출력하기 위해 사용하는 사용자 정의 필터를 정의합니다. 저는 사용자 정의 필터의 이름을 CsrfTokenLogger로 지었습니다. 호출될 때, 필터는 _csrf 요청 속성에서 CSRF 토큰의 값을 얻어내고 이를 콘솔에 출력합니다. 요청 속성의 이름인 _csrf는 CsrfFilter가 생성된 CSRF 토큰의 값을 CsrfToken 클래스의 인스턴스로 설정하는 곳입니다. 이 CsrfToken 인스턴스는 CSRF 토큰의 문자열 값을 포함하고 있습니다. getToken() 메소드를 호출함으로써 이를 얻을 수 있습니다.
Listing 9.2 The definition of the custom filter class
public class CsrfTokenLogger implements Filter {
private Logger logger =
Logger.getLogger(CsrfTokenLogger.class.getName());
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain)
throws IOException, ServletException {
CsrfToken token = (CsrfToken) request.getAttribute("_csrf"); #A
logger.info("CSRF token " + token.getToken());
filterChain.doFilter(request, response);
}
}
#A Takes the value of the token from the _csrf request attribute and prints it in the console
구성 클래스에서는 사용자 정의 필터를 추가합니다. 다음 목록은 구성 클래스를 제시합니다. 목록에서 CSRF 보호를 비활성화하지 않는다는 점을 주목하세요.
Listing 9.3 Adding the custom filter in the configuration class
@Configuration
public class ProjectConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.addFilterAfter(new CsrfTokenLogger(),
CsrfFilter.class)
.authorizeHttpRequests(
c -> c.anyRequest().permitAll()
);
return http.build();
}
}
이제 엔드포인트를 테스트할 수 있습니다. HTTP GET으로 엔드포인트를 호출하는 것으로 시작합니다. CsrfTokenRepository 인터페이스의 디폴트 구현이 서버 측에서 토큰 값을 저장하기 위해 HTTP 세션을 사용하기 때문에, 세션 ID도 기억해야 합니다. 이러한 이유로, 응답에서 세션 ID를 포함한 더 많은 세부 정보를 볼 수 있도록 호출에 -v 플래그를 추가합니다. 엔드포인트 호출
curl -v http://localhost:8080/hello
returns this response:
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200
< Set-Cookie: JSESSIONID=FB649C3784D945162D0D8BEF915D3EBA; Path=/; HttpOnly
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 10
< Date: Sun, 03 Mar 2024 02:11:12 GMT
<
{ [10 bytes data]
100 10 100 10 0 0 70 0 --:--:-- --:--:-- --:--:-- 70Get Hello!
* Connection #0 to host localhost left intact
애플리케이션 콘솔에서 요청을 따라가면, CSRF 토큰을 포함한 로그 라인을 찾을 수 있습니다:
INFO 21412 --- [nio-8080-exec-1] c.l.ssia.filters.CsrfTokenLogger : CSRF
token tAlE3LB_R_KN48DFlRChc…
※교재의 위 내용은 잘못되었습니다. 잘못된 내용은 빨간색으로 하이라이트되었습니다.
참고로, 클라이언트가 CSRF 토큰을 어떻게 얻을 수 있는지 궁금할 수 있습니다. 클라이언트는 토큰을 추측할 수도 없고 서버 로그에서 읽을 수도 없습니다. 이 예제는 CSRF 보호 구현이 어떻게 작동하는지 이해하기 쉽도록 설계되었습니다. 9.2절에서 알게 될 것처럼, 백엔드 애플리케이션은 클라이언트가 사용할 수 있도록 HTTP 응답에 CSRF 토큰의 값을 추가하는 책임이 있습니다.
CSRF 토큰을 제공하지 않고 HTTP POST 메소드를 사용하여 엔드포인트를 호출하면, 응답 상태는 다음 명령어 줄이 보여주는 것처럼 403 Forbidden입니다:
curl -XPOST http://localhost:8080/hello
The response body is
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello"
}
하지만 CSRF 토큰의 올바른 값을 제공하면 호출이 성공합니다. 기본 구현인 CsrfTokenRepository가 세션에 CSRF 토큰의 값을 저장하기 때문에 세션 ID(JSESSIONID)도 지정해야 합니다.
curl -X POST http://localhost:8080/hello
-H 'Cookie: JSESSIONID=21ADA55E10D70BA81C338FFBB06B0206'
-H 'X-CSRF-TOKEN: tAlE3LB_R_KN48DFlRChc…'
Postman에서 CSRF 토큰을 확인하고 사용하는 과정은 다음과 같습니다. 일반적으로, CSRF 토큰은 웹 애플리케이션에서 폼을 제출할 때 서버로부터 생성되고, 클라이언트 측에 전달됩니다. 이 토큰은 이후의 POST 요청에 포함되어 서버에 전송되어야 합니다. Postman을 사용하여 이러한 요청을 테스트할 때, 당신은 먼저 CSRF 토큰을 얻어야 하고, 그 다음 이 토큰을 사용하여 요청을 구성해야 합니다.
1. CSRF 토큰 얻기:
- 먼저, CSRF 토큰이 포함된 페이지를 GET 요청으로 호출합니다. 예를 들어, 로그인 페이지나 폼 제출 페이지가 될 수 있습니다.
- 이 요청에 대한 응답에서, CSRF 토큰을 찾습니다. 이 토큰은 보통 HTML 폼 내에 숨겨진 필드로 제공되거나, 쿠키에 저장되거나, HTTP 응답 헤더를 통해 전달될 수 있습니다.
- Postman에서 응답 본문이나 헤더를 검사하여 CSRF 토큰 값을 확인합니다.
2. CSRF 토큰을 포함하여 POST 요청 보내기:
- 새로운 POST 요청을 생성합니다.
- 요청 헤더나 본문에 CSRF 토큰을 포함시킵니다. 토큰을 어디에 포함시켜야 하는지는 애플리케이션의 구현에 따라 다릅니다. 일반적으로는 요청 헤더에 `X-CSRF-TOKEN`이라는 이름으로 추가하거나, 폼 데이터 또는 JSON 본문 내에 포함시킬 수 있습니다.
- 필요한 경우, 적절한 세션 쿠키(JSESSIONID 등)도 요청과 함께 보내야 할 수 있습니다. 이는 Postman의 Cookies 섹션에서 설정할 수 있습니다.
3. 요청 보내기 및 결과 확인:
- 구성된 POST 요청을 보냅니다.
- 서버로부터의 응답을 검토하여 요청이 성공적으로 처리되었는지 확인합니다. CSRF 토큰이 올바르게 전달되지 않았다면, 일반적으로 403 Forbidden 응답을 받게 됩니다.
Postman에서 이 과정을 수행함으로써, CSRF 보호가 활성화된 애플리케이션에 대한 API 테스트를 효과적으로 수행할 수 있습니다.
9.2 Using CSRF protection in practical scenarios
이 섹션에서는 실제 상황에서 CSRF 보호를 적용하는 방법에 대해 논의합니다. 이제 Spring Security에서 CSRF 보호가 어떻게 작동하는지 알게 되었으니, 실제 세계에서 어디에 사용해야 하는지 알아야 합니다. 어떤 종류의 애플리케이션에서 CSRF 보호를 사용해야 할까요?
브라우저에서 실행되는 웹 애플리케이션에 CSRF 보호를 사용해야 하는데, 이는 애플리케이션의 표시된 내용을 로드하는 브라우저가 변형 작업을 수행할 수 있음을 예상해야 하기 때문입니다. 여기에서 제공할 수 있는 가장 기본적인 예는 표준 Spring MVC 흐름에 기반한 간단한 웹 애플리케이션입니다. 우리는 이미 6장에서 폼 로그인을 논의할 때 이러한 애플리케이션을 만들었고, 그 웹 앱은 실제로 CSRF 보호를 사용했습니다. 그 애플리케이션에서 로그인 작업이 HTTP POST를 사용했다는 것을 알아차렸나요? 그런데 왜 그 경우에 CSRF에 대해 명시적으로 아무것도 할 필요가 없었을까요? 우리가 이를 관찰하지 못한 이유는 우리 자신이 그 내부에서 어떤 변형 작업도 개발하지 않았기 때문입니다. 디폴트 폼 로그인의 경우, Spring Security가 우리를 대신해 올바르게 CSRF 보호를 적용합니다. 프레임워크는 로그인 요청에 CSRF 토큰을 추가하는 것을 처리합니다. 이제 CSRF 보호가 어떻게 작동하는지 더 자세히 살펴보기 위해 비슷한 애플리케이션을 개발해 봅시다. 9.5 그림이 보여주듯이, 이 섹션에서 우리는
- 로그인 폼이 있는 웹 애플리케이션 예제를 구축하고
- 로그인의 디폴트 구현이 CSRF 토큰을 어떻게 사용하는지 살펴보며
- 메인 페이지에서 HTTP POST 호출을 구현합니다.
그림 9.5 계획입니다. 이 섹션에서는, Spring Security가 CSRF 보호를 어떻게 적용하는지 이해하기 위해 간단한 앱을 구축하고 분석하기로 시작합니다. 그리고 나서 우리 자신의 POST 호출을 작성합니다.
이 예제 애플리케이션에서는, CSRF 토큰을 올바르게 사용할 때까지 HTTP POST 호출이 작동하지 않음을 알게 될 것이고, 이러한 웹 페이지에서 양식에 CSRF 토큰을 적용하는 방법을 배우게 될 것입니다. 이 애플리케이션을 구현하기 위해, 우리는 새로운 Spring Boot 프로젝트를 생성하기로 시작합니다. 이 예제는 프로젝트 ssia-ch9-ex2에서 찾을 수 있습니다. 다음 코드 스니펫은 필요한 의존성을 제시합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
물론, 폼 로그인을 구성하고 최소한 한 명의 사용자를 추가해야 합니다. 다음 목록은 UserDetailsService를 정의하고 사용자를 추가하며 formLogin 메소드를 구성하는 구성 클래스를 제시합니다.
Listing 9.4 The definition of the configuration class
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService uds() {
var uds = new InMemoryUserDetailsManager();
var u1 = User.withUsername("mary")
.password("12345")
.authorities("READ")
.build();
uds.createUser(u1);
return uds;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean #A
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(
c -> c.defaultSuccessUrl("/main", true)
);
http.addFilterAfter(new CsrfTokenLogger(),
CsrfFilter.class)
.authorizeHttpRequests(
c -> c.anyRequest().authenticated()
);
return http.build();
}
}
#A SecurityFilterChain 타입의 빈을 생성하여 폼 로그인 인증 방법을 설정하고 인증된 사용자만 모든 엔드포인트에 접근할 수 있도록 지정합니다.
Maven 프로젝트의 resources/templates 폴더 안에 main.html 파일을, 그리고 controllers라는 패키지에 컨트롤러 클래스를 메인 페이지를 위해 추가합니다. main.html 파일은 애플리케이션의 첫 실행에서 우리가 로그인 페이지가 CSRF 토큰을 어떻게 사용하는지에만 초점을 맞추기 때문에 당분간 비어 있을 수 있습니다. 다음 목록은 메인 페이지를 제공하는 MainController 클래스를 제시합니다.
Listing 9.5 The definition of the MainController class
@Controller
public class MainController {
@GetMapping("/main")
public String main() {
return "main.html";
}
}
애플리케이션을 실행한 후, 기본 로그인 페이지에 접근할 수 있습니다. 브라우저의 요소 검사 기능을 사용하여 폼을 검사하면, 로그인 폼의 기본 구현이 CSRF 토큰을 보내는 것을 관찰할 수 있습니다. 이것이 HTTP POST 요청을 사용하더라도 CSRF 보호가 활성화된 상태에서 로그인이 작동하는 이유입니다! 그림 9.6은 로그인 폼이 숨겨진 입력을 통해 CSRF 토큰을 어떻게 보내는지 보여줍니다.
그렇다면 POST, PUT, 또는 DELETE 같은 HTTP 메소드를 사용하는 우리 자신의 엔드포인트를 개발하는 것은 어떨까요? 이 경우, CSRF 보호가 활성화되어 있다면 CSRF 토큰의 값을 전송해야 합니다. 이를 테스트하기 위해, 우리 애플리케이션에 HTTP POST를 사용하는 엔드포인트를 추가해봅시다. 이 엔드포인트를 메인 페이지에서 호출하고, 이를 위한 두 번째 컨트롤러인 ProductController를 생성합니다. 이 컨트롤러 내에서, HTTP POST를 사용하는 /product/add 엔드포인트를 정의합니다. 더 나아가, 메인 페이지에 있는 폼을 사용하여 이 엔드포인트를 호출합니다. 다음 목록은 ProductController 클래스를 정의합니다.
그림 9.6 디폴트 폼 로그인은 요청에서 CSRF 토큰을 보내기 위해 숨겨진 입력을 사용합니다. 이것이 CSRF 보호가 활성화된 상태에서 HTTP POST 방식을 사용하는 로그인 요청이 작동하는 이유입니다.
Listing 9.6 The definition of the ProductController class
@Controller
@RequestMapping("/product")
public class ProductController {
private Logger logger =
Logger.getLogger(ProductController.class.getName());
@PostMapping("/add")
public String add(@RequestParam String name) {
logger.info("Adding product " + name);
return "main.html";
}
}
엔드포인트는 Request Parameter를 받아 애플리케이션 콘솔에 출력합니다. 다음 목록은 main.html 파일에 정의된 폼의 정의를 보여줍니다.
Listing 9.7 The definition of the form in the main.html page
<form action="/product/add" method="post">
<span>Name:</span>
<span><input type="text" name="name" /></span>
<span><button type="submit">Add</button></span>
</form>
이제 애플리케이션을 다시 실행하고 폼을 테스트할 수 있습니다. 관찰할 수 있는 것은 위 main.html의 /product/add 요청을 post 메소드로 제출할 때, 서버로부터의 응답에 HTTP 403 Forbidden 상태를 확인하는 기본 오류 페이지가 표시된다는 것입니다(그림 9.7). HTTP 403 Forbidden 상태의 이유는 CSRF 토큰의 부재 때문입니다.
그림 9.7 CSRF 토큰을 전송하지 않으면, 서버는 HTTP POST 메소드로 수행된 요청을 받아들이지 않습니다. 애플리케이션은 사용자를 기본 오류 페이지로 리디렉션하는데, 이는 응답 상태가 HTTP 403 Forbidden임을 확인시켜 줍니다.
이 문제를 해결하려면, 폼을 통해 수행된 요청에 CSRF 토큰을 추가해야 합니다. 이를 수행하는 쉬운 방법은 디폴트 폼 로그인에서 보았던 것처럼 숨겨진 입력 컴포넌트를 사용하는 것입니다. 다음 리스팅에서 제시된 대로 이를 구현할 수 있습니다.
Listing 9.8 Adding the CSRF token to the request done through the form
<form action="/product/add" method="post">
<span>Name:</span>
<span><input type="text" name="name" /></span>
<span><button type="submit">Add</button></span>
<input type="hidden" #A
th:name="${_csrf.parameterName}" #B
th:value="${_csrf.token}" /> #B
</form>
#A 숨겨진 입력을 사용하여 요청에 CSRF 토큰을 추가합니다.
#B "th" 접두사는 Thymeleaf가 토큰 값을 출력하도록 합니다.
참고: 이 예제에서는 Thymeleaf를 사용합니다. Thymeleaf는 뷰에서 요청 속성 값을 간편하게 얻을 수 있는 방법을 제공하기 때문입니다. 우리의 경우, CSRF 토큰을 출력해야 합니다. 기억해야 할 점은 CsrfFilter가 요청의 _csrf 속성에 토큰 값을 추가한다는 것입니다. Thymeleaf를 사용하는 것이 필수는 아닙니다. 응답에 토큰 값을 출력하기 위해 선택한 다른 대안을 사용할 수 있습니다.
애플리케이션을 다시 실행한 후에 폼을 다시 테스트할 수 있습니다. 이번에는 서버가 요청을 수락하고, 애플리케이션은 콘솔에 로그 라인을 출력하여 실행이 성공했음을 증명합니다. 또한, 폼을 검사하면 CSRF 토큰의 값을 가진 숨겨진 입력을 찾을 수 있습니다(그림 9.8).
그림 9.8 메인 페이지에 정의된 폼은 이제 요청에 대한 CSRF 토큰 값을 전송합니다. 이렇게 하면 서버가 요청을 허용하고 컨트롤러 액션을 실행합니다. 페이지의 소스 코드에서는 이제 폼이 요청에 CSRF 토큰을 전송하는 데 사용하는 숨겨진 입력을 찾을 수 있습니다.
폼을 제출한 후에는 애플리케이션 콘솔에서 다음과 유사한 줄을 찾아야 합니다:
INFO 20892 --- [nio-8080-exec-7] c.l.s.controllers.ProductController :
Adding product Chocolate
물론 페이지가 변경 작업을 호출하기 위해 사용하는 모든 동작이나 비동기 JavaScript 요청에 대해서도 유효한 CSRF 토큰을 보내야 합니다. 이는 요청이 제3자에서 오지 않도록 하는 애플리케이션에서 가장 일반적인 방법입니다. 제3자 요청은 사용자를 흉내내어 사용자 대신에 동작을 실행하려고 할 수 있습니다. CSRF 토큰은 프론트엔드와 백엔드를 모두 처리하는 동일한 서버가 책임을 지는 아키텍처에서 잘 작동합니다. 이는 주로 간단함 때문입니다. 그러나 CSRF 토큰은 클라이언트가 사용하는 백엔드 솔루션과 독립적인 경우에는 잘 작동하지 않습니다. 이러한 시나리오는 클라이언트로 모바일 애플리케이션이나 독립적으로 개발된 웹 프론트엔드가 있는 경우에 발생합니다. Angular, ReactJS 또는 Vue.js와 같은 프레임워크로 개발된 웹 클라이언트는 웹 애플리케이션 아키텍처에서 널리 사용되며, 이러한 경우에도 보안 접근 방식을 구현하는 방법을 알아야 합니다. 이러한 디자인에 대해 이 책의 제 4부에서 논의할 것입니다.
제 13장부터 제 16장까지는 OAuth 2 사양을 구현하는 방법을 배우게 될 것인데, 이는 컴포넌트를 분리하는 데 매우 우수한 장점을 가지고 있습니다. 이로써 애플리케이션이 클라이언트에게 허가한 리소스로부터 인증을 수행할 수 있게 됩니다.
참고: 착각처럼 보일 수 있지만, 내 경험상으로는 이러한 실수를 너무 많이 발견합니다. 절대로 HTTP GET을 사용하여 데이터를 변경하는 작업을 수행하지 마십시오! 데이터를 변경하는 행동을 구현하고 이를 HTTP GET 엔드포인트로 호출하지 마십시오. HTTP GET 엔드포인트에 대한 호출은 CSRF 토큰이 필요하지 않기 때문에 이를 기억하세요.
※ Postman으로 테스트하는 방법입니다.
Request의 Body에 form-data 형식으로 Credential과 함께 _csrf 토큰을 전송합니다.
폼 로그인 인증이후에는 Request의 Body에 x-www-form-urlencoded 형식으로 전송합니다
(인증 성공후, 서버에서 새로 전송한, 쿠키에 저장되어 있는 Session ID를 이 Request 헤더에 포함시켜야 합니다)
9.3 Customizing CSRF protection
이 섹션에서는 Spring Security가 제공하는 CSRF 보호 솔루션을 사용자 정의하는 방법을 배우게 됩니다. 애플리케이션에는 다양한 요구 사항이 있기 때문에, 프레임워크에서 제공하는 모든 구현은 다양한 시나리오에 쉽게 적응할 수 있을 정도로 유연해야 합니다. Spring Security의 CSRF 보호 메커니즘도 이에 예외는 아닙니다. 이 섹션에서는 가장 자주 발생하는 요구 사항을 적용하여 CSRF 보호 메커니즘을 사용자 정의하는 예제를 제공합니다. 이러한 요구 사항은 다음과 같습니다.
- CSRF가 적용되는 경로 구성
- CSRF 토큰 관리
우리는 서버에서 생성된 리소스를 사용하는 페이지 자체가 동일한 서버에서 생성될 때만 CSRF 보호를 사용합니다. 이것은 다른 출처에서 노출된 소비 엔드포인트를 사용하는 웹 애플리케이션이거나 (9.2절에서 논의한 것과 같이) 모바일 애플리케이션일 수 있습니다. 모바일 애플리케이션의 경우 OAuth 2 플로우를 사용할 수 있으며, 이에 대해 13장부터 16장까지 논의할 예정입니다.
기본적으로 CSRF 보호는 GET, HEAD, TRACE 또는 OPTIONS 이외의 HTTP 메서드로 호출되는 모든 경로에 적용됩니다. 5장에서 CSRF 보호를 완전히 비활성화하는 방법을 이미 알고 있습니다. 그러나 일부 애플리케이션 경로에서만 CSRF 보호를 비활성화하려면 어떻게 해야 할까요? 이를 위해 커스터마이저 객체를 사용하여 구성을 빠르게 수행할 수 있습니다. 이는 6장에서 폼 로그인 방법에 HTTP 기본 인증을 사용자 정의한 방법과 유사합니다. 이를 예제를 통해 살펴보겠습니다.
우리의 예제에서는 다음 코드 스니펫에서 제시된대로 웹 및 보안 종속성만 추가하고 새 프로젝트를 생성합니다. 이 예제는 프로젝트 ssia-ch9-ex3에서 찾을 수 있습니다. 다음은 종속성입니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
이 애플리케이션에서는 HTTP POST로 호출되는 두 개의 엔드포인트를 추가하지만, 이 중 하나는 CSRF 보호를 사용하지 않도록 제외하려고 합니다(그림 9.9). 아래는 이를 위한 컨트롤러 클래스를 정의한 리스트 9.9입니다. 이를 HelloController라고 이름 짓겠습니다.
그림 9.9 애플리케이션에는 HTTP POST로 호출된 /hello 엔드포인트에 대한 CSRF 토큰이 필요하지만 CSRF 토큰 없이 /ciao 엔드포인트에 대한 HTTP POST 요청을 허용합니다.
Listing 9.9 The definition of the HelloController class
@RestController
public class HelloController {
@PostMapping("/hello") #A
public String postHello() {
return "Post Hello!";
}
@PostMapping("/ciao") #B
public String postCiao() {
return "Post Ciao";
}
}
#A /hello 경로는 여전히 CSRF 보호를 받습니다. 유효한 CSRF 토큰 없이 이 엔드포인트를 호출할 수 없습니다.
#B /ciao 경로는 CSRF 토큰 없이 호출할 수 있습니다.
CSRF 보호를 커스터마이징하려면 configuration() 메서드 내에서 HttpSecurity 객체의 csrf() 메서드를 사용하여 Customizer 객체와 함께 사용할 수 있습니다. 다음 코드는 이 접근 방법을 보여줍니다.
Listing 9.10 A Customizer object for the configuration of CSRF protection
@Configuration
public class ProjectConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http.csrf(c -> { #A
c.ignoringRequestMatchers("/ciao");
});
http.authorizeRequests(
c -> c.anyRequest().permitAll()
);
return http.build();
}
#A 람다 표현식의 파라미터는 CsrfConfigurer입니다. 이를 통해 다양한 방식으로 CSRF 보호를 구성할 수 있습니다.
ignoringRequestMatchers(String paths) 메서드를 호출하면 CSRF 보호 메커니즘에서 제외할 경로를 나타내는 경로 표현식을 지정할 수 있습니다. 더 일반적인 접근 방법은 RequestMatcher를 사용하는 것입니다. 이를 사용하면 정규 경로 표현식 및 정규식(regexes)을 사용하여 제외 규칙을 적용할 수 있습니다. CsrfCustomizer 객체의 ignoringRequestMatchers() 메서드를 사용할 때는 RequestMatcher를 파라미로 제공할 수 있습니다. 다음 코드 조각은 String 값으로 주어진 경로 대신 MvcRequestMatcher와 함께 ignoringRequestMatchers() 메서드를 사용하는 방법을 보여줍니다:
HandlerMappingIntrospector i = new HandlerMappingIntrospector();
MvcRequestMatcher r = new MvcRequestMatcher(i, "/ciao");
c.ignoringRequestMatchers(r);
또는 다음 코드 조각에서와 같이 정규식 matcher를 사용할 수도 있습니다:
String pattern = ".*[0-9].*";
String httpMethod = HttpMethod.POST.name();
RegexRequestMatcher r = new RegexRequestMatcher(pattern, httpMethod);
c.ignoringRequestMatchers(r);
애플리케이션 요구 사항에서 자주 발견되는 다른 필요성은 CSRF 토큰의 관리를 커스터마이징하는 것입니다. 기본적으로 애플리케이션은 CSRF 토큰을 서버 측의 HTTP 세션에 저장합니다. 이 간단한 접근 방법은 작은 애플리케이션에 적합하지만 많은 요청을 처리하고 수평 확장이 필요한 애플리케이션에는 적합하지 않습니다. HTTP 세션은 상태를 유지하며 애플리케이션의 확장성을 감소시킵니다.
예를 들어, 애플리케이션에서 토큰을 관리하는 방식을 변경하고 HTTP 세션 대신 데이터베이스에 저장하려고 합니다. Spring Security는 이를 수행하기 위해 구현해야 하는 세 가지 계약을 제공합니다:
- CsrfToken — CSRF 토큰 자체를 설명합니다.
- CsrfTokenRepository — CSRF 토큰을 생성, 저장 및 로드하는 객체를 설명합니다.
- CsrfTokenRequestHandler — 생성된 CSRF 토큰이 HTTP 요청에 설정되는 방식을 관리하는 객체를 설명합니다.
CsrfToken 객체는 계약을 구현할 때 지정해야 할 세 가지 주요 특성이 있습니다(listing 9.11이 CsrfToken 계약을 정의합니다):
- 요청에 포함된 CSRF 토큰 값을 포함하는 헤더의 이름(기본값은 X-CSRF-TOKEN)
- 토큰 값을 저장하는 요청의 속성의 이름(기본값은 _csrf)
- 토큰의 값
Listing 9.11 The definition of the CsrfToken interface
public interface CsrfToken extends Serializable {
String getHeaderName();
String getParameterName();
String getToken();
}
일반적으로 세부 정보를 저장하려면 CsrfToken 유형의 인스턴스만 있으면 됩니다. 이 기능을 위해 Spring Security는 DefaultCsrfToken이라는 구현을 제공합니다. 우리 예제에서도 이를 사용합니다.
DefaultCsrfToken은 CsrfToken 계약을 구현하고 요청 속성 및 헤더의 이름과 토큰 자체를 포함하는 변경할 수 없는 인스턴스를 생성합니다.
CsrfTokenRepository 인터페이스는 CSRF 토큰을 관리하는 구성 요소를 나타내는 계약입니다. 애플리케이션이 토큰을 관리하는 방식을 변경하려면 CsrfTokenRepository 인터페이스를 구현해야하며, 이를 통해 사용자 지정 구현을 프레임워크에 연결할 수 있습니다. 이 섹션에서 사용하는 현재 애플리케이션을 변경하여 CsrfTokenRepository에 대한 새로운 구현을 추가하여 토큰을 데이터베이스에 저장하도록 해보겠습니다. Figure 9.10은 이 예제에 대해 구현하는 구성 요소와 그들 간의 연결을 나타냅니다.
Figure 9.10은 CsrfToken이 CsrfTokenRepository의 사용자 지정 구현을 사용합니다. 이 사용자 지정 구현은 데이터베이스에서 CSRF 토큰을 관리하기 위해 JpaRepository를 사용합니다.
우리의 예제에서는 데이터베이스의 테이블을 사용하여 CSRF 토큰을 저장합니다. 클라이언트가 그들 자체를 고유하게 식별하는 ID를 갖고 있다고 가정합니다. 응용 프로그램은 이 식별자를 사용하여 CSRF 토큰을 얻고 유효성을 검사해야 합니다. 일반적으로 이러한 고유 식별자는 로그인 중에 얻어지며 사용자가 로그인할 때마다 다를 수 있어야 합니다. 이 토큰 관리 전략은 메모리에 저장하는 것과 유사합니다. 이 경우 세션 ID를 사용합니다. 이 예제에서 새로운 식별자는 세션 ID를 대체하는 것일 뿐입니다.
이 접근 방식의 대안으로 특정 수명을 갖는 CSRF 토큰을 사용하는 것이 있습니다. 이러한 접근 방식에서는 토큰이 정의한 시간이 지난 후에 만료됩니다. 이 접근 방식에서는 토큰을 데이터베이스에 저장하고 특정 사용자 ID와 연결하지 않아도 됩니다. 요청이 허용되는지 여부를 결정하려면 제공된 토큰이 존재하고 만료되지 않았는지만 확인하면 됩니다.
연습문제
CSRF 토큰을 할당하는 식별자를 사용하는 이 예제를 마치면 토큰이 만료되는 CSRF 토큰을 사용하는 두 번째 접근 방식을 구현해보세요.
예제를 간단히 만들기 위해, 우리는 CsrfTokenRepository의 구현에만 초점을 맞추고, 클라이언트가 이미 생성된 식별자를 가지고 있다고 가정해야 합니다. 데이터베이스와 작업하기 위해, pom.xml 파일에 몇 가지 더 의존성을 추가해야 합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
application.properties 파일에는 데이터베이스 연결을 위한 속성을 추가해야 합니다.
spring.datasource.url=jdbc:mysql://localhost/spring?
useLegacyDatetimeCode=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=
spring.sql.init.mode=always
애플리케이션이 시작될 때 필요한 테이블을 데이터베이스에 생성할 수 있도록 하려면 프로젝트의 리소스 폴더에 schema.xml 파일을 추가할 수 있습니다. 이 파일은 다음 코드 스니펫에 나와 있는대로 테이블을 생성하는 쿼리를 포함해야 합니다.
CREATE TABLE IF NOT EXISTS `spring`.`token` (
`id` INT NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(45) NULL,
`token` TEXT NULL,
PRIMARY KEY (`id`));
우리는 데이터베이스에 연결하기 위해 Spring Data와 JPA 구현을 사용하므로 엔티티 클래스와 JpaRepository 클래스를 정의해야 합니다. entities라는 패키지에 JPA 엔티티를 다음 목록과 같이 정의합니다.
Listing 9.12 The definition of the JPA entity class
@Entity
public class Token {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String identifier; #A
private String token; #B
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getIdentifier() {
return identifier;
}
public void setIdentifier(String identifier) {
this.identifier = identifier;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
}
#A The identifier of the client
#B The CSRF token generated by the application for the client
우리의 JpaRepository 계약인 JpaTokenRepository는 다음 목록과 같이 정의될 수 있습니다. 필요한 유일한 메서드는 findTokenByIdentifier()이며, 이 메서드는 특정 클라이언트의 CSRF 토큰을 데이터베이스에서 가져옵니다.
Listing 9.13 The definition of the JpaTokenRepository interface
public interface JpaTokenRepository
extends JpaRepository<Token, Integer> {
Optional<Token> findTokenByIdentifier(String identifier);
}
구현된 데이터베이스에 액세스할 수 있으므로 이제 CustomCsrfTokenRepository라고하는 CsrfTokenRepository 구현을 작성할 수 있습니다. 다음 목록은 CsrfTokenRepository의 세 가지 메서드를 오버라이드하는이 클래스를 정의합니다.
Listing 9.14 The implementation of the CsrfTokenRepository contract
@Component
public class CustomCsrfTokenRepository implements CsrfTokenRepository {
private final JpaTokenRepository jpaTokenRepository;
public CustomCsrfTokenRepository(JpaTokenRepository jpaTokenRepository) {
this.jpaTokenRepository = jpaTokenRepository;
}
@Override
public CsrfToken generateToken(HttpServletRequest httpServletRequest) {
String uuid = UUID.randomUUID().toString();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", uuid);
}
@Override
public void saveToken(CsrfToken csrfToken,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
if (existingToken.isPresent()) {
Token token = existingToken.get();
token.setToken(csrfToken.getToken());
} else {
Token token = new Token();
token.setToken(csrfToken.getToken());
token.setIdentifier(identifier);
jpaTokenRepository.save(token);
}
}
@Override
public CsrfToken loadToken(HttpServletRequest httpServletRequest) {
String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
if (existingToken.isPresent()) {
Token token = existingToken.get();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token.getToken());
}
return null;
}
}
CustomCsrfTokenRepository는 데이터베이스에 액세스하기 위해 Spring 컨텍스트에서 JpaTokenRepository의 인스턴스를 주입합니다. CustomCsrfTokenRepository는 이 인스턴스를 사용하여 데이터베이스에서 CSRF 토큰을 검색하거나 저장합니다. CSRF 보호 메커니즘은 응용 프로그램이 새로운 토큰을 생성해야 할 때 generateToken() 메서드를 호출합니다. 다음 리스팅에서는이 연습에 대한 이 메서드의 구현을 찾을 수 있습니다. 우리는 UUID 클래스를 사용하여 새로운 무작위 UUID 값을 생성하고, 요청 헤더와 속성에 대해 Spring Security가 제공하는 디폴트 구현과 동일한 이름, X-CSRF-TOKEN 및 _csrf를 유지합니다.
Listing 9.15 The implementation of the generateToken() method
@Override
public CsrfToken generateToken(HttpServletRequest httpServletRequest) {
String uuid = UUID.randomUUID().toString();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", uuid);
}
saveToken() 메서드는 특정 클라이언트를 위해 생성된 토큰을 저장합니다. 디폴트 CSRF 보호 구현의 경우, 응용 프로그램은 HTTP 세션을 사용하여 CSRF 토큰을 식별합니다. 우리의 경우, 클라이언트가 고유한 식별자를 가지고 있다고 가정합니다. 클라이언트는 고유한 ID 값을 X-IDENTIFIER라는 헤더와 함께 요청에 보냅니다. 메서드 로직에서는 해당 값이 데이터베이스에 있는지 확인합니다. 값이 존재하면 데이터베이스를 새로운 토큰 값으로 업데이트합니다. 그렇지 않으면이 ID에 대한 새로운 레코드를 생성하고 CSRF 토큰의 새로운 값을 저장합니다. 아래 목록은 saveToken() 메서드의 구현을 보여줍니다.
Listing 9.16 The implementation of the saveToken() method
@Override
public void saveToken(CsrfToken csrfToken,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = #A
jpaTokenRepository.findTokenByIdentifier(identifier);
if (existingToken.isPresent()) { #B
Token token = existingToken.get();
token.setToken(csrfToken.getToken());
} else { #C
Token token = new Token();
token.setToken(csrfToken.getToken());
token.setIdentifier(identifier);
jpaTokenRepository.save(token);
}
}
#A Obtains the token from the database by client ID
#B If the ID exists, updates the value of the token with a newly generated value
#C If the ID doesn’t exist, creates a new record for the ID with a generated value for the CSRF token
loadToken() 메서드의 구현은 토큰 세부 정보를 로드하고, 이러한 정보가 있는 경우 이를 반환하거나, 그렇지 않은 경우 null을 반환합니다. 다음 리스팅은 이 구현을 보여줍니다.
Listing 9.17 The implementation of the loadToken() method
@Override
public CsrfToken loadToken(HttpServletRequest httpServletRequest) {
String identifier = httpServletRequest.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier);
if (existingToken.isPresent()) {
Token token = existingToken.get();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token.getToken());
}
return null;
}
우리는 구성 클래스에서 CsrfTokenRepository의 사용자 정의 구현을 선언하여 빈을 생성합니다. 그런 다음 CsrfConfigurer의 csrfTokenRepository() 메서드를 사용하여 이 빈을 CSRF 보호 메커니즘에 연결합니다. 다음 리스팅은 이 구성 클래스를 정의합니다.
Listing 9.18 The configuration class for the custom CsrfTokenRepository
@Configuration
public class ProjectConfig {
private final CustomCsrfTokenRepository customTokenRepository;
public ProjectConfig(CustomCsrfTokenRepository customTokenRepository) {
this.customTokenRepository = customTokenRepository;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(c -> {
c.csrfTokenRepository(customTokenRepository); #A
});
http.authorizeHttpRequests(
c -> c.anyRequest().permitAll()
);
return http.build();
}
}
#A는 Customizer<CsrfConfigurer<HttpSecurity>> 객체를 사용하여 새로운 CsrfTokenRepository 구현을 CSRF 보호 메커니즘에 연결합니다.
모든 것이 잘 작동하려면 마지막으로 CsrfTokenRequestHandler가 필요합니다. 다행히 Spring Security가 제공하는 구현인 CsrfTokenRequestAttributeHandler를 사용할 수 있습니다. 이 구현은 HTTP GET 메서드를 사용하여 엔드포인트가 호출될 때 CsrfTokenRepository의 generateToken() 메서드를 사용하여 새 토큰을 생성하고 생성된 CSRF 토큰을 요청에 속성으로 추가합니다.
CsrfTokenRequestAttributeHandler 객체의 간단한 동작을 확장하여 사용자 정의할 수 있습니다. 예를 들어 Spring Security가 사용하는 디폴트 구현인 XorCsrfTokenRequestAttributeHandler는 더 복잡한 동작을 합니다. 이 구현은 SecuredRandom 객체를 사용하여 무작위 값으로 토큰을 생성한 다음, XOR 논리 연산을 사용하여 CsrfTokenRepository에서 생성된 토큰과 혼합합니다.
하지만 예제에 너무 많은 복잡성을 추가하지 않고 구성 부분에 집중할 수 있도록 간단한 CsrfTokenRequestAttributeHandler를 설정하겠습니다. 다음은 구성 클래스에서 CsrfTokenRequestAttributeHandler를 구성하는 방법을 보여주는 코드입니다.
Listing 9.19 The configuration class for the custom CsrfTokenRepository
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(c -> {
c.csrfTokenRepository(customTokenRepository);
c.csrfTokenRequestHandler( #A
new CsrfTokenRequestAttributeHandler());
});
http.authorizeHttpRequests(
c -> c.anyRequest().permitAll()
);
return http.build();
}
#A Csrf 토큰을 HTTP 요청에서 설정하는 CsrfTokenRequestAttributeHandler 객체 설정.
리스팅 9.9에 제시된 컨트롤러 클래스의 정의에서 HTTP GET 메서드를 사용하는 엔드포인트를 추가합니다. 이 메서드는 구현을 테스트할 때 CSRF 토큰을 얻기 위해 필요합니다.
@GetMapping("/hello")
public String getHello() {
return "Get Hello!";
}
이제 애플리케이션을 시작하여 토큰을 관리하는 새로운 구현을 테스트할 수 있습니다. CSRF 토큰의 값을 얻기 위해 HTTP GET을 사용하여 엔드포인트를 호출합니다. 호출할 때 요구 사항에서 가정한 대로 X-IDENTIFIER 헤더 내에 클라이언트의 ID를 사용해야 합니다. 새로운 CSRF 토큰 값이 생성되어 데이터베이스에 저장됩니다. 다음은 호출 예시입니다.
curl -H "X-IDENTIFIER:12345" http://localhost:8080/hello
Get Hello!
데이터베이스에서 토큰 테이블을 검색하면 응용 프로그램이 식별자가 12345인 클라이언트를 위해 새 레코드를 추가한 것을 찾을 수 있습니다. 저의 경우 데이터베이스에서 볼 수 있는 CSRF 토큰의 생성된 값은 2bc652f5-258b-4a26-b456-928e9bad71f8입니다. 이 값을 사용하여 다음 코드 스니펫에서와 같이 HTTP POST 방법으로 /hello 엔드포인트를 호출합니다. 물론 응용 프로그램이 데이터베이스에서 토큰을 검색하여 요청에서 제공하는 토큰과 비교하기 위해 사용하는 클라이언트 ID도 제공해야 합니다. Figure 9.11에서 이러한 흐름을 설명합니다.
curl -XPOST -H "X-IDENTIFIER:12345" -H "X-CSRF-TOKEN:2bc652f5-258b-4a26-
b456-928e9bad71f8" http://localhost:8080/hello
Post Hello!
Figure 9.11에서 먼저 GET 요청이 CSRF 토큰을 생성하고 그 값을 데이터베이스에 저장합니다. 이어지는 POST 요청은 이 값을 보내야 합니다. 그런 다음, CsrfFilter가 요청의 값이 데이터베이스의 값과 일치하는지 확인합니다. 이를 기반으로 요청이 수락되거나 거부됩니다.
만약 필요한 헤더를 제공하지 않고 POST로 /hello 엔드포인트를 호출하려고 하면, 우리는 403 Forbidden 상태 코드와 함께 응답을 받습니다.
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello"
}
9.4 Summary
1. 크로스 사이트 요청 위조(CSRF)는 사용자가 위조된 스크립트가 포함된 페이지에 액세스하도록 속이는 유형의 공격입니다. 이 스크립트는 응용 프로그램에 로그인한 사용자를 모방하고 그들을 대신하여 동작을 실행할 수 있습니다.
2. CSRF protection는 Spring Security에서 디폴트로 인에이블 되어 있습니다.
3. Spring Security 아키텍처에서 CSRF 보호 로직의 진입점은 HTTP 필터입니다.
4. Spring Security는 사용자가 구현하고 연결하여 사용자 정의 CSRF 보호 기능을 정의할 수 있는 세 가지 간단한 계약을 제공합니다.
- CsrfToken—Describes the CSRF token itself
- CsrfTokenRepository—Describes the object that creates, stores, and loads CSRF tokens
- CsrfTokenRequestHandler – Describes and object that manages the way in which the generated CSRF token is set on the HTTP request.
'Spring Security' 카테고리의 다른 글
ch11 Implement authorization at the method level (0) | 2024.03.03 |
---|---|
ch10 Configuring Cross-Origin Resource Sharing(CORS) (0) | 2024.03.03 |
ch08 Configuring endpoint-level authorization: Applying restrictions (0) | 2024.03.02 |
ch07 Configuring endpoint-level authorization: Restricting access (0) | 2024.03.01 |
ch06 Implementing authenticaiton (0) | 2024.02.26 |