@[Toc]
一、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
Spring Security操作的过程中它会提供Servlet 过滤器DelegatingFilterProxy,这个过滤器会通过 Spring Web IoC 容器去获取 Spring Security 所自动创建的 FilterChainProxy 对象,这个对象上存在一个拦截器列表( List ),列表上存在用户验证的拦截器、跨站点请求伪造等拦截器 ,这样它就可以提供多种拦截功能。
FilterChainProxy
于是焦点又落到了FilterChainProxy 对象上,通过它还可以注册 Filter ,也就允许注册自定义 Filter 来实现对应的拦截逻辑,以满足不同的需要。当然 Spring Security 也实现了大部分常用的安全功能,并提供了相应的机制来简化开发者的工作,所以大部分情况下并不需要自定义开发 ,使用它提供的机制即可。
Multiple SecurityFilterChain
二、整合Spring Security
1、基本配置
1.1、创建项目,添加依赖
创建 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>
1.2、添加 hello 接口
写一个简单地hello接口:
@GetMapping("/hello")
public String hello(){
return "Hello,it's safe";
}
1.3. 启动项目测试
启动项目,访问hello接口,会发现需要登录,,这个登录页面是由Spring Security 提供的:
默认的用户名是 user ,默认的登录密码 在每次启动项目时随机生成 查看项目启动日志:
从项目启动日志中可以看到默认的登录密码,登录成功后,就可以访问hello 接口了。
1.4、配置用户名和密码
在上面代码中,用户名是默认的,密码是随机生成的,可以对用户名和密码以及用户角色进行配置:
spring.security.user.name=sao
spring.security.user.password=123456
spring.security.user.roles=admin
1.5、使用 WebSecurityConfigurerAdapter 自定义
为了给 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 ;
2、基于内存的认证
2.1、自定义配置
继承自 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");
}
}
2.2、HttpSecurity
上面虽然现在可以实现认证功能,但是受保护的资源都是默认的 , 根据实际情况进行角色管理,如果要实现这些功能 就需要重写 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";
}
}
2.3、登录表单详细配置
登录表单一直使 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 进行登录测试,登录成功后返回用户的基本信息 密码已经过滤掉了。如果登录失败, 会有相应的提示。
登录成功
登录失败
2.4、注销登录配置
如果想要注销登录 也只需要提供简单的配置即可:
//注销登录
.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()
2.5、方法安全
上面介绍的认证与授权都是基于 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";
}
}
3、加密
3.1、加密方案
密码加密一般会用到散列函数,又称散列算法、哈希函数,这是一种从任何数据中创建数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,重新创建一个散列值。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据会使得数据库记录更难找到。我们常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm )。
123456 ---MD5---> e10adc3949ba59abbe56e057f20f883e
实际上,上面的实例在现实使用中还存在着一个不小的问题。虽然 MD5 算法是不可逆的,但是因为它对同一个字符串计算的结果是唯一 的,所以一些人可能会使用“字典攻击”的方式来攻破 MD5 加密的系统。这虽然属于暴力解密,却十分有效,因为大多数系统的用户密码都不会很长。
为了解决这个问题,我们可以使用盐值加密“salt-source”,所谓加盐加密,是指在加密之前,为原文附上额外的随机值,再进行加密。具体实现方法并不固定。
3.2、 实践
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();
}
4、基于数据库的认证
在真实项目中,用户的基本信息以及角色等都存储在数据库中,因此需要从数据库中获取数据进行认证。
4.1、数据库设计
一共三张表,分别是用户表、角色表、用户_角色关联表。
创建表并插入一些测试数据:
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_"
4.2、创建项目
这里选择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>
4.3、application.properties
数据库连接和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
4.4、实体类
用户实体类需要实现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
}
4.5、持久层
接口:
@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>
4.6、服务层
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 方法将在用户登录时自动调用。
4.7、配置
这里是一个比较精简的配置,用户名和密码从数据库中获取:
@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();
}
}
4.8、控制层
根据配置类编写不同权限的接口:
@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 "你好,数据库管理员!";
}
}
启动项目,就可以访问不同权限的接口进行测试了。
5、高级配置
5.1、角色继承
在上面的实例中中定义了三种角色,但是这三种角色之间不具备任何关系,一 般来说角色之间是有关系的,例如 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 角色才能访问的资源。
5.2、动态配置权限
使用 ttpSecurity 配置的认证授权规则还是不够灵活,无法实现资源和角色之间的动态调整,要实现动态配置 URL 权限,就需要我们自定义权限配置,在第4节的基础上进行改造。
5.2.1、数据库设计
这里的数据库在4数据库的基础上再增加一张资源表和资源角色关联表,资源表中定义了用户能够访问的 URL 模式,资源角色表则定义了访问该模式的 URL 需要什么样的角色。
添加两张表之后的数据库表结构如下:
创建资源表和资源角色关联表并插入一些测试数据:
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');
5.2.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>
5.2.3、自定义 FilterlnvocationSecurityMetadataSource
要实现动态配置权限,首先要自定义 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) ;
}
}
5.2.4、自定义 AccessDecisionManager
当一个请求走完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;
}
}
5.2.5、配置
- 重写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是一样的效果。
本文为学习笔记类博客,学习资料来源见参考!
参考:
【1】:《Spring Security实战》 【2】:[Spring Security官方文档](https://docs.spring.io/spring-security/site/docs/current/reference/html5/) 【3】:《Spring Security 教程(Spring Security Tutorial)》 【4】:《SpringBoot Vue全栈开发实战》 【5】:《深入浅出Spring Boot 2.x》 【6】:[手把手教你搞定权限管理,结合Spring Security实现接口的动态权限控制!](https://juejin.im/post/5e5519fd6fb9a07cc7473fcf)