Spring Security 디버깅
Spring Security 디버깅
Spring Security Filters
스프링 프레임워크는 Tomcat 서블릿 컨테이너 위에서 돌아가므로 Tomcat의 서블릿 필터 기능에 의존한다.
스프링 시큐리티를 사용하게 되면 이 Tomcat의 FilterChain에 스프링 시큐리티 전용 필터를 하나 끼워넣어 모든 요청을 가로채는데, 이것이 바로 DelegatingFilterProxy
이다.
DelegatingFilterProxy
는 기타 로직이 하나도 없이 단지 스프링 프레임워크 내부의 FilterChainProxy
빈으로 요청을 넘겨주는 프록시 역할만을 할 뿐이다.
FilterChainProxy
는 엔드 유저의 요청에 따라 스프링 시큐리티가 관리하는 SecurityFilterChain
을 호출하여 요청을 거기로 프록시한다. SecurityFilterChain
는 인증 및 인가와 관련된 여러 사항을 검증하고 적절한 응답을 반환하는 것을 목표로 한다.
SecurityFilterChain
는 위 그림과 같이 여러개로 구성될 수 있으며, 엔드 유저의 요청 URL에 따라 FilterChainProxy
가 적절한 SecurityFilterChain
에게 프록시 해준다.
Default Spring Security Filter Chain
FilterChainProxy.class
를 열어보면 다음과 같은 getFilters
메서드를 확인할 수 있다. 이 메서드는 FilterChainProxy
가 적절한 SecurityFilterChain
빈을 가져오기 위해 사용한다. (do-while문 참조)
해당 메서드에서 Breakpoint를 걸고 서버로 요청을 보내면, 우리는 기본으로 등록된 필터들을 살펴볼 수 있다.
Unwrapping Security Filters
해당 Breakpoint에서 우리는 filterChains
의 size가 1임을 확인할 수 있다. 이는 Spring Security 의존성을 추가하면 기본적으로 하나의 인증 인가 필터만 등록되기 때문이다.
DefaultSecurityFilterChain
을 열어보면 총 0~15까지 총 16개의 필터로 구성된 ArrayList를 확인할 수 있다.
이제부터는 이 필터들의 역할에 대해 알아볼 것이다.
예시: 보안이 필요한 페이지에 접근되었을 때, 로그인 페이지로 리다이렉션
- 사용자가 보호된 URL에 접근한다.
- AuthorizationFilter에서 접근이 거부된다.
- ExceptionTranslationFilter가 AccessDeniedException을 처리하고, AuthenticationEntryPoint를 통해 로그인 페이지로 리다이렉션된다.
- 클라이언트가 로그인 페이지로 리다이렉션된 요청을 보낸다.
- FilterChain을 처음부터 다시 시작한다.
- DefaultLoginPageGeneratingFilter가 로그인 페이지를 생성하여 응답으로 반환한다. 이후의 필터는 작동하지 않는다.
💡한 필터가 응답을 반환하면, 다음 필터들은 스킵된다.
AuthorizationFilter
우리는 스프링 시큐리티 필터 체인에서 중요한 몇가지 필터들을 직접 디버깅해보며 확인해볼건데, 가장 먼저 확인할 필터는 AuthorizationFilter
이다.
해당 피렅의 책임은 엔드 유저가 접근하고자 하는 URL에 접근을 제한하는 것이다.
우리는 doFilter
메서드의 try 부분에서 해당 로직을 확인할 수 있다.
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
if (this.observeOncePerRequest && this.isApplied(request)) {
chain.doFilter(request, response);
} else if (this.skipDispatch(request)) {
chain.doFilter(request, response);
} else {
String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
chain.doFilter(request, response);
} finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
DefaultLoginPageGenerateFilter
우리가 보안 URL에 접근하려고 하면 로그인 페이지가 표시되는 것을 알 수 있는데 그 페이지는 이 필터에서 생성한다.
이 페이지에서 엔드유저가 본인의 username, password와 같은 자격 증명을 입력하고 나면 다음 등장할 필터는 UsernamePasswordAuthenticationFilter
이다.
UsernamePasswordAuthenticationFilter
💡Authentication 객체를 갱신하는 필터가 바로 이 필터이다.
이 필터 안에는 attemptAuthentication
이라는 메서드가 있다. 이 필터의 주된 책임은 수신하는 http 요청으로부터 username과 password를 추출하는 것이다.
attemptAuthentication
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
username = username != null ? username.trim() : "";
String password = this.obtainPassword(request);
password = password != null ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
여기서는 Authentication
객체가 아닌 UsernamePasswordAuthenticationToken
가 등장하는데, 이는
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer
위와 같이 UsernamePasswordAuthenticationToken
이 Authentication
객체의 구현체이기 때문이다.
Authentication
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
위처럼 Authentication
은 인터페이스이다!
또, attempAuthentication
에서드에서
return this.getAuthenticationManager().authenticate(authRequest);
이 부분을 보면, AuthenticationManager의 authenticate
메서드를 호출하는 것을 볼 수 있는데,
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
AutehnticationManager
또한 인터페이스이다.
attempAuthentication
에서드의 return 문에 Breakpoint를 걸어 확인해보면 AuthenticationManager
의 구현체로 ProviderManager
가 등록된 것을 볼 수 있다.
또, 갱신된 Authentication
객체(여기서는 UsernamePasswordAuthenticationToken
)의 정보도 확인할 수 있다. 나같은 경우는 일부러 틀린 비밀번호를 입력하여 authenticated가 false로 나온다.
ProviderManager implements AuthenticationManager
우리는 앞서 AuthenticationManager
의 구현체로 등록된 ProviderManager
가 있음을 확인했다.
ProviderManager
의 authenticate
메서드에서는
while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}
위 반복문을 통해 모든 AuthenticationProviders
를 호출하여 인증절차를 진행한다. (인증이 되는 AuthenticationProvider
가 있을 때까지!)
DaoAuthenticationProvider
우리의 스프링 시큐리티 기본 흐름에 있어서 앞선 ProviderManager
는 AuthenticationProviders
중 하나를 호출하는데, 기본흐름에서는 이 AuthenticationProvider
가 바로 DaoAuthenticationProvider
이다.
DaoAuthenticationProvider
는 추상클래스 AbstractUserDetailsAuthenticationProvider
를 상속하며, AbstractUserDetailsAuthenticationProvider
에는 모든 실제 인증 로직이 포함된 authenticate
메서드를 확인할 수 있다.
authenticate method
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw var6;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
if (!cacheWasUsed) {
throw var7;
}
cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
로직에서 retrieveUser
메서드를 사용하는데, 이 메서드는 AbstractUserDetailsAuthenticationProvider
가 아닌, DaoAuthenticationProvider
에 있다.
retrieveUser method
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
} catch (UsernameNotFoundException var4) {
this.mitigateAgainstTimingAttack(authentication);
throw var4;
} catch (InternalAuthenticationServiceException var5) {
throw var5;
} catch (Exception var6) {
throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
}
}
해당 메서드 내부를 살펴보면, UserDetailsService
와 PasswordEncoder
의 도움을 받는 것을 확인할 수 있다.
이렇게 생성된 Authentication
객체는 BasicAuthenticationFilter
애서 SecurityContext
에 등록된다.
Summary
이번 포스팅에서는 Spring Security의 기본 흐름을 살펴보고 디버깅하면서 각 클래스와 메서드들의 유기적인 관계에 대해 큰 틀을 잡았다.