若依使用springsecurity实现RBAC权限控制,前端部分我们就不探讨,我们主要说明的是后端部分。 我们从用户登录到访问资源这一步骤来讲解。
1. 定义角色和权限
首先,你需要定义你的角色和权限。例如,假设你有以下角色和权限:
角色:
- ADMIN
- USER
权限:
- READ_PRIVILEGES
- WRITE_PRIVILEGES
2. 数据库模型设计
你可以在数据库中设计如下表结构:
- User 表:存储用户信息。
- Role 表:存储角色信息。
- Permission 表:存储权限信息。
- User_Role 表:关联用户与角色。
- Role_Permission 表:关联角色与权限。
本文章使用若依整合springsecurity实现RBAC权限控制进行讲解
一、若依的SecurityConfig 配置
这个配置中的代码只在项目启动的时候才会执行且执行一次
- Spring Security 会根据你在这个方法中定义的规则(如哪些 URL 允许匿名访问、哪些需要认证、使用什么过滤器等),构建一个安全过滤器链。
- 这个过滤器链被创建后,每次请求都会经过该链,但不会重新执行
configure(HttpSecurity)
方法。uolv
安全过滤链属于filter,想要了解过滤器和拦截器之间的关系,可以看
在若依框架中,安全过滤链的执行顺序非常重要,确保每个请求在处理时按照以下步骤进行安全检查:
CorsFilter
:首先处理跨域请求,决定是否允许该请求进入系统。JwtAuthenticationTokenFilter
:解析 JWT Token,并将认证信息写入SecurityContext
,从而确保后续的请求能够识别出用户身份。UsernamePasswordAuthenticationFilter
(如果启用表单登录):处理用户登录请求。SecurityContextPersistenceFilter
:恢复和清理用户的认证状态。ExceptionTranslationFilter
:捕获并处理认证和授权异常。FilterSecurityInterceptor
:执行最终的权限检查,决定用户是否有权限访问该资源。
1、@EnableGlobalMethodSecurity注解
使用了这个注解,控制类中的
@PreAuthorize注解才能生效
1、@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
是 Spring Security 中的一个注解,用于启用方法级别的安全控制。它允许在方法调用之前或之后进行权限检查,以确保调用者具有适当的权限执行某些操作。以下是该注解的两个关键属性的解释:
prePostEnabled = true
:
- 这会启用 Spring Security 的
@PreAuthorize
和@PostAuthorize
注解。@PreAuthorize
: 在方法执行之前进行权限检查。例如,@PreAuthorize("hasRole('ADMIN')")
会在方法调用前检查用户是否具有ADMIN
角色。@PostAuthorize
: 在方法执行之后进行权限检查。例如,@PostAuthorize("returnObject.owner == authentication.name")
会检查返回值的某些属性是否符合用户权限。
securedEnabled = true
:
- 这会启用
@Secured
注解。@Secured
主要用于指定哪些角色可以访问某个方法。比如,@Secured("ROLE_ADMIN")
表示只有拥有ROLE_ADMIN
角色的用户才能调用这个方法。简单来说,
@EnableGlobalMethodSecurity
允许你使用注解来控制方法级别的安全,确保只有具备相应权限的用户才能执行特定方法。
2、@PreAuthorize
如果想要验证请求是否有权限,就在方法或者类上加上@PreAuthorize,如果匿名也可以访问就加上@PreAuthorize
3、在configure方法中进行配置(这个方法是关键方法)
antMatchers方法是进行url匹配规则的,permitAll是不登录也能访问,hasRole是指定角色才能访问 我们可以通过配置一堆permitAll,最后使用.anyRequest().authenticated()表示剩下的一切请求都需要认证。
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
下面是若依的spring security配置,管理了若依项目所有有关权限、登录等配置.
/**
* spring security配置
*
* @author ruoyi
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 允许匿名访问的地址
*/
@Autowired
private PermitAllUrlProperties permitAllUrl;
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
在configure方法中配置url,指明哪些不需要登录验证
二、登录流程
- 前端 提交用户名和密码到
/login
。- 请求进入 Spring Security 的过滤器链,经过
UsernamePasswordAuthenticationFilter
。AuthenticationManager
进行用户认证,通过UserDetailsService
获取用户信息并进行验证。- 根据认证结果:
- 成功:更新
SecurityContext
,返回 JWT Token 或用户信息。- (并将token写入redis中)
- 失败:触发
AuthenticationEntryPoint
返回错误信息。- 前端 接收响应并处理登录结果。
其实若依有一点做的不好,就是密码明文传输,我们开发的时候可以对密码进行非对称加密传到后端。
请求验证码的时候,后端会生成一个uuid给前端,前端发送login请求的时候会带上,这样是为了标识登录请求就是请求验证码的那个浏览器。起到绑定会话的作用。防止在这台设备请求验证码,拿着验证码在另一个设备登录。
除此之外,还能够防止重复登录.
这个uuid作为验证码存储在redis中的key,在初学登录流程的时候,我们经常用用户名作为redis中的key保存验证码,其实这样做是不对的。
下面是若依的login方法,步骤分为 验证码校验 、登录前置检验(用户名、密码的约束性判断)、
用户验证 (与数据库中的信息比对)、
public String login(String username, String password, String code, String uuid)
{
// 验证码校验
validateCaptcha(username, code, uuid);
// 登录前置校验
loginPreCheck(username, password);
// 用户验证
Authentication authentication = null;
try
{
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
三、权限控制
用户在登录成功之后,若依会自动发送getInfo请求查看用户的权限、角色、用户等信息。
这样可以实现不展示没有权限查看的页面或按钮(这个是前端部分,我们不展开讨论)
getInfo就是实现RBAC的接口
@GetMapping("getInfo")
public AjaxResult getInfo()
{
SysUser user = SecurityUtils.getLoginUser().getUser();
// 角色集合
Set<String> roles = permissionService.getRolePermission(user);
// 权限集合
Set<String> permissions = permissionService.getMenuPermission(user);
AjaxResult ajax = AjaxResult.success();
ajax.put("user", user);
ajax.put("roles", roles);
ajax.put("permissions", permissions);
return ajax;
}
获取角色集合和权限集合的逻辑都差不多,都是联系三张表
先查出角色集合,再通过角色集合查出权限集合
1、获取角色集合
传入用户的属性,先判断是不是超级管理员。如果是添加完直接返回了(因为超级管理员拥有所有权限)
这里使用set可以起到去重的目的
public Set<String> getRolePermission(SysUser user)
{
Set<String> roles = new HashSet<String>();
// 管理员拥有所有权限
if (user.isAdmin())
{
roles.add("admin");
}
else
{
roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
}
return roles;
}
查询该用户所有的角色
就是将查出来的角色添加到要返回的set集合
/**
* 根据用户ID查询权限
*
* @param userId 用户ID
* @return 权限列表
*/
@Override
public Set<String> selectRolePermissionByUserId(Long userId)
{
List<SysRole> perms = roleMapper.selectRolePermissionByUserId(userId);
Set<String> permsSet = new HashSet<>();
for (SysRole perm : perms)
{
if (StringUtils.isNotNull(perm))
{
permsSet.addAll(Arrays.asList(perm.getRoleKey().trim().split(",")));
}
}
return permsSet;
}
这是他的sql语句
<sql id="selectRoleVo">
select distinct r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.menu_check_strictly, r.dept_check_strictly,
r.status, r.del_flag, r.create_time, r.remark
from sys_role r
left join sys_user_role ur on ur.role_id = r.role_id
left join sys_user u on u.user_id = ur.user_id
left join sys_dept d on u.dept_id = d.dept_id
</sql>
<select id="selectRolePermissionByUserId" parameterType="Long" resultMap="SysRoleResult">
<include refid="selectRoleVo"/>
WHERE r.del_flag = '0' and ur.user_id = #{userId}
</select>
2、获取菜单数据权限
如果是超级管理员 就在权限集合中添加 "*:*:*",表示拥有最高权限。并返回
遍历用户的角色集合找出这些角色对应的权限,放入用户的权限集合中(使用set进行去重)
/**
* 获取菜单数据权限
*
* @param user 用户信息
* @return 菜单权限信息
*/
public Set<String> getMenuPermission(SysUser user)
{
Set<String> perms = new HashSet<String>();
// 管理员拥有所有权限
if (user.isAdmin())
{
perms.add("*:*:*");
}
else
{
List<SysRole> roles = user.getRoles();
if (!CollectionUtils.isEmpty(roles))
{
// 多角色设置permissions属性,以便数据权限匹配权限
for (SysRole role : roles)
{
Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
role.setPermissions(rolePerms);
perms.addAll(rolePerms);
}
}
else
{
perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
}
}
return perms;
}
------------------------------------------------------------------------
@Override
public Set<String> selectMenuPermsByRoleId(Long roleId)
{
List<String> perms = menuMapper.selectMenuPermsByRoleId(roleId);
Set<String> permsSet = new HashSet<>();
for (String perm : perms)
{
if (StringUtils.isNotEmpty(perm))
{
permsSet.addAll(Arrays.asList(perm.trim().split(",")));
}
}
return permsSet;
}
<select id="selectMenuPermsByRoleId" parameterType="Long" resultType="String">
select distinct m.perms
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
where m.status = '0' and rm.role_id = #{roleId}
</select>
在代码中我们可以看到,查看角色和权限是分开查的
为什么分开查询?
解耦设计:角色和权限是两个不同的概念。角色是用户的身份标识,而权限是对某些功能或资源的访问控制。将它们分开查询使得系统在逻辑上更加清晰和灵活。
灵活的权限管理:用户可以拥有多个角色,每个角色可以具有不同的权限。通过分开查询,可以更灵活地管理权限。例如,一个用户可能拥有多个角色,每个角色赋予不同的权限集合。
性能优化:在一些场景下,查询角色和权限可以分别优化。例如,某些权限可能基于动态条件生成,因此单独查询可以根据需要进行调整。
便于扩展:将角色和权限的逻辑分开,可以方便地扩展权限管理功能。例如,未来可以实现基
于角色的权限继承,或对角色进行批量修改,而不影响其他功能。
3、使用注解对用户访问进行权限控制
getInfo接口返回给前端用户、角色、权限信息,前端将不会展示没有权限的页面、按钮等。
但是后端接口也要对请求进行权限控制(前端防君子,后端防小人)
若依使用的是@PreAuthorize注解(只需要记住这个注解,面试的时候回答就行),在请求之前进行判断 @ss是若依自定义的
@PreAuthorize("@ss.hasPermi('monitor:cache:list')")
若依首创,自定义了下面这些方法
下面提供了两个方法的源码,其他就写出来了。
hasPermi就关键方法,理解他就行
public boolean hasPermi(String permission) {
// 检查传入的权限参数是否为空
if (StringUtils.isEmpty(permission)) {
return false; // 如果为空,返回 false
}
// 获取当前登录用户的信息
LoginUser loginUser = SecurityUtils.getLoginUser();
// 检查登录用户是否存在,以及用户的权限集合是否为空
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) {
return false; // 如果用户不存在或权限集合为空,返回 false
}
// 将当前权限设置到权限上下文中
PermissionContextHolder.setContext(permission);
// 调用 hasPermissions 方法判断用户是否拥有指定的权限
return hasPermissions(loginUser.getPermissions(), permission);
}
/**
* 判断是否包含权限
*
* @param permissions 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
private boolean hasPermissions(Set<String> permissions, String permission)
{
return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
}
方法逻辑:
检查所有权限:
permissions.contains(Constants.ALL_PERMISSION):首先检查用户的权限集合中是否包含一个常量 Constants.ALL_PERMISSION。这个常量通常代表用户拥有“所有权限”的标识,如果包含该标识,则直接返回 true,表示用户具备所有权限。
检查特定权限:
permissions.contains(StringUtils.trim(permission)):如果用户不具备“所有权限”,接下来会检查用户的权限集合中是否包含传入的特定权限(经过 StringUtils.trim() 方法处理,去除前后的空格)。如果包含,则返回 true。
注意:如果接口可以匿名访问可以使用@Anonymous注解,只是一个标识,没有特殊的用途。
4、扩展
那用户这些权限集合、角色集合的数据会存在哪里呢?总不能每次请求都查一遍吧,但如果是前端getInfo得到这些数据,每一次都携带这些数据过来。想想好像不安全,你本来没有这些权限的,你在请求头、请求体一加不就有了。
我首先想到的就是redis,一看结果确实是这样,前端传递token过来,后端通过token查询redis得到用户的各种信息。存入线程上下文中就可以使用了。(这个一般是拦截器实现的)
四、面试回答
问:你的项目使用了RBAC,请问他是怎么实现的吗?
答:
①RBAC是权限控制,要用到5张表,分别是用户表、用户-角色表、角色表、角色-权限表、权限表。
②在我的项目中使用springsecurity进行配置,在springsecurity加上@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)注解,使控制类中的
@PreAuthorize的注解生效。
③然后在要进行权限认证的接口上加上@PreAuthorize注解,参数自定义权限实现,里面有一个关键方法hasPermi用于验证用户是否具备某权限。主要逻辑就是判断该用户有没有超级管理员权限或者hasPermi方法指明的权限(如果用户的权限集合中有这个权限字符串或者超级管理员权限就直接返回true)
总结:五张表-->@EnableGlobalMethodSecurity注解->@PreAuthorize注解 -->传入自定义权限实现的hasPermi方法进行权限验证
可以做补充:
④在登录之后,前端发送getInfo请求得到登录用户的角色、权限等信息,这样可以实现屏蔽没有权限查看的页面和操作。当然我主要做的是后端。