Spring Security는 스프링 기반 애플리케이션의 보안(인증, 인가)을 담당하는 스프링 하위 프레임워크이다. Spring Security는 Filter 기반으로 작동해 Dispatcher Servlet보다 요청을 먼저 받아 처리하고, 다양한 보안 관련 옵션으로 일일이 보안 관련 로직을 작성하지 않아도 되는 장점이 있다.
Spring Security는 서블릿 필터 기반으로 작동한다. 사용자가 요청을 보내면 컨테이너는 하나의 필터 체인을 생성한다. 필터 체인에는 필터가 순서대로 저장되어 있고, 마지막에는 서블릿이 있다.
필터 체인 내부의 필터들은 들어온 요청을 처리해 다음 순서 필터로 넘기거나 넘기지 않을 수 있다. 마지막 필터는 서블릿으로 요청을 넘기게 된다.
Spring Security의 필터들은 스프링 컨테이너에 등록된 빈이기 때문에, 서블릿 필터에서는 이를 인식할 수 없다. 따라서, Spring Security에서는 DelegatingFilterProxy라는 서블릿 필터의 구현체를 제공한다. DelegatingFilterProxy는 서블릿 필터에 등록될 수 있고, Spring Security의 필터 빈들을 의존성 주입할 수 있다.
public class DelegatingFilterProxy extends GenericFilterBean {
...
@Nullable
private volatile Filter delegate;
...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Lazily initialize the delegate if necessary.
Filter delegateToUse = this.delegate;
if (delegateToUse == null) {
synchronized (this.delegateMonitor) {
delegateToUse = this.delegate;
if (delegateToUse == null) {
WebApplicationContext wac = findWebApplicationContext();
if (wac == null) {
throw new IllegalStateException("No WebApplicationContext found: " +
"no ContextLoaderListener or DispatcherServlet registered?");
}
delegateToUse = initDelegate(wac);
}
this.delegate = delegateToUse;
}
}
// Let the delegate perform the actual doFilter operation.
invokeDelegate(delegateToUse, request, response, filterChain);
}
...
protected void invokeDelegate(
Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
delegate.doFilter(request, response, filterChain);
}
위의 코드를 보면, 내부 필터의 doFilter()를 호출하면서 작업을 위임하는 것을 볼 수 있다.
DelegatingFilterProxy 내부에 FilterChainProxy를 둘 수 있다. FilterChainProxy는 DelegatingFilterProxy를 통해 받은 요청을 SecurityFilterChain에 전달해 작업을 위임한다.
public class FilterChainProxy extends GenericFilterBean {
...
private List<SecurityFilterChain> filterChains;
...
필터 체인을 List로 관리하기 때문에 여러 개의 필터체인이 존재할 수 있다.
SecurityFilterChain은 여러 개의 Spring Security 필터가 담겨 있다. FilterChainProxy에서 위임받은 작업을 어떤 필터에서 수행할지 결정하는 역할을 한다.
여러 개의 필터 체인을 등록해 URL에 따라 다른 필터 체인이 사용되도록 할 수도 있다.
위에서 언급된 SecurityFilterChain은 여러 개의 필터들이 순서대로 연결되어 있다.
여기서는 UsernamePasswordAuthenticationFilter 기준으로 설명한다.
사용자가 아이디, 비밀번호를 입력하고 요청을 보낸다.
UsernamePasswordAuthenticationFilter가 요청을 받아서 UsernamePasswordAuthenticationToken을 생성하고 AuthenticationManager에게 처리를 위임한다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
...
try {
Authentication authenticationResult = attemptAuthentication(request, response);
...
}
@Override
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());
}
String username = obtainUsername(request);
username = (username != null) ? username : "";
username = username.trim();
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
doFilter() 에서 attemptAuthentication()을 호출하고 attemptAuthentication() 에서 request 내용을 통해 UsernameAuthenticationToken 객체를 생성한다. 이후 AuthenticationManager의 authenticate() 호출로 UsernameAuthenticationToken 객체를 전달하면서 처리를 위임하는 모습을 볼 수 있다.
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
...
UserNameAuthenticationToken에는 아이디, 비밀번호가 각각 principal, credentials에 들어 있다.
AuthenticationManager를 구현한 ProviderManager가 처리를 실제로 위임받고, 인증 작업 처리가 가능한 AuthenticationProvider를 선택한다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
...
private List<AuthenticationProvider> providers = Collections.emptyList();
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
...
}
ProviderManager는 AuthenticationProvider들을 List로 관리하고 있다. ProviderManager가 인증 처리를 위임받으면 List를 탐색하면서 각 AuthenticationProvider의 supports()를 호출해 인증 처리가 가능한 AuthenticationProvider를 선택해 authenticate() 를 호출한다.
AuthenticationProvider는 UserDetailsService를 통해 유저 정보를 가져와 아이디, 비밀번호를 확인 후 인증 성공 시 Authentication 객체를 반환한다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
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);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
...
return createSuccessAuthentication(principalToReturn, authentication, user);
}
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.upgradeEncoding(user.getPassword());
if (upgradeEncoding) {
String presentedPassword = authentication.getCredentials().toString();
String newPassword = this.passwordEncoder.encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
return super.createSuccessAuthentication(principal, authentication, user);
}
AuthenticationProvider의 authenticate()가 호출되면 retrieveUser()와 additionalAuthenticationChecks()를 통해 UserDetailService에서 DB에 있는 유저 정보를 가져와 아이디, 비밀번호가 맞는지 확인한다. 맞다면 createSuccessAuthentication()을 통해 Authentication 객체를 반환한다.
AuthenticationProvider -> ProviderManager -> UsernamePasswordAuthenticationFilter 순으로 Authentication 객체가 반환된다.
UsernamePasswordAuthenticationFilter는 Authentication 객체를 SecurityContext에 저장한다.
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
인증된 사용자가 리소스에 접근할 수 있는지를 확인한다.
사용자 요청이 FilterSecurityInterceptor에 도달한다.
FilterSecurityInterceptor는 AccessDecisionManager에게 인가 처리를 요청한다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
invoke(new FilterInvocation(request, response, chain));
}
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
...
InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
...
}
protected InterceptorStatusToken beforeInvocation(Object object) {
...
attemptAuthorization(object, attributes, authenticated);
...
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
}
private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
Authentication authenticated) {
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
...
finally {
super.finallyInvocation(token);
}
...
}
FilterSecurityInterceptor의 doFilter() -> invoke() -> beforeInvocation() -> attemptAuthorization() 순으로 호출되고, AccessDicisionManager의 decide()가 호출되면서 인가 처리를 요청한다.
AccessDicisionManager는 AccessDicisionVoter에게 승인 여부를 요청한다. 승인 거부 시 AccessDeniedException이 발생하게 된다. 이를 결정하는 것은 세 가지 유형이 있다.
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(
this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
checkAllowIfAllAbstainDecisions();
}
위의 코드는 AccessDecisionManager를 구현한 AffirmativeBased의 모습이다. AccessDicisionManager는 AccessDecisionVoter를 List로 관리하고 있다. List를 탐색하면서 vote()를 호출해 승인 여부를 요청한다.
최종 승인이 결정되면 SecurityContext에 저장한다.
protected void finallyInvocation(InterceptorStatusToken token) {
if (token != null && token.isContextHolderRefreshRequired()) {
SecurityContextHolder.setContext(token.getSecurityContext());
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.of(
() -> "Reverted to original authentication " + token.getSecurityContext().getAuthentication()));
}
}
}