Spring security认证流程

167 阅读5分钟

一、背景

我一直很好奇Spring security是如何处理认证流程的,以及白名单中的url是如何跳过认证的,通过debug及阅读部分源码有了大概的思路了,所以做一下记录。

在很多项目中会使用Spring security搭配jwt实现请求认证功能。通常会有一个配置类。如下:

@EnableWebSecurity
@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {

@Resource
private LogoutSuccessHandlerImpl logoutSuccessHandler;
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationFilter;
@Resource
private AuthenticationEntryPointImpl unauthorizedHandler;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
 return http
         // 禁用跨域验证
         .csrf(cs -> cs.disable())
         // 禁用表单登录,表单登录是Spring-Security默认的登录页面
         // .formLogin(AbstractHttpConfigurer::disable)
         // 基于token,所以不需要session
         .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
         // 配置白名单及需要认证的url
         .authorizeHttpRequests((requests) -> {
             // 白名单
             requests.requestMatchers(SecurityConstant.EXCLUDE_SECURITY_URLS.toArray(String[]::new)).permitAll()
                     // 除了白名单都需要认证
                     .anyRequest().authenticated();
         })
         // 添加Logout filter
         .logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
         // 添加JWT filter
         .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
         // 认证失败处理类
         .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
         .build();
 }
}

在上面的代码中配置了url白名单及添加了jwt过滤器。

二、问题的提出

我们先看下jwt过滤器中干了什么

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    private final static String AUTHORIZATION = "Authorization";
    public static final String BEARER = "Bearer ";

    @Resource
    private SpringSecurityDetailService springSecurityDetailService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        // 校验jwt token
        String authorization = request.getHeader(AUTHORIZATION);
        if (StringUtils.isBlank(authorization) || !authorization.startsWith(BEARER)) {
            chain.doFilter(request, response);
            return;
        }
        authorization = authorization.substring(BEARER.length());
        Claims claim = null;
        try {
            claim = JwtUtils.getClaimsByToken(authorization);
            if (claim == null) {
                throw new JwtException("token异常");
            }
        } catch (JwtException e) {
            this.handleJwtException(response);
            return;
        }
        String username = claim.getSubject();
        SpringSecurityUserDetail springSecurityUserDetail = springSecurityDetailService.loadUserByUsername(username);
        // 这一步创建了认证对象,在AuthorizationFilter这个过滤器中进行认证
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, null, springSecurityUserDetail.getAuthorities());
        token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(token);
        chain.doFilter(request, response);
    }

首先从请求头中拿到token,然后使用jwt工具校验token,校验通过后创建了一个UsernamePasswordAuthenticationToken对象,并放置在SecurityContextHolder的上下文中,为什么将UsernamePasswordAuthenticationToken对象放进上下文中就能认证成功呢?

带着这个疑问开始debug。

三、第一个问题的探索

先使用一个不在白名单中的请求,在DisableEncodeUrlFilter这个过滤器中打下断点,目的是看下在filterChain中还有哪些filter。

http://localhost:8888/demo/user/getById?id=1

image.png

可以看到有12个filter,这些过滤器除了jwt过滤器是自己添加的,其他都是有Spring Security默认添加的,具体如何添加的就不在这详细说了,可以看下FilterOrderRegistration和HttpSecurity这个两个类。

我们看下最后一个过滤器AuthorizationFilter的doFilter方法,

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
		throws ServletException, IOException {

	HttpServletRequest request = (HttpServletRequest) servletRequest;
	HttpServletResponse response = (HttpServletResponse) servletResponse;

	if (this.observeOncePerRequest && isApplied(request)) {
		chain.doFilter(request, response);
		return;
	}

	if (skipDispatch(request)) {
		chain.doFilter(request, response);
		return;
	}

	String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
	request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
	try {
          // 这里通过authorizationManager对Authentication对象进行认证
          // 如果我们没有往SecurityContext中添加Authentication对象,那么这里的Authentication对象是一个username为anonymousUser(匿名用户)的对象
		AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
		this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
           // decision是认证结果
		if (decision != null && !decision.isGranted()) {
			throw new AccessDeniedException("Access Denied");
		}
		chain.doFilter(request, response);
	}
	finally {
		request.removeAttribute(alreadyFilteredAttributeName);
	}
}
    

在这个方法中打下断点。

image.png

看下面这行代码

AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);

首先通过 this::getAuthentication获取Authentication对象,看下这个方法

private Authentication getAuthentication() {
    // 这里不就是SecurityContext中获取Authentication对象嘛,所以我们在jwt过滤器中设置的对象会从这里取出来进行校验
    Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
    if (authentication == null) {
       throw new AuthenticationCredentialsNotFoundException(
             "An Authentication object was not found in the SecurityContext");
    }
    return authentication;
}

到这里说明我们添加的UsernamePasswordAuthenticationToken被拿来做认证校验了

继续debug

image.png

这里是RequestMatcherDelegatingAuthorizationManager类的方法,从Mappings中遍历找到对应的RequestMatcher,然后获取对应的AuthorizationManager调用check()方法,得到认证结果,关于从Mappings中遍历找到对应的RequestMatcher是什么等会讲白名单url的处理时再说。继续往下走

image.png

发现匹配到的RequestMatcher是anyRequestMatcher,而AuthorizationManager是AuthenticatedAuthorizationManager,看下AuthenticatedAuthorizationManager的check方法

@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = this.authorizationStrategy.isGranted(authentication.get());
    return new AuthorizationDecision(granted);
}
boolean isGranted(Authentication authentication) {
    // 如果Authentication对象不为空,且不是匿名用户,且Authentication的isAuthenticated()返回true,满足上述条件则返回true,也就是认证通过
    return authentication != null && !this.trustResolver.isAnonymous(authentication)
          && authentication.isAuthenticated();
}

我们再回头看下jwt过滤器中new的UsernamePasswordAuthenticationToken

public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
       Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    this.principal = principal;
    this.credentials = credentials;
    // 这里是true
    super.setAuthenticated(true); 
}

到这里就知道为什么我们在jwt过滤器中将自己new的UsernamePasswordAuthenticationToken对象放入SecurityContext中就能认证成功

到这里解决了第一个问题。

四、第二个问题

为什么我们配置的白名单能生效

public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
    return http
            // 禁用跨域验证
            .csrf(cs -> cs.disable())
            // 禁用表单登录,表单登录是Spring-Security默认的登录页面
            // .formLogin(AbstractHttpConfigurer::disable)
            // 基于token,所以不需要session
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 配置白名单及需要认证的url
            .authorizeHttpRequests((requests) -> {
                // 白名单
                requests.requestMatchers(SecurityConstant.EXCLUDE_SECURITY_URLS.toArray(String[]::new)).permitAll()
                        // 除了白名单都需要认证
                        .anyRequest().authenticated();
            })
            // 添加Logout filter
            .logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
            // 添加JWT filter
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            // 认证失败处理类
            .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
            .build();
}

在白名单中我配置了两个url

public interface SecurityConstant {
    List<String> EXCLUDE_SECURITY_URLS = Arrays.asList(
            "/login",
            "/logout"
    );
}

我们在上面debug时,在RequestMatcherDelegatingAuthorizationManager这个类中的mappings中是有三个对象的,分别是"/login","/logout",“any request”,也就是说我们配置的白名单url已经被添加到了mappings中,我们看下如何添加的。也就是这个permitAll()方法。

public final class AuthorizeHttpRequestsConfigurer<H extends HttpSecurityBuilder<H>>
       extends AbstractHttpConfigurer<AuthorizeHttpRequestsConfigurer<H>, H> {

    static final AuthorizationManager<RequestAuthorizationContext> permitAllAuthorizationManager = (a,
          o) -> new AuthorizationDecision(true);
          
    public AuthorizationManagerRequestMatcherRegistry permitAll() {
    // 这个permitAllAuthorizationManager是什么呢?看上面,他首先是一个lambda表达式,它是一个匿名类,实现了AuthorizationManager接口的check方法,返回一个值为true的AuthorizationDecision对象,而这个对象就是在AuthorizationFilter中判断是否认证通过的对象
    return access(permitAllAuthorizationManager);
    }    
 }

public AuthorizationManagerRequestMatcherRegistry access(
       AuthorizationManager<RequestAuthorizationContext> manager) {
    Assert.notNull(manager, "manager cannot be null");
    return AuthorizeHttpRequestsConfigurer.this.addMapping(this.matchers, manager);
}


private AuthorizationManagerRequestMatcherRegistry addMapping(List<? extends RequestMatcher> matchers,
       AuthorizationManager<RequestAuthorizationContext> manager) {
    for (RequestMatcher matcher : matchers) {
       this.registry.addMapping(matcher, manager);
    }
    return this.registry;
}
                   

上面的代码就是将每个白名单中的url注册进mappings中,并且对应的AuthorizationManager是一个匿名类,该匿名类实现的方法总是返回值为true的AuthorizationDecision对象,即认证结果为认证通过。

我们再使用白名单中路径请求下。

http://localhost:8888/demo/login

image.png

ok 到这里第二个问题也解决了

五、总结

  1. Spring Security的认证流程是在AuthorizationFilter中完成的,它从SecurityContext中取出Authentication对象并利用AuthorizationManager的check方法进行校验。我们在jwt过滤器中往SecurityContext置入的 UsernamePasswordAuthenticationToken是Authentication的一个实现类
  2. 我们在配置类中添加的白名单url会注册为多个RequestMatcher,并且这些RequestMatcher对应同一个AuthorizationManager对象,该对象是一个lambda匿名类,该对象返回一个值为true的AuthorizationDecision对象