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的請求,故意輸錯用戶名,第一次請求時遇到異常,請求信息會被寫入緩存,再次請求時,我們發現緩存中已經可以命中,取出信息如下: