RequestCacheAwareFilter
介绍
这个Filter官方解释为:“用于用户登录成功后,重新恢复因为登录被打断的请求”,被打断也是有前提条件的,支持打断后可以被恢复的异常有AuthenticationException、AccessDeniedException,这个操作是ExceptionTranslationFilter中触发的,并且RequestCacheAwareFilter只支持GET方法,而默认TokenEndpoint支持Post获取Token信息,进行登录,我们带着这些问题去分析代码。
代码分析
步骤1
RequestCacheAwareFilter从requestCache命中缓存是有一定的机制的,如果盲目去触发接口,可能发现requestCache永远都不能生效。RequestCacheAwareFilter中注入了一个RequestCache,它的实现也有2中方式,分别是HttpSessionRequestCache和NullRequestCache,默认是HttpSessionRequestCache,也就不用考虑如何注入HttpSessionRequestCache的问题了。要想命中缓存,必须先写入缓存,RequestCache#saveRequest()就是执行写入缓存请求的操作。很容易发现,saveRequest()仅在ExceptionTranslationFilter#sendStartAuthentication()有调用,并且sendStartAuthentication()又都是在handleSpringSecurityException()调用的。仅在异常属于AuthenticationException或AccessDeniedException才会调用sendStartAuthentication(),而这2种异常往往是在授权认证服务认证失败时抛出的异常,只要能触发这两种异常,requestCache就能会将请求信息写入缓存,笔者发现继承AuthenticationException还挺多的,以InsufficientAuthenticationException(其他异常笔者发现诸如LockedException等会被转成非AuthenticationException)为例,可以自定一个UserDetailsService重写loadUserByUsername方法,当用户不存在时抛出一个InsufficientAuthenticationException即可,代码以及截图如下:
//RequestCacheAwareFilter
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
//从HttpSessionRequestCache中取一下缓存
HttpServletRequest wrappedSavedRequest = requestCache.getMatchingRequest(
(HttpServletRequest) request, (HttpServletResponse) response);
chain.doFilter(wrappedSavedRequest == null ? request : wrappedSavedRequest,
response);
}
public void saveRequest(HttpServletRequest request, HttpServletResponse response) {
//这里requestMatcher 匹配的是["/**",GET], 对于POST登录请求无法匹配
if (requestMatcher.matches(request)) {
DefaultSavedRequest savedRequest = new DefaultSavedRequest(request,
portResolver);
if (createSessionAllowed || request.getSession(false) != null) {
request.getSession().setAttribute(this.sessionAttrName, savedRequest);
logger.debug("DefaultSavedRequest added to Session: " + savedRequest);
}
}
}
private void handleSpringSecurityException(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
//遇到AuthenticationException[身份认证异常]缓存请求
sendStartAuthentication(request, response, chain,
(AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
//遇到AccessDeniedException[访问权限受限异常],并且认证信息时匿名的或者认证信息属于"记住我"身份认证缓存请求
sendStartAuthentication(
request,
response,
chain,
new InsufficientAuthenticationException(
messages.getMessage(
"ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
accessDeniedHandler.handle(request, response,
(AccessDeniedException) exception);
}
}
}
protected void sendStartAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
SecurityContextHolder.getContext().setAuthentication(null);
//缓存请求,把请求信息放到Session中
requestCache.saveRequest(request, response);
//处理异常
authenticationEntryPoint.commence(request, response, reason);
}
@Override
public Oauth2User loadUserByUsername(String username) throws UsernameNotFoundException {
final String key = RedisKey.userDetails(username);
RBucket<Oauth2User> bucket = redissonClient.getBucket(key);
if (!bucket.isExists()) {
R<UserRetDTO> ret;
try {
ret = userFeignService.match(username, CryptoUtil.sign(60L));
} catch (Throwable e) {
throw new AccessDeniedException("请求用户信息失败", e);
}
//抛出InsufficientAuthenticationException就能被ExceptionTranslationFilter处理
if (!R.isRequestSuccessCanNotNullData(ret)) {
throw new InsufficientAuthenticationException("用户不存在");
}
Oauth2User user = this.toCarpUser(ret.getData());
if (user != null) {
bucket.set(user);
bucket.expire(carpAuthClientProperties.getAccessTokenValiditySeconds().longValue(), TimeUnit.SECONDS);
}
return user;
}
return bucket.get();
}
步骤2
经过上一步后,我发送一个获取token的请求,发现仍然无法命中缓存,关键在这行代码saveRequest()方法的requestMatcher.matches(request),此处requestMatcher只匹配get请求。默认的TokenEndpoint的请求token的请求只能是POST方式,我们需要进一步改造,通过bean的后置处理改变TokenEndpoint的允许的请求方式就可以了。笔者这里自定义一个TokenEndpoint,请求Token信息的url不能Spring Security重复,不然项目可能都无法启动,而且在配置类需要配置现请求路径与原有请求路径关系才行,这个系列文章FrameworkEndpointHandlerMapping会做讲解,这里不在赘述,自定义TokenEndpoint也不是必须的,只是这里提到了,加上一些知识点。截图以及自定义TokenEndpoint代码如下:
@RestController
@RequestMapping("/oauth2")
public class CarpTokenEndpoint implements InitializingBean {
@Autowired
private TokenEndpoint tokenEndpoint;
@ApiOperation(value = "登录接口", httpMethod = "GET")
@RequestMapping(value = "/token/access", method = RequestMethod.GET)
public R<OAuth2AccessToken> getAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
ResponseEntity<OAuth2AccessToken> ret = tokenEndpoint.getAccessToken(principal, parameters);
return R.success(ret.getBody());
}
@ApiOperation(value = "登录接口", httpMethod = "POST")
@RequestMapping(value = "/token/access", method = RequestMethod.POST)
public R<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
ResponseEntity<OAuth2AccessToken> ret = tokenEndpoint.postAccessToken(principal, parameters);
return R.success(ret.getBody());
}
...
//使其支持GET方法
@Override
public void afterPropertiesSet() throws Exception {
tokenEndpoint.setAllowedRequestMethods(new HashSet<>(
Arrays.asList(HttpMethod.POST, HttpMethod.GET)));
}
}
步骤3
我们可以借助Postman发送一个获取token的请求,故意输错用户名,第一次请求时遇到异常,请求信息会被写入缓存,再次请求时,我们发现缓存中已经可以命中,取出信息如下: