一、起因
同事发现在项目中新启的线程没有办法访问Spring Security的上下文
二、猜想
上下文是线程隔离的,估计是用ThreadLocal这种数据结构存
三、分析
像上述的猜想,我们一般都能想到,这可能是线程隔离造成的,这突然让我意识到,每次对后端接口的请求,都是一个单独的线程,它们是怎么通过认证的。
难道它们每次请求都要初始化一个上下文出来。这不就相当于每次请求,都做了一次认证吗?这样也太消耗性能了。于是我开始分析。
1.共识
首先我们要达成几点共识
- 项目中使用的是Spring Security+Oauth2
- Spring Security 是通过过滤器(Filter)来实现认证校验的
- Spring Security 是处理认证的,Oauth2是处理授权的,这里不展开讲,它们结合在一块的话,可以简单理解为,认证完后会颁发一个token,这个token对应着其身份,角色,权限等信息。
2.登录状态下如何验证请求的token和如何初始化上下文
当我们在登录状态下,已经获取了这个token,然后随便调取一个接口
请求会经过一个过滤器OAuth2AuthenticationProcessingFilter
,让我们去掉不必要的代码,聚焦看一doFilter()
方法
/**
* A pre-authentication filter for OAuth2 protected resources. Extracts an OAuth2 token from the incoming request and
* uses it to populate the Spring Security context with an {@link OAuth2Authentication} (if used in conjunction with an
* {@link OAuth2AuthenticationManager}).
*
* @author Dave Syer
*
*/
public class OAuth2AuthenticationProcessingFilter implements Filter, InitializingBean {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
ServletException {
final HttpServletRequest request = (HttpServletRequest) req;
final HttpServletResponse response = (HttpServletResponse) res;
try {
// 1.从request获取token
Authentication authentication = tokenExtractor.extract(request);
if (authentication == null) {
if (stateless && isAuthenticated()) {
SecurityContextHolder.clearContext();
}
} else {
// 2.对token进行校验
Authentication authResult = authenticationManager.authenticate(authentication);
// 3.将认证信息放入上下文中
SecurityContextHolder.getContext().setAuthentication(authResult);
}
} catch (OAuth2Exception failed) {
// 认证失败
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(request, response, new InsufficientAuthenticationException(failed.getMessage(), failed));
return;
}
// 一切正常,继续执行认证链
chain.doFilter(request, response);
}
}
从上述代码中可以看出校验后,SecurityContextHolder.getContext().setAuthentication(authResult);
负责将认证后的信息初始化到上下文中。
我们在getContext()这个方法中,看到确实是ThreadLocal的数据结构,也就是线程隔离的
这使得我们在项目的新启线程中需要手动将认证上下文的数据传入。
3.上下文原始数据是从什么地方取得?
上文过滤器中第2步对token进行认证,如下
Authentication authResult = authenticationManager.authenticate(authentication); // authenticationManager类型为OAuth2AuthenticationManager
authenticate(…)方法认证时内部通过tokenServices[DefaultTokenServices].loadAuthentication(String accessTokenValue)
,其中依赖tokenStore来取token信息,tokenStore根据存储方式不同有不同实现类,如下图
token可以从内存,jwt,或数据库中获取。
项目中使用的是JdbcTokenStore的形式,也就是从数据库中根据token获取的方式
我们项目中oauht_access_token表中数据
至此,登录状态下初始化认证上下文的过程已经结束。
它确实是针对每次请求都初始化一个认证上下文出来。我们所能做的优化,就是让这个认证或者校验的过程尽量快速,所以Spring Security也提供了缓存形式的查询实现,以提高效率。
4.登录时如何存储存储token
那Spring Security是怎么将token的相关数据放入到数据库的呢?
这里我仅介绍下我们项目中的实现
- 首先Login页面填入用户名密码
- 项目中有自己的认证接口
方法中验证了验证码,然后携带用户名密码,通过RestTemplate去访问了Spring Security的TokenEndpoint
中的oauth/token
方法中具体存储的代码如下
@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
String clientId = getClientId(principal);
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
// 。。。省略一万字 。。。
// 在grant(...)方法中进行了存储
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
if (token == null) {
throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
}
return getResponse(token);
}
这里不详细展开讲了,看一下栈调用过程吧
登录后,就可以携带token去访问资源,那时就是第一步中讲的过程了