本文通過一個簡易安全認證示例的開發實踐,理解過濾器和攔截器的工作原理。 很多文章都將過濾器(Filter)、攔截器(Interceptor)和監聽器(Listener)這三者和Spring關聯起來講解,並認為過濾器(Filter)、攔截器(Interceptor)和監聽器(Listener)是Spr ...
本文通過一個簡易安全認證示例的開發實踐,理解過濾器和攔截器的工作原理。
很多文章都將過濾器(Filter)、攔截器(Interceptor)和監聽器(Listener)這三者和Spring關聯起來講解,並認為過濾器(Filter)、攔截器(Interceptor)和監聽器(Listener)是Spring提供的應用廣泛的組件功能。
但是嚴格來說,過濾器和監聽器屬於Servlet範疇的API,和Spring沒什麼關係。
因為過濾器繼承自javax.servlet.Filter介面,監聽器繼承自javax.servlet.ServletContextListener介面,只有攔截器繼承的是org.springframework.web.servlet.HandlerInterceptor介面。
上面的流程圖參考自網上資料,一圖勝千言。看完本文以後,將對過濾器和攔截器的調用過程會有更深刻理解。
一、安全認證設計思路
有時候內外網調用API,對安全性的要求不一樣,很多情況下外網調用API的種種限制在內網根本沒有必要,但是網關部署的時候,可能因為成本和複雜度等問題,內外網要調用的API會部署在一起。
實現REST介面的安全性,可以通過成熟框架如Spring Security或者shiro搞定。
但是因為安全框架往往實現複雜(我數了下Spring Security,洋洋灑灑大概有11個核心模塊,shiro的源碼代碼量也比較驚人)同時可能要引入複雜配置(能不能讓人痛快一點),不利於中小團隊的靈活快速開發、部署及問題排查。
很多團隊自己造輪子實現安全認證,本文這個簡易認證示例參考自我所在的前廠開發團隊,可以認為是個基於token的安全認證服務。
大致設計思路如下:
1、自定義http請求頭,每次調用API都在請求頭裡傳人一個token值
2、token放在緩存(如redis)中,根據業務和API的不同設置不同策略的過期時間
3、token可以設置白名單和黑名單,可以限制API調用頻率,便於開發和測試,便於緊急處理異狀,甚至臨時關閉API
4、外網調用必須傳人token,token可以和用戶有關係,比如每次打開頁面或者登錄生成token寫入請求頭,頁面驗證cookie和token有效性等
在Spring Security框架里有兩個概念,即認證和授權,認證指可以訪問系統的用戶,而授權則是用戶可以訪問的資源。
實現上述簡易安全認證需求,你可能需要獨立出一個token服務,保證生成token全局唯一,可能包含的模塊有自定義流水生成器、CRM、加解密、日誌、API統計、緩存等,但是和用戶(CRM)其實是弱綁定關係。某些和用戶有關係的公共服務,比如我們經常用到的發送簡訊SMS和郵件服務,也可以通過token機制解決安全調用問題。
綜上,本文的簡易安全認證其實和Spring Security框架提供的認證和授權有點不一樣,當然,這種“安全”處理方式對專業人士沒什麼新意,但是可以對外擋掉很大一部分小白用戶。
二、自定義過濾器
和Spring MVC類似,Spring Boot提供了很多servlet過濾器(Filter)可使用,並且它自動添加了一些常用過濾器,比如CharacterEncodingFilter(用於處理編碼問題)、HiddenHttpMethodFilter(隱藏HTTP函數)、HttpPutFormContentFilter(form表單處理)、RequestContextFilter(請求上下文)等。通常我們還會自定義Filter實現一些通用功能,比如記錄日誌、判斷是否登錄、許可權驗證等。
1、自定義請求頭
很簡單,在request header添加自定義請求頭authtoken:
@RequestMapping(value = "/getinfobyid", method = RequestMethod.POST) @ApiOperation("根據商品Id查詢商品信息") @ApiImplicitParams({ @ApiImplicitParam(paramType = "header", name = "authtoken", required = true, value = "authtoken", dataType = "String"), }) public GetGoodsByGoodsIdResponse getGoodsByGoodsId(@RequestHeader String authtoken, @RequestBody GetGoodsByGoodsIdRequest request) { return _goodsApiService.getGoodsByGoodsId(request); }getGoodsByGoodsId
加了@RequestHeader修飾的authtoken欄位就可以在swagger這樣的框架下顯示出來。
調用後,可以根據http工具看到請求頭,本文示例是authtoken(和某些框架的token區分開):
備註:很多httpclient工具都支持動態傳人請求頭,比如RestTemplate。
2、實現Filter
Filter介面共有三個方法,即init,doFilter和destory,看到名稱就大概知道它們主要用途了,通常我們只要在doFilter這個方法內,對Http請求進行處理:
package com.power.demo.controller.filter; import com.power.demo.common.AppConst; import com.power.demo.common.BizResult; import com.power.demo.service.contract.AuthTokenService; import com.power.demo.util.PowerLogger; import com.power.demo.util.SerializeUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; @Component public class AuthTokenFilter implements Filter { @Autowired private AuthTokenService authTokenService; @Override public void init(FilterConfig var1) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; String token = req.getHeader(AppConst.AUTH_TOKEN); BizResult<String> bizResult = authTokenService.powerCheck(token); System.out.println(SerializeUtil.Serialize(bizResult)); if (bizResult.getIsOK() == true) { PowerLogger.info("auth token filter passed"); chain.doFilter(request, response); } else { throw new ServletException(bizResult.getMessage()); } } @Override public void destroy() { } }AuthTokenFilter
註意,Filter這樣的東西,我認為從實際分層角度,多數處理的還是表現層偏多,不建議在Filter中直接使用數據訪問層Dao,雖然這樣的代碼一兩年前我在很多老古董項目中看到過很多次,而且<<Spring實戰>>的書里也有這樣寫的先例。
3、認證服務
這裡就是主要業務邏輯了,示例代碼只是簡單寫下思路,不要輕易就用於生產環境:
package com.power.demo.service.impl; import com.power.demo.cache.PowerCacheBuilder; import com.power.demo.common.BizResult; import com.power.demo.service.contract.AuthTokenService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @Component public class AuthTokenServiceImpl implements AuthTokenService { @Autowired private PowerCacheBuilder cacheBuilder; /* * 驗證請求頭token是否合法 * */ @Override public BizResult<String> powerCheck(String token) { BizResult<String> bizResult = new BizResult<>(true, "驗證通過"); System.out.println("token的值為:" + token); if (StringUtils.isEmpty(token) == true) { bizResult.setFail("authtoken為空"); return bizResult; } //處理黑名單 bizResult = checkForbidList(token); if (bizResult.getIsOK() == false) { return bizResult; } //處理白名單 bizResult = checkAllowList(token); if (bizResult.getIsOK() == false) { return bizResult; } String key = String.format("Power.AuthTokenService.%s", token); //cacheBuilder.set(key, token); //cacheBuilder.set(key, token.toUpperCase()); //從緩存中取 String existToken = cacheBuilder.get(key); if (StringUtils.isEmpty(existToken) == true) { bizResult.setFail(String.format("不存在此authtoken:%s", token)); return bizResult; } //比較token是否相同 Boolean isEqual = token.equals(existToken); if (isEqual == false) { bizResult.setFail(String.format("不合法的authtoken:%s", token)); return bizResult; } //do something return bizResult; } }AuthTokenServiceImpl
用到的緩存服務可以參考這裡,這個也是我在前廠的經驗總結。
4、註冊Filter
常見的有兩種寫法:
(1)、使用@WebFilter註解來標識Filter
@Order(1) @WebFilter(urlPatterns = {"/api/v1/goods/*", "/api/v1/userinfo/*"}) public class AuthTokenFilter implements Filter {
使用@WebFilter註解,還可以配合使用@Order註解,@Order註解表示執行過濾順序,值越小,越先執行,這個Order大小在我們編程過程中就像處理HTTP請求的生命周期一樣大有用處。當然,如果沒有指定Order,則過濾器的調用順序跟添加的過濾器順序相反,過濾器的實現是責任鏈模式。
最後,在啟動類上添加@ServletComponentScan 註解即可正常使用自定義過濾器了。
(2)、使用FilterRegistrationBean對Filter進行自定義註冊
本文以第二種實現自定義Filter註冊:
package com.power.demo.controller.filter; import com.google.common.collect.Lists; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; import java.util.List; @Configuration @Component public class RestFilterConfig { @Autowired private AuthTokenFilter filter; @Bean public FilterRegistrationBean filterRegistrationBean() { FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(filter); //設置(模糊)匹配的url List<String> urlPatterns = Lists.newArrayList(); urlPatterns.add("/api/v1/goods/*"); urlPatterns.add("/api/v1/userinfo/*"); registrationBean.setUrlPatterns(urlPatterns); registrationBean.setOrder(1); registrationBean.setEnabled(true); return registrationBean; } }RestFilterConfig
請大家特別註意urlPatterns,屬性urlPatterns指定要過濾的URL模式。對於Filter的作用區域,這個參數居功至偉。
註冊好Filter,當Spring Boot啟動時監測到有javax.servlet.Filter的bean時就會自動加入過濾器調用鏈ApplicationFilterChain。
調用一個API試試效果:
通常情況下,我們在Spring Boot下都會自定義一個全局統一的異常管理增強GlobalExceptionHandler(和上面這個顯示會略有不同)。
根據我的實踐,過濾器里拋出異常,不會被全局唯一的異常管理增強捕獲到併進行處理,這個和攔截器Inteceptor以及下一篇文章介紹的自定義AOP攔截不同。
到這裡,一個通過自定義Filter實現的簡易安全認證服務就搞定了。
三、自定義攔截器
1、實現攔截器
繼承介面HandlerInterceptor,實現攔截器,介面方法有下麵三個:
preHandle是請求執行前執行
postHandle是請求結束執行
afterCompletion是視圖渲染完成後執行
package com.power.demo.controller.interceptor; import com.power.demo.common.AppConst; import com.power.demo.common.BizResult; import com.power.demo.service.contract.AuthTokenService; import com.power.demo.util.PowerLogger; import com.power.demo.util.SerializeUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /* * 認證token攔截器 * */ @Component public class AuthTokenInterceptor implements HandlerInterceptor { @Autowired private AuthTokenService authTokenService; /* * 請求執行前執行 * */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { boolean handleResult = false; String token = request.getHeader(AppConst.AUTH_TOKEN); BizResult<String> bizResult = authTokenService.powerCheck(token); System.out.println(SerializeUtil.Serialize(bizResult)); handleResult = bizResult.getIsOK(); PowerLogger.info("auth token interceptor攔截結果:" + handleResult); if (bizResult.getIsOK() == true) { PowerLogger.info("auth token interceptor passed"); } else { throw new Exception(bizResult.getMessage()); } return handleResult; } /* * 請求結束執行 * */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } /* * 視圖渲染完成後執行 * */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }AuthTokenInterceptor
示例中,我們選擇在請求執行前進行token安全認證。
認證服務就是過濾器里介紹的AuthTokenService,業務邏輯層實現復用。
2、註冊攔截器
定義一個InterceptorConfig類,繼承自WebMvcConfigurationSupport,WebMvcConfigurerAdapter已經過時。
將AuthTokenInterceptor作為bean註入,其他設置攔截器攔截的URL和過濾器非常相似:
package com.power.demo.controller.interceptor; import com.google.common.collect.Lists; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; import java.util.List; @Configuration @Component public class InterceptorConfig extends WebMvcConfigurationSupport { //WebMvcConfigurerAdapter已經過時 private static final String FAVICON_URL = "/favicon.ico"; /** * 發現如果繼承了WebMvcConfigurationSupport,則在yml中配置的相關內容會失效。 * * @param registry */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/").addResourceLocations("/**"); registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/"); } /** * 配置servlet處理 */ @Override public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { configurer.enable(); } @Override public void addInterceptors(InterceptorRegistry registry) { //設置(模糊)匹配的url List<String> urlPatterns = Lists.newArrayList(); urlPatterns.add("/api/v1/goods/*"); urlPatterns.add("/api/v1/userinfo/*"); registry.addInterceptor(authTokenInterceptor()).addPathPatterns(urlPatterns).excludePathPatterns(FAVICON_URL); super.addInterceptors(registry); } //將攔截器作為bean寫入配置中 @Bean public AuthTokenInterceptor authTokenInterceptor() { return new AuthTokenInterceptor(); } }InterceptorConfig
啟動應用後,調用介面就可以看到攔截器攔截的效果了。全局統一的異常管理GlobalExceptionHandler捕獲異常後處理如下:
和過濾器顯示的主要錯誤提示信息幾乎一樣,但是堆棧信息更加豐富。
四、過濾器和攔截器區別
主要區別如下:
1、攔截器主要是基於java的反射機制的,而過濾器是基於函數回調
2、攔截器不依賴於servlet容器,過濾器依賴於servlet容器
3、攔截器只能對action請求起作用,而過濾器則可以對幾乎所有的請求起作用
4、攔截器可以訪問action上下文、值棧里的對象,而過濾器不能訪問
5、在action的生命周期中,攔截器可以多次被調用,而過濾器只能在容器初始化時被調用一次
參考過的一些文章,有的說“攔截器可以獲取IOC容器中的各個bean,而過濾器就不行,這點很重要,在攔截器里註入一個service,可以調用業務邏輯”,經過實際驗證,這是不對的。
註意:過濾器的觸發時機是容器後,servlet之前,所以過濾器的doFilter(ServletRequest request, ServletResponse response, FilterChain chain)的入參是ServletRequest,而不是HttpServletRequest,因為過濾器是在HttpServlet之前。下麵這個圖,可以讓你對Filter和Interceptor的執行時機有更加直觀的認識:
只有經過DispatcherServlet 的請求,才會走攔截器鏈,自定義的Servlet請求是不會被攔截的,比如我們自定義的Servlet地址http://localhost:9090/testServlet是不會被攔截器攔截的。但不管是屬於哪個Servlet,只要符合過濾器的過濾規則,過濾器都會執行。
根據上述分析,理解原理,實際操作就簡單了,哪怕是ASP.NET過濾器亦然。
問題:實現更加靈活的安全認證
在Java Web下通過自定義過濾器Filter或者攔截器Interceptor配置urlPatterns,可以實現對特定匹配的API進行安全認證,比如匹配所有API、匹配某個或某幾個API等,但是有時候這種匹配模式對開發人員相對不夠友好。
我們可以參考Spring Security那樣,通過註解+SpEL實現強大功能。
又比如在ASP.NET中,我們經常用到Authorized特性,這個特性可以加在類上,也可以作用於方法上,可以更加動態靈活地控制安全認證。
我們沒有選擇Spring Security,那就自己實現類似Authorized的靈活的安全認證,主要實現技術就是我們所熟知的AOP。
通過AOP方式實現更靈活的攔截的基礎知識本文就先不提了,更多的關於AOP的話題將在下篇文章分享。
參考:
<<Spring實戰>>
https://blog.csdn.net/qyp1314/article/details/42023725
https://blog.csdn.net/sun_t89/article/details/51916834
https://www.cnblogs.com/moonlightL/p/8126910.html