微服務架構中整合閘道器、許可權服務

aoho發表於2017-12-10

前言:之前的文章有講過微服務的許可權系列和閘道器實現,都是孤立存在,本文將整合後端服務與閘道器、許可權系統。安全許可權部分的實現還講解了基於前置驗證的方式實現,但是由於與業務聯絡比較緊密,沒有具體的示例。業務許可權與業務聯絡非常密切,本次的整合專案將會把這部分的操作許可權校驗實現基於具體的業務服務。

1. 前文回顧與整合設計

認證鑑權與API許可權控制在微服務架構中的設計與實現系列文章中,講解了在微服務架構中Auth系統的授權認證和鑑權。在微服務閘道器中,講解了基於netflix-zuul元件實現的微服務閘道器。下面我們看一下這次整合的架構圖。

ms
微服務架構許可權

整個流程分為兩類:

  • 使用者尚未登入。客戶端(web和移動端)發起登入請求,閘道器對於登入請求直接轉發到auth服務,auth服務對使用者身份資訊進行校驗(整合專案省略使用者系統,讀者可自行實現,直接硬編碼返回使用者資訊),最終將身份合法的token返回給客戶端。
  • 使用者已登入,請求其他服務。這種情況,客戶端的請求到達閘道器,閘道器會呼叫auth系統進行請求身份合法性的驗證,驗證不通則直接拒絕,並返回401;如果通過驗證,則轉發到具體服務,服務經過過濾器,根據請求頭部中的userId,獲取該user的安全許可權資訊。利用切面,對該介面需要的許可權進行校驗,通過則proceed,否則返回403。

第一類其實比較簡單,在講解認證鑑權與API許可權控制在微服務架構中的設計與實現就基本實現,現在要做的是與閘道器進行結合;第二類中,我們新建了一個後端服務,與閘道器、auth系統整合。

下面對整合專案涉及到的三個服務分別介紹。閘道器和auth服務的實現已經講過,本文主要講下這兩個服務進行整合需要的改動,還有就是對於後端服務的主要實現進行講解。

2. gateway實現

微服務閘道器已經基本介紹完了閘道器的實現,包括服務路由、幾種過濾方式等。這一節將重點介紹實際應用時的整合。對於需要修改增強的地方如下:

  • 區分暴露介面(即對外直接訪問)和需要合法身份登入之後才能訪問的介面
  • 暴露介面直接放行,轉發到具體服務,如登入、重新整理token等
  • 需要合法身份登入之後才能訪問的介面,根據傳入的Access token進行構造頭部,頭部主要包括userId等資訊,可根據自己的實際業務在auth服務中進行設定。
  • 最後,比較重要的一點,引入Spring Security的資源伺服器配置,對於暴露介面設定permitAll(),其餘介面進入身份合法性校驗的流程,呼叫auth服務,如果通過則正常繼續轉發,否則丟擲異常,返回401。

繪製的流程圖如下:

gwflow
閘道器路由流程圖

2.1 permitAll實現

對外暴露的介面可以直接訪問,這可以依賴配置檔案,而配置檔案又可以通過配置中心進行動態更新,所以不用擔心有hard-code的問題。 在配置檔案中定義需要permitall的路徑。

auth:
  permitall:
    -
      pattern: /login/**
    -
      pattern: /web/public/**
複製程式碼

服務啟動時,讀入相應的Configuration,下面的配置屬性讀取以auth開頭的配置。

    @Bean
    @ConfigurationProperties(prefix = "auth")
    public PermitAllUrlProperties getPermitAllUrlProperties() {
        return new PermitAllUrlProperties();
    }
複製程式碼

當然還需要有PermitAllUrlProperties對應的實體類,比較簡單,不列出來了。

2.2 加強頭部

Filter過濾器,它是Servlet技術中最實用的技術,Web開發人員通過Filter技術,對web伺服器管理的所有web資源進行攔截。這邊使用Filter進行頭部增強,解析請求中的token,構造統一的頭部資訊,到了具體服務,可以利用頭部中的userId進行操作許可權獲取與判斷。

public class HeaderEnhanceFilter implements Filter {

	//...

    @Autowired
    private PermitAllUrlProperties permitAllUrlProperties;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

	//主要的過濾方法
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        String authorization = ((HttpServletRequest) servletRequest).getHeader("Authorization");
        String requestURI = ((HttpServletRequest) servletRequest).getRequestURI();
        // test if request url is permit all , then remove authorization from header
        LOGGER.info(String.format("Enhance request URI : %s.", requestURI));
        //將isPermitAllUrl的請求進行傳遞
        if(isPermitAllUrl(requestURI) && isNotOAuthEndpoint(requestURI)) {
        	//移除頭部,但不包括登入端點的頭部
            HttpServletRequest resetRequest = removeValueFromRequestHeader((HttpServletRequest) servletRequest);
            filterChain.doFilter(resetRequest, servletResponse);
            return;
        }
        //判斷是不是符合規範的頭部
        if (StringUtils.isNotEmpty(authorization)) {
            if (isJwtBearerToken(authorization)) {
                try {
                    authorization = StringUtils.substringBetween(authorization, ".");
                    String decoded = new String(Base64.decodeBase64(authorization));

                    Map properties = new ObjectMapper().readValue(decoded, Map.class);
					//解析authorization中的token,構造USER_ID_IN_HEADER
                    String userId = (String) properties.get(SecurityConstants.USER_ID_IN_HEADER);

                    RequestContext.getCurrentContext().addZuulRequestHeader(SecurityConstants.USER_ID_IN_HEADER, userId);
                } catch (Exception e) {
                    LOGGER.error("Failed to customize header for the request", e);
                }
            }
        } else {
          //為了適配,設定匿名頭部
            RequestContext.getCurrentContext().addZuulRequestHeader(SecurityConstants.USER_ID_IN_HEADER, ANONYMOUS_USER_ID);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {

    }
    
    //...
    
}
複製程式碼

上面程式碼列出了頭部增強的基本處理流程,將isPermitAllUrl的請求進行直接傳遞,否則判斷是不是符合規範的頭部,然後解析authorization中的token,構造USER_ID_IN_HEADER。最後為了適配,設定匿名頭部。
需要注意的是,HeaderEnhanceFilter也要進行註冊。Spring 提供了FilterRegistrationBean類,此類提供setOrder方法,可以為filter設定排序值,讓spring在註冊web filter之前排序後再依次註冊。

2.3 資源伺服器配置

利用資源伺服器的配置,控制哪些是暴露端點不需要進行身份合法性的校驗,直接路由轉發,哪些是需要進行身份loadAuthentication,呼叫auth服務。

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

	//... 
	//配置permitAll的請求pattern,依賴於permitAllUrlProperties物件
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .requestMatchers().antMatchers("/**")
                .and()
                .authorizeRequests()
                .antMatchers(permitAllUrlProperties.getPermitallPatterns()).permitAll()
                .anyRequest().authenticated();
    }

	//通過自定義的CustomRemoteTokenServices,植入身份合法性的相關驗證
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        CustomRemoteTokenServices resourceServerTokenServices = new CustomRemoteTokenServices();
        //...
        resources.tokenServices(resourceServerTokenServices);
    }
}
複製程式碼

資源伺服器的配置大家看了筆者之前的文章應該很熟悉,此處不過多重複講了。關於ResourceServerSecurityConfigurer配置類,之前的安全系列文章已經講過,ResourceServerTokenServices介面,當時我們也用到了,只不過用的是預設的DefaultTokenServices。這邊通過自定義的CustomRemoteTokenServices,植入身份合法性的相關驗證。

當然這個配置還要引入Spring Cloud Security oauth2的相應依賴。

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
複製程式碼

2.4 自定義RemoteTokenServices實現

ResourceServerTokenServices介面其中的一個實現是RemoteTokenServices

Queries the /check_token endpoint to obtain the contents of an access token. If the endpoint returns a 400 response, this indicates that the token is invalid.

RemoteTokenServices主要是查詢auth服務的/check_token端點以獲取一個token的校驗結果。如果有錯誤,則說明token是不合法的。筆者這邊的的CustomRemoteTokenServices實現就是沿用該思路。需要注意的是,筆者的專案基於Spring cloud,auth服務是多例項的,所以這邊使用了Netflix Ribbon獲取auth服務進行負載均衡。Spring Cloud Security新增如下預設配置,對應auth服務中的相應端點。

security:
  oauth2:
    client:
      accessTokenUri: /oauth/token
      clientId: gateway
      clientSecret: gateway
    resource:
      userInfoUri: /user
      token-info-uri: /oauth/check_token
複製程式碼

至於具體的CustomRemoteTokenServices實現,可以參考上面講的思路以及RemoteTokenServices,很簡單,此處略去。

至此,閘道器服務的增強完成,下面看一下我們對auth服務和後端backend服務的實現。
強調一下,為什麼頭部傳遞的userId等資訊需要在閘道器構造?讀者可以自己思考一下,結合安全等方面,?筆者暫時不給出答案。

3. auth整合

auth服務的整合修改,其實沒那麼多,之前對於user、role以及permission之間的定義和關係沒有給出實現,這部分的sql語句已經在auth.sql中。所以為了能給出一個完整的例項,筆者把這部分實現給補充了,主要就是user-role,role、role-permission的相應介面定義與實現,實現增刪改查。

讀者要是想參考整合專案進行實際應用,這部分完全可以根據自己的業務進行增強,包括token的建立,其自定義的資訊還可以在閘道器中進行統一處理,構造好之後傳遞給後端服務。

這邊的介面只是列出了需要的幾個,其他介面沒寫(因為懶。。)

這兩個介面也是給backend專案用來獲取相應的userId許可權。

//根據userId獲取使用者對應的許可權
 @RequestMapping(method = RequestMethod.GET, value = "/api/userPermissions?userId={userId}",
            consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    List<Permission> getUserPermissions(@RequestParam("userId") String userId);

//根據userId獲取使用者對應的accessLevel(好像暫時沒用到。。)
    @RequestMapping(method = RequestMethod.GET, value = "/api/userAccesses?userId={userId}",
            consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    List<UserAccess> getUserAccessList(@RequestParam("userId") String userId);
複製程式碼

好了,這邊的實現已經講完了,具體見專案中的實現。

4. backend專案實現

本節是進行實現一個backend的例項,後端專案主要實現哪些功能呢?我們考慮一下,之前閘道器服務和auth服務所做的準備:

  • 閘道器構造的頭部userId(可能還有其他資訊,這邊只是示例),可以在backend獲得
  • 轉發到backend服務的請求,都是經過身份合法性校驗,或者是直接對外暴露的介面
  • auth服務,提供根據userId進行獲取相應的許可權的介面

根據這些,筆者繪製了一個backend的通用流程圖:

bf
backend流程圖

上面的流程圖其實已經非常清晰了,首先經過filter過濾器,填充SecurityContextHolder的上下文。其次,通過切面來實現註解,是否需要進入切面表示式處理。不需要的話,直接執行介面內的方法;否則解析註解中需要的許可權,判斷是否有許可權執行,有的話繼續執行,否則返回403 forbidden。

4.1 filter過濾器

Filter過濾器,和上面閘道器使用一樣,攔截客戶的HttpServletRequest。

public class AuthorizationFilter implements Filter {

    @Autowired
    private FeignAuthClient feignAuthClient;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        logger.info("過濾器正在執行...");
        // pass the request along the filter chain
        String userId = ((HttpServletRequest) servletRequest).getHeader(SecurityConstants.USER_ID_IN_HEADER);

        if (StringUtils.isNotEmpty(userId)) {
            UserContext userContext = new UserContext(UUID.fromString(userId));
            userContext.setAccessType(AccessType.ACCESS_TYPE_NORMAL);

            List<Permission> permissionList = feignAuthClient.getUserPermissions(userId);
            List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
            for (Permission permission : permissionList) {
                SimpleGrantedAuthority authority = new SimpleGrantedAuthority();
                authority.setAuthority(permission.getPermission());
                authorityList.add(authority);
            }

            CustomAuthentication userAuth  = new CustomAuthentication();
            userAuth.setAuthorities(authorityList);
            userContext.setAuthorities(authorityList);
            userContext.setAuthentication(userAuth);
            SecurityContextHolder.setContext(userContext);
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }
    
	//...
}
複製程式碼

上述程式碼主要實現了,根據請求頭中的userId,利用feign client獲取auth服務中的該user所具有的許可權集合。之後構造了一個UserContext,UserContext是自定義的,實現了Spring Security的UserDetails, SecurityContext介面。

4.2 通過切面來實現@PreAuth註解

基於Spring的專案,使用Spring的AOP切面實現註解是比較方便的一件事,這邊我們使用了自定義的註解@PreAuth

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuth {
    String value();
}
複製程式碼

Target用於描述註解的使用範圍,超出範圍時編譯失敗,可以用在方法或者類上面。在執行時生效。不瞭解註解相關知識的,可以自行Google。

@Component
@Aspect
public class AuthAspect {


    @Pointcut("@annotation(com.blueskykong.auth.demo.annotation.PreAuth)")
    private void cut() {
    }

    /**
     * 定製一個環繞通知,當想獲得註解裡面的屬性,可以直接注入該註解
     *
     * @param joinPoint
     * @param preAuth
     */
    @Around("cut()&&@annotation(preAuth)")
    public Object record(ProceedingJoinPoint joinPoint, PreAuth preAuth) throws Throwable {
		//取出註解中的表示式
        String value = preAuth.value();
        //Spring EL 對value進行解析
        SecurityExpressionOperations operations = new CustomerSecurityExpressionRoot(SecurityContextHolder.getContext().getAuthentication());
        StandardEvaluationContext operationContext = new StandardEvaluationContext(operations);
        ExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression(value);
        //獲取表示式判斷的結果
        boolean result = expression.getValue(operationContext, boolean.class);
        if (result) {
        	//繼續執行介面內的方法
            return joinPoint.proceed();
        }
        return "Forbidden";
    }
}
複製程式碼

因為Aspect作用在bean上,所以先用Component把這個類新增到容器中。@Pointcut定義要攔截的註解。@Around定製一個環繞通知,當想獲得註解裡面的屬性,可以直接注入該註解。切面表示式內主要實現了,利用Spring EL對value進行解析,將SecurityContextHolder.getContext()轉換成標準的操作上下文,然後解析註解中的表示式,最後獲取對錶達式判斷的結果。

public class CustomerSecurityExpressionRoot extends SecurityExpressionRoot {

    public CustomerSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }
}
複製程式碼

CustomerSecurityExpressionRoot繼承的是抽象類SecurityExpressionRoot,而我們用到的實際表示式是定義在SecurityExpressionOperations介面,SecurityExpressionRoot又實現了SecurityExpressionOperations介面。不過這裡面的具體判斷實現,Spring Security 呼叫的也是Spring EL。

4.3 controller介面

下面我們看看最終介面是怎麼用上面實現的註解。

    @RequestMapping(value = "/test", method = RequestMethod.GET)
    @PreAuth("hasAuthority('CREATE_COMPANY')") // 還可以定義很多表示式,如hasRole('Admin')
    public String test() {
        return "ok";
    }
複製程式碼

@PreAuth中,可以定義的表示式很多,可以看SecurityExpressionOperations介面中的方法。目前筆者只是實現了hasAuthority()表示式,如果你想支援其他所有表示式,只需要構造相應的SecurityContextHolder即可。

4.4 為什麼這樣設計?

有些讀者看了上面的設計,既然好多用到了Spring Security的工具類,肯定會問,為什麼要引入這麼複雜的工具類?

其實很簡單,首先因為SecurityExpressionOperations介面中定義的表示式足夠多,且較為合理,能夠覆蓋我們在平時用到的大部分場景;其次,筆者之前的設計是直接在註解中指定所需許可權,沒有擴充套件性,且可讀性查;最後,Spring Security 4 確實引入了@PreAuthorize,@PostAuthorize等註解,本來想用來著,自己嘗試了一下,發現對於微服務架構這樣的介面級別的操作許可權校驗不是很適合,十多個過濾器太過複雜,而且還涉及到的Principal、Credentials等資訊,這些已經在auth系統實現了身份合法性校驗。筆者認為這邊的功能實現並不是很複雜,需要很輕量的實現,讀者有興趣可以試著這部分的實現封裝成jar包或者Spring Boot的starter。

5. 總結

如上,首先講了整合的設計思路,主要包含三個服務:gateway、auth和backend demo。整合的專案,總體比較複雜,其中gateway服務擴充了好多內容,對於暴露的介面進行路由轉發,這邊引入了Spring Security 的starter,配置資源伺服器對暴露的路徑進行放行;對於其他介面需要呼叫auth服務進行身份合法性校驗,保證到達backend的請求都是合法的或者公開的介面;auth服務在之前的基礎上,補充了role、permission、user相應的介面,供外部呼叫;backend demo是新起的服務,實現了介面級別的操作許可權的校驗,主要用到了自定義註解和Spring AOP切面。

由於實現的細節實在有點多,本文限於篇幅,只對部分重要的實現進行列出與講解。如果讀者有興趣實際的應用,可以根據實際的業務進行擴增一些資訊,如auth授權的token、閘道器攔截請求構造的頭部資訊、註解支援的表示式等等。

可以優化的地方當然還有很多,整合專案中設計不合理的地方,各位同學可以多多提意見。

推薦閱讀

  1. 微服務閘道器netflix-zuul
  2. 認證鑑權與API許可權控制在微服務架構中的設計與實現(一)
  3. 認證鑑權與API許可權控制在微服務架構中的設計與實現(二)
  4. 認證鑑權與API許可權控制在微服務架構中的設計與實現(三)
  5. 認證鑑權與API許可權控制在微服務架構中的設計與實現(四)

原始碼

閘道器、auth許可權服務和backend服務的整合專案地址為:
GitHub:https://github.com/keets2012/microservice-integration
或者 碼雲:https://gitee.com/keets/microservice-integration

訂閱最新文章,歡迎關注我的公眾號

微信公眾號

相關文章