天天看点

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

文章目录

Spring Security 的前身是 Acegi Security,在被收纳为Spring子项目后正式更名为Spring Security。 截止到目前(2020年4月16日),Spring Security已经升级到5.3.2 RELEASE版本,加入了原生OAuth2.0框架,支持更加现代化的密码加密方式。

应用程序的安全性通常体现在两个方面:认证和授权:

  • 认证是确认某主体在某系统中是否合法、可用的过程。这里的主体既可以是登录系统的用户,也可以是接入的设备或者其他系统。
  • 授权是指当主体通过认证之后,是否允许其执行某项操作的过程。

这些概念并非Spring Security独有,而是应用安全的基本关注点。Spring Security可以帮助我们更便捷地完成认证和授权。

Spring Security提供了一组深入的授权功能。有三个主要领域:

  • 对Web 请求进行授权
  • 授权某个方法是否可以被调用
  • 授权访问单个领域对象实例

Spring Security原理简易说明:

一旦启用了 Spring Security, Spring IoC 容器就会创建一个名称为 SpringSecurityFiltrChain的Spring Bean 。它的类型为 FilterChainProxy,事实上它也实现了filter 接口,只是它是个特殊的拦截器。

FilterChainProxy

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

Spring Security操作的过程中它会提供Servlet 过滤器DelegatingFilterProxy,这个过滤器会通过 Spring Web IoC 容器去获取 Spring Security 所自动创建的 FilterChainProxy 对象,这个对象上存在一个拦截器列表( List ),列表上存在用户验证的拦截器、跨站点请求伪造等拦截器 ,这样它就可以提供多种拦截功能。

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

于是焦点又落到了FilterChainProxy 对象上,通过它还可以注册 Filter ,也就允许注册自定义 Filter 来实现对应的拦截逻辑,以满足不同的需要。当然 Spring Security 也实现了大部分常用的安全功能,并提供了相应的机制来简化开发者的工作,所以大部分情况下并不需要自定义开发 ,使用它提供的机制即可。

Multiple SecurityFilterChain

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

创建 Spring Boot Web 项目,然后添加 spring-boot-starter-security 依赖即可:

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>      

写一个简单地hello接口:

@GetMapping("/hello")
    public String hello(){
        return "Hello,it's safe";
    }      

启动项目,访问hello接口,会发现需要登录,,这个登录页面是由Spring Security 提供的:

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

默认的用户名是 user ,默认的登录密码 在每次启动项目时随机生成 查看项目启动日志:

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

从项目启动日志中可以看到默认的登录密码,登录成功后,就可以访问hello 接口了。

在上面代码中,用户名是默认的,密码是随机生成的,可以对用户名和密码以及用户角色进行配置:

spring.security.user.name=sao
spring.security.user.password=123456
spring.security.user.roles=admin      

为了给 FilterChainProxy 加入自定义的初始化, SpringSecurity 供了 SecurityConfigurer 口,通过它就能够实现现对 Spring Security 的配置。 有了这个接口还不太方便,因为它只是能够提供接口定义的功能,为了更方便 Spring Web 工程还提供了专门的接口 WebSecurityConfigurer, 并且在这个接口的定义上提供了以个抽象类 WebSecurityConfigAdapter—— 开发者通过继承它就能 得到Spring Security 默认的安全功能。 也可以通过覆盖它提供的方法来自定义自 的安全拦截方案。

这里需要 WebSecurityConfigAdapter 中默认存在的方法:

/**
*用来配置用户签名服务,主要是 user-details 制,你还可以给予用户赋予角色
* @param auth 签名管理器构造器 用于构建用户具体权限控制
*/
protected roid configure (AuthenticationManagerBuilder auth ); 
/** 
*用来配置Filter链
* @param web Spring Web Securty 对象
*/ 
public roid configure (WebSecurity web) ; 
/**
*用来配置拦截保护的请求,比如什么请求放行,什么请求需要验证
*@param http http 安全请求对象
*/ 
protected void configure (HttpSecurity http) throws Exception ;      

继承自 WebSecurityConfigurerAdapter ,实现基于内存的认证,配置方式如下:

/**
 * @Author 三分恶
 * @Date 2020/1/11
 * @Description
 */
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 从 Spring5 开始,强制要求密码要加密,如果不想加密,可以使用一个过时的 PasswordEncoder 的实例 NoOpPasswordEncoder
     * BCryptPasswordEncoder 密码编码工具
     * @return
     */
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                //配置了两个用户,包括用户的用户名、角色、密码,用户密码已经加密(123)
                .withUser("zhangsan")
                   //角色
                   .roles("ADMIN")
                   //密码已经加密,可以通过 passwordEncoder.encode("123")获取
                  .password("$2a$10$dQLFreAJHM0F.4XAWQMTA.kB5W3H2.hjA6xBUJFHTFT7iHRzO0flm")
                 //连接方法
                .and()
                .withUser("lisi").roles("USER").password("$2a$10$RDdoj3sm/RD7HzqSnU864eEE5kEZZxbyQqnYQJGrO2pgkUGCDutTC");
    }

}      

上面虽然现在可以实现认证功能,但是受保护的资源都是默认的 , 根据实际情况进行角色管理,如果要实现这些功能 就需要重写 WebSecurityConfigurerAdapter 的另一 方法:

@Override
    protected void configure(HttpSecurity http) throws Exception {
       http.authorizeRequests()             //开启登录配置
               .antMatchers("/admin/**")
               //访问"admin/**"模式的URL必须具备ADMIN权限
               .hasRole("ADMIN")
               .antMatchers("/user/**")
               //访问"user/**"模式的 URL必须具备 AD1IN、USER 的角色
               .access( "hasAnyRole('ADMIN','USER') ")
               //除上面配置外的其它url都需要登录
               .anyRequest()
               .authenticated()
               .and()
               //开启表单登录
               .formLogin()
               //配置登录接口为“/login”
               .loginProcessingUrl("/login")
               //登录相关的接口不需要认证
               .permitAll()
               .and()
               //关闭 csrf
               .csrf()
               .disable();
    }      

在HelloController中添加相应的接口,admin/hello只能被具备ADMIN角色的用户访问,user/hello能被具备ADMIN和UAER角色的用户访问:

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello(){
        return "Hello,it's safe";
    }

    @GetMapping("admin/hello")
    public String adminHello(){
        return "Hello,admin is safe";
    }

    @GetMapping("user/hello")
    public String userHello(){
        return "Hello,user is safe";
    }
}
      

登录表单一直使 Spring Security 提供的页面,登录成功后也是默认的页面跳转,但是,前后端分离正在成为企业级应用开发的主流,在前后端分离的开发方式中,前后端的数据交互通过 JSON 进行,这时,登录成功后就不是页面跳转了,而是一段 JSON 提示。要实现这些功能,需要继续完善上文的配置:

//开启表单登录
                .formLogin()
                //配置登录接口为“/login”
                .loginProcessingUrl("/login")
                //登录页面
                .loginPage("/login_page")
                //自定义认证所需的用户名和密码的参数名
                .usernameParameter("username")
                .passwordParameter("password")
                //定义登录成功的处理逻辑,这里是模拟前后端分离的情况,返回一段json,不前后端分离的话可以直接返回页面
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException, ServletException {
                        Object principal = auth.getPrincipal();
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        response.setStatus(200);
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 200);
                        map.put("msg", principal);
                        ObjectMapper om = new ObjectMapper();
                        out.write(om.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
                //定义登录失败的处理逻辑
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        response.setStatus(401);
                        Map<String, Object> map = new HashMap<>();
                        map.put("status", 401);
                        if (e instanceof LockedException) {
                            map.put("msg", "账号被锁定,登录失败 !");
                        } else if (e instanceof BadCredentialsException) {
                            map.put("msg", "账户名或密码输入错误, 登录失败!");
                        } else if (e instanceof DisabledException) {
                            map.put("msg", "账户被禁用,登录失败!");
                        } else if (e instanceof AccountExpiredException) {
                            map.put("msg", "账户已过期,登录失败!");
                        } else {
                            map.put("msg", "登录失败!");
                        }
                        ObjectMapper om = new ObjectMapper ();
                        out .write(om.writeValueAsString(map));
                        out.flush();
                        out.close();
                    }
                })
                //登录相关的接口不需要认证
                .permitAll()
                .and()      

配置完成后,使用 Postman 进行登录测试,登录成功后返回用户的基本信息 密码已经过滤掉了。如果登录失败, 会有相应的提示。

登录成功

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

登录失败

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

如果想要注销登录 也只需要提供简单的配置即可:

//注销登录
                .logout()
                //注销登录请求url
                .logoutUrl("/logout")
                //清除身份认证信息
                .clearAuthentication(true)
                //使 Session失效
                .invalidateHttpSession(true)
                //定义注销成功的业务逻辑,这里返回一段json
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication authentication) throws IOException, ServletException {
                        resp.setContentType("application/json;charset=utf-8");
                        PrintWriter out = resp.getWriter();
                        out.write("logout success");
                        out.flush();
                    }
                })
                .permitAll()
                .and()      

上面介绍的认证与授权都是基于 URL 的,也可以通过注解来灵活地配置方法安全,要使用相关注解,首先要通过@EnableGloba!MethodSecurity 注解开启基于注解的安全配置:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled=true,securedEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

}      
  • prePostEnabled=true会解锁@PreAuthorize和@PostAuthorize两个注解,@PreAuthorize会在执行方法前验证,@PostAuthorize会在执行方法后验证。
  • securedEnabled=true会解锁@Secured 注解。
@Service
public class MethodService {
    //示访问该方法需要 ADMIN 角色
    @Secured("ROLE ADMIN")
    public String admin () {
        return "hello admin ";
    }

    //访问该方法既需要ADMIN角色又需要USER角色
    @PreAuthorize("hasRole ('ADMIN') and hasRole ('USER ')")
        public String user(){
        return "Hello User";
    }

    //访问该方法需要ADMIN 或 USER角色
    @PreAuthorize("hasAnyRole('ADMIN','USER')")
    public String any(){
        return "Hello Every One";
    }

}      

密码加密一般会用到散列函数,又称散列算法、哈希函数,这是一种从任何数据中创建数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,重新创建一个散列值。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据会使得数据库记录更难找到。我们常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm )。

123456 ---MD5---> e10adc3949ba59abbe56e057f20f883e      

实际上,上面的实例在现实使用中还存在着一个不小的问题。虽然 MD5 算法是不可逆的,但是因为它对同一个字符串计算的结果是唯一 的,所以一些人可能会使用“字典攻击”的方式来攻破 MD5 加密的系统。这虽然属于暴力解密,却十分有效,因为大多数系统的用户密码都不会很长。

为了解决这个问题,我们可以使用盐值加密“salt-source”,所谓加盐加密,是指在加密之前,为原文附上额外的随机值,再进行加密。具体实现方法并不固定。

Spring Security内置了密码加密机制,只需使用一个PasswordEncoder接口即可。

PasswordEncoder接口定义了encode和matches两个方法,当用数据库存储用户密码时,加密过程用 encode方法,matches方法用于判断用户登录时输入的密码是否正确。

Spring Security 还内置了几种常用的 PasswordEncoder 接口,例如, StandardPasswordEncoder中的常规摘要算法(SHA-256等)、BCryptPasswordEncoder加密,以及类似 BCrypt的慢散列加密Pbkdf2PasswordEncoder等,官方推荐使用BCryptPasswordEncoder。

配置密码加密非常简单:

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

同样可以自定义加密方式,例如不想使用推荐的BCryptPasswordEncoder,想使用其它的加密方法,例如MD5加密,很简单,我们自己实现一个PasswordEncoder,在配置中使用自定义的加密类即可。

public class Md5PasswordEncoder implements PasswordEncoder {
    @Override
    public String encode(CharSequence charSequence) {
        //省略md5加密过程
        return md5String;
    }

    @Override
    public boolean matches(CharSequence charSequence, String s) {
        //省略比对过程
        return false;
    }
}      
@Bean
    PasswordEncoder passwordEncoder() {
        return new Md5PasswordEncoder();
    }      

在真实项目中,用户的基本信息以及角色等都存储在数据库中,因此需要从数据库中获取数据进行认证。

一共三张表,分别是用户表、角色表、用户_角色关联表。

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

创建表并插入一些测试数据:

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `rolename` varchar(50) NOT NULL COMMENT '角色名',
  `note` varchar(255) NOT NULL COMMENT '角色描述',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='角色表';

-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES ('1', 'ROLE_ADMIN', '管理员');
INSERT INTO `role` VALUES ('2', 'ROLE_DBA', '数据库管理员');
INSERT INTO `role` VALUES ('3', 'ROLE_USER', '用户');

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(255) NOT NULL COMMENT '密码',
  `enabled` tinyint(1) NOT NULL COMMENT '是否可用,1表示可用,0表示不可用',
  `locked` tinyint(1) NOT NULL COMMENT '是否上锁,1表示上锁,0表示未上锁',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='关联表';

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES ('1', 'admin', '$2a$10$7zE.mjxSWV6w6jyh8iL8Q.5ouGokPg1PuL531YKPmRWCysN30I.qO', '1', '0');
INSERT INTO `user` VALUES ('2', 'root', '$2a$10$7zE.mjxSWV6w6jyh8iL8Q.5ouGokPg1PuL531YKPmRWCysN30I.qO', '1', '0');
INSERT INTO `user` VALUES ('3', 'laosan', '$2a$10$7zE.mjxSWV6w6jyh8iL8Q.5ouGokPg1PuL531YKPmRWCysN30I.qO', '1', '0');

-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uid` int(11) NOT NULL COMMENT '用户id',
  `rid` int(11) NOT NULL COMMENT '角色id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COMMENT='用户-角色关联表';

-- ----------------------------
-- Records of user_role
-- ----------------------------
INSERT INTO `user_role` VALUES ('1', '1', '1');
INSERT INTO `user_role` VALUES ('2', '2', '2');
INSERT INTO `user_role` VALUES ('3', '3', '3');
INSERT INTO `user_role` VALUES ('4', '1', '3');
INSERT INTO `user_role` VALUES ('5', '1', '2');      
  • 角色名有一个默认的前缀"ROLE_"

这里选择MyBatis作为持久层框架,添加依赖:

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.12</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>      

数据库连接和MyBatis相关配置:

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql://localhost:3306/demo_security?serverTimezone=CTT&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true

mybatis.type-aliases-package=edu.hpu.pojo
mybatis.mapper-locations=classpath:mybatis/mapper/*.xml
      

用户实体类需要实现UserDetails接口:

public class User implements UserDetails {
    private Integer id;
    private  String username;
    private  String password;
    private Boolean enabled;
    private Boolean locked;
    private List<Role> roles;

    /**
     * 获取当前用户对象所具有的角色信息
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>() ;
        for (Role role : roles){
            authorities.add (new SimpleGrantedAuthority (role.getRolename()) ) ;
        }
        return authorities;
    }

    /**
     * 获取当前用户的密码
     * @return
     */
    @Override
    public String getPassword() {
        return password;
    }

    /**
     * 获取d当前用户的用户名
     * @return
     */
    @Override
    public String getUsername() {
        return username;
    }

    /**
     * 当前账户是否未过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 当前账户是否未锁定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return !locked;
    }

    /**
     * 当前账户密码是否未过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 当前账户是否可用
     * @return
     */
    @Override
    public boolean isEnabled() {
        return enabled;
    }

    //省略getter、setter
}      
  • 实现了UserDetails接口的七个方法,方法作用见注释
  • 用户根据实际情况设直这个方法的返回值 因为默认情况下不需要开发者自己进行密码角色等信息的比对,开发者只需要提供相关信息即可。例如 getPassword()方法返回的密码和用户输入的登录密码不匹配,会自动抛出BadCredentialsException 异常。
  • getAuthorities()方法用来获取当前用户所具有的角色信息。

角色实体类:

public class Role {
    private  Integer id;
    private  String rolename;
    private  String note;
    //省略getter、setter
}      

接口:

@Mapper
public interface UserMapper {
    User loadUserByUsername(String username);
    List<Role> getUserRolesByUid (Integer id) ;
}
      

对应的映射文件:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="edu.hpu.mapper.UserMapper">

    <select id="loadUserByUsername" resultType="User">
            select * from user where username=#{username}
    </select>

    <select id="getUserRolesByUid" resultType="Role">
        select r.* from role r
           join user_role ur on  r.id=ur.rid
           join user u on  u.id=ur.uid
        where  u.id=#{id}
    </select>
</mapper>      

UserService需要实现UserDetailsService接口

@Service
public class UserService implements UserDetailsService {
    @Autowired
    UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.loadUserByUsername(username);
        if (user==null){
            throw new UsernameNotFoundException("账号不存在");
        }
        user.setRoles(userMapper.getUserRolesByUid(user.getId()));
        return user;
    }
}      
  • 实现 UserDetailsService 接口,并实现该接口中的 loadUserByUsername 方法,该方法的参数就是用户登录时输入的用户名,通过用户名去数据库中查找用户,如果没有查找到用户,就抛出 个账户不存在的异常,如果查找到了用户,就继续查找该用户所具有的角色信息,并将获取到的 user 对象返回,再由系统提供的 DaoAuthenticationProvider 类去比对密码是否正确。
  • loadUserByUsername 方法将在用户登录时自动调用。

这里是一个比较精简的配置,用户名和密码从数据库中获取:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserService userService;

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService (userService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/db/**").hasRole("DBA")
                .antMatchers("/user/**").hasRole("USER")
                .and()
                .formLogin()
                .loginProcessingUrl("/login").permitAll()
                .and()
                .csrf().disable();
    }
}      

根据配置类编写不同权限的接口:

@RestController
public class HelloController {

    @GetMapping("/user/hello")
    public String userHello(){
        return "你好,普通用户!";
    }

    @GetMapping("/admin/hello")
    public String adminHello(){
        return "你好,管理员!";
    }

    @GetMapping("/db/hello")
    public String dbaHello(){
        return "你好,数据库管理员!";
    }
}      

启动项目,就可以访问不同权限的接口进行测试了。

在上面的实例中中定义了三种角色,但是这三种角色之间不具备任何关系,一 般来说角色之间是有关系的,例如 ROLE_ADMIN 一般既具有 ADMIN 的权限,又具有 USER 的权限。那么如何配置这种角色继承关系呢?在 pring Security 中只需要开发者提供一个 RoleHierarchy 即可。

假设 ROLE_DBA是终极大 Boss ,具有所有的权限, ROLE_ADMIN具有 ROLE_USER权限, ROLE_USER是一个公共角色,即 ROLE_ADMIN继承 ROLE_USER, ROLE_DBA继承ROLE_ADMIN ,要描述这种继承关系,只需要开发者在 Spring Security 的配置类中提供RoleHierarchy 即可,代码如下:

@Bean
    RoleHierarchy roleHierarchy(){
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        String hireachy = "ROLE_DBA > ROLE_ADMIN> ROLE_USER";
        roleHierarchy.setHierarchy(hireachy);
        return roleHierarchy;
    }      

配置完 RoleHierarchy 之后,具有 ROLE_DBA 角色的用户就可以访问所有资源了, 具有

ROLE_ADMIN 角色的用户也可以访问具有 ROLE_USER 角色才能访问的资源。

使用 ttpSecurity 配置的认证授权规则还是不够灵活,无法实现资源和角色之间的动态调整,要实现动态配置 URL 权限,就需要我们自定义权限配置,在第4节的基础上进行改造。

这里的数据库在4数据库的基础上再增加一张资源表和资源角色关联表,资源表中定义了用户能够访问的 URL 模式,资源角色表则定义了访问该模式的 URL 需要什么样的角色。

添加两张表之后的数据库表结构如下:

SpringBoot学习笔记(十四:Spring Security安全管理 )一、Spring Security简介二、整合Spring Security

创建资源表和资源角色关联表并插入一些测试数据:

DROP TABLE IF EXISTS `menu`;
CREATE TABLE `menu` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `pattern` varchar(50) NOT NULL COMMENT '路径',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='资源表';

-- ----------------------------
-- Records of menu
-- ----------------------------
INSERT INTO `menu` VALUES ('1', '/db/**');
INSERT INTO `menu` VALUES ('2', '/admin/**');
INSERT INTO `menu` VALUES ('3', '/user/**');

-- ----------------------------
-- Table structure for menu_role
-- ----------------------------
DROP TABLE IF EXISTS `menu_role`;
CREATE TABLE `menu_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `mid` int(11) NOT NULL COMMENT '资源id',
  `rid` int(11) NOT NULL COMMENT '角色id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COMMENT='资源_角色关联表';

-- ----------------------------
-- Records of menu_role
-- ----------------------------
INSERT INTO `menu_role` VALUES ('1', '1', '2');
INSERT INTO `menu_role` VALUES ('2', '2', '1');
INSERT INTO `menu_role` VALUES ('3', '3', '3');      

Menu.java:

public class Menu {
    private Integer id;
    private String pattern;
    private List<Role> roles;
    //省略getter、setter
}
      

MenuMapper.java:

@Mapper
public interface MenuMapper {
    List<Menu> getAllMenus();
}      

MenuMapper.xml:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="edu.hpu.mapper.MenuMapper">
    <resultMap id="BaseResultMap" type="edu.hpu.pojo.Menu">
        <id property="id" column="id" />
        <result property="pattern" column="pattern"/>
        <collection property="roles" ofType="edu.hpu.pojo.Role">
            <id property="id" column="id" />
            <result property="rolename" column="rolename"/>
            <result property="note" column="note"/>
        </collection>
    </resultMap>

    <select id="getAllMenus" resultMap="BaseResultMap">
        SELECT
          m.*,
          r.id AS rid,
          r.rolename AS rolename,
          r.note AS note
        FROM
         menu m
        LEFT JOIN menu_role mr ON m.id=mr.mid
        LEFT JOIN role r ON mr.rid= r.id
    </select>
</mapper>      

要实现动态配置权限,首先要自定义 FilterlnvocationSecurityMetadataSource,Spring Security中通过FilterlnvocationSecurityMetadataSource接口的getAttributes方法来确定一个请求需要哪些角色,接口的默认实现类是DefaultFilterlnvocationSecurityMetadataSource,参考DefaultFilterlnvocationSecurityMetadataSource,可以自定义FilterlnvocationSecurityMetadataSource接口实现类。

@Component
public class CustomFilterinvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    //创建一个AntPathMatcher实例,主要用来实现ant风格的URL匹配。
    AntPathMatcher antPathMatcher =new AntPathMatcher() ;
    @Autowired
    MenuMapper menuMapper;

    /**
     *
     * @param o
     * 参数是一个FilterInvocation,可以从中去除请求的url
     * @return Collection<ConfigAttribute>:表示当前请求 URL 所需的角色。
     * @throws IllegalArgumentException
     */
    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        //从FilterInvocation 中提取出当前请求的url
        String requestUtl=( (FilterInvocation) o).getRequestUrl();
        //从数据库中取出资源信息
        List<Menu> menus=menuMapper.getAllMenus();
        for (Menu menu:menus) {
            if (antPathMatcher.match(menu.getPattern(),requestUtl)){
                //获取当前请求的 URL 所需要的角色信息
                List<Role> roles=menu.getRoles();
                String[] roleArr =new String[roles.size()];
                for (int i=0;i<roleArr.length;i++){
                    roleArr[i] = roles.get(i).getRolename();
                }
                //返回角色信息
                return SecurityConfig.createList(roleArr);
            }
        }
        //如果不存在匹配的角色信息,返回ROLE_LOGIN,即登录就可访问
        return SecurityConfig.createList("ROLE_LOGIN");
    }

    /**
     *
     * @return 返回所有定义好的权限资源, Spring Security 在启动时会校验
     * 相关配置是否正确 ,如果不需要校验,那么该方法直接返回 null 即可
     */
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    /**
     *
     * @param aClass
     * @return 返回类对象是否支持校验
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return  FilterInvocation.class.isAssignableFrom(aClass) ;
    }
}      

当一个请求走完FilterlnvocationSecurityMetadataSource的getAttributes方法之后,会

来到AccessDecisionManager类中进行角色信息的比对,自定义AccessDecisionManager类如下:

@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {

    /**
     * 判断当前登录的用户是否具备当前请求 URL 所需要的角色信息,如果不具备,就抛出AccessDeniedException异常
     * @param authentication
     * 参数1:当前登录用户的信息
     * @param o
     * 参数2:Filterlnvocation对象,可以取当前请求对象等
     * @param collection
     * 参数3:FilterlnvocationSecurityMetadataSource中getAttributes 方法的返回值,即当前请求URL所需的角色
     * @throws AccessDeniedException
     * @throws InsufficientAuthenticationException
     */
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
        Collection <? extends GrantedAuthority> auths = authentication.getAuthorities();
        //角色信息对比
        for(ConfigAttribute configAttribute:collection){
            //当前URL权限为ROLE_LOGIN,登录即可访问
            if ("ROLE_LOGIN".equals(configAttribute.getAttribute())&& authentication instanceof UsernamePasswordAuthenticationToken){
               return;
            }
            //全新判断
            for (GrantedAuthority authority:auths){
                //当前用户具备访问当前URL的权限
                if (configAttribute.getAttribute().equals(authority.getAuthority())){
                    return;
                }
            }
            //当前用户不具备访问当前URL的权限,抛出异常
            throw new AccessDeniedException("权限不足");
        }
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}
      

  • 重写configure(HttpSecurity http)方法,根据数据库中的数据动态分配权限
  • 将写的两个类的实例设置进去
/**
     * 动态配置访问权限
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                //定义FilterSecurityInterceptor将自定义的两个类放进去
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(cfisms());
                        o.setAccessDecisionManager(cadm());
                        return o;
                    }
                })
                .and()
                .formLogin()
                .loginProcessingUrl("/login").permitAll()
                .and()
                .csrf().disable() ;
    }

    @Bean
    CustomFilterinvocationSecurityMetadataSource cfisms(){
        return new CustomFilterinvocationSecurityMetadataSource () ;
    }

    @Bean
    CustomAccessDecisionManager cadm (){
        return  new CustomAccessDecisionManager();
    }      

重新启动,和4是一样的效果。