Spring Security----RBAC权限控制模型,和权限相关知识点整理

x33g5p2x  于2021-11-19 转载在 Spring  
字(12.8k)|赞(0)|评价(0)|浏览(679)

我们开发一个系统,必然面临权限控制的问题,即不同的用户具有不同的访问、操作、数据权限。形成理论的权限控制模型有:自主访问控制(DAC: Discretionary Access Control)、强制访问控制(MAC: Mandatory Access Control)、基于属性的权限验证(ABAC: Attribute-Based Access Control)等。最常被开发者使用也是相对易用、通用的就是RBAC权限模型(Role-Based Access Control),本文就将向大家介绍该权限模型。

RBAC权限模型简介

RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。模型中有几个关键的术语:

  • 用户:系统接口及功能访问的操作者
  • 权限:能够访问某接口或者做某操作的授权资格
  • 角色:具有一类相同操作权限的用户的总称

RBAC权限模型核心授权逻辑如下:

  • 某用户是什么角色?
  • 某角色具有什么权限?
  • 通过角色的权限推导用户的权限

RBAC的演化进程

用户与权限直接关联

想到权限控制,人们最先想到的一定是用户与权限直接关联的模式,简单地说就是:某个用户具有某些权限。如图:

  • 张三具有创建用户和删除用户的权限,所以他可能系统维护人员
  • 李四具有产品记录管理和销售记录管理权限,所以他可能是一个业务销售人员

这种模型能够清晰的表达用户与权限之间的关系,足够简单。但同时也存在问题:

  • 现在用户是张三、李四,以后随着人员增加,每一个用户都需要重新授权
  • 或者张三、李四离职,需要针对每一个用户进行多种权限的回收

一个用户拥有一个角色

在实际的团体业务中,都可以将用户分类。比如对于薪水管理系统,通常按照级别分类:经理、高级工程师、中级工程师、初级工程师。也就是按照一定的角色分类,通常具有同一角色的用户具有相同的权限。这样改变之后,就可以将针对用户赋权转换为针对角色赋权。因为角色少、权限多,所以基于角色管理权限,减少用户在授权与权限回收过程中的过多操作。

  • 一个用户有一个角色
  • 一个角色有多个操作(菜单)权限
  • 一个操作权限可以赋予多个角色

我们可以用下图中的数据库设计模型,描述这样的关系。

一个用户一个或多个角色

但是在实际的应用系统中,一个用户一个角色远远满足不了需求。如果我们希望一个用户既担任销售角色、又暂时担任副总角色。该怎么做呢?为了增加系统设计的适用性,我们通常设计:

  • 一个用户有一个或多个角色
  • 一个角色包含多个用户
  • 一个角色有多种权限
  • 一个权限可以赋予多个角色

我们可以用下图中的数据库设计模型,描述这样的关系。

  • sys_user是用户信息表,用于存储用户的基本信息,如:用户名、密码
  • sys_role是角色信息表,用于存储系统内所有的角色
  • sys_menu是系统的菜单信息表,用于存储系统内所有的菜单。用id与父id的字段关系维护一个菜单树形结构。
  • sys_user_role是用户角色多对多关系表,一条userid与roleid的关系记录表示该用户具有该角色,该角色包含该用户。
  • sys_role_menu是角色菜单(权限)关系表,一条roleid与menuid的关系记录表示该角色由某菜单权限,该菜单权限可以被某角色访问。

页面访问权限与操作权限

  • 页面访问权限:
    所有系统都是由一个个的页面组成,页面再组成模块,用户是否能看到这个页面的菜单、是否能进入这个页面就称为页面访问权限。
  • 操作权限:
    用户在操作系统中的任何动作、交互都需要有操作权限,如增删改查等。比如:某个按钮,某个超链接用户是否可以点击,是否应该看见的权限。

为了适应这种需求,我们可以把页面资源(菜单)和操作资源(按钮)分表存放,如上图。也可以把二者放到一个表里面存放,用一个字段进行标志区分。

数据权限

数据权限比较好理解,就是某个用户能够访问和操作哪些数据。

  • 通常来说,数据权限由用户所属的组织来确定。比如:生产一部只能看自己部门的生产数据,生产二部只能看自己部门的生产数据;销售部门只能看销售数据,不能看财务部门的数据。而公司的总经理可以看所有的数据。
  • 在实际的业务系统中,数据权限往往更加复杂。非常有可能销售部门可以看生产部门的数据,以确定销售策略、安排计划等。

所以为了面对复杂的需求,数据权限的控制通常是由程序员书写个性化的SQL来限制数据范围的,而不是交给权限模型或者Spring Security或shiro来控制。当然也可以从权限模型或者权限框架的角度去解决这个问题,但适用性有限。

动态加载用户角色权限数据

我们所有的用户、角色、权限信息都是在配置文件里面写死的,然而在实际的业务系统中,这些信息通常是存放在RBAC权限模型的数据库表中的。

下面来把这些信息从数据库里面进行加载。

下面我们来回顾一下其中的核心概念:

  • RBAC的权限模型可以从用户获取为用户分配的一个或多个角色,从用户的角色又可以获取该角色的多种权限。通过关联查询可以获取某个用户的角色信息和权限信息
  • 如果我们不希望用户、角色、权限信息写死在配置里面。我们应该实现UserDetails与UserDetailsService接口,从而从数据库或者其他的存储上动态的加载这些信息。

UserDetails与UserDetailsService接口

UserDetails接口表达你是谁?你有什么角色权限。UserDetailsService接口表达的是如何动态加载UserDetails数据。

  • UserDetailsService接口有一个方法叫做loadUserByUsername,我们实现动态加载用户、角色、权限信息就是通过实现该方法。函数见名知义:通过用户名加载用户。该方法的返回值就是UserDetails
  • UserDetails就是用户信息,即:用户名、密码、该用户所具有的权限。

下面我们来看一下UserDetails接口都有哪些方法。

public interface UserDetails extends Serializable {
    //获取用户的权限集合
    Collection<? extends GrantedAuthority> getAuthorities();

    //获取密码
    String getPassword();

    //获取用户名
    String getUsername();

    //账号是否没过期
    boolean isAccountNonExpired();

    //账号是否没被锁定
    boolean isAccountNonLocked();

    //密码是否没过期
    boolean isCredentialsNonExpired();

    //账户是否可用
    boolean isEnabled();
}

现在我们明白了,只要我们把这些信息提供给Spring Security,Spring Security就知道怎么做登录验证了,根本不需要我们自己写Controller实现登录验证逻辑。那我们怎么把这些信息提供给Spring Security,用的就是下面的接口方法

public interface UserDetailsService {
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

实现UserDetails 接口

public class MyUserDetails implements UserDetails {

  String password;  //密码
  String username;  //用户名
  boolean accountNonExpired;   //是否没过期
  boolean accountNonLocked;   //是否没被锁定
  boolean credentialsNonExpired;  //密码是否没过期
  boolean enabled;  //账号是否可用
  Collection<? extends GrantedAuthority> authorities;  //用户的权限集合

  public void setPassword(String password) {
    this.password = password;
  }

  public void setUsername(String username) {
    this.username = username;
  }

  public void setAccountNonExpired(boolean accountNonExpired) {
    this.accountNonExpired = accountNonExpired;
  }

  public void setAccountNonLocked(boolean accountNonLocked) {
    this.accountNonLocked = accountNonLocked;
  }

  public void setCredentialsNonExpired(boolean credentialsNonExpired) {
    this.credentialsNonExpired = credentialsNonExpired;
  }

  public void setEnabled(boolean enabled) {
    this.enabled = enabled;
  }

  public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
    this.authorities = authorities;
  }

  @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;   //暂时未用到,直接返回true,表示账户未过期
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;   //暂时未用到,直接返回true,表示账户未被锁定
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;   //暂时未用到,直接返回true,表示账户密码未过期
  }

  @Override
  public boolean isEnabled() {
    return enabled;
  }
}

我们就是写了一个适应于UserDetails的java POJO类,所谓的 UserDetails接口实现就是一些get方法。

  • get方法由Spring Security调用,获取认证及鉴权的数据
  • 我们通过set方法或构造函数为 Spring Security提供UserDetails数据(从数据库查询)。
  • 当enabled的值为false的时候,Spring Security会自动的禁用该用户,禁止该用户进行系统登录。
  • 通常数据库表sys_user字段要和MyUserDetails 属性一一对应,比如username、password、enabled。

目前数据库表里面没有定义accountNonExpired、accountNonLocked、credentialsNonExpired这三个字段,目前暂时也用不到,因此这三个属性对应的get方法,返回值默认为true

实现UserDetailsService接口

@Component
public class MyUserDetailsService implements UserDetailsService {

    @Resource
    private MyUserDetailsServiceMapper myUserDetailsServiceMapper;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {

        //获得用户信息
        MyUserDetails myUserDetails =
                myUserDetailsServiceMapper.findByUserName(username);

        if(myUserDetails == null){
            throw new UsernameNotFoundException("用户名不存在");
        }

        //获得用户角色列表
        List<String> roleCodes =
                myUserDetailsServiceMapper.findRoleByUserName(username);

        //通过角色列表获取权限列表
        List<String> authorities =
                myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes);

        //为角色标识加上ROLE_前缀(Spring Security规范)
        roleCodes = roleCodes.stream()
                .map(rc -> "ROLE_" + rc )
                .collect(Collectors.toList());

        //角色是一种特殊的权限,所以合并
        authorities.addAll(roleCodes);
        //转成用逗号分隔的字符串,为用户设置权限标识
        myUserDetails.setAuthorities(
        
                AuthorityUtils.commaSeparatedStringToAuthorityList(
                    String.join(",",authorities)
                )
        );

        return myUserDetails;
    }
}
  • 角色是一种特殊的权限,在Spring Security我们可以使用hasRole(角色标识)表达式判断用户是否具有某个角色,决定他是否可以做某个操作;通过hasAuthority(权限标识)表达式判断是否具有某个操作权限。
  • 上述实现中用到的MyUserDetailsServiceMapper 是Mybatis操作数据库的接口实现,看文末代码。

注册UserDetailsService

重写WebSecurityConfigurerAdapter的 configure(AuthenticationManagerBuilder auth)方法

@Bean("passwordEncoder")
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}

@Resource
private MyUserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    builder.userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder());
}

使用BCryptPasswordEncoder,表示存储中(数据库)取出的密码必须是经过BCrypt加密算法加密的。

这里需要注意的是,因为我们使用了BCryptPasswordEncoder加密解密,所以数据库表里面存的密码应该是加密之后的密码(造数据的过程),可以使用如下代码加密(如密码是:123456)。将打印结果保存保存到密码字段。

@Resource
PasswordEncoder passwordEncoder;

@Test
public void contextLoads() {
    System.out.println(passwordEncoder.encode("123456"));
}

总结

至此,我们将系统里面的所有的用户、角色、权限信息都通过UserDetailsService和UserDetails告知了Spring Security。但是多数朋友可能仍然不知道该怎样实现登录的功能,其实剩下的事情很简单了:

  • 写一个登录界面,写一个登录表单,表单使用post方法提交到默认的/login路径
  • 表单的用户名、密码字段名称默认是username、password。
  • 写一个登录成功之后的跳转页面,比如index.html

然后把这些信息通过配置方式告知Spring Security ,以上的配置信息名称都可以灵活修改。

附录:Mybatis持久层数据接口

public interface MyUserDetailsServiceMapper {

    //根据userID查询用户信息
    @Select("SELECT username,password,enabled\n" +
            "FROM sys_user u\n" +
            "WHERE u.username = #{userId}")
    MyUserDetails findByUserName(@Param("userId") String userId);

    //根据userID查询用户角色
    @Select("SELECT role_code\n" +
            "FROM sys_role r\n" +
            "LEFT JOIN sys_user_role ur ON r.id = ur.role_id\n" +
            "LEFT JOIN sys_user u ON u.id = ur.user_id\n" +
            "WHERE u.username = #{userId}")
    List<String> findRoleByUserName(@Param("userId") String userId);

    //根据用户角色查询用户权限
    @Select({
      "<script>",
         "SELECT url " ,
         "FROM sys_menu m " ,
         "LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id " ,
         "LEFT JOIN sys_role r ON r.id = rm.role_id ",
         "WHERE r.role_code IN ",
         "<foreach collection='roleCodes' item='roleCode' open='(' separator=',' close=')'>",
            "#{roleCode}",
         "</foreach>",
      "</script>"
    })
    List<String> findAuthorityByRoleCodes(@Param("roleCodes") List<String> roleCodes);

}

动态加载资源鉴权规则

我们已经实现了从RBAC数据库中加载用户的角色、权限信息。在我们的Spring Security配置类SecurityConfig中仍然有一部分内容是通过代码静态配置的,那就是:资源鉴权规则。

简单说“资源鉴权规则”就是:你有哪些权限?这些权限能够访问哪些资源?即:权限与资源的匹配关系。

实现效果

上图是资源鉴权规则完成之后的效果:

  • 首先将静态规则去掉(注释掉的部分内容),这部分内容我们将替换为动态从数据库加载
  • 登录页面“login.html”和登录认证处理路径“/login”需完全对外开发,不需任何鉴权就可以访问
  • 首页"/index"必须authenticated,即:登陆之后才能访问。不做其他额外鉴权规则控制。
  • 最后,其他的资源的访问我们通过权限规则表达式实现,表达式规则中使用了rbacService,这个类我们自定义实现。该类服务hasPermission从内存(或数据库)动态加载资源匹配规则,进行资源访问鉴权。

动态资源鉴权规则

@Component("rbacService")
public class MyRBACService {

    /** * 判断某用户是否具有该request资源的访问权限 */
    public boolean hasPermission(HttpServletRequest request, Authentication authentication){

//获取认证主体
        Object principal = authentication.getPrincipal();

        if(principal instanceof UserDetails){
            //获取当前登录用户的UserDetails
            UserDetails userDetails = ((UserDetails)principal);

            //将当前请求的访问资源路径,如:"/syslog",包装成资源权限标识
            SimpleGrantedAuthority simpleGrantedAuthority
                    = new SimpleGrantedAuthority(request.getRequestURI());
            //判断用户已授权访问的资源中,是否包含“本次请求的资源”
            return userDetails.getAuthorities().contains(simpleGrantedAuthority);
        }
        return false;
    }
}

上述代码逻辑很简单:

  • 首先从authentication中获取principal (即UserDetails),UserDetails里面包含authorities(即当前登录用户可以访问的所有的资源访问路径、资源唯一标识)
  • 如果authorities列表中任何一个元素,能够和request.getRequestURI()请求资源路径相匹配,则表示该用户具有访问该资源的权限。
  • hasPermission有两个参数,第一个参数是HttpServletRequest ,第二个参数是Authentication认证主体
  • 用户每一次访问系统资源的时候,都会执行这个方法,判断该用户是否具有访问该资源的权限。

测试一下

如果使用admin用户登录,其加载数据内容如下图(根据之前章节调整RBAC模型数据库表里面的数据)。所以通过admin登录只能访问“用户管理”和“日志管理”功能。

如果使用admin用户登录,其加载数据内容如下图(根据之前章节调整RBAC模型数据库表里面的数据)。所以通过admin登录只能访问“具体业务一”和“具体业务二”功能。

权限表达式使用方法总结

SPEL表达式权限控制

spring security 3.0开始已经可以使用spring Expression表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。Spring Security可用表达式对象的基类是SecurityExpressionRoot

表达式函数描述
hasRole([role])用户拥有指定的角色时返回true (Spring security默认会带有ROLE_前缀),去除前缀参考Remove the ROLE_
hasAnyRole([role1,role2])用户拥有任意一个指定的角色时返回true
hasAuthority([authority])拥有某资源的访问权限时返回true
hasAnyAuthority([auth1,auth2])拥有某些资源其中部分资源的访问权限时返回true
permitAll永远返回true
denyAll永远返回false
anonymous当前用户是anonymous时返回true
rememberMe当前用户是rememberMe用户返回true
authentication当前登录用户的authentication对象
fullAuthenticated当前用户既不是anonymous也不是rememberMe用户时返回true
hasIpAddress(‘192.168.1.0/24’)请求发送的IP匹配时返回true

部分朋友可能会对Authority和Role有些混淆。Authority作为资源访问权限可大可小,可以是某按钮的访问权限(如资源ID:biz1),也可以是某类用户角色的访问权限(如资源ID:ADMIN)。当Authority作为角色资源权限时,hasAuthority(‘ROLE_ADMIN’)与hasRole(‘ADMIN’)是一样的效果

SPEL在全局配置中的使用

我们可以通过继承WebSecurityConfigurerAdapter,实现相关的配置方法,进行全局的安全配置 。下面就为大家介绍一些如何在全局配置中使用SPEL表达式。

URL安全表达式

config.antMatchers("/person/*").access("hasRole('admin') or hasAuthority('ROLE_user')")
      .anyRequest().authenticated();

这里我们定义了应用/person/*URL的范围,只有拥有ADMIN或者USER权限的用户才能访问这些person资源。

安全表达式中引用bean

这种方式,比较适合有复杂权限验证逻辑的情况,当Spring Security提供的默认表达式方法无法满足我们的需求的时候。实际上在上面的动态加载资源鉴权规则里面,我么已经使用了这种方法。首先我们定义一个权限验证的RbacService。

@Component("rbacService")
@Slf4j
public class RbacService {
    //返回true表示验证通过
    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
        //验证逻辑代码
        return true;
    }
    public boolean checkUserId(Authentication authentication, int id) {
        //验证逻辑代码
        return true;
    }
}

对于"/person/{id}"对应的资源的访问,调用rbacService的bean的方法checkUserId进行权限验证,传递参数为authentication对象和person的id。该id为PathVariable,以#开头表示。

config.antMatchers("/person/{id}").access("@rbacService.checkUserId(authentication,#id)")
      .anyRequest().access("@rbacService.hasPermission(request,authentication)");

Method表达式安全控制

如果我们想实现方法级别的安全配置,Spring Security提供了四种注解,分别是@PreAuthorize , @PreFilter ,``@PostAuthorize@PostFilter

开启方法级别注解的配置

在Spring安全配置代码中,加上EnableGlobalMethodSecurity注解,开启方法级别安全配置功能。

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {

@EnableGlobalMethodSecurity是一个组合注解,里面包含了@Configuration,所以不需要再加一个@Configuration注解了

使用PreAuthorize注解

@PreAuthorize 注解适合进入方法前的权限验证。只有拥有ADMIN角色才能访问findAll方法。

@PreAuthorize("hasRole('admin')")
public List<PersonDemo> findAll(){
    return null;
}

如果当前登录用户没有PreAuthorize需要的权限,将抛出org.springframework.security.access.AccessDeniedException异常!

使用PostAuthorize注解

@PostAuthorize 在方法执行后再进行权限验证,适合根据返回值结果进行权限验证。Spring EL 提供返回对象能够在表达式语言中获取返回的对象returnObject。下文代码只有返回值的name等于authentication对象的name(当前登录用户名)才能正确返回,否则抛出异常。

@PostAuthorize("returnObject.name == authentication.name")
public PersonDemo findOne(){
    String authName =
            SecurityContextHolder.getContext().getAuthentication().getName();
    System.out.println(authName);
    return new PersonDemo("admin");
}

使用PreFilter注解

PreFilter 针对参数进行过滤,下文代码表示针对ids参数进行过滤,只有id为偶数的元素才被作为参数传入函数。

//当有多个对象是使用filterTarget进行标注
@PreFilter(filterTarget="ids", value="filterObject%2==0")
public void delete(List<Integer> ids, List<String> usernames) {

}

使用PostFilter 注解

PostFilter 针对返回结果进行过滤,特别适用于集合类返回值,过滤集合中不符合表达式的对象。

@PostFilter("filterObject.name == authentication.name")
public List<PersonDemo> findAllPD(){

    List<PersonDemo> list = new ArrayList<>();
    list.add(new PersonDemo("kobe"));
    list.add(new PersonDemo("admin"));

    return list;
}

如果使用admin登录系统,上面的函数返回值list中kobe将被过滤掉,只剩下admin。

相关文章