10. CSRF 처리
- -
이번 포스트에서는 대표적인 보안 공격 중 하나인 CSRF(Cross Site Request Forgery)의 대책에 대해 살펴보자.
CSRF 대책
CSRF?
CSRF(Cross Site Request Forgery: 크로스 사이트 요청 위조)란 웹에서 행해지는 대표적인 해커의 공격 유형 중 하나이다. 다음 이미지는 어떻게 CSRF 공격이 일어나는지 보여준다.
- www.fictitiousbank.com의 고객인 bob은 정상 은행 사이트에 로그인 하고 정상적인 쿠키가 발행된다.
- 우리의 희생자 Bob은 "저리에 돈을 빌려준다"는 혹하는 이메일을 받고 첨부된 이미지를 클릭한다.
- 하지만 그 이미지의 경로에는 공격자에게 이체하는 동작이 연결되어있었다. ㅜㅜ
- 이제 Bob의 이체 동작은 쿠키를 타고 www.fictitiousbank.com 서버로 정상적으로 날아간다.
- 서버에서는 Bob의 정당한 행동으로 파악되기 때문에 아무런 의심 없이 처리하고 밥은 망하게 된다. ㅠㅠ
이런 일을 방지하려면 어떤 절차가 필요할까?
서버에서는 이게 정말 Bob이 한 일인지 확인할 필요가 있다. Spring Security에서는 CSRF 토큰이라는 것을 통해 이것을 처리한다.
CSRF 토큰
CSRF 공격을 방지하기 위해서 Spring Security가 채택한 방법은 CSRF 토큰이라는 인증 정보를 이용하는 것이다.
- 입력 화면(login form)을 요청하는 request를 서버로 날린다.
- 서버는 요청을 받고 검증용의 CSRF 토큰 "ABC"를 생성한다.
- 서버는 클라이언트로 응답할 때 CSRF 토큰을 request에 담아서 넘긴다. (<form>은 이 토큰을 저장해줘야 한다.)
- username/password와 함께 CSRF 토큰이 서버로 전송된다.
- 서버는 파라미터에 묻어온 토큰과 서버가 가지고 있던 토큰이 일치하는지 판단하고 불일치하면 request를 거부한다.
물론 이런 과정을 개발자가 직접 처리하지는 않는다. Spring Security 프레임워크가 대부분 일을 처리하고 개발자가 할 일은 적절한 설정(CSRF 활성화)과 토큰 관리이다.
Spring Security의 CSRF 처리
다음은 Spring Security의 CSRF 처리 절차이다.
- 먼저, 지속된 CsrfToken이 나중에 (4)에서 로드될 수 있도록 CsrfTokenRepository에 대한 참조를 보유하는 DeferredCsrfToken이 로드된다.
- DeferredCsrfToken에서 생성된 Supplier<CsrfToken>이 CsrfTokenRequestHandler에 전달된다. 이 헨들러는 요청에 CSRF토큰을 추가해서 애플리케이션에서 사용할 수 있도록 한다.
- 메인 CSRF 보호 처리가 시작되고 현재 요청에 CSRF 보호가 필요한지 확인한다. 필요하지 않은 경우 CsrfFilter 처리가 종료되고 다음 필터 체인이 계속진행된다.
- CSRF 보호가 필요한 경우 최종적으로 DeferredCsrfToken에서 지속된 CsrfToken이 로드된다.
- 클라이언트가 제공한 실제 CSRF 토큰을 CsrfTokenRequestHandler를 사용하여 확인되고 실제 CSRF 토큰이 저장된 CsrfToken과 비교된다.
- 유효하면 필터 체인이 계속되고 처리가 종료된다.
- 실제 CSRF 토큰이 유효하지 않거나 누락된 경우 AccessDeniedException이 AccessDeniedHandler로 전달되고 처리가 종료된다.
CSRF 처리
CSRF 처리 활성화 및 저장소 지정
HttpSecurity#csrf 메서드는 csrf를 비활성화 시키거나 저장소를 지정해서 활성화 시킬 수 있다.
http.csrf(csrf -> csrf.disable()); // csrf 비활성화
http.csrf(Customizer.withDefaults()); // csrf 활성화 - 기본 값
Customizer.withDefaults()의 기본 설정이 갖는 내용은 다음과 같다.
http.csrf(csrf -> {
csrf
// CsrfTokenRepository 설정 - 기본값은 HttpSessionCsrfTokenRepository
.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
// CSRF 검증에서 제외할 요청 패턴들
.ignoringRequestMatchers(
"/logout", // 로그아웃
"/csrf", // CSRF 토큰 조회
"/favicon.ico",
"/**/*.png", "/**/*.gif", "/**/*.jpg", "/**/*.css", "/**/*.js" // 정적 리소스
)
// CSRF 토큰 생성 및 검증을 처리할 필터
.csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler());
});
CSRF 처리 커스터마이징
CSRF 처리에 대한 부분도 당연히 커스터마이징 가능하다. 일단 저장소를 기본 값인 세션이 아닌 Cookie 로 변경할 수 있다. 또한 REST 서비스를 주로 한다면 /api/** 아래로 오는 요청은 CSRF 검증에서 제외할 수도 있다.
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/**") // API 요청은 CSRF 검증 제외
);
XorCsrfTokenRequestAttributeHandler는 HttpServletRequest 파라미터로 _csrf를 추가해서 CsrfToken을 사용할 수 있게 한다. 또한 만약 요청 헤더에서 값을 가져올 때는 X-CSRF-TOKEN 또는 X-XSRF-TOKEN 헤더 중 하나에서 CSRF 토큰을 가져온다.
다음은 CsrfToken 객체의 속성이다.
속성 | 내용 | 기본 값 |
token | CSRF 토큰의 값 | |
parameterName | CSRF 토큰이 저장된 request parameter이름 | _csrf |
headerName | 헤더를 통해 토큰을 송신할 때 헤더명 | X-CSRF-TOKEN |
SecurityConfig에서 csrf 설정을 다음과 같이 수정해보자.
http.csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
.ignoringRequestMatchers("/logout", // logout 요청은 무시
"/csrf", // csrf 요청은 무시
"/favicon.ico", "/**/*.png", "/**/*.jpg", "/**/*.js", "/**/*.css", // 정적 리소스
"/api/**" // API 요청
));
화면 수정
이제 마지막으로 할 일은 화면단의 form에서 csrf 관련 태그들을 작성해주면 된다. <form>이 여러 곳에 등장하기 때문에 태그를 include에 작성해서 재사용하자.
{{! csrf 템플릿 : /templates/include/csrf.html}}
<input type="hidden" name="{{_csrf.parameterName}}" value="{{_csrf.token}}"/>
이제 <form>에서 csrf.html을 include 시키면 작업 완료다.
{{^SPRING_SECURITY_CONTEXT}}
<form action="/login" method="post">
{{>include/csrf}}
<label for="username">아이디:</label>
<input type="text" id="username" name="username" required />
<label for="password">비밀번호:</label>
<input type="password" id="password" name="password" required />
<button type="submit">로그인</button>
</form>
{{/SPRING_SECURITY_CONTEXT}}
이제 /login을 실행시키고 개발자도구를 이용해서 <form>을 분석해보면 csrf 관련 태그가 생성된 것을 확인할 수 있다.
그리고 추가로 logout을 호출할 때 post 방식으로 변경하고 csrf 토큰을 넘겨줘야 한다. HttpSecurity의 기본 logout 동작은 csrf 토큰을 확인하기 때문이다.
<form action="/logout" method="post">
{{>include/csrf}}
<button type="submit">로그아웃</button>
</form>
테스트 수정
csrf를 활성화 시켰기 때문에 테스트 과정에서도 csrf 토큰이 삽입되어야 한다. 이를 위해 mock 요청을 만들 때 with를 통해 mock csrf 정보를 전달한다.
@Test
@DisplayName("로그인에 실패하면 /login?error로 redirect되고 redirect된 페이지에서 메시지 확인하기")
void 인증_처리_테스트_로그인실패() throws Exception {
mockMvc
.perform(
MockMvcRequestBuilders.post("/login")
.with(SecurityMockMvcRequestPostProcessors.csrf())
.param("username", "doding")
.param("password", "wrong"))
.andDo(MockMvcResultHandlers.print()) // 실행 결과를 콘솔에 출력
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("/login?error"));
mockMvc
.perform(MockMvcRequestBuilders.get("/login").param("error", ""))
.andExpect(MockMvcResultMatchers.model().attribute("error", "ID/비밀번호를 확인하세요."));
}
@Test
@DisplayName("로그인 성공한 사용자의 로그아웃 처리")
void 로그인성공_로그아웃처리() throws Exception {
mockMvc
.perform(
MockMvcRequestBuilders.post("/login")
.with(SecurityMockMvcRequestPostProcessors.csrf())
.param("username", "doding")
.param("password", "1234"))
.andDo(MockMvcResultHandlers.print()) // 실행 결과를 콘솔에 출력
.andExpect(MockMvcResultMatchers.status().is3xxRedirection())
.andExpect(MockMvcResultMatchers.redirectedUrl("/"));
mockMvc
.perform(
MockMvcRequestBuilders.post("/logout")
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(MockMvcResultMatchers.redirectedUrl("/login?logout"));
mockMvc
.perform(MockMvcRequestBuilders.get("/login").param("logout", ""))
.andExpect(MockMvcResultMatchers.view().name("user/login"))
.andExpect(MockMvcResultMatchers.model().attribute("logout", "로그아웃되었습니다."));
}
'Spring security > 01.Security' 카테고리의 다른 글
12. Controller에서의 Security (0) | 2022.11.22 |
---|---|
11. RememberMe 서비스 (0) | 2022.11.22 |
09. UserDetails와 UserDetailsService 커스터마이징 (0) | 2022.11.22 |
08. JDBC를 이용한 인증 (0) | 2022.11.22 |
07. 로그인/로그아웃 커스터마이징 (0) | 2022.11.18 |
소중한 공감 감사합니다