为什么要这样做?
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 |