해당 게시글은 스프링 시큐리티 5.7 이하 버전으로 진행했습니다.
오늘 스프링 시큐리티를 만지면서 로그인 시도를 하면 쿠키에 존재하는 CSRF 토큰이 자꾸 갱신돼서 CSRF 토큰 불일치 문제가 발생했었다.(해결에만 5시간을 쏟았다.)
CSRF의 경우 아래처럼 쿠키에 저장하도록 설정했다. 이렇게 설정하면 CSRF 토큰이 쿠키로 만들어져서 저장이 된다. 클라이언트는 해당 쿠키를 서버로 넘겨서 서버의 CSRF와 일치하는지 판단.
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
가상의 시큐리티는 아래와 같이 구성했다.(폼 로그인을 사용한다.)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity security) throws Exception {
security
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.authorizeHttpRequests(request -> request
.antMatchers("/login", "/loginProcessing").permitAll()
.anyRequest().authenticated())
.formLogin(login -> login
.loginPage("/login")
.loginProcessingUrl("/loginProcessing")
.successHandler(customSuccessHandler)
.failureHandler(customFailHandler));
return security.build();
}
- csrf 토큰은 생성해서 쿠키로 넘겨주도록 설정했다.
- /login, /loginProcessing만 전부 허용했고, 나머지는 인증을 받아야 접근이 가능하다.
- 인증을 받지 않은 상태이면 /login으로 넘어가게 된다.
- /loginProcessing으로 요청이 들어오면 시큐리티가 가로채서 로그인 처리를 진행한다.
- 이후 성공하면 성공 핸들러, 실패하면 실패 핸들러가 동작.
우선 발생했던 문제를 알아보자.
로그인 페이지에 오면 다음와 같이 쿠키에는 CSRF 쿠키가 발급이 되어 있는 상태이다. 서버에서도 동일한 value의 값을 가지고 있기 때문에 동일한 유저가 보낸 요청인지 확인을 거치게 된다.
여기서 아이디와 비밀번호를 입력하면 /loginProcessing으로 요청을 보내게 된다.(이때 csrf도 같이 보낸다.)
<form th:action="@{/loginProcessing}" method="post">
<input name="username" type="text" placeholder="아이디"><br>
<input name="password" type="password" placeholder="비밀번호">
<input type="hidden" name="${_csrf.getParameterName()}" th:value="${_csrf.token}"/>
<button type="submit">로그인 요청</button>
</form>
이때 스프링 시큐리티 필터 체인을 주르르륵 타다가 CsrfFilter를 타면서 csrf 토큰의 값을 확인한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
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)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match "
+ this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(
LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
: new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, exception);
return;
}
filterChain.doFilter(request, response);
}
위 코드에서 csrfToken 변수와 actualToken 변수만 확인하면 된다.
csrfToken의 값은 서버에서 생성했던 csrf의 값이고, actualToken은 클라이언트에게서 받은 csrf 값을 의미한다.
아래 디버깅을 보면 두 값이 일치하기 때문에 필터는 통과하게 된다.
이후에는 유저 찾고 SecurityContextHolder에 인증 객체 담으면서 인증 절차를 진행하게 된다.(해당 내용은 다른 블로그에 많이 존재합니다.)
그런데 인증이 완료된다고 끝나는 게 아니었다. 아래의 클래스를 타게 된다.
CompositeSessionAuthenticationStrategy
public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
private final Log logger = LogFactory.getLog(getClass());
private final List<SessionAuthenticationStrategy> delegateStrategies;
public CompositeSessionAuthenticationStrategy(List<SessionAuthenticationStrategy> delegateStrategies) {
Assert.notEmpty(delegateStrategies, "delegateStrategies cannot be null or empty");
for (SessionAuthenticationStrategy strategy : delegateStrategies) {
Assert.notNull(strategy, () -> "delegateStrategies cannot contain null entires. Got " + delegateStrategies);
}
this.delegateStrategies = delegateStrategies;
}
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) throws SessionAuthenticationException {
int currentPosition = 0;
int size = this.delegateStrategies.size();
for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Preparing session with %s (%d/%d)",
delegate.getClass().getSimpleName(), ++currentPosition, size));
}
delegate.onAuthentication(authentication, request, response);
}
}
@Override
public String toString() {
return getClass().getName() + " [delegateStrategies = " + this.delegateStrategies + "]";
}
}
여기서 onAuthentication 메서드를 동작하게 된다. 이때의 디버깅 값은 아래 사진과 같다.
우선 principal에 인증 받은 객체가 들어있는 것이 확인된다.(정상적으로 인증이 되었다는 의미.)
그리고 delegateStrategies에 2개의 값이 들어있다. 2개가 순서대로 실행이 된다.
하나는 ChangeSessionIdAuthenticationStrategy로 세션의 ID를 변경시킨다.(보안의 이유)
나머지는 CsrfAuthenticationStrategy로 Csrf와 관련된 것이라고 추측할 수 있다.
CsrfAuthenticationStrategy
가장 요주 인물이었다.
public final class CsrfAuthenticationStrategy implements SessionAuthenticationStrategy {
private final Log logger = LogFactory.getLog(getClass());
private final CsrfTokenRepository csrfTokenRepository;
/**
* Creates a new instance
* @param csrfTokenRepository the {@link CsrfTokenRepository} to use
*/
public CsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
this.csrfTokenRepository = csrfTokenRepository;
}
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) throws SessionAuthenticationException {
boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
if (containsToken) {
this.csrfTokenRepository.saveToken(null, request, response);
CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
this.csrfTokenRepository.saveToken(newToken, request, response);
request.setAttribute(CsrfToken.class.getName(), newToken);
request.setAttribute(newToken.getParameterName(), newToken);
this.logger.debug("Replaced CSRF Token");
}
}
}
내부 코드를 보면 만약에 containsToken이 true라면
- 서버의 csrf를 null로 설정
- 새롭게 csrf 토큰 생성 -> newToken
- 서버의 csrf를 newToken으로 설정
- request에 newToken 설정.
즉 이전 csrf 토큰을 없애고 새롭게 csrf 토큰을 만드는 로직이 동작하게 된다.
이렇게 newToken을 만들기 때문에 클라이언트의 csrf 쿠키의 값도 변동이 생기게 되는 것이다.
해당 로직은 시큐리티의 인증이 성공하면 자동으로 타도록 설정이 되어있다. 그래서 혹시나 csrf를 전달하고 로그인을 했으나 추가 로직에서 기존 csrf를 전달하게 된다면 새롭게 갱신된 csrf 토큰과 일치하지 않아서 csrf 토큰 불일치 문제가 발생할 수 있다.
대충 아래와 같은 기가 막힌 현상이 발생한다.
해당 로직이 스프링 시큐리티 6 이상부터는 개선이 된 것 같은데....5.7 밑이라면 주의하는 편이 좋겠다.
'오류해결' 카테고리의 다른 글
Input length must be multiple of 16 when decrypting with padded cipher 문제 해결 방법 (0) | 2024.10.31 |
---|---|
mysql.cj.jdbc.Driver에 빨간글씨가 뜨는 문제 (1) | 2024.09.22 |
pinia의 piniaPluginPersistedstate가 적용이 안 된다. (0) | 2024.05.15 |
스프링 시큐리티 permitall이 먹지 않는 상황 발생 (0) | 2024.04.16 |