原創/朱季謙
在Spring Security權限框架里,若要對后端http接口實現權限授權控制,有兩種實現方式。
一、一種是基於注解方法級的鑒權,其中,注解方式又有@Secured和@PreAuthorize兩種。
@Secured如:
1 @PostMapping("/test")
2 @Secured({WebResRole.ROLE_PEOPLE_W})
3 public void test(){
4 ......
5 return null;
6 }
@PreAuthorize如:
1 @PostMapping("save")
2 @PreAuthorize("hasAuthority('sys:user:add') AND hasAuthority('sys:user:edit')")
3 public RestResponse save(@RequestBody @Validated SysUser sysUser, BindingResult result) {
4 ValiParamUtils.ValiParamReq(result);
5 return sysUserService.save(sysUser);
6 }
二、一種基於config配置類,需在對應config類配置@EnableGlobalMethodSecurity(prePostEnabled = true)注解才能生效,其權限控制方式如下:
1 @Override
2 protected void configure(HttpSecurity httpSecurity) throws Exception {
3 //使用的是JWT,禁用csrf
4 httpSecurity.cors().and().csrf().disable()
5 //設置請求必須進行權限認證
6 .authorizeRequests()
7 //首頁和登錄頁面
8 .antMatchers("/").permitAll()
9 .antMatchers("/login").permitAll()
10 // 其他所有請求需要身份認證
11 .anyRequest().authenticated();
12 //退出登錄處理
13 httpSecurity.logout().logoutSuccessHandler(...);
14 //token驗證過濾器
15 httpSecurity.addFilterBefore(...);
16 }
這兩種方式各有各的特點,在日常開發當中,普通程序員接觸比較多的,則是注解方式的接口權限控制。
那么問題來了,我們配置這些注解或者類,其security框是如何幫做到能針對具體的后端API接口做權限控制的呢?
單從一行@PreAuthorize("hasAuthority('sys:user:add') AND hasAuthority('sys:user:edit')")注解上看,是看不出任何頭緒來的,若要回答這個問題,還需深入到源碼層面,方能對security授權機制有更好理解。
若要對這個過程做一個總的概述,筆者整體以自己的思考稍作了總結,可以簡單幾句話說明其整體實現,以該接口為例:
1 @PostMapping("save")
2 @PreAuthorize("hasAuthority('sys:user:add')")
3 public RestResponse save(@RequestBody @Validated SysUser sysUser, BindingResult result) {
4 ValiParamUtils.ValiParamReq(result);
5 return sysUserService.save(sysUser);
6 }
即,認證通過的用戶,發起請求要訪問“/save”接口,若該url請求在配置類里設置為必須進行權限認證的,就會被security框架使用filter攔截器對該請求進行攔截認證。攔截過程主要一個動作,是把該請求所擁有的權限集與@PreAuthorize設置的權限字符“sys:user:add”進行匹配,若能匹配上,說明該請求是擁有調用“/save”接口的權限,那么,就可以被允許執行該接口資源。
在springboot+security+jwt框架中,通過一系列內置或者自行定義的過濾器Filter來達到權限控制,如何設置自定義的過濾器Filter呢?例如,可以通過設置httpSecurity.addFilterBefore(new JwtFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class)來自定義一個基於JWT攔截的過濾器JwtFilter,這里的addFilterBefore方法將在下一篇文詳細分析,這里暫不展開,該方法大概意思就是,將自定義過濾器JwtFilter加入到Security框架里,成為其中的一個優先安全Filter,代碼層面就是將自定義過濾器添加到List<Filter> filters。
設置增加自行定義的過濾器Filter偽代碼如下:
1 @Configuration
2 @EnableWebSecurity
3 @EnableGlobalMethodSecurity(prePostEnabled = true)
4 public class SecurityConfig extends WebSecurityConfigurerAdapter {
5 ......
6 @Override
7 protected void configure(HttpSecurity httpSecurity) throws Exception {
8 //使用的是JWT,禁用csrf
9 httpSecurity.cors().and().csrf().disable()
10 //設置請求必須進行權限認證
11 .authorizeRequests()
12 ......
13 //首頁和登錄頁面
14 .antMatchers("/").permitAll()
15 .antMatchers("/login").permitAll()
16 // 其他所有請求需要身份認證
17 .anyRequest().authenticated();
18 ......
19 //token驗證過濾器
20 httpSecurity.addFilterBefore(new JwtFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
21 }
22 }
該過濾器類extrends繼承BasicAuthenticationFilter,而BasicAuthenticationFilter是繼承OncePerRequestFilter,該過濾器確保在一次請求只通過一次filter,而不需要重復執行。這樣配置后,當請求過來時,會自動被JwtFilter類攔截,這時,將執行重寫的doFilterInternal方法,在SecurityContextHolder.getContext().setAuthentication(authentication)認證通過后,會執行過濾器鏈FilterChain的方法chain.doFilter(request, response);
1 public class JwtFilter extends BasicAuthenticationFilter {
2
3 @Autowired
4 public JwtFilter(AuthenticationManager authenticationManager) {
5 super(authenticationManager);
6 }
7
8 @Override
9 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
10 // 獲取token, 並檢查登錄狀態
11 // 獲取令牌並根據令牌獲取登錄認證信息
12 Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request);
13 // 設置登錄認證信息到上下文
14 SecurityContextHolder.getContext().setAuthentication(authentication);
15
16 chain.doFilter(request, response);
17 }
18
19 }
那么,問題來了,過濾器鏈FilterChain究竟是什么?
這里,先點進去看下其類源碼:
1 package javax.servlet;
2
3 import java.io.IOException;
4
5 public interface FilterChain {
6 void doFilter(ServletRequest var1, ServletResponse var2) throws IOException, ServletException;
7 }
FilterChain只有一個 doFilter方法,這個方法的作用就是將請求request轉發到下一個過濾器filter進行過濾處理操作,執行過程如下:
過濾器鏈就像一條鐵鏈,中間的每個過濾器都包含對另一個過濾器的引用,從而把相關的過濾器鏈接起來,像一條鏈的樣子。這時請求線程就如螞蟻一樣,會沿着這條鏈一直爬過去-----即,通過各過濾器調用另一個過濾器引用方法chain.doFilter(request, response),實現一層嵌套一層地將請求傳遞下去,當該請求傳遞到能被處理的的過濾器時,就會被處理,處理完成后轉發返回。通過過濾器鏈,可實現在不同的過濾器當中對請求request做處理,且過濾器之間彼此互不干擾。
Spring Security框架上過濾器鏈上都有哪些過濾器呢?
可以在DefaultSecurityFilterChain類根據輸出相關log或者debug來查看Security都有哪些過濾器,如在DefaultSecurityFilterChain類中的構造器中打斷點,如圖所示,可以看到,自定義的JwtFilter過濾器也包含其中:
這些過濾器都在同一條過濾器鏈上,即通過chain.doFilter(request, response)可將請求一層接一層轉發,處理請求接口是否授權的主要過濾器是FilterSecurityInterceptor,其主要作用如下:
1. 獲取到需訪問接口的權限信息,即@Secured({WebResRole.ROLE_PEOPLE_W}) 或@PreAuthorize定義的權限信息;
2. 根據SecurityContextHolder中存儲的authentication用戶信息,來判斷是否包含與需訪問接口的權限信息,若包含,則說明擁有該接口權限;
3. 主要授權功能在父類AbstractSecurityInterceptor中實現;
我們將從FilterSecurityInterceptor這里開始重點分析Security授權機制原理的實現。
過濾器鏈將請求傳遞轉發FilterSecurityInterceptor時,會執行FilterSecurityInterceptor的doFilter方法:
1 public void doFilter(ServletRequest request, ServletResponse response,
2 FilterChain chain) throws IOException, ServletException {
3 FilterInvocation fi = new FilterInvocation(request, response, chain);
4 invoke(fi);
5 }
在這段代碼當中,FilterInvocation類是一個有意思的存在,其實它的功能很簡單,就是將上一個過濾器傳遞過濾的request,response,chain復制保存到FilterInvocation里,專門供FilterSecurityInterceptor過濾器使用。它的有意思之處在於,是將多個參數統一歸納到一個類當中,其到統一管理作用,你想,若是N多個參數,傳進來都分散到類的各個地方,參數多了,代碼多了,方法過於分散時,可能就很容易造成閱讀過程中,弄糊塗這些個參數都是哪里來了。但若統一歸納到一個類里,就能很快定位其來源,方便代碼閱讀。網上有人提到該FilterInvocation類還起到解耦作用,即避免與其他過濾器使用同樣的引用變量。
總而言之,這個地方的設定雖簡單,但很值得我們學習一番,將其思想運用到實際開發當中,不外乎也是一種能簡化代碼的方法。
FilterInvocation主要源碼如下:
1 public class FilterInvocation {
2
3 private FilterChain chain;
4 private HttpServletRequest request;
5 private HttpServletResponse response;
6
7
8 public FilterInvocation(ServletRequest request, ServletResponse response,
9 FilterChain chain) {
10 if ((request == null) || (response == null) || (chain == null)) {
11 throw new IllegalArgumentException("Cannot pass null values to constructor");
12 }
13
14 this.request = (HttpServletRequest) request;
15 this.response = (HttpServletResponse) response;
16 this.chain = chain;
17 }
18 ......
19 }
FilterSecurityInterceptor的doFilter方法里調用invoke(fi)方法:
1 public void invoke(FilterInvocation fi) throws IOException, ServletException {
2 if ((fi.getRequest() != null)
3 && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
4 && observeOncePerRequest) {
5 //篩選器已應用於此請求,每個請求處理一次,所以不需重新進行安全檢查
6 fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
7 }
8 else {
9 // 第一次調用此請求時,需執行安全檢查
10 if (fi.getRequest() != null && observeOncePerRequest) {
11 fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
12 }
13 //1.授權具體實現入口
14 InterceptorStatusToken token = super.beforeInvocation(fi);
15 try {
16 //2.授權通過后執行的業務
17 fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
18 }
19 finally {
20 super.finallyInvocation(token);
21 }
22 //3.后續處理
23 super.afterInvocation(token, null);
24 }
25 }
授權機制實現的入口是super.beforeInvocation(fi),其具體實現在父類AbstractSecurityInterceptor中實現,beforeInvocation(Object object)的實現主要包括以下步驟:
一、獲取需訪問的接口權限,這里debug的例子是調用了前文提到的“/save”接口,其權限設置是@PreAuthorize("hasAuthority('sys:user:add') AND hasAuthority('sys:user:edit')"),根據下面截圖,可知變量attributes獲取了到該請求接口的權限:
二、獲取認證通過之后保存在 SecurityContextHolder的用戶信息,其中,authorities是一個保存用戶所擁有全部權限的集合;
這里authenticateIfRequired()方法核心實現:
1 private Authentication authenticateIfRequired() {
2 Authentication authentication = SecurityContextHolder.getContext()
3 .getAuthentication();
4 if (authentication.isAuthenticated() && !alwaysReauthenticate) {
5 ......
6 return authentication;
7 }
8 authentication = authenticationManager.authenticate(authentication);
9 SecurityContextHolder.getContext().setAuthentication(authentication);
10 return authentication;
11 }
在認證過程通過后,執行SecurityContextHolder.getContext().setAuthentication(authentication)將用戶信息保存在Security框架當中,之后可通過SecurityContextHolder.getContext().getAuthentication()獲取到保存的用戶信息;
三、嘗試授權,用戶信息authenticated、請求攜帶對象信息object、所訪問接口的權限信息attributes,傳入到decide方法;
decide()是決策管理器AccessDecisionManager定義的一個方法。
1 public interface AccessDecisionManager {
2 void decide(Authentication authentication, Object object,
3 Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
4 InsufficientAuthenticationException;
5 boolean supports(ConfigAttribute attribute);
6 boolean supports(Class<?> clazz);
7 }
AccessDecisionManager是一個interface接口,這是授權體系的核心。FilterSecurityInterceptor 在鑒權時,就是通過調用AccessDecisionManager的decide()方法來進行授權決策,若能通過,則可訪問對應的接口。
AccessDecisionManager類的方法具體實現都在子類當中,包含AffirmativeBased、ConsensusBased、UnanimousBased三個子類;
AffirmativeBased表示一票通過,這是AccessDecisionManager默認類;
ConsensusBased表示少數服從多數;
UnanimousBased表示一票反對;
如何理解這個投票機制呢?
點進去AffirmativeBased類里,可以看到里面有一行代碼int result = voter.vote(authentication, object, configAttributes):
這里的AccessDecisionVoter是一個投票器,用到委托設計模式,即AffirmativeBased類會委托投票器進行選舉,然后將選舉結果返回賦值給result,然后判斷result結果值,若為1,等於ACCESS_GRANTED值時,則表示可一票通過,也就是,允許訪問該接口的權限。
這里,ACCESS_GRANTED表示同意、ACCESS_DENIED表示拒絕、ACCESS_ABSTAIN表示棄權:
1 public interface AccessDecisionVoter<S> {
2 int ACCESS_GRANTED = 1;//表示同意
3 int ACCESS_ABSTAIN = 0;//表示棄權
4 int ACCESS_DENIED = -1;//表示拒絕
5 ......
6 }
那么,什么情況下,投票結果result為1呢?
這里需要研究一下投票器接口AccessDecisionVoter,該接口的實現如下圖所示:
這里簡單介紹兩個常用的:
1. RoleVoter:這是用來判斷url請求是否具備接口需要的角色,這種主要用於使用注解@Secured處理的權限;
2. PreInvocationAuthorizationAdviceVoter:針對類似注解@PreAuthorize("hasAuthority('sys:user:add') AND hasAuthority('sys:user:edit')")處理的權限;
到這一步,代碼就開始難懂了,這部分封裝地過於復雜,總體的邏輯,是將用戶信息所具有的權限與該接口的權限表達式做匹配,若能匹配成功,返回true,在三目運算符中,
allowed ? ACCESS_GRANTED : ACCESS_DENIED,就會返回ACCESS_GRANTED ,即表示通過,這樣,返回給result的值就為1了。
到此為止,本文就結束了,筆者仍存在不足之處,歡迎各位讀者能夠給予珍貴的反饋,也算是對筆者寫作的一種鼓勵。