Loading

SpringSecurity系列学习(六):基于RBAC的授权

系列导航

SpringSecurity系列

SpringSecurityOauth2系列

基于RBAC的授权

代码参考

有的小伙伴一看到标题就有点麻,说好的授权,怎么就基于RBAC了呢?啥是RBAC啊?为什么要基于它啊?

先莫慌,我们首先来看一看啥是RBAC

RBAC

RBAC是基于角色的访问控制(Role-Based Access Control )在RBAC中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。这样管理都是层级相互依赖的,权限赋予给角色,而把角色又赋予用户,这样的权限设计很清楚,管理起来很方便。

管理员会设置一个角色应该拥有的访问权限,以及哪些用户被分配到哪些角色。一个用户有可能被分配到多个角色,使他们可以访问许多不同的资源

企业级应用中RBAC非常广泛,因为有了RBAC ,不需要在每次一个人离开组织或更换工作时都进行更改:可以简单地从角色组中删除或分配到一个新的角色。

新员工可以根据他们所履行的组织角色,相对快速地被授予访问权限。

角色可以被理解为权限的集合;一个用户可有拥有多个角色

这个模型是简化的模型,在此基础之上还可以进行复杂话,比如添加一个组的概念等.具体的模型需要通过业务逻辑去设计。

RBAC 又分为RBAC0、RBAC1、RBAC2、RBAC3

  • RBAC0:是RBAC的核心思想。
  • RBAC1:是把RBAC的角色分层模型。
  • RBAC2:增加了RBAC的约束模型。
  • RBAC3:其实是RBAC2 + RBAC1。

具体细节就不做探讨了,这种属于业务范畴,也是最难的部分,篇幅有限,还是先把基础技术搞明白了,再讨论其他的吧。

可以复杂化模型,那么可以再简化模型吗?

当然是可以的,大部分系统其实到角色这一层就可以了,其原子权限是不需要的。什么时候才需要原子权限呢?即权限划分的粒度非常小的时候,比如:一个角色user,只能对某个数据进行查询,不能进行增删改,这个时候就需要原子权限了。

基于SpringSecurity

在SpringSecurity中,有两个概念:RoleAuthority

当我们在写安全表达式的时候,写hasRole()就有一个ROLE_前缀,写hasAuthority()就不需要,这说明SS其实是把Authority当成了一个更为灵活选项。Authority没有一个语义上的限制。

我们可以把Authority当作一个原子权限,然后把Role当作一个原子权限的组合

动态配置权限的概念

在RBAC中,动态配置权限的概念是 权限的组合 动态配置给谁

如果有更为复杂的场景,也就是除了原子这一级的权限,其他的权限的自由组合动态分配

因为不管你怎么动态分配,在最后的最后,你还是需要一个固定的原子权限去决定你的组合能够去访问什么资源。

动态组合之后,你需要去限制他们去访问什么资源,所以固定的原子权限是必不可少的,也就是事先划分好的权限。

SpringSecurity中内建的角色层级划分

这几个角色通过包含的关系来实现角色的层级

如果我们想自定义角色的层级该怎么做呢?

通过SpringSecurity提供的RoleHierarchyVoter

    @Bean
    public RoleHierarchy roleHierarchy(){
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        //ADMIN 包含 USER 的权限
        roleHierarchy.setHierarchy("ADMIN > USER");
        return roleHierarchy;
    }

只要将其做成一个Bean即可,比如我们想实现上图的关系,可以通过一个表达式去设定ROLE_ADMIN > ROLE_MANAGER\nROLE_MANAGER > ROLE_USER结果入上图,即ROLE_ADMIN包含ROLE_MANAGERROLE_MANAGER包含ROLE_USER

ps:在SpringSecurity 5.2开始,使用\n进分割

建立RBAC数据库

角色和权限是多对多的关系

用户和角色是多对多的关系

也就是说至少得有5张表

其模型如下

DDL如下:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for ss_authority
-- ----------------------------
DROP TABLE IF EXISTS `ss_authority`;
CREATE TABLE `ss_authority` (
  `id` int(11) NOT NULL COMMENT '主键',
  `parent_id` int(11) DEFAULT NULL COMMENT '父权限id',
  `name` varchar(255) NOT NULL COMMENT '权限名称',
  `desc` varchar(255) DEFAULT NULL COMMENT '权限描述',
  `resource` varchar(255) DEFAULT NULL COMMENT '权限类型。0:菜单,1:接口',
  `type` int(1) NOT NULL COMMENT '权限类型。0:菜单,1:组件',
  `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='权限表';

-- ----------------------------
-- Records of ss_authority
-- ----------------------------
BEGIN;
INSERT INTO `ss_authority` VALUES (1, NULL, '用户菜单', '用户菜单', NULL, 0, '2021-08-23 16:15:15');
INSERT INTO `ss_authority` VALUES (101, 1, '菜单1', '菜单1', NULL, 0, '2021-08-23 16:15:36');
INSERT INTO `ss_authority` VALUES (102, 1, '菜单2', '菜单2', NULL, 0, '2021-08-23 16:15:54');
INSERT INTO `ss_authority` VALUES (10101, 101, '问好', '菜单1功能:问好', 'api:hello', 1, '2021-08-23 16:18:01');
INSERT INTO `ss_authority` VALUES (10201, 102, '用户名', '菜单2功能:输出用户名', 'user:name', 1, '2021-08-23 17:02:08');
COMMIT;

-- ----------------------------
-- Table structure for ss_authority_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `ss_authority_role_rel`;
CREATE TABLE `ss_authority_role_rel` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `authority_id` int(11) NOT NULL COMMENT '权限id',
  `role_id` int(11) NOT NULL COMMENT '角色id',
  PRIMARY KEY (`id`,`authority_id`,`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8 COMMENT='权限—角色关联表';

-- ----------------------------
-- Records of ss_authority_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `ss_authority_role_rel` VALUES (1, 1, 1);
INSERT INTO `ss_authority_role_rel` VALUES (2, 101, 1);
INSERT INTO `ss_authority_role_rel` VALUES (3, 102, 1);
INSERT INTO `ss_authority_role_rel` VALUES (4, 10101, 1);
INSERT INTO `ss_authority_role_rel` VALUES (5, 10201, 1);
INSERT INTO `ss_authority_role_rel` VALUES (6, 1, 2);
INSERT INTO `ss_authority_role_rel` VALUES (7, 101, 2);
INSERT INTO `ss_authority_role_rel` VALUES (8, 10101, 2);
COMMIT;

-- ----------------------------
-- Table structure for ss_role
-- ----------------------------
DROP TABLE IF EXISTS `ss_role`;
CREATE TABLE `ss_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL COMMENT '角色名',
  `desc` varchar(255) DEFAULT NULL COMMENT '角色描述',
  `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='角色表';

-- ----------------------------
-- Records of ss_role
-- ----------------------------
BEGIN;
INSERT INTO `ss_role` VALUES (1, 'ADMIN', '超级管理员', '2021-08-23 16:08:02');
INSERT INTO `ss_role` VALUES (2, 'USER', '用户', '2021-08-23 16:08:02');
COMMIT;

-- ----------------------------
-- Table structure for ss_user
-- ----------------------------
DROP TABLE IF EXISTS `ss_user`;
CREATE TABLE `ss_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL COMMENT '用户名',
  `password` varchar(255) NOT NULL COMMENT '密码',
  `status` int(4) DEFAULT NULL COMMENT '状态',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;

-- ----------------------------
-- Records of ss_user
-- ----------------------------
BEGIN;
INSERT INTO `ss_user` VALUES (1, 'user', '{bcrypt}$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm', 1);
INSERT INTO `ss_user` VALUES (2, 'test', '{bcrypt}$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm', 1);
COMMIT;

-- ----------------------------
-- Table structure for ss_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `ss_user_role_rel`;
CREATE TABLE `ss_user_role_rel` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `rid` int(11) NOT NULL COMMENT '角色表id',
  `uid` int(11) NOT NULL COMMENT '用户表id',
  PRIMARY KEY (`id`,`rid`,`uid`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='用户-角色关联表';

-- ----------------------------
-- Records of ss_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `ss_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `ss_user_role_rel` VALUES (2, 2, 1);
INSERT INTO `ss_user_role_rel` VALUES (3, 2, 2);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;

SpringSecurity中的RBAC

我们首先将数据库的实体类定义出来

实体类

权限类

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.security.core.GrantedAuthority;

/**
 * 权限表实体类
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Data
@Accessors(chain = true)
@TableName(value = "ss_authority")
public class Authority implements GrantedAuthority {
    private static final long serialVersionUID = -8770868540836182134L;

    /**
     * 权限id
     */
    @TableId
    private Integer id;

    /**
     * 父权限id
     */
    private Integer parentId;

    /**
     * 权限名称
     */
    private String name;

    /**
     * 权限描述
     */
    private String desc;

    /**
     * 权限资源,当type为1时有值
     */
    @TableField(value = "resource")
    private String authority;

    /**
     * 权限类型:0 菜单;1 接口权限
     */
    private Integer type;

    @Override
    public String getAuthority() {
        return this.authority;
    }
}

这里的权限类实现了GrantedAuthority接口,这个接口就是SpringSecurity的投票器所需要的权限列表,投票器通过这个接口的String getAuthority()方法获取权限信息

这个权限类也就是之前ClaimInfo荷载中的权限,我们在创建JWT时,需要将用户的权限配置进去

角色类

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 角色表
 * @author 硝酸铜
 * @date 2021/9/22
 */
@TableName(value = "ss_role")
@Accessors(chain = true)
@Data
public class Role implements Serializable {

    private static final long serialVersionUID = 8444473027670783298L;

    @TableId
    private Integer id;

    /**
     * 角色名
     */
    private String name;

    /**
     * 角色描述
     */
    private String desc;

    /**
     * 审计字段,创建时间
     */
    private LocalDateTime createAt;
}

用户类

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import lombok.experimental.Accessors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * 用户表
 * @author 硝酸铜
 * @date 2021/9/22
 */
@AllArgsConstructor
@NoArgsConstructor
@With
@Data
@Accessors(chain = true)
@TableName(value = "ss_user")
@Builder
public class User implements UserDetails {

    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 状态
     */
    private Integer status;

    /**
     * 角色
     */
    @TableField(exist = false)
    private List<Authority> authorities = new ArrayList<>();


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
    
    @Override
    public String getPassword() {
        return password;
    }
    
    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    
    @Override
    public boolean isEnabled() {
        return true;
    }
}

注意,这里的用户类实现了UserDetails接口,这个接口是SpringSecurity的用户接口,如还记得分析认证流程的内容吗?有一个流程:

如果是数据库认证的AuthenticationProvider来说,就会去调用UserDetailsService去获取用户信息进行认证。

这里UserDetailsService所获取到的用户信息就是UserDetails接口的实现类

Service

看到用户类实现了UserDetails接口,就知道我们还需要去实现UserDetailsService接口,完成从数据库获取用户信息的逻辑,UserDetailsService接口调用通过UserDetails loadUserByUsername(String s) 方法获取用户信息

@Service
public class UserDetailsServiceImpl extends ServiceImpl<UserMapper, User> implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return baseMapper.findByUsername(s)
                .orElseThrow(() -> new UsernameNotFoundException("未找到用户名为 " + s + "的用户"));
    }
}

这里我用的ORM框架是Mybatis-plus,其mapper定义如下:

public interface UserMapper extends BaseMapper<User> {
    @Select("select * from ss_user where username=#{username}")
    @Results({
            @Result(id = true, property = "id", column = "id"),
            @Result(property = "authorities", column = "id", javaType = List.class,
                    many = @Many(select = "com.cupricnitrate.mapper.AuthorityMapper.findByUid"))
    })
    Optional<User> findByUsername(String username);
}


public interface AuthorityMapper extends BaseMapper<Authority> {

    /**
     * 根据 用户id 查询权限
     * @param uid
     * @return
     */
			@Select("SELECT a.*, a.resource as 'authority'\n" +
            "FROM ss_user u LEFT JOIN ss_user_role_rel ur ON u.id = ur.uid\n" +
            "LEFT JOIN ss_role r ON ur.rid = r.id\n" +
            "LEFT JOIN ss_authority_role_rel ar ON r.id = ar.role_id\n" +
            "LEFT JOIN ss_authority a ON ar.authority_id = a.id\n" +
            "WHERE\n" +
            "u.id = #{uid}\n" +
            "and a.type = 1\n" +
            "GROUP BY a.id")
    List<Authority> findByUid(Integer uid);
}

最后实现的效果就是能够通过用户名,查询到用户信息,用户信息包括了权限,权限是通过用户-角色,再通过角色-权限去查询的

安全配置

从数据库中读取用户信息的逻辑已经完成了,怎么将这个逻辑配置到SpringSecurity呢?

在安全配置中重写protected void configure(AuthenticationManagerBuilder auth)方法即可

@EnableWebSecurity(debug = true)
@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    ...

    @Resource
    private UserDetailsServiceImpl userDetailsService;
    
    @Resource
    private UserDetailsPasswordSerivceImpl userDetailsPasswordSerivce;
  
    @Resource
    private PasswordEncoder passwordEncoder;
  ...
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider());
    }
  
    /**
     * 配置 DaoAuthenticationProvider
     * @return DaoAuthenticationProvider
     */
    private DaoAuthenticationProvider daoAuthenticationProvider(){
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        // 配置 AuthenticationManager 使用 userService
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        // 配置密码自动升级服务
        daoAuthenticationProvider.setUserDetailsPasswordService(userDetailsPasswordSerivce);
        // 密码编码器
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
        return daoAuthenticationProvider;
    }


    @Bean
    public PasswordEncoder passwordEncoder(){

        //默认编码算法的Id,新的密码编码都会使用这个id对应的编码器
        String idForEncode = "bcrypt";
        //要支持的多种编码器
        //举例:历史原因,之前用的SHA-1编码,现在我们希望新的密码使用bcrypt编码
        //老用户使用SHA-1这种老的编码格式,新用户使用bcrypt这种编码格式,登录过程无缝切换
        Map encoders = new HashMap();
        encoders.put(idForEncode,new BCryptPasswordEncoder());

        //(默认编码器id,编码器map)
        return new DelegatingPasswordEncoder(idForEncode,encoders);
    }
}

看完代码,小伙伴们有麻了,那个密码自动升级服务时啥啊?之前你也妹说有这个玩意儿呀?

别急别急,这就说!

之前在讲密码验证的时候,我们说过:

那么问题来了,要是我们不想使用旧的编码格式了,就要使用新的,可以将老的密码迁移到新的编码格式下面吗?

答案肯定是可以的,使用UserDetailsPasswordService中提供的updatePassword方法,这个后面会说明,这里不深入。

这里我们深入一下啊

密码编码器可以设定多种编码格式,以达到新老编码格式都能够匹配到密码。解决了老数据库中,密码使用的编码系统方法较老,但是随着着计算能力的发展,如果不迁移,老的编码系统很容易就会被破解,所以迁移到更安全的编码标准之上是一个必要的过程。

UserDetailsPasswordService提供的updatePassword(),其主要作用就是将密码中老的编码格式变成新的编码格式。这个就能解决密码编码格式的迁移

@Service
public class UserDetailsPasswordSerivceImpl implements UserDetailsPasswordService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails updatePassword(UserDetails userDetails, String newPasswordHash) {
        return userMapper.findByUsername(userDetails.getUsername())
                .map(user -> {
                    //是密码经过默认的编码器编码后的哈希值
                    userMapper.updateById(user.withPassword(newPasswordHash));
                    return (UserDetails) user;
                })
                .orElse(userDetails);
    }
}

这里的newPasswordHash时已经经过默认的编码器编码后的哈希值,只要更新一下数据库即可

定义接口

我们定义两个接口,测试一下

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

/**
 * @author 硝酸铜
 * @date 2021/6/2
 */
@RestController
@RequestMapping(value = "/api")
public class UserResource {

    @PreAuthorize("hasAuthority('api:hello')")
    @GetMapping(value = "/hello")
    public String getHello(){
        return "Hello ";
    }

    @PreAuthorize("hasAuthority('user:name')")
    @GetMapping(value = "/users")
    public String getCurrentUsername(){
        return "Hello " + SecurityContextHolder.getContext().getAuthentication().getName();
    }

}

先进行认证:

解析accessToken

调用接口:

test用户只能访问/api/hello接口:

test用户不能访问/api/users接口:

总结

到目前为止,一个简单的RBAC权限控制服务demo就结束了,通过这个简单的demo,我们学习了SpringSecurity的相关源码,JWT相关的知识,简单的RBAC等,也就是说,在技术层面上来说,单体应用的权限控制已经难不倒我们了。

为什么说是技术层面上来说呢?因为其实权限控制最难的部分在于业务上的设计,根据需求设计一个好的RBAC架构才是真正的难点,这方面的学习我们这个技术文就不多做赘述了,有兴趣的同学可以谷歌学习一波。

其实不论是看项目案例还是写demo,到最后还是要落实在项目中,实践才是检验真理的唯一标准。

posted @ 2021-09-27 16:46  硝酸铜  阅读(2425)  评论(0编辑  收藏  举报