这篇文章将以表结构和代码示例介绍在Spring Security中怎么实现菜单-角色的动态分配及动态鉴权。
作者:后端小肥肠
目录
1. 前言
2. 动态鉴权
2.1. 动态鉴权原理
2.2. 动态鉴权实现方式
3. 成果界面展示
4. 表结构关系
5. 核心代码讲解
5.1. 菜单-角色动态分配代码讲解
5.1.1. 将菜单与角色绑定
5.1.2. 根据用户名查询菜单
5.1.3 SysRoleAndPermissionVo实体类编写
5.2. 动态鉴权代码讲解
6. 结语
1. 前言
欢迎来到【Spring Security系列】!上一章中介绍了怎么在SpringSecurity中实现用户-角色的动态分配,本文将继续深入探讨如何实现菜单动态分配及动态鉴权。
菜单动态分配可以根据用户权限动态生成菜单,提高系统实时性、简化权限管理、降低误操作风险、增强系统安全性。
动态鉴权允许我们在更细粒度的层面上控制用户对资源的访问权限。通过业务逻辑、用户属性等动态因素,我们能够灵活地调整访问策略,提高系统的安全性。
2. 动态鉴权
2.1. 动态鉴权原理
Spring Security 的动态鉴权原理涉及到一系列的组件和概念。以下是 Spring Security 中实现动态鉴权的一般原理:
1. AccessDecisionManager(访问决策管理器): 这是 Spring Security 中的一个核心组件,负责最终判断用户是否有权限访问特定资源。AccessDecisionManager 通常包含多个 AccessDecisionVoter,每个 Voter 负责投票判断用户是否具有访问权限。
2. AccessDecisionVoter(访问决策投票器): 这是 AccessDecisionManager 中的子组件,用于投票判断用户是否有权访问资源。Spring Security 提供了多个默认的投票器,如 RoleVoter、AuthenticatedVoter 等。也可以自定义投票器来实现特定的鉴权逻辑。
3. SecurityMetadataSource(安全元数据源): 安全元数据源负责提供资源(URL、方法等)与权限的映射关系。这个映射关系通常在配置中定义,可以是静态的,也可以是动态的。SecurityMetadataSource 在运行时为 AccessDecisionManager 提供了资源与权限的对应关系。
4. 动态权限更新: 在一些场景下,权限可能需要在运行时动态更新。Spring Security 提供了一些机制来处理权限的动态变化,确保系统可以在运行时适应权限的变化。
5. 表达式语言:Spring Security支持使用表达式语言(如SpEL)来定义访问控制规则。这使得开发人员可以在运行时使用动态条件来决定是否允许访问,增加了鉴权规则的灵活性。
2.2. 动态鉴权实现方式
Spring Security 提供了多种方式来实现动态鉴权,具体的选择取决于项目的需求和复杂性。以下是一些常见的 Spring Security 动态鉴权的实现方式:
1. 基于注解的动态鉴权: 使用 @PreAuthorize
、@Secured
等注解,结合 SpEL 表达式来定义方法级别的访问控制规则。这种方式允许在代码中直接标注需要鉴权的方法,并使用表达式动态定义权限规则。
@PreAuthorize("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')")
public void adminMethod() {
// 方法逻辑
}
2. 基于方法的动态鉴权配置: 在配置类中使用 ExpressionUrlAuthorizationConfigurer
配置动态鉴权规则。通过 access
方法传递表达式,动态定义不同 URL 的权限规则。 本文将采用这种方式实现动态鉴权,以下是一个简单示例。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").access("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')")
.antMatchers("/user/**").access("hasRole('USER')")
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
}
}
3. 自定义 AccessDecisionManager: 实现自己的 AccessDecisionManager
接口,定义如何投票决策用户是否有权限访问资源。这种方式允许你完全自定义鉴权逻辑。
public class CustomAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException {
// 自定义鉴权逻辑
}
// 其他方法的实现
}
4. 自定义 SecurityMetadataSource: 实现 SecurityMetadataSource
接口,提供资源与权限的映射关系。通过实现这个接口,你可以在运行时动态获取资源与权限的对应关系。
public class CustomSecurityMetadataSource implements SecurityMetadataSource {
@Override
public Collection<ConfigAttribute> getAttributes(Object object) {
// 返回资源对应的权限配置
}
// 其他方法的实现
}
5. 使用数据库存储权限配置: 将权限配置存储在数据库中,通过自定义实现 FilterInvocationSecurityMetadataSource
从数据库中动态加载资源与权限的对应关系。
public class DatabaseSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 从数据库加载资源对应的权限配置
}
// 其他方法的实现
}
3. 成果界面展示
因目前我做的业务只控制到菜单级别,所以只显示菜单控制的相关页面,按钮级别的控制(动态鉴权)也会讲解,但是仅仅有后台表和相关代码,相关界面实现可以参考菜单管理分配及用户管理分配。
管理菜单列表,进行增删改查操作:
根据不同角色,为其分配不同对应菜单:
4. 表结构关系
sys_frontend_menu表为菜单表,存储系统中的菜单项,通过在sys_role_frontend_menu增删数据即可将相关角色与菜单进行绑定,进而实现根据角色进行菜单的动态分配。
sys_role_backend_api表中存储的是接口列表,表中存储的是所有api接口信息,可以通过在sys_role_backend_api中增删数据实现角色与菜单绑定,进而实现根据角色进行动态鉴权。
5. 核心代码讲解
5.1. 菜单-角色动态分配代码讲解
5.1.1. 将菜单与角色绑定
@Transactional
public boolean saveRoleMenu(String roleId, SysRoleAndPermissionVo... sysRoleAndPermissionVos) {
System.out.println("roleId = " + roleId);
//先删除数据
this.delRoleId(roleId);
//
if(sysRoleAndPermissionVos !=null) {
Set<SysRoleFrontendMenuTable> set = new HashSet<>();
SysRoleFrontendMenuTable roleFrontendMenu = null;
for (SysRoleAndPermissionVo roleVo : sysRoleAndPermissionVos) {
roleFrontendMenu = new SysRoleFrontendMenuTable();
//存储roleID和FrontendMenuId到多对对的中间表
roleFrontendMenu.setRoleId(roleVo.getRoleId());
roleFrontendMenu.setFrontendMenuId(roleVo.getId());
set.add(roleFrontendMenu);
}
System.out.println("set = " + set);
//再批量保存
return this.saveBatch(set);
}
return false;
}
5.1.2. 根据用户名查询菜单
根据用户名查询菜单有两种实现方式,一种是写成接口由前端来调取,另外一种是重写AuthenticationSuccessHandler,将当前登录用户对应菜单在认证成功后直接返回给前端。如下述代码:
@Component
@Slf4j
public class SecurAuthenticationSuccessHandler extends JSONAuthentication implements AuthenticationSuccessHandler {
@Autowired
ISysUserService service;
@Autowired
RedisUtils redisUtils;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
//取得账号信息
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
//用户鉴权
SecurityContextHolder.getContext().setAuthentication(authentication);
String token=redisUtils.get(TOKEN_KEY+userDetails.getUsername())==null?"":redisUtils.get(TOKEN_KEY+userDetails.getUsername()).toString();
if(token =="") {
System.out.println("初次登录,token还没有,生成新token。。。。。。");
//如果token为空,则去创建一个新的token
jwtTokenUtil = new JwtTokenUtil();
token = jwtTokenUtil.generateToken(userDetails);
//把新的token存储到缓存中
redisUtils.set(TOKEN_KEY+userDetails.getUsername(),token,3600L * 11);
}
redisUtils.sSetAndTime(VISIT_USER_KEY,60*60*24,userDetails.getUsername()+ System.currentTimeMillis());
//加载前端菜单
Map<String,Object> map = new HashMap<>();
List<UserMenuVo> menus = null;
try {
menus = service.getUserMenus(userDetails.getUsername());
} catch (Exception e) {
e.printStackTrace();
R<Map<String,Object>> data = R.failed("获取用户菜单失败");
this.WriteJSON(request, response, data);
return;
}
//
map.put("username",userDetails.getUsername());
map.put("auth",userDetails.getAuthorities());
map.put("menus",menus);
map.put("token",token);
//装入token
ResponseStructure data = ResponseStructure.success(map);
//输出
this.WriteJSON(request, response, data);
}
}
service.getUserMenus(userDetails.getUsername())为根据用户名获取菜单列表方法:
public List<UserMenuVo> getUserMenus(String username) throws Exception {
List<UserMenuVo> resUserMenus = null;
if (StringUtils.isEmpty(username)){
throw new Exception("传入参数不可为空");
}
resUserMenus = new ArrayList<>();
List<UserMenuVo> userMenuVos = baseMapper.getUserMenus(username);
// log.info("用户关联的菜单数量为:"+userMenuVos.size());
for (int i = 0; i < userMenuVos.size(); i++) {
UserMenuVo resUserMenu = new UserMenuVo();
UserMenuVo userMenuVo = userMenuVos.get(i);
resUserMenu.setFrontendMenuId(userMenuVo.getFrontendMenuId() == null ? "" : userMenuVo.getFrontendMenuId());
resUserMenu.setPid(userMenuVo.getPid() == null ? "" : userMenuVo.getPid());
resUserMenu.setFrontendMenuName(userMenuVo.getFrontendMenuName() == null ? "" : userMenuVo.getFrontendMenuName());
resUserMenu.setFrontendMenuSort(userMenuVo.getFrontendMenuSort());
resUserMenu.setFrontendMenuUrl(userMenuVo.getFrontendMenuUrl() == null ? "" : userMenuVo.getFrontendMenuUrl());
resUserMenus.add(resUserMenu);
}
return resUserMenus;
}
baseMapper.getUserMenus(username)方法为根据用户名获取菜单列表的Mapper层方法:
@Select("select DISTINCT a.id as frontendMenuId, a.frontend_menu_name as frontendMenuName,\n" +
"a.pid,a.frontend_menu_url as frontendMenuUrl,a.frontend_menu_sort as frontendMenuSort from sys_frontend_menu a,\n" +
"sys_role b,sys_frontend_menu_role c,sys_user d,\n" +
"sys_user_role e where (\n" +
"a.id=c.front_menu_id and b.id=c.role_id and\n" +
"d.id=e.user_id and e.role_id=c.role_id and d.username=#{username})ORDER BY\n" +
"a.frontend_menu_sort asc")
List<UserMenuVo> getUserMenus(@Param("username") String username);
5.1.3 SysRoleAndPermissionVo实体类编写
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SysRoleAndPermissionVo {
String id;
String name;
String roleId;
String pid;
}
5.2. 动态鉴权代码讲解
动态鉴权组件注入及配置
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
DynamicPermission dynamicPermission;
@Override
protected void configure(HttpSecurity http) throws Exception {
//放行注册API请求,其它任何请求都必须经过身份验证.
http.authorizeRequests()
.antMatchers(HttpMethod.POST,"/user/register").permitAll()
//动态加载资源
.anyRequest().access("@dynamicPermission.checkPermisstion(request,authentication)");
}
}
动态鉴权组件编写
@Component
public class DynamicPermission {
@Autowired
SysBackendApiTableService service;
/**
* 判断有访问API的权限
*
* @param request
* @param authentication
* @return
* @throws MyaccessDeniedException
*/
public boolean checkPermisstion(HttpServletRequest request,
Authentication authentication) throws MyaccessDeniedException {
Object principal = authentication.getPrincipal();
System.out.println("DynamicPermission principal = " + principal);
if (principal instanceof UserDetails) {
UserDetails userDetails = (UserDetails) principal;
//得到当前的账号
String username = userDetails.getUsername();
//Collection<? extends GrantedAuthority> roles = userDetails.getAuthorities();
// System.out.println("DynamicPermission username = " + username);
//通过账号获取资源鉴权
List<SysBackendApiTable> apiUrls = service.getApiUrlByUserName(username);
AntPathMatcher antPathMatcher = new AntPathMatcher();
//当前访问路径
String requestURI = request.getRequestURI();
//提交类型
String urlMethod = request.getMethod();
// System.out.println("DynamicPermission requestURI = " + requestURI);
//判断当前路径中是否在资源鉴权中
boolean rs = apiUrls.stream().anyMatch(item -> {
//判断URL是否匹配
boolean hashAntPath = antPathMatcher.match(item.getBackendApiUrl(), requestURI);
//判断请求方式是否和数据库中匹配(数据库存储:GET,POST,PUT,DELETE)
String dbMethod = item.getBackendApiMethod();
//处理null,万一数据库存值
dbMethod = (dbMethod == null) ? "" : dbMethod;
int hasMethod = dbMethod.indexOf(urlMethod);
System.out.println("hashAntPath = " + hashAntPath);
System.out.println("hasMethod = " + hasMethod);
System.out.println("hashAntPath && hasMethod = " + (hashAntPath && hasMethod != -1));
//两者都成立,返回真,否则返回假
return hashAntPath && (hasMethod != -1);
});
//返回
if (rs) {
return rs;
} else {
throw new MyaccessDeniedException("您没有访问该API的权限!");
}
} else {
throw new MyaccessDeniedException("不是UserDetails类型!");
}
}
}
如要实现角色与后台api接口的动态绑定可以参考菜单的相关代码,这里不再赘述。
6. 结语
本文讲解了在SpringSecurity中基于表结构和相关核心代码如何实现菜单动态分配及动态鉴权,针对本文的中的技术问题如您有更好的实现或解决方式,欢迎在评论区留言探讨~~