3個註解,優雅的實現微服務鑑權

2022-05-31 18:01:00

這是《Spring Cloud 進階》第39篇文章,前面的文章中介紹了閘道器整合Spring Security實現閘道器層面的統一的認證鑑權。

有不清楚的可以看之前的文章:實戰乾貨!Spring Cloud Gateway 整合 OAuth2.0 實現分散式統一認證授權!

最近訂閱了《Spring Cloud Alibaba 實戰》視訊專欄的讀者經常問陳某兩個問題,如下:

  1. 鑑權放在各個微服務中如何做?
  2. feign的呼叫如何做到的鑑權?

今天針對以上兩個問題深入聊聊如何通過三個註解解決。

實現思路

前面的幾篇文章陳某都是將鑑權和認證統一的放在了閘道器層面,架構如下:

微服務中的鑑權還有另外一種思路:將鑑權交給下游的各個微服務,閘道器層面只做路由轉發

這種思路其實實現起來也是很簡單,下面針對閘道器層面鑑權的程式碼改造一下即可完成:實戰乾貨!Spring Cloud Gateway 整合 OAuth2.0 實現分散式統一認證授權!

1. 幹掉鑑權管理器

在閘道器統一鑑權實際是依賴的鑑權管理器ReactiveAuthorizationManager,所有的請求都需要經過鑑權管理器的去對登入使用者的許可權進行鑑權。

這個鑑權管理器在閘道器鑑權的文章中也有介紹,在陳某的《Spring Cloud Alibaba 實戰》中設定攔截也很簡單,如下:

除了設定的白名單,其他的請求一律都要被閘道器的鑑權管理器攔截鑑權,只有鑑權通過才能放行路由轉發給下游服務。

看到這裡思路是不是很清楚了,想要將鑑權交給下游服務,只需要在閘道器層面直接放行,不走鑑權管理器,程式碼如下:

http
	....
	//白名單直接放行
 	.pathMatchers(ArrayUtil.toArray(whiteUrls.getUrls(), String.class)).permitAll()
	//其他的任何請求直接放行
 	.anyExchange().permitAll()
	 .....

2. 定義三個註解

經過第①步,鑑權已經下放給下游服務了,那麼下游服務如何進行攔截鑑權呢?

其實Spring Security 提供了3個註解用於控制許可權,如下:

  1. @Secured
  2. @PreAuthorize
  3. @PostAuthorize

關於這三個註解就不再詳細介紹了,有興趣的可以去查閱官方檔案。

陳某這裡並不打算使用的內建的三個註解實現,而是自定義了三個註解,如下:

1.@RequiresLogin

見名知意,只有使用者登入才能放行,程式碼如下:

/**
 * @url: www.java-family.cn
 * @description 登入認證的註解,標註在controller方法上,一定要是登入才能的存取的介面
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequiresLogin {
}

2.@RequiresPermissions

見名知意,只有擁有指定許可權才能放行,程式碼如下:

/**
 * @url: www.java-family.cn
 * @description 標註在controller方法上,確保擁有指定許可權才能存取該介面
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequiresPermissions {
    /**
     * 需要校驗的許可權碼
     */
    String[] value() default {};

    /**
     * 驗證模式:AND | OR,預設AND
     */
    Logical logical() default Logical.AND;
}

3.@RequiresRoles

見名知意,只有擁有指定角色才能放行,程式碼如下:

/**
 * @url: www.java-family.cn
 * @description 標註在controller方法上,確保擁有指定的角色才能存取該介面
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequiresRoles {
    /**
     * 需要校驗的角色標識,預設超管和管理員
     */
    String[] value() default {OAuthConstant.ROLE_ROOT_CODE,OAuthConstant.ROLE_ADMIN_CODE};

    /**
     * 驗證邏輯:AND | OR,預設AND
     */
    Logical logical() default Logical.AND;
}

以上三個註解的含義想必都很好理解,這裡就不再解釋了....

3. 註解切面定義

註解有了,那麼如何去攔截呢?這裡陳某定義了一個切面進行攔截,關鍵程式碼如下:

/**
 * @url: www.java-family.cn
 * @description @RequiresLogin,@RequiresPermissions,@RequiresRoles 註解的切面
 */
@Aspect
@Component
public class PreAuthorizeAspect {
    /**
     * 構建
     */
    public PreAuthorizeAspect() {
    }

    /**
     * 定義AOP簽名 (切入所有使用鑑權註解的方法)
     */
    public static final String POINTCUT_SIGN = " @annotation(com.mugu.blog.common.annotation.RequiresLogin) || "
            + "@annotation(com.mugu.blog.common.annotation.RequiresPermissions) || "
            + "@annotation(com.mugu.blog.common.annotation.RequiresRoles)";

    /**
     * 宣告AOP簽名
     */
    @Pointcut(POINTCUT_SIGN)
    public void pointcut() {
    }

    /**
     * 環繞切入
     *
     * @param joinPoint 切面物件
     * @return 底層方法執行後的返回值
     * @throws Throwable 底層方法丟擲的異常
     */
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 註解鑑權
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        checkMethodAnnotation(signature.getMethod());
        try {
            // 執行原有邏輯
            Object obj = joinPoint.proceed();
            return obj;
        } catch (Throwable e) {
            throw e;
        }
    }

    /**
     * 對一個Method物件進行註解檢查
     */
    public void checkMethodAnnotation(Method method) {
        // 校驗 @RequiresLogin 註解
        RequiresLogin requiresLogin = method.getAnnotation(RequiresLogin.class);
        if (requiresLogin != null) {
            doCheckLogin();
        }

        // 校驗 @RequiresRoles 註解
        RequiresRoles requiresRoles = method.getAnnotation(RequiresRoles.class);
        if (requiresRoles != null) {
            doCheckRole(requiresRoles);
        }

        // 校驗 @RequiresPermissions 註解
        RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class);
        if (requiresPermissions != null) {
            doCheckPermissions(requiresPermissions);
        }
    }


    /**
     * 校驗有無登入
     */
    private void doCheckLogin() {
        LoginVal loginVal = SecurityContextHolder.get();
        if (Objects.isNull(loginVal))
            throw new ServiceException(ResultCode.INVALID_TOKEN.getCode(), ResultCode.INVALID_TOKEN.getMsg());
    }

    /**
     * 校驗有無對應的角色
     */
    private void doCheckRole(RequiresRoles requiresRoles){
        String[] roles = requiresRoles.value();
        LoginVal loginVal = OauthUtils.getCurrentUser();

        //該登入使用者對應的角色
        String[] authorities = loginVal.getAuthorities();
        boolean match=false;

        //and 邏輯
        if (requiresRoles.logical()==Logical.AND){
            match = Arrays.stream(authorities).filter(StrUtil::isNotBlank).allMatch(item -> CollectionUtil.contains(Arrays.asList(roles), item));
        }else{  //OR 邏輯
            match = Arrays.stream(authorities).filter(StrUtil::isNotBlank).anyMatch(item -> CollectionUtil.contains(Arrays.asList(roles), item));
        }

        if (!match)
            throw new ServiceException(ResultCode.NO_PERMISSION.getCode(), ResultCode.NO_PERMISSION.getMsg());
    }

    /**
     * TODO 自己實現,由於並未整合前端的選單許可權,根據業務需求自己實現
     */
    private void doCheckPermissions(RequiresPermissions requiresPermissions){

    }
}

其實這中間的邏輯非常簡單,就是解析的Token中的許可權、角色然後和註解中的指定的進行比對。

@RequiresPermissions這個註解的邏輯陳某並未實現,自己根據業務模仿著完成,算是一道思考題了....

4. 註解使用

比如《Spring Cloud Alibaba 實戰》專案中有一個新增文章的介面,只有超管和管理員的角色才能新增,那麼可以使用@RequiresRoles註解進行標註,如下:

@RequiresRoles
@AvoidRepeatableCommit
@ApiOperation("新增文章")
@PostMapping("/add")
public ResultMsg<Void> add(@RequestBody @Valid ArticleAddReq req){
	.......
}

效果這裡就不演示了,實際的效果:非超管和管理員角色使用者登入存取,將會直接被攔截,返回無許可權

注意:這裡僅僅解決了下游服務鑑權的問題,那麼feign呼叫是否也適用?

當然適用,這裡使用的是切面方式,feign內部其實使用的是http方式呼叫,對於介面來說一樣適用。

比如《Spring Cloud Alibaba 實戰》專案中獲取文章列表的介面,其中會通過feign的方式呼叫評論服務中的介面獲取文章評論總數,這裡一旦加上了@RequiresRoles,那麼呼叫將會失敗,程式碼如下:

@RequiresRoles
@ApiOperation(value = "批次獲取文章總數")
@PostMapping(value = "/list/total")
public ResultMsg<List<TotalVo>> listTotal(@RequestBody @Valid List<CommentListReq> param){
....
}

總結

本文主要介紹了微服務中如何將鑑權下放到微服務中,也是為了解決讀者的疑惑,實際生產中除非業務需要,陳某還是建議將鑑權統一放到閘道器中。