배경
로그인 기능을 정상적으로 구현하여 적용하게 된 후, 이제 모든 api에 테스트 요청을 보낼 때 먼저 로그인을 통해 액세스 토큰을 추가하여야 하는 환경으로 변경되었습니다.
그런데 api 요청 시마다 계속 토큰을 추가하는 작업은 테스트 단계에서는 다소 번거로울 수 있었기 때문에, 테스트 기간에는 임시적으로 액세스 토큰 없이도 api 호출이 가능하게 변경을 해달라는 팀원들의 요청이 있었는데요.
security 설정에서 현재는 제한된 부분들만 permit이고, 나머지 부분은 authenticated 인 상황이라 간단하게 기존 부분을 주석처리 후, anyRequest().permitAll()
를 추가하면 아무 문제 없이 로직이 잘 작동하리라고 예상했습니다.
// 기존 설정
http.authorizeHttpRequests(
auth -> auth.requestMatchers("/login", "/oauth2/**").permitAll()
// 나머지 요청들은 모두 authenticated
.anyRequest().authenticated());
// 다음과 같이 모든 요청을 permit 하도록 변경
http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
프로젝트에서의 로그인 설정 코드에 대해 잠깐 설명하자면, 일반 로그인과 병행하지 않고 Kakao OAuth2 로그인만 구현하도록 했고, 따라서 따로 저희가 만든 /login
페이지 화면이 없는 상황입니다.
그러다 보니 처음에는 /login
으로 요청을 하면 OAuth2에서 기본적으로 제공하는 login 페이지가 응답되었습니다. 하지만 기본 제공 페이지가 아니라 바로 카카오 로그인 페이지로 넘어갔으면 좋겠다는 니즈가 있어서 이를 위해 로그인 페이지를 다음과 같이 따로 설정하게 됩니다.
http.oauth2Login(oauth2 -> oauth2.loginPage("/oauth2/authorization/kakao"));
그래서 /login
으로 요청이 들어오면 해당 리다이렉션 페이지로 넘어가서 로그인이 바로 진행되도록 문제없이 작동되고 있는 상황이었습니다.
문제 상황
코드 변경 후 이제 토큰 없이도 잘 작동하는 것을 확인 후 배포했는데, 추가적으로 프론트 담당 팀원분이 문의를 주셨습니다.
이제 액세스 토큰 없이 모든 리소스에 접근은 잘 되는데, 혹시 로그인 기능은 다시 작동하도록 해주실 수 있나요?
그래서 확인해 보니 로그인을 하는 경우 정상적으로 로그인 창이 뜨지 않고 404 에러 코드와 함께 화이트 라벨 페이지로 연결되고 있었는데요 🤯
로그인과 관련된 부분은 건들지 않았는데, 갑자기 기존에 잘 되던 로그인 리다이렉션 파트에서 에러가 생겼습니다...
해결 과정
404 Whitelabel Error Page 예외가 발생하고 있고, 저희 서버에서는 따로 /login
을 받아서 처리하는 부분도 없기 때문에 해당 에러가 /login
요청을 캐치해서 처리하거나, 정적 리소스를 제공하는 로직이 없어서 발생하는 것임을 먼저 의심하게 되었습니다.
그런데 이상한 것은 그러면 기존에도 동일하게 404 예외가 발생해야 맞는데, 기존 코드로는 정상적으로 돌아가고 있다는 것이었는데요...
차이가 무엇인지 확인하기 위해 로깅 레벨을 DEBUG로 놓고 변경 전 / 변경 후 두 상황을 비교하여 서버를 실행해 보았습니다.
변경 전 코드
변경 후 코드
확인 결과 두 상황 모두 GET /login
에서 404 Resource not found 로그가 찍히고 있고, 예외가 발생하여 /error
요청이 발생하고 있음을 확인할 수 있습니다.
다만 둘의 차이점은 /error
요청 이후에 /oauth2/authorization/kakao
로 리다이렉션이 일어나는지 여부인 것으로 보입니다.
그래서 어디에서 이러한 차이가 발생하는지 코드 레벨에서 확인을 해 보았습니다.
코드 확인
먼저 결론부터 이야기하자면, authenticated 리소스(/error
)에 인증 없이 요청을 했기 때문에, AccessDeniedException
예외가 발생하여 설정해 둔 로그인 페이지 (/oauth2/authorization/kakao
)로 리다이렉션 되는 것입니다.
기존 변경 전 config 설정 코드를 보면 requestMatchers
에 /error
는 추가되어 있지 않은 것을 볼 수 있는데, 따라서 /error
는 인증 토큰이 있어야만 접근할 수 있는 리소스라는 의미인 것이죠..!
당연히 로그인을 시도하다가 에러가 발생했기 때문에 인증 토큰이 없는 상황. 따라서 security 내부에서 예외가 발생하고, 해당 예외는 ExceptionTranslationFilter
에서 catch 되어 조건에 따라 AuthenticationException
혹은 AccessDeniedException
타입으로 캐스팅됩니다. (해당 예외의 차이는 다른 포스팅에..!)
일단 여기서는 이 경우에 AccessDeniedException
예외가 던져진다는 것만 숙지하시면 좋을 것 같습니다.
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
// 여기에서 AccessDeniedException 로 catch
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
//호출
handleSpringSecurityException(request, response, chain, securityException);
}
}
}
예외 캐스팅이 되고 나면 그다음으로 같은 클래스의 handleSpringSecurityException
메서드가 호출되어 실행되는데, 해당 메서드에서 어떤 예외가 호출 시 전달되었는지에 따라 각자 적합한 핸들러 메서드로 분기됩니다.
현재 발생한 예외는 AccessDeniedException
이기 때문에 최종적으로는 handleAccessDeniedException
메서드가 실행됩니다.
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
// 확인 후 다시 Authentication 진행
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
this.accessDeniedHandler.handle(request, response, exception);
}
}
여기서 보면 인증에 문제가 있는 유저(현재 상황에서는 로그인하지 않은 유저)라면 다시 sendStartAuthentication
를 호출하는데, 이를 통해 다시 재 인증을 하는 프로세스를 시작하게 됩니다.
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
// 예외 처리 동작 실행
this.authenticationEntryPoint.commence(request, response, reason);
}
마지막 줄을 보면 현재 클래스의 AuthenticationEntryPoint
구현체를 호출하는데, 여기서는 LoginUrlAuthenticationEntryPoint
클래스를 이용합니다.
내부적으로 다시 코드를 살펴보면 여기에서 리다이렉션을 세팅하는 모습을 확인할 수 있는데요, 여기서 redirectUrl
는 우리가 config에서 설정한 /oauth2/authorization/kakao
가 들어갑니다.
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (!this.useForward) {
// redirect to login page. Use https if forceHttps true
String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
return;
}
// 이하 코드 생략
}
지금까지 살펴본 흐름과 다르게 anyRequest().permitAll()
설정으로 변경한 경우에는, security 내부 필터에서 인증을 확인하는 로직을 실행하지 않고 넘어갑니다. 따라서 예외가 발생할 일이 없으니 예외가 캐치되지 않고 doFilter()
로 필터가 넘어가버리기 때문에, 해당 리다이렉션 동작이 발생하지 않았던 것입니다.
결론
다시 한번 최종적으로 정리를 해보겠습니다. 정상적으로 잘 돌아갔던 수정 전 config는 다음과 같았는데요.
http.authorizeHttpRequests(
auth -> auth.requestMatchers("/login", "/oauth2/**").permitAll()
// 나머지 요청들은 모두 authenticated
.anyRequest().authenticated());
이 경우 /login
, /oauth2/**
를 제외한 다른 모든 경로의 요청은 다 인증이 필요한 리소스가 되고, /error
리퀘스트 요청 또한 마찬가지입니다.
그렇기 때문에 위와 같은 과정을 거쳐서 /error
로 액세스 토큰 없는 요청이 들어오는 경우 예외가 발생하고 다시 재 로그인을 요청하는 로직이 돌아가게 되는데, 설정을 anyRequest().permitAll()
로 풀어버리는 순간 /error
역시 인증이 필요 없는 리소스로 인식되고, 인증 예외가 발생하지 않기 때문에 재 로그인 요청 로직이 실행되지 않았던 것입니다.
사실상 로그인이 잘 되었던 상황도 정상적인 상황 흐름은 아니었는데 어쩌다 보니 요청이 잘 되고 있는 상황이었던 거였네요.. 🥲
결론적으로 이렇게 이유를 파악한 후에, 로그인 시에 /oauth2/authorization/kakao
로 직접 요청하도록 수정하여 문제를 해결하게 되었습니다!