博客系统演示账号与游客角色实现:Spring Security RBAC 基于角色的访问控制
本小节属于补充内容,因为我本地开发的代码,需要兼顾发布到演示环境。在演示环境中,除了超级管理员角色外,为了方便小伙伴们登录后台查看效果,所以,还额外添加了一个游客账号,该账号的角色为游客,仅能执行查询相关操作,对于新增,修改,删除操作都是不允许的,防止将演示环境的数据搞的一团糟。
如下图所示,当游客登录后台管理系统后,并执行删除文章分类操作时,会被后端服务的 Spring Security 安全框架鉴权不通过,并返回前端错误提示:演示账号仅支持查询操作!
功能背景
本小节介绍如何在博客系统中为演示环境添加一个特殊账号——演示账号(游客角色)。该账号仅支持查询操作,不允许新增,修改,删除数据,以防止演示环境被误操作。
演示账号登录后执行删除文章分类等操作时,会通过 Spring Security 框架鉴权失败,返回 “演示账号仅支持查询操作!” 的错误提示。
PS:后续上传的小节代码中,可能会带有该功能,某些小伙伴下载小节源码后,在本地运行时,会报错 t_user_role 角色表不存在等问题,所以,把这块的功能也讲解一下,防止后续下载小节源码,跑起来报错,搞得一头雾水。
实现思路
- 使用数据库表存储用户角色信息,实现 RBAC(基于角色的访问控制)。
- 通过 UserDetailsService 查询用户角色。
- 使用 Spring Security 的方法级注解
@PreAuthorize进行细粒度控制。 - 自定义
AccessDeniedHandler返回友好提示。
1. 写死的角色
还记得前面小节实现的用户认证鉴权功能吗?在 UserDetailServiceImpl 这个类中 loadUserByUsername() 方法中,我们通过用户名去数据库中查询对应的用户记录,当用户存在时,则返回用户详情对象,里面包括了用户名,密码,角色信息。
这里的 authorities 角色,我们写死的字符串 ADMIN,用于表示管理员角色。要想实现演示用户功能仅支持查询,就需要围绕它做文章。
2. 创建角色表
首先,在数据库中创建一张角色表,DDL 建表语句如下:
CREATE TABLE `t_user_role` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`username` varchar(60) NOT NULL COMMENT '用户名',
`role` varchar(60) NOT NULL COMMENT '角色',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_username` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='用户角色表';
该表中,我们通过 username 作为关联字段,和 t_user 用户记录关联起来。role 字段用来表示角色名称。用户表和角色表的关系为多对多,一个用户可能有多个角色,而某个角色可能又被多位用户拥有。
2.1 新增两个用户
这里给 t_user 用户表保留了两条记录,admin 表示管理员账号,test 表示演示账号:
TIP:对应的加密密码,可以通过 PasswordEncoderConfig 配置类中的 main 方法来生成。
2.2 新增对应的角色记录
接下来,新增每个账号对应的角色记录,INSERT 语句如下:
INSERT INTO `weblog`.`t_user_role` (`id`, `username`, `role`, `create_time`) VALUES (1, 'admin', 'ROLE_ADMIN', '2023-07-07 01:21:15');
INSERT INTO `weblog`.`t_user_role` (`id`, `username`, `role`, `create_time`) VALUES (2, 'test', 'ROLE_VISITOR', '2023-07-07 01:23:33');
这里,admin 账号对应的是 ROLE_ADMIN 管理员角色,test 账号对应的是 ROLE_VISITOR 演示用户。
3. 创建 DO 类和 Mapper 接口
表、记录搞定后,我们来创建对应的 DO 类和 Mapper 接口:
在 weblog-module-common 模块下的 /domain/dos 包中,创建对应的 DO 类,代码如下:
@Data
@Builder
@TableName("t_user_role")
public class UserRoleDO {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String role;
private Date createTime;
}
在 /mapper 包下,创建接口,代码如下:
public interface UserRoleMapper extends BaseMapper<UserRoleDO> {
/**
* 根据用户名查询
* @param username
* @return
*/
default List<UserRoleDO> selectByUsername(String username) {
LambdaQueryWrapper<UserRoleDO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UserRoleDO::getUsername, username);
return selectList(wrapper);
}
}
在接口中,我们添加了一个默认方法 selectByUsername(),通过用户名查询角色列表。
4. 添加查询角色逻辑
修改 UserDetailServiceImpl 类中的代码,添加通过用户名查询角色的逻辑,代码如下:
@Service
@Slf4j
public class UserDetailServiceImpl implements UserDetailsService {
// 省略...
@Autowired
private UserRoleMapper userRoleMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 省略...
// 判断用户是否存在
if (Objects.isNull(userDO)) {
throw new UsernameNotFoundException("该用户不存在");
}
// 用户角色
List<UserRoleDO> roleDOS = userRoleMapper.selectByUsername(username);
String[] roleArr = null;
// 转数组
if (!CollectionUtils.isEmpty(roleDOS)) {
List<String> roles = roleDOS.stream().map(p -> p.getRole()).collect(Collectors.toList());
roleArr = roles.toArray(new String[roles.size()]);
}
// authorities 用于指定角色
return User.withUsername(userDO.getUsername())
.password(userDO.getPassword())
.authorities(roleArr)
.build();
}
}
这里的 authorities 角色字段,我们不再写死,而是改从数据库中查询,当角色不为空时,将其转换为数组,并传入到 authorities 字段中。
5. 鉴权不通过响应提示
RestAccessDeniedHandler 专门用于处理用户登录成功,但是访问的是受保护的资源,之前,我们仅仅打印了一行警告级别的日志,现在我们给它一个错误返参,提示用户 “演示账号仅支持查询操作!”,代码如下:
@Slf4j
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.warn("登录成功访问受保护的资源,但是权限不够: ", accessDeniedException);
// 预留,后面引入多角色时会用到
ResultUtil.fail(response, Response.fail(ResponseCodeEnum.FORBIDDEN));
}
}
5.1 添加 FORBIDDEN 枚举值
还需要在全局枚举类 ResponseCodeEnum 中添加 FORBIDDEN 枚举值,代码如下:
FORBIDDEN("20004", "演示账号仅支持查询操作!"),
6. 启用 Spring Security 方法级注解
编辑 WebSecurityConfig 配置类,添加 @EnableGlobalMethodSecurity 注解:
// 省略...
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 省略...
}
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true):这是一个 Spring Security 注解,用于启用方法级别的安全性设置。prePostEnabled = true 表示启用 @PreAuthorize 和 @PostAuthorize 注解,securedEnabled = true 表示启用 @Secured 注解。这意味着您可以在方法级别使用这些注解来定义访问控制规则。
7. 全局异常捕获
以上还没完,我们还需要在全局异常捕取类中将 AccessDeniedException 手动抛出,否则就被它给吞了。这里需要统一交给 RestAccessDeniedHandler 去处理:
package com.quanxiaoha.weblog.common.exception;
import com.quanxiaoha.weblog.common.enums.ResponseCodeEnum;
import com.quanxiaoha.weblog.common.utils.Response;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation@ResponseBody;
import javax.servlet.http.HttpServletRequest;
import java.util.Optional;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 省略...
@ExceptionHandler({ AccessDeniedException.class })
public void throwAccessDeniedException(AccessDeniedException e) throws AccessDeniedException {
// 捕获到鉴权失败异常,主动抛出,交给 RestAccessDeniedHandler 去处理
log.info("============= 捕获到 AccessDeniedException");
throw e;
}
}
注意:AccessDeniedException 的包路径为 org.springframework.security.access.AccessDeniedException,千万别导错包了!!!
TIP:如果 AccessDeniedException.class 爆红,因为它属于 Spring Security 这个框架,你还需要在 weblog-module-common 模块的 pom.xml 文件中,添加 Spring Security 的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
8. 添加鉴权注解
OK,到上面为止核心代码都添加完毕了,接下来我们在 TestController 控制器中,添加一个新的接口 /admin/update,并对其添加 @PreAuthorize("hasRole('ROLE_ADMIN')") 注解,表示在调用接口之前进行鉴权,必须是拥有 ROLE_ADMIN 管理员角色的账号发来的请求,才允许正常执行,否则提示鉴权不通过提示,代码如下:
@RestController
@Slf4j
@Api(tags = "首页模块")
public class TestController {
// 省略...
@PostMapping("/admin/update")
@ApiOperationLog(description = "测试更新接口")
@ApiOperation(value = "测试更新接口")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public Response testUpdate() {
log.info("更新成功...");
return Response.success();
}
}
9. 测试一下
最后,我们测试一下,分别用两种不同角色的账户进行登录,请求接口,看看演示账号是否真的鉴权不通过,管理员全部放行:
9.1 用管理员账号登录测试
首先登录管理员账号,拿到对应的 Token 令牌:
[插入截图]
9.2 全局参数设置
knife4j 提供了全局参数设置功能。让我们可以全局设置 Token 令牌,这样后续每次接口测试中,都会带上它,无需每次单独设置。操作步骤如下:
[插入截图1]
点击 文档管理 | 全局参数设置 | 添加参数:
[插入截图2]
按照后端登录接口约定的格式,添加请求头,名称为 Authorization,值的格式为 Bearer + 空格 + Token 令牌,点击确定。
然后,我们刷新页面,应用新的令牌,通过该令牌分别访问 /admin/test 和 /admin/update 两个接口,看看是否能够正常请求:
[插入截图3]
可以看到有校验提示信息,说明鉴权是通过的,接口能够正常执行。再来测试一下带有鉴权注解的 /admin/update 接口,图示如下:
[插入截图4]
返参提示成功,说明鉴权通过。
7.2 用游客账号登录测试
再来用游客账号登录,重新获取 Token 令牌,并设置到全局参数中,记得刷新页面。
[插入截图5]
分别再次请求两个接口,看看效果:
[插入截图6]
当我们请求 /admin/update 接口时,因为该接口必须拥有 ROLE_ADMIN 角色的用户才能正常访问,所以,后端返参提示 “演示账号仅支持查询操作!”。
10. 本小节源码下载
https://t.zsxq.com/13sP38IBy
11. 结语
本小节中,带着大家为博客添加了一个演示账号功能,通过对不同用户分配不同的角色,以及开启 Spring Security 框架的方法级别的安全性设置,手动对非查询接口添加 @PreAuthorize 鉴权注解,指定必须是拥有 ROLE_ADMIN 管理员角色才允许操作。基于这个思路,我们实现了一个通用的 RBAC 演示账号管理方案,适用于任何需要区分管理员与只读游客角色的前后端项目。
如果你有更好的 RBAC 实现方式,欢迎在评论区分享!