Shiro實現用戶對動態資源細粒度的許可權校驗

  • 2019 年 10 月 3 日
  • 筆記

前言

在實際系統應用中,普遍存在這樣的一種業務場景,需要實現用戶對要訪問的資源進行動態許可權校驗。
譬如,在某平台的商家系統中,存在商家、品牌、商品等業務資源。它們之間的關係為:一個商家可以擁有多個品牌,一個品牌下可以擁有多個商品。

一個商家用戶可以擁有多個賬戶,每個賬戶擁有不同級別的許可權。
例如,小王負責商家A下的所有資源的運營工作,小張負責品牌A和品牌A下所有商品的運營工作。而小李負責品牌B

Shiro本身提供了RequiresAuthentication、RequiresPermissions和RequiresRoles等註解用於實現靜態許可權認證,
但不適合對於這種細粒度的動態資源的許可權認證校驗。基於以上描述,這篇文章就是補充了一種對細粒度動態資源的訪問許可權校驗。

大概的設計思路

  • 1.新增一個自定義註解Permitable,用於將資源轉換為shiro的許可權表示字元串(支援SpEL表達式)
  • 2.新增加一個AOP切面,用於將自定義註解標註的方法和Shiro許可權校驗關聯起來
  • 3.校驗當前用戶是否擁有足夠的許可權去訪問受保護的資源

編碼實現

  • 1、新建PermissionResolver介面
import java.util.Collections;  import java.util.List;  import java.util.Optional;    import static java.util.stream.Collectors.toList;    /**   * 資源許可權解析器   *   * @author wuyue   * @since 1.0, 2019-09-07   */  public interface PermissionResolver {        /**       * 解析資源       *       * @return 資源的許可權表示字元串       */      String resolve();        /**       * 批量解析資源       */      static List<String> resolve(List<PermissionResolver> list) {          return Optional.ofNullable(list).map(obj -> obj.stream().map(PermissionResolver::resolve).collect(toList()))                  .orElse(Collections.emptyList());      }    }
  • 2、新增業務資源實體類,並實現PermissionResolver介面,此處以商品資源為例,例如新建Product.java
import com.wuyue.shiro.shiro.PermissionResolver;  import lombok.Getter;  import lombok.Setter;  import lombok.ToString;  import org.hibernate.annotations.GenericGenerator;    import javax.persistence.*;  import java.util.Date;    @Getter  @Setter  @ToString  @Entity  @Table(name = "product")  public class Product implements PermissionResolver {        @Override      public String resolve() {          return merchantId + ":" + brandId + ":" + id;      }        @Id      @GenericGenerator(name = "idGen", strategy = "uuid")      @GeneratedValue(generator = "idGen")      private String id;        @Column(name = "merchant_id")      private String merchantId;        @Column(name = "brand_id")      private String brandId;        @Column(name = "name")      private String name;        @Column(name = "create_time")      private Date createTime;        @Column(name = "update_time")      private Date updateTime;    }
  • 3、新增自定義註解Permitable
import java.lang.annotation.*;    /**   * 自定義細粒度許可權校驗註解,配合SpEL表達式使用   */  @Target(ElementType.METHOD)  @Retention(RetentionPolicy.RUNTIME)  @Documented  public @interface Permitable {        /**       * 前置校驗資源許可權表達式       *       * @return 資源的許可權字元串表示(如「字節跳動」下的「抖音」可以表達為BYTE_DANCE:TIK_TOK)       */      String pre() default "";        /**       * 後置校驗資源許可權表達式       *       * @return       */      String post() default "";    }
  • 4、新增許可權校驗切面
import lombok.extern.slf4j.Slf4j;  import org.aopalliance.intercept.MethodInterceptor;  import org.apache.commons.lang3.StringUtils;  import org.apache.shiro.SecurityUtils;  import org.springframework.aop.support.StaticMethodMatcherPointcutAdvisor;  import org.springframework.context.expression.MethodBasedEvaluationContext;  import org.springframework.core.DefaultParameterNameDiscoverer;  import org.springframework.core.annotation.AnnotationUtils;  import org.springframework.expression.EvaluationContext;  import org.springframework.expression.spel.standard.SpelExpressionParser;    import java.lang.annotation.Annotation;  import java.lang.reflect.Method;  import java.util.List;    /**   * 靜態自定義許可權認證切面   */  @Slf4j  public class PermitAdvisor extends StaticMethodMatcherPointcutAdvisor {        private static final Class<? extends Annotation>[] AUTHZ_ANNOTATION_CLASSES =              new Class[] {                      Permitable.class              };        public PermitAdvisor(SpelExpressionParser parser) {          // 構造一個通知,當方法上有加入Permitable註解時,會觸發此通知執行許可權校驗          MethodInterceptor advice = mi -> {              Method method = mi.getMethod();              Object targetObject = mi.getThis();              Object[] args = mi.getArguments();              Permitable permitable = method.getAnnotation(Permitable.class);              // 前置許可權認證              checkPermission(parser, permitable.pre(), method, args, targetObject, null);              Object proceed = mi.proceed();              // 後置許可權認證              checkPermission(parser, permitable.post(), method, args, targetObject, proceed);              return proceed;          };          setAdvice(advice);      }        /**       * 匹配加了Permitable註解的方法,用於通知許可權校驗       */      @Override      public boolean matches(Method method, Class<?> targetClass) {          Method m = method;            if (isAuthzAnnotationPresent(m)) {              return true;          }          return false;      }        private boolean isAuthzAnnotationPresent(Method method) {          for (Class<? extends Annotation> annClass : AUTHZ_ANNOTATION_CLASSES) {              Annotation a = AnnotationUtils.findAnnotation(method, annClass);              if ( a != null ) {                  return true;              }          }          return false;      }        /**       * 動態許可權認證       */      private void checkPermission(SpelExpressionParser parser, String expr,                                   Method method, Object[] args, Object target, Object result){            if (StringUtils.isBlank(expr)){              return;          }            // 解析SpEL表達式,獲得資源的許可權表示字元串          Object resources = parser.parseExpression(expr)                  .getValue(createEvaluationContext(method, args, target, result), Object.class);            // 調用Shiro進行許可權校驗          if (resources instanceof String) {              SecurityUtils.getSubject().checkPermission((String) resources);          } else if (resources instanceof List){              List<Object> list = (List) resources;              list.stream().map(obj -> (String) obj).forEach(SecurityUtils.getSubject()::checkPermission);          }      }        /**       * 構造SpEL表達式上下文       */      private EvaluationContext createEvaluationContext(Method method, Object[] args, Object target, Object result) {          MethodBasedEvaluationContext evaluationContext = new MethodBasedEvaluationContext(                  target, method, args, new DefaultParameterNameDiscoverer());          evaluationContext.setVariable("result", result);          try {              evaluationContext.registerFunction("resolve", PermissionResolver.class.getMethod("resolve", List.class));          } catch (NoSuchMethodException e) {              log.error("Get method error:", e);          }          return evaluationContext;      }    }
  • 5、實現對用戶的授權
    /**       * 授權       */      @Override      protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {          Map<String, Object> principal = (Map<String, Object>) principals.getPrimaryPrincipal();          String accountId = (String) principal.get("accountId");            // 擁有的商家資源許可權          List<AccountMerchantLink> merchantLinks = accountService.findMerchantLinks(accountId);          Set<String> merchantPermissions = merchantLinks.stream().map(AccountMerchantLink::getMerchantId).collect(toSet());          SimpleAuthorizationInfo authzInfo = new SimpleAuthorizationInfo();          authzInfo.addStringPermissions(merchantPermissions);            // 擁有的品牌資源許可權          List<AccountBrandLink> brandLinks = accountService.findBrandLinks(accountId);          Set<String> brandPermissions = brandLinks.stream().map(link -> link.getMerchantId() + ":" + link.getBrandId()).collect(toSet());          authzInfo.addStringPermissions(brandPermissions);            return authzInfo;      }
  • 6、自定義註解的應用

    6.1、根據id獲取商家資訊

      @Permitable(pre = "#id")    @Override    public Optional<Merchant> findById(String id) {        if (StringUtils.isBlank(id)) {            return Optional.empty();        }        return merchantDao.findById(id);    }

    6.2、根據id獲取商品資訊

      @Permitable(post = "#result?.get().resolve()")    @Override    public Optional<Product> findById(String id) {        if (StringUtils.isBlank(id)) {            return Optional.empty();        }        return productDao.findById(id);    }

    6.3、查找品牌下的商品列表

      @Permitable(post = "#resolve(#result)")    @Override    public List<Product> findByBrandId(String brandId) {        if (StringUtils.isBlank(brandId)) {            return Collections.emptyList();        }        return productDao.findByBrandId(brandId);    }
  • 7、測試

7.1、按照上面描述的業務場景,準備3個用戶數據

7.2、使用小王登錄後測試

7.2.1、獲取商家資訊(擁有許可權)

7.2.2、獲取商品資訊(擁有許可權)

7.3、使用小李登錄後測試

7.3.1、獲取商家資訊(許可權不足)

7.3.2、獲取商品資訊(許可權不足)

7.3.3、獲取商品資訊(擁有許可權)

7.4、小結

從上面的介面測試截圖中可以看出,此方案符合我們設計之初要實現的業務場景。

完整源碼