image.png

简介

官网:https://spring.io/projects/spring-security
实际应用系统中,为了安全起见,一般都必备用户认证(登录)和权限控制的功能,以识别用户是否合法,以及根据权限来控制用户是否能够执行某项操作。
Spring Security 是一个安全相关的框架,能够与 Spring 项目无缝整合,本文主要是介绍 Spring Security 默认的用户认证和权限控制的使用方法和原理,但不涉及到自定义实现。
Spring Security 用户认证和权限控制(自定义实现)这篇文章专门讲解用户认证和权限控制相关的自定义实现。
(1)用户认证指的是:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。通俗点说就是系统认为用户是否能登录
(2)用户授权指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。通俗点讲就是系统判断用户是否有权限去做某些事情。

同款产品对比

Spring Security

Spring 技术栈的组成部分。通过提供完整可扩展的认证和授权支持保护你的应用程序。
SpringSecurity 特点:

  • 和 Spring 无缝整合。
  • 全面的权限控制。
  • 专门为 Web 开发而设计。
    • 旧版本不能脱离 Web 环境使用。
    • 新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境。
  • 重量级。

Shiro

Apache 旗下的轻量级权限控制框架。
特点:

  • 轻量级。Shiro 主张的理念是把复杂的事情变简单。针对对性能有更高要求的互联网应用有更好表现。
  • 通用性。
    • 好处:不局限于 Web 环境,可以脱离 Web 环境使用。
    • 缺陷:在 Web 环境下一些特定的需求需要手动编写代码定制。

Spring Security 是 Spring 家族中的一个安全管理框架,实际上,在 Spring Boot 出现之前,Spring Security 就已经发展了多年了,但是使用的并不多,安全管理这个领域,一直是 Shiro 的天下。
相对于 Shiro,在 SSM 中整合 Spring Security 都是比较麻烦的操作,所以,SpringSecurity 虽然功能比 Shiro 强大,但是使用反而没有 Shiro 多(Shiro 虽然功能没有 Spring Security 多,但是对于大部分项目而言,Shiro 也够用了)。
自从有了 Spring Boot 之后,Spring Boot 对于 Spring Security 提供了自动化配置方案,可以使用更少的配置来使用 SpringSecurity。因此,一般来说,常见的安全管理技术栈的组合是这样的:

  • SSM + Shiro
  • Spring Boot/Spring Cloud + SpringSecurity

以上只是一个推荐的组合而已,如果单纯从技术上来说,无论怎么组合,都是可以运行的。

Security 如何工作

SpringSecurity 本质

本质是一个过滤器链

image.png
代码底层流程:
重点看三个过滤器:
FilterSecurityInterceptor
ExceptionTranslationFilter
UsernamePasswordAuthenticationFilter
FilterSecurityInterceptor::是一个方法级的权限过滤器, 基本位于过滤链的最底部
image.png
super.beforeInvocation(fi) 表示查看之前的 filter 是否通过。
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());表示真正的调用后台的服务。
ExceptionTranslationFilter::是个异常过滤器,用来处理在认证授权过程中抛出的异常
image.png
*UsernamePasswordAuthenticationFilter *:对/login 的 POST 请求做拦截,校验表单中用户 名,密码。
image.png

SpringSecurity 过滤器启动原理

1.使用 springsecurity 配置过滤器(DelegatingFilterProxy); 2.该类的 doFilter 方法会通过 initDelegate(wac)先获取到过滤器 bean(FilterChainProxy),通过他去初始化其他的 filter(delegate.init(this.getFilterConfig()););
3.FilterChainProxy 的 doFilter 方法会通过 getFilters(fwRequest)方法获取到 WebApplicationContext 配置中的所有配置过的的 filter; 4.紧接着开始往后加载其他的 filter
image.png

UserDetailsService 接口讲解

当什么也没有配置的时候,账号和密码是由 Spring Security 定义生成的。而在实际项目中 账号和密码都是从数据库中查询出来的。 所以我们要通过自定义逻辑控制认证逻辑。
如果需要自定义逻辑时,只需要实现 UserDetailsService 接口即可。接口定义如下:
image.png

返回值 UserDetails
这个类是系统默认的用户”主体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 表示获取登录用户所有权限
Collection<? extends GrantedAuthority> getAuthorities();
// 表示获取密码
String getPassword();
// 表示获取用户名
String getUsername();
// 表示判断账户是否过期
boolean isAccountNonExpired();
// 表示判断账户是否被锁定
boolean isAccountNonLocked();
// 表示凭证{密码}是否过期
boolean isCredentialsNonExpired();
// 表示当前用户是否可用
boolean isEnabled();

image.png

以后我们只需要使用 User 这个实体类即可!
image.png

PasswordEncoder 接口讲解

1
2
3
4
5
6
7
8
9
10
11
// 表示把参数按照特定的解析规则进行解析
String encode(CharSequence rawPassword);
// 表示验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹
配,则返回 true;如果不匹配,则返回 false。第一个参数表示需要被解析的密码。第二个
参数表示存储的密码。
boolean matches(CharSequence rawPassword, String encodedPassword);
// 表示如果解析的密码能够再次进行解析且达到更安全的结果则返回 true,否则返回
false。默认返回 false
default boolean upgradeEncoding(String encodedPassword) {
return false;
}

image.png

BCryptPasswordEncoder 是 Spring Security 官方推荐的密码解析器,平时多使用这个解析器。
BCryptPasswordEncoder 是对 bcrypt 强散列方法的具体实现。是基于 Hash 算法实现的单向加密。可以通过 strength 控制加密强度,默认 10.
查用方法演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void test01(){
// 创建密码解析器
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
// 对密码进行加密
String atguigu = bCryptPasswordEncoder.encode("atguigu");
// 打印加密之后的数据
System.out.println("加密之后数据:\t"+atguigu);
//判断原字符加密后和加密之前是否匹配
boolean result = bCryptPasswordEncoder.matches("atguigu", atguigu);
// 打印比较结果
System.out.println("比较结果:\t"+result);
}

集成 Security

在 SpringBoot 的基础上添加依赖即可

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

image.png
使用 Spring Security 为的就是写最少的代码,实现更多的功能,在定制化 Spring Security,核心思路就是:重写某个功能,然后配置。

  • 比如你要查自己的用户表做登录,那就实现 UserDetailsService 接口;
  • 比如前后端分离项目,登录成功和失败后返回 json,那就实现 AuthenticationFailureHandler/AuthenticationSuccessHandler 接口;
  • 比如扩展 token 存放位置,那就实现 HttpSessionIdResolver 接口;
  • 等等…

最后,将上述做的更改配置到 security 里。套路就是这个套路,下边咱们实战一下。

用户登录认证逻辑

从数据库中查出登录用户的信息(如密码)、角色、权限等,然后返回一个 UserDetails 类型的实体,security 会自动根据密码和用户相关状态(是否锁定、是否启停、是否过期等)判断用户登录成功或者失败。

1、创建自定义 UserDetailsService

这是实现自定义用户认证的核心逻辑,loadUserByUsername(String username)的参数就是登录时提交的用户名,返回类型是一个叫 UserDetails 的接口,需要在这里构造出他的一个实现类 User,这是 Spring security 提供的用户信息实体。

1
2
3
4
5
6
7
public class UserDetailsServiceImpl  implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//需要构造出 org.springframework.security.core.userdetails.User 对象并返回
return null;
}
}

2、准备 sql

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for tb_user_auth
-- ----------------------------
DROP TABLE IF EXISTS `tb_user_auth`;
CREATE TABLE `tb_user_auth` (
`id` int NOT NULL AUTO_INCREMENT,
`user_info_id` int NOT NULL COMMENT '用户信息id',
`username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
`login_type` tinyint(1) NOT NULL COMMENT '登录类型',
`ip_address` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户登录ip',
`ip_source` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'ip来源',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
`last_login_time` datetime NULL DEFAULT NULL COMMENT '上次登录时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `username`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;

-- ----------------------------
-- Records of tb_user_auth
-- ----------------------------
INSERT INTO `tb_user_auth` VALUES (1, 1, 'admin@qq.com', '$2a$10$AkxkZaqcxEXdiNE1nrgW1.ms3aS9C5ImXMf8swkWUJuFGMqDl.TPW', 1, '127.0.0.1', '', '2021-08-12 15:43:18', '2021-10-06 16:44:41', '2021-10-06 16:44:41');

SET FOREIGN_KEY_CHECKS = 1;

3. 使用 MybatisPlus 生成实体类和 mapper

引入依赖

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.1</version>
</dependency>

编写自动生成代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
public class MybatisPlusGenerator {
/**
* 读取控制台内容
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotBlank(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}

public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();

// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
gc.setAuthor("dzgu");

//是否自动生成之后打开资源管理器
gc.setOpen(false);
//再次生产时是否覆盖文件
gc.setFileOverride(false);
//生成默认生成时IService,变成Service
gc.setServiceName("%sService");
gc.setIdType(IdType.AUTO);
//设置data的类型,只是用java.util.data代替
gc.setDateType(DateType.ONLY_DATE);
//实体属性 swagger2属性
gc.setSwagger2(true);
mpg.setGlobalConfig(gc);

// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=UTC");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("gudongzhou678");
dsc.setDbType(DbType.MYSQL);
mpg.setDataSource(dsc);

// 包配置
PackageConfig pc = new PackageConfig();
//pc.setModuleName(scanner("模块名"));
pc.setParent("com.dzgu.myblog");
pc.setController("controller");
pc.setService("service");
pc.setServiceImpl("service.impl");
pc.setMapper("mapper");
pc.setEntity("entity");
mpg.setPackageInfo(pc);

// 自定义配置生成的mapper
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 velocity
String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);

// 策略配置
StrategyConfig strategy = new StrategyConfig();
//驼峰命名
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
//strategy.setSuperEntityClass("你自己的父类实体,没有就不用设置!");
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
// 公共父类
// strategy.setSuperControllerClass("你自己的父类控制器,没有就不用设置!");
// 写于父类中的公共字段
// strategy.setSuperEntityColumns("id");
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
//驼峰转连接符
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix(pc.getModuleName() + "tb_");
mpg.setStrategy(strategy);
mpg.execute();
}
}

4. 制作登录实现类和自定义 UserDetails

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Data
@Builder
public class UserDetailsDTO implements UserDetails, CredentialsContainer {

………………

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 普通写法
//List<GrantedAuthority> authorities = new ArrayList<>();
//for (String role : roleList) {
// authorities.add(new SimpleGrantedAuthority(role));
//}
//return authorities;

//jdk8函数式编程写法
return this.roleList.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
}

@Override
public String getPassword() {
return this.password;
}

@Override
public String getUsername() {
return this.username;
}

/**
* 账户是否未过期,过期无法验证
*/
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 指定用户是否解锁,锁定的用户无法进行身份验证
* <p>
* 密码锁定
* </p>
*/
@Override
public boolean isAccountNonLocked() {
return this.isDisable == FALSE;
}

/**
* 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 用户是否被启用或禁用。禁用的用户无法进行身份验证。
*/
@Override
public boolean isEnabled() {
return true;
}

/**
* 认证完成后,擦除密码
*/
@Override
public void eraseCredentials() {
this.setPassword(null);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
UserAuthMapper userAuthMapper;
@Resource
private HttpServletRequest request;

@Override
public UserDetails loadUserByUsername(String username) {
if (StringUtils.isBlank((username))) {
throw new BizException("用户名不能为空");
}
// 在数据库中查询用户是否存在
UserAuth userAuth = userAuthMapper.selectOne(new LambdaQueryWrapper<UserAuth>()
.select(UserAuth::getId, UserAuth::getUserInfoId, UserAuth::getUsername, UserAuth::getPassword, UserAuth::getLoginType)
.eq(UserAuth::getUsername, username));
if (Objects.isNull(userAuth)) {
throw new BizException("用户不存在");
}
// 封装登录信息给前台
return convertUserDetails(userAuth, request);
}

/**
* 封装用户登录信息
*
* @param userAuth 用户账号
* @param request 请求
* @return 用户登录信息
*/
private UserDetailsDTO convertUserDetails(UserAuth userAuth, HttpServletRequest request) {
//.......
return UserDetailsDTO.builder()
//
.build();

}

}

5. 配置 Spring Security

5.1 接管 AuthenticationSuccessHandler
在登录认证成功后会被调用,一般要记录登录日志,然后把认证之后的用户 authentication 返给前端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Slf4j
@Component
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {

@Autowired
private UserAuthMapper userAuthMapper;

@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {

// 获取当前登录用户
UserDetailsDTO principal = (UserDetailsDTO) authentication.getPrincipal();
UserInfoDTO userInfoDTO = BeanCopyUtils.copyObject(principal, UserInfoDTO.class);
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSON.toJSONString(ApiResponse.ok(userInfoDTO)));
//登陆成功后 更新用户的登录ip和时间
UserAuth updatedPrincipal = UserAuth.builder()
.id(principal.getId())
.ipAddress(principal.getIpAddress())
.ipSource(principal.getIpSource())
.lastLoginTime(principal.getLastLoginTime())
.build();
userAuthMapper.updateById(updatedPrincipal);
//记录登录日志
log.info("===用户 {} 登录成功,登录ip {} ,登录时间 {}", principal.getUsername(), principal.getIpAddress(), principal.getLastLoginTime());
}
}

5.2 接管 AuthenticationFailHandlerImpl
登录失败后,可以根据不同的 AuthenticationException,来区分是为什么登录失败,这里需要有日志打印,然后根据业务需求,返回信息给前端。比如要求是无论什么错误,都返回登录失败,这里简单的返回登陆失败

1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@Component
public class AuthenticationFailHandlerImpl implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
String requestURI = httpServletRequest.getRequestURI();
log.warn("==用户登陆失败:" + requestURI + "==" + e.getMessage());
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSON.toJSONString(ApiResponse.fail(requestURI,LOGIN_ERROR)));
}
}

5.3 接管 LogoutSuccessHandlerImpl
和登录成功、失败类似,记录日志,然后返回前端 json。

1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Component
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
UserAuth userAuth = (UserAuth) authentication.getPrincipal();
log.warn("==用户 {} 注销成功",userAuth.getUsername() );
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(JSON.toJSONString(ApiResponse.ok()));
}
}

5.4 接管 Spring Security 异常
使用 Spring Boot2+ Spring Security 实现用户登录权限控制等操作,但是在用户登录的时候,怎么处理 Spring Security 抛出的异常呢?我们发现使用了@RestControllerAdvice 和@ExceptionHandler 不能处理 Spring Security 抛出的异常,因为 SpringSecurity 默认自带异常处理机制。Spring Security 有两个重要的异常类 :

  • AuthenticationEntryPoint :认证异常: 匿名用户在认证过程中的异常
  • AccessDeniedException : 鉴权异常,认证用户访问无权限资源时的异常

接管 AuthenticationEntryPoint 异常

1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
String requestURI = httpServletRequest.getRequestURI();
log.warn("==用户未登录" + requestURI + "==" + e.getMessage());
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(JSON.toJSONString(ApiResponse.fail(requestURI, AUTHORIZED_ERROR)));
}
}

接管 AccessDeniedHandler 异常
AccessDeniedHandler 用于在用户已经登录了,但是访问了其自身没有权限的资源时做出对应的处理。ExceptionTranslationFilter 拥有的 AccessDeniedHandler 默认是 AccessDeniedHandlerImpl,这个默认实现类会根据 errorPage 和状态码来判断,最终决定跳转的页面。对于前后端分离项目,我们需要返回 json 因此接管此个组件。

1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
String requestURI = httpServletRequest.getRequestURI();
String exceptionMsg = httpServletResponse.getHeader("ServiceException");
log.warn("==用户权限不足访问:" + requestURI + "==" + exceptionMsg);
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(JSON.toJSONString(ApiResponse.fail(requestURI, AUTHORIZED_ERROR)));
}
}

5.5 重写鉴权方法
标准的 RABC, 权限需要支持动态配置,spring security 默认是在代码里约定好权限,真实的业务场景通常需要可以支持动态配置角色访问权限,即在运行时去配置 url 对应的访问角色。
基于 spring security,如何实现这个需求呢?
最简单的方法就是自定义一个 Filter 去完成权限判断,但这脱离了 spring security 框架,如何基于 spring security 优雅的实现呢?
这里就要提到 spring security 实现动态配置 url 权限的两种方法
首先 自定义 UrlFilterInvocationSecurityMetadataSource 实现 FilterInvocationSecurityMetadataSource 重写 getAttributes()方法 获取访问该 url 所需要的角色权限信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class FilterInvocationSecurityMetadataSourceImpl implements FilterInvocationSecurityMetadataSource {


private List<ResourceRoleDTO> resourceRoleList;

@Autowired
private RoleMapper roleMapper;

/**
* 清空接口角色信息
*/
public void clearDataSource() {
resourceRoleList = null;
}

/**
* 返回该url所需要的用户权限信息
*
* @param object 储存请求url信息
* @return 标识不需要任何权限都可以访问
* @throws IllegalArgumentException
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 获取用户请求url和method
FilterInvocation fi = (FilterInvocation) object;
String method = fi.getRequest().getMethod();
String url = fi.getRequest().getRequestURI();
// TODO 查出数据库中所有url列表
// 如果修改了接口角色关系后重新加载
if (CollectionUtils.isEmpty(resourceRoleList)) {
resourceRoleList = roleMapper.listResourceRoles();
}
// 获取接口角色信息,若为匿名接口则放行,若无对应角色则禁止
AntPathMatcher antPathMatcher = new AntPathMatcher();
for (ResourceRoleDTO resourceRoleDTO : resourceRoleList) {
if (antPathMatcher.match(resourceRoleDTO.getUrl(), url) && resourceRoleDTO.getRequestMethod().equals(method)) {
List<String> roleList = resourceRoleDTO.getRoleList();
if (CollectionUtils.isEmpty(roleList)) {
return SecurityConfig.createList("disable");
}
// 保存该url对应角色权限信息
return SecurityConfig.createList(roleList.toArray(new String[]{}));
}
}
// 若无对应角色则禁止
return null;
}

@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}

@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass);
}
}

执行完之后到 下一步 AccessDecisionManager 中认证权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class AccessDecisionManagerImpl implements AccessDecisionManager {
/**
*
* @param authentication 当前登录用户的角色信息
* @param object 请求url信息
* @param collection `FilterInvocationSecurityMetadataSourceImpl`中的getAttributes方法传来的,表示当前请求需要的角色(可能有多个)
* @throws AccessDeniedException
* @throws InsufficientAuthenticationException
*/
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
// 获取当前用户的角色
List<String> permissionList = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
for (ConfigAttribute item : collection) {
if(permissionList.contains(item.getAttribute())){
return;
}
}
throw new AccessDeniedException("没有操作权限");

}

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

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

https://www.cnblogs.com/xiaoqi/p/spring-security-rabc.html
做了这么多的准备工作后,终于到了配置的时候了,Spring Security 通过建造者模式,使得配置变得简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationEntryPointImpl authenticationEntryPoint;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private AuthenticationSuccessHandlerImpl authenticationSuccessHandler;
@Autowired
private AuthenticationFailHandlerImpl authenticationFailHandler;
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;

/**
* 密码加密
*
* @return 加密方式
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

/**
* 注入我们接管的 根据请求url获取权限信息
*
* @return
*/

@Bean
public FilterInvocationSecurityMetadataSource securityMetadataSource() {
return new FilterInvocationSecurityMetadataSourceImpl();
}

/**
* 注入 我们接管的权限认证处理AccessDecisionManager
*
* @return
*/
@Bean
public AccessDecisionManager accessDecisionManager() {
return new AccessDecisionManagerImpl();
}

/**
* 注册 SessionRegistry,该 Bean 用于管理 Session 会话并发控制
*/
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}

/**
* 配置 Session 的监听器(注意:如果使用并发 Sessoion 控制,一般都需要配置该监听器)
* 解决 Session 失效后, SessionRegistry 中 SessionInformation 没有同步失效的问题
*/
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}

/**
* 配置权限
*
* @param http http
* @throws Exception 异常
*/
@Override
protected void configure(HttpSecurity http) throws Exception {


// 配置登录注销路径
http.formLogin()
.loginProcessingUrl("/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailHandler)
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler);
// 配置路由权限信息
http.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O fsi) {
fsi.setSecurityMetadataSource(securityMetadataSource());
fsi.setAccessDecisionManager(accessDecisionManager());
return fsi;
}
})
.anyRequest().permitAll()
.and()
// 关闭跨站请求防护
.csrf().disable().exceptionHandling()
// 未登录处理-授权异常
.authenticationEntryPoint(authenticationEntryPoint)
// 权限不足处理-权限异常
.accessDeniedHandler(accessDeniedHandler)
.and()
// 开启 Session 会话管理配置
.sessionManagement()
// 设置单用户的 Session 最大并发会话数量,-1 表示不受限制
.maximumSessions(2)
// 设置为 true,表示某用户达到最大会话并发数后,新会话请求会被拒绝登录
.maxSessionsPreventsLogin(true)
// 设置所要使用的 sessionRegistry,默认为 SessionRegistryImpl 实现类
.sessionRegistry(sessionRegistry());
}
}

这里注意 FilterInvocationSecurityMetadataSourceImpl 的使用,和 accessDecisionManager 不一样,ExpressionUrlAuthorizationConfigurer 并没有提供 set 方法设置 FilterSecurityInterceptor FilterInvocationSecurityMetadataSource,how to do?发现一个扩展方法 withObjectPostProcessor,通过该方法自定义一个处理 FilterSecurityInterceptor 类型的 ObjectPostProcessor 就可以修改 FilterSecurityInterceptor。
http://itboyhub.com/2021/01/25/spring-security-object-post-processor/

6. 测试访问

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
public class TestController {
@GetMapping("/hi")
public ApiResponse<?> test01() {
return ApiResponse.ok("正常访问匿名用户可访问的hi");
}

@GetMapping("/admin")
public ApiResponse<?> test02() {
return ApiResponse.ok("正常访问需要admin权限的页面");
}
}

image.png)image.png

image.png
image.png
image.png