一、背景
我一直很好奇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
可以看到有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);
}
}
在这个方法中打下断点。
看下面这行代码
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
这里是RequestMatcherDelegatingAuthorizationManager类的方法,从Mappings中遍历找到对应的RequestMatcher,然后获取对应的AuthorizationManager调用check()方法,得到认证结果,关于从Mappings中遍历找到对应的RequestMatcher是什么等会讲白名单url的处理时再说。继续往下走
发现匹配到的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
ok 到这里第二个问题也解决了
五、总结
- Spring Security的认证流程是在AuthorizationFilter中完成的,它从SecurityContext中取出Authentication对象并利用AuthorizationManager的check方法进行校验。我们在jwt过滤器中往SecurityContext置入的 UsernamePasswordAuthenticationToken是Authentication的一个实现类
- 我们在配置类中添加的白名单url会注册为多个RequestMatcher,并且这些RequestMatcher对应同一个AuthorizationManager对象,该对象是一个lambda匿名类,该对象返回一个值为true的AuthorizationDecision对象