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、小結
從上面的介面測試截圖中可以看出,此方案符合我們設計之初要實現的業務場景。