為什么要這樣做?
Security和Shiro相信大家都用過,常見的兩種權限框架,既然都是屬於權限框架,那么肯定都有自己的權限控制,為什么還要使用Security的同時去實現Shiro的權限控制呢?
由於新項目使用的是Security,於是去百度了一波詳解,知道了Security是使用@PreAuthorize注解來實現接口權限控制,
當我們在接口上加標注:@PreAuthorize時,流程大概是這樣:
先會去調用到User類的getAuthorities接口,取出authorities,類型為List<Role> ,然后調用每個Role實例的getAuthority接口,該接口返回Role名稱,比如“ADMIN”,只要其中某個Role返回了“ADMIN”,即可停止遍歷,表示當前用戶具備了訪問該接口的權限,放行。
這樣在數據庫里面就會有user、role、user_role表,分表存儲用戶信息、角色信息以及用戶和角色關聯(多對多)信息。如果每個接口對應一個role,那實際上作為角色的role在我們看來跟以往的權限(permission)對應了,即實際上是user和permission的關系,只是叫做role罷了。於是就會在所有具有ADMIN角色才能訪問的接口上加上標注:@PreAuthorize
這樣的話,我們在編寫接口代碼的時候,就要把這個標注寫上去,讓具備ADMIN角色的用戶可以訪問之,那如果某天我不想讓ADMIN用戶訪問這個接口呢,我該怎么辦?要么我需要回收該用戶的ADMIN角色,要么我得去修改接口標注。如果該角色確實只對應一個接口的權限,那回收倒是沒有問題,但Boss要你實現一個角色擁有多個權限,實際上會在多個接口上做了同樣的標注,回收角色后你會發現其他一些本來該用戶可以訪問的接口現在訪問不了了,頭大吧,想來想去你只能去改代碼了,去修改某個特定接口的標注。
於是乎,怎么辦,之前有用過Shiro做權限,覺得@RequiresPermissions注解很方便,它是針對菜單資源的的接口權限,並且還有Logical.AND(全部包含)和Logical.OR(任意包含)屬性,於是決定采用CV大法,實現一波~
Shiro的@RequiresPermissions的實現
ok,我們先來看看@RequiresPermissions的實現流程,所有標注了@RequiresPermissions注解的都會進到這里來校驗,不通過的話會拋出AuthorizationException異常
1 public void assertAuthorized(Annotation a) throws AuthorizationException {
2 if (!(a instanceof RequiresPermissions)) return; 3 4 RequiresPermissions rpAnnotation = (RequiresPermissions) a; 5 String[] perms = getAnnotationValue(a); 6 Subject subject = getSubject(); 7 8 if (perms.length == 1) { 9 subject.checkPermission(perms[0]); 10 return; 11 } 12 if (Logical.AND.equals(rpAnnotation.logical())) { 13 getSubject().checkPermissions(perms); 14 return; 15 } 16 if (Logical.OR.equals(rpAnnotation.logical())) { 17 // Avoid processing exceptions unnecessarily - "delay" throwing the exception by calling hasRole first 18 boolean hasAtLeastOnePermission = false; 19 for (String permission : perms) if (getSubject().isPermitted(permission)) hasAtLeastOnePermission = true; 20 // Cause the exception if none of the role match, note that the exception message will be a bit misleading 21 if (!hasAtLeastOnePermission) getSubject().checkPermission(perms[0]); 22 (這一行的作用貌似只是為了拋異常?) 23 24 } 25 }
順着往下看最終的調用都是implies方法
1 //visibility changed from private to protected per SHIRO-332
2 protected boolean isPermitted(Permission permission, AuthorizationInfo info) {
3 Collection<Permission> perms = getPermissions(info); 4 if (perms != null && !perms.isEmpty()) { 5 for (Permission perm : perms) { 6 if (perm.implies(permission)) { 7 return true; 8 } 9 } 10 } 11 return false; 12 }
拿當前登錄用戶的權限循環與@RequirePermissions中的注解對比
1 public boolean implies(Permission p) {
2 // By default only supports comparisons with other WildcardPermissions
3 if (!(p instanceof WildcardPermission)) { 4 return false; 5 } 6 7 WildcardPermission wp = (WildcardPermission) p; 8 9 List<Set<String>> otherParts = wp.getParts(); 10 11 int i = 0; 12 for (Set<String> otherPart : otherParts) { 13 // If this permission has less parts than the other permission, everything after the number of parts contained 14 // in this permission is automatically implied, so return true 15 if (getParts().size() - 1 < i) { 16 return true; 17 } else { 18 Set<String> part = getParts().get(i); 19 if (!part.contains(WILDCARD_TOKEN) && !part.containsAll(otherPart)) { 20 return false; 21 } 22 i++; 23 } 24 } 25 26 // If this permission has more parts than the other parts, only imply it if all of the other parts are wildcards 27 for (; i < getParts().size(); i++) { 28 Set<String> part = getParts().get(i); 29 if (!part.contains(WILDCARD_TOKEN)) { 30 return false; 31 } 32 } 33 34 return true; 35 }
代碼實現
既然知道了實現流程,那么我們開啟CV大法,自己實現一個。
首先,自定義注解@PermissionCheck(默認是全部包含),搞里頭~
1 // 標注這個類它可以標注的位置
2 @Target({ElementType.METHOD,ElementType.ANNOTATION_TYPE})
3 // 標注這個注解的注解保留時期
4 @Retention(RetentionPolicy.RUNTIME) 5 // 是否生成注解文檔 6 @Documented 7 public @interface PermissionCheck { 8 9 String[] value(); 10 11 Logical logical() default Logical.AND; 12 }
Logical枚舉類,搞里頭~
public enum Logical { AND, OR }
然后定義一個攔截器PermissionCheckAspect,搞里頭~
@Aspect
@Component
@Slf4j
public class PermissionCheckAspect { //切入點表達式決定了用注解方式的方法切還是針對某個路徑下的所有類和方法進行切,方法必須是返回void類型 @Pointcut(value = "@annotation(com.cn.tianxia.admin.base.annotation.PermissionCheck)") private void permissionCheckCut(){}; //定義了切面的處理邏輯。即方法上加了@PermissionCheck @Around("permissionCheckCut()") public Object around(ProceedingJoinPoint pjp) throws Throwable{ Signature signature = pjp.getSignature(); SecurityUser user = SecurityAuthorHolder.getSecurityUser(); //角色權限校驗 MethodSignature methodSignature = (MethodSignature)signature; Method targetMethod = methodSignature.getMethod(); if (targetMethod.isAnnotationPresent(PermissionCheck.class)){ //獲取方法上注解中表明的權限 PermissionCheck permission = targetMethod.getAnnotation(PermissionCheck.class); Logical logical = permission.logical(); //獲取權限注解value,可能有多個 String[] permissionArr = permission.value(); //取出用戶擁有的權限 List<String> permsList = user.getMenus().stream().map(SysMenu::getPerms).distinct().collect(Collectors.toList()); //取出permsList和permissionArr的交集 permsList.retainAll(Arrays.asList(permissionArr)); /** AND處理(完全包含) OR處理(任意包含)**/ if(Logical.AND.equals(logical)){ if(permsList.size() == permissionArr.length){ return pjp.proceed(); } }else{ if(permsList.size() > 0){ return pjp.proceed(); } } //非法操作 記錄日志信息 log.error("非法操作!當前接口請求的用戶={},訪問路徑={}",user.getLoginName(),Arrays.asList(permissionArr).toString()); } return RR.exception("無權調用接口!"); } }
使用
@PermissionCheck(value = {"user:info","user:edit"},logical = Logical.OR)
@PostMapping(value = "/getUserInfo", produces = BaseConsts.REQUEST_HEADERS_CONTENT_TYPE) @ApiOperation(value = "用戶管理-獲取用戶信息", notes = "用戶管理-獲取用戶信息", httpMethod = BaseConsts.REQUEST_METHOD, response = RR.class) public RR getUserInfo() throws Exception { ...... }
參考來源:https://www.nndev.cn/archives/869
總結 : 接口權限驗證無非就是過濾器和攔截器實現,由於定義攔截器是環切,也可以將@PermissionCheck定義在Service層,即Controller入口是一個,Service是動態的。
后續
2020-11-18更新
這幾天看了詳細解析Security的視頻,知道Security也是可以關於資源和角色去控制接口訪問權限的,哈哈哈哈哈,要使用一門技術還是要先去詳細的了解它啊,不然就重復造輪子了。
具體實現:在實現UserDetails的實體類中重寫getAuthorities就好了,根據自己的業務返回用戶對應的權限,然后通過 @PreAuthorize("hasAuthority('sys:user:page')") 注解去控制該用戶對指定接口的訪問權限,哦對了,要使用此注解需要在繼承 WebSecurityConfigurerAdapter 的Security核心配置類上加上 @EnableGlobalMethodSecurity(prePostEnabled = true)
SecurityConfig.java
@EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { ... }
SecurityUser.java
public class SecurityUser implements UserDetails { private static final long serialVersionUID = 2503461170316674714L; @ApiModelProperty(value = "用戶ID", name = "id",required = true) private Long id; @ApiModelProperty(value = "用戶名", name = "name",required = true) private String name; @ApiModelProperty(value = "登錄賬號", name = "loginName",required = true) private String loginName; @ApiModelProperty(value = "登錄賬號密碼", name = "password",required = true) private String password; @ApiModelProperty(value = "登錄賬號密碼加密鹽", name = "salt",required = true) private String salt; @ApiModelProperty(value = "用戶狀態:0-啟用 1-禁用", name = "status",required = true) private Integer status; @ApiModelProperty(value = "性別 0-男 1-女") private Integer sex; @ApiModelProperty(value = "年齡") private Integer age; @ApiModelProperty(value = "手機號") private String phone; @ApiModelProperty(value = "創建時間", name = "createTime",required = true) private Date createTime; @ApiModelProperty(value = "是否為首次登錄", name = "loginstatus",required = true) private Integer loginstatus; @ApiModelProperty(value = "用戶角色", name = "role",required = true) private SysRoleEntity role; /** * 當前用戶具備的菜單 */ private List<SysMenuEntity> menus; /** * 授權標識 */ private List<String> prems; @ApiModelProperty(value = "登錄令牌", name = "token",required = true) private String token; @Override public String getPassword() { return password; } @Override public String getUsername() { return loginName; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } /** * 將當前用戶的權限封裝成GrantedAuthority對象放入security中 * @return */ @Override public Collection<? extends GrantedAuthority> getAuthorities() { if(CollectionUtils.isEmpty(this.prems)) return null; return this.prems.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()); } }
SysUserController.java
@PreAuthorize("hasAuthority('sys:user:page')") @PostMapping(value = "/page", produces = BaseConsts.REQUEST_HEADERS_CONTENT_TYPE) @ApiOperation(value = "用戶管理-用戶列表", notes = "用戶管理-用戶列表", httpMethod = BaseConsts.REQUEST_METHOD, response = RR.class) public RR page(@Validated @RequestBody UserDTO dto) { ... }
如果一個接口對應多個權限的話,可以使用 and 、 or 或者 && 、 || 來拼接,達到Shiro的Logical.AND和Logical.OR的效果,並且Security支持表達式,更靈活更強大。
如: @PreAuthorize("hasAuthority('sys:user:page') and hasAuthority('sys:user:add')")
支持的表達式有:
表達式 |
描述 |
hasRole(role) |
當前用戶是否擁有指定角色。 |
hasAnyRole([role1,role2]) |
多個角色是一個以逗號進行分隔的字符串。如果當前用戶擁有指定角色中的任意一個則返回true。 |
hasAuthority(auth) |
等同於hasRole |
hasAnyAuthority([auth1,auth2]) |
等同於hasAnyRole |
Principle |
代表當前用戶的principle對象 |
authentication |
直接從SecurityContext獲取的當前Authentication對象 |
permitAll |
總是返回true,表示允許所有的 |
denyAll |
總是返回false,表示拒絕所有的 |
isAnonymous() |
當前用戶是否是一個匿名用戶 |
isRememberMe() |
表示當前用戶是否是通過Remember-Me自動登錄的 |
isAuthenticated() |
表示當前用戶是否已經登錄認證成功了。 |
isFullyAuthenticated() |
如果當前用戶既不是一個匿名用戶,同時又不是通過Remember-Me自動登錄的,則返回true。 |
hasIpAddress('192.168.1.0') |
請求發送的Ip匹配時返回true |