学习风`宇博客用户权限菜单模块
文章目录
- 用户-角色-菜单-资源 各表关系图
- 菜单 和 路由
- 菜单表及分析
- 分析
- /api/admin/user/menus接口
- MenuServiceImpl#listUserMenus
- 接口返回示例及分析
- 前端代码分析
- menu.js
- SideBar.vue
- 接口权限控制
- 资源表 及 分析
- 分析
- WebSecurityConfig
- 权限控制整体流程
- 先说登录
- UserDetailsServiceImpl
- 再看权限控制
- 自定义FilterInvocationSecurityMetadataSourceImpl
- 自定义AccessDecisionManagerImpl
- 会话管理
- 限制单个用户登录人数(挤掉登录 或 阻止登录)
- 踢人下线
- 注册会话
- 会话监听
用户-角色-菜单-资源 各表关系图

菜单 和 路由
菜单表及分析

分析
要形成右边这种菜单,需要2部分来做支撑。
- 第一部分:需要构建出菜单之间的父子级关系出来。
- 在上表中,通过id与parent_id,就可以构建出来,但是应当注意到:它这种似乎没有做超过2级的菜单。它的这种,第一级要么是菜单,要么是目录,第二级只能是菜单,不能是目录。也就是说,目录下面不能是目录,只能是菜单(目前的前端vue项目里面没有对菜单做递归)。
- 第二部分:需要嵌套路由支持。
- 也就是要做到后台管理系统的这种布局,在切换菜单的时候,右侧主区域切换到不同的组件需要嵌套路由作支撑。
- 在考虑动态路由的时候,就不要考虑父子级之间的关系,只需要知道:要展示到主区域的组件在vue项目里面的路径(vue组件所在项目路径-表中的component字段),以及该路由组件的路径(vue组件对应的路由路径-表中的path字段)
/api/admin/user/menus接口
MenuServiceImpl#listUserMenus
@Override
public List<UserMenuDTO> listUserMenus() {// 查询用户菜单信息//(仅通过tb_user_role、tb_role_menu、tb_menu连表查询 用户拥有的角色 有哪些菜单)List<Menu> menuList = menuDao.listMenusByUserInfoId(UserUtils.getLoginUser().getUserInfoId());// 获取目录列表//(仅返回上一步查询到的菜单中parent_id为null的菜单)List<Menu> catalogList = listCatalog(menuList);// 获取目录下的子菜单//(将相同parent_id的菜单进行分组,以parent_id作为key,放入map中,// 从这里就看出来了,它不支持多级菜单了)Map<Integer, List<Menu>> childrenMap = getMenuMap(menuList);// 转换前端菜单格式return convertUserMenuList(catalogList, childrenMap);
}private List<UserMenuDTO> convertUserMenuList(List<Menu> catalogList, Map<Integer, List<Menu>> childrenMap) {// 遍历每个parent_id为null的菜单,// - 如果通过上面构建的map中,能找到它的子菜单,// 那么就把它当作多级菜单处理(认为是目录),// 将它的子菜单排序,并添加到children属性中;// - 如果没有找到,就作为菜单处理(认为是菜单),创建一个UserMenuDTO,并设置component为Layout,path为菜单的path// 将此菜单(path会被置为空字符串,这个设置空字符串是有意义的),添加到刚刚创建的UserMenuDTO的children中,// 也就是说,如果一级是菜单,会把它包到里面去;// 整个过程,没有使用递归 或者 通过构建map的方式 构建多级菜单,但是如果是一级菜单,它是会把它包一层的return catalogList.stream().map(item -> {// 获取目录UserMenuDTO userMenuDTO = new UserMenuDTO();List<UserMenuDTO> list = new ArrayList<>();// 获取目录下的子菜单List<Menu> children = childrenMap.get(item.getId());if (CollectionUtils.isNotEmpty(children)) {// 多级菜单处理userMenuDTO = BeanCopyUtils.copyObject(item, UserMenuDTO.class);list = children.stream().sorted(Comparator.comparing(Menu::getOrderNum)).map(menu -> {UserMenuDTO dto = BeanCopyUtils.copyObject(menu, UserMenuDTO.class);dto.setHidden(menu.getIsHidden().equals(TRUE));return dto;}).collect(Collectors.toList());} else {// 一级菜单处理userMenuDTO.setPath(item.getPath());userMenuDTO.setComponent(COMPONENT); // "Layouot"list.add(UserMenuDTO.builder().path("").name(item.getName()).icon(item.getIcon()).component(item.getComponent()).build());}userMenuDTO.setHidden(item.getIsHidden().equals(TRUE));userMenuDTO.setChildren(list);return userMenuDTO;}).collect(Collectors.toList());
}
接口返回示例及分析
- 观察下面的 首页 和 个人中心 的确是被包了一层,
- 刚刚提到 前端需要 侧边栏菜单 和 添加动态路由,那么这里只提供一个接口的话,并且里面没有分
菜单和路由,那么前端势必就要自己组装 出合适的数据格式了。- 大致猜想下,这2部分内容该如何组装出来?
- 路由:首先分析路由,这个比较简单,从下面的数据返回就可以看出来,它实际上已经大致和vue-router所需要的路由类似了,只需要把component的部分,通过
异步组件加载方式,把它导入进去就可以了。比如:下面的首页,当匹配到/,就会默认展示Layout,然后由于里面有一个path为空字符串的子路由,vue-router会把这个子路由渲染到Layout的路由出口的地方。里面还有个小问题,比如说下面的文章管理,它的path是/article-submenu,那我直接在地址上输入这个路径的话,它是会渲染一个Layout组件,然后路由出口是空的,也就是主区域是空白的,此时也可以给文章管理加一个path为空字符串的子路由,让它显示一个默认的页面,当然这个目录是点击不了的,只是为了防止用户输入这个路劲而已。此处可以参考:vue3后台管理系统、vue2异步组件 - 菜单: 侧边栏第一层级的菜单有可能是菜单,也有可能是目录,目录是不能点击的,只能作展开/收缩。那如何区分它们呢?因为使用element-ui组件去渲染左侧菜单,那么就必须知道,当前这个菜单有没有子菜单,如果有子菜单,用的是el-sub-menu,如果直接是一个菜单的话,那就是el-menu-item(此处可参考:vue3后台管理系统 的 使用el-menu创建侧边栏菜单 部分),可以通过name来进行判断,因为通过包了一层的方式生成的最外面的那层菜单的name是没有赋值的,因此,它肯定为null,也就是说,碰到为null的name的一级菜单,直接拿这个菜单下面的一个子菜单(这种只会存在一个子菜单),比如首页、个人中心就是这样的。还有的就是有name的菜单就通过el-sub-menu把它渲染出来,这样,他就是一个目录了。
- 路由:首先分析路由,这个比较简单,从下面的数据返回就可以看出来,它实际上已经大致和vue-router所需要的路由类似了,只需要把component的部分,通过
- 大致猜想下,这2部分内容该如何组装出来?
{"flag":true,"code":20000,"message":"操作成功","data":[{"name":null,"path":"/","component":"Layout","icon":null,"hidden":false,"children":[{"name":"首页","path":"","component":"/home/Home.vue","icon":"el-icon-myshouye","hidden":null,"children":null}]},{"name":"文章管理","path":"/article-submenu","component":"Layout","icon":"el-icon-mywenzhang-copy","hidden":false,"children":[{"name":"发布文章","path":"/articles","component":"/article/Article.vue","icon":"el-icon-myfabiaowenzhang","hidden":false,"children":null},{"name":"修改文章","path":"/articles/*","component":"/article/Article.vue","icon":"el-icon-myfabiaowenzhang","hidden":true,"children":null},{"name":"文章列表","path":"/article-list","component":"/article/ArticleList.vue","icon":"el-icon-mywenzhangliebiao","hidden":false,"children":null},{"name":"分类管理","path":"/categories","component":"/category/Category.vue","icon":"el-icon-myfenlei","hidden":false,"children":null},{"name":"标签管理","path":"/tags","component":"/tag/Tag.vue","icon":"el-icon-myicontag","hidden":false,"children":null}]},{"name":"消息管理","path":"/message-submenu","component":"Layout","icon":"el-icon-myxiaoxi","hidden":false,"children":[{"name":"评论管理","path":"/comments","component":"/comment/Comment.vue","icon":"el-icon-mypinglunzu","hidden":false,"children":null},{"name":"留言管理","path":"/messages","component":"/message/Message.vue","icon":"el-icon-myliuyan","hidden":false,"children":null}]},{"name":"用户管理","path":"/users-submenu","component":"Layout","icon":"el-icon-myyonghuliebiao","hidden":false,"children":[{"name":"用户列表","path":"/users","component":"/user/User.vue","icon":"el-icon-myyonghuliebiao","hidden":false,"children":null},{"name":"在线用户","path":"/online/users","component":"/user/Online.vue","icon":"el-icon-myyonghuliebiao","hidden":false,"children":null}]},{"name":"权限管理","path":"/permission-submenu","component":"Layout","icon":"el-icon-mydaohanglantubiao_quanxianguanli","hidden":false,"children":[{"name":"角色管理","path":"/roles","component":"/role/Role.vue","icon":"el-icon-myjiaoseliebiao","hidden":false,"children":null},{"name":"接口管理","path":"/resources","component":"/resource/Resource.vue","icon":"el-icon-myjiekouguanli","hidden":false,"children":null},{"name":"菜单管理","path":"/menus","component":"/menu/Menu.vue","icon":"el-icon-mycaidan","hidden":false,"children":null}]},{"name":"系统管理","path":"/system-submenu","component":"Layout","icon":"el-icon-myshezhi","hidden":false,"children":[{"name":"网站管理","path":"/website","component":"/website/Website.vue","icon":"el-icon-myxitong","hidden":false,"children":null},{"name":"页面管理","path":"/pages","component":"/page/Page.vue","icon":"el-icon-myyemianpeizhi","hidden":false,"children":null},{"name":"友链管理","path":"/links","component":"/friendLink/FriendLink.vue","icon":"el-icon-mydashujukeshihuaico-","hidden":false,"children":null},{"name":"关于我","path":"/about","component":"/about/About.vue","icon":"el-icon-myguanyuwo","hidden":false,"children":null}]},{"name":"相册管理","path":"/album-submenu","component":"Layout","icon":"el-icon-myimage-fill","hidden":false,"children":[{"name":"相册列表","path":"/albums","component":"/album/Album.vue","icon":"el-icon-myzhaopian","hidden":false,"children":null},{"name":"照片管理","path":"/albums/:albumId","component":"/album/Photo.vue","icon":"el-icon-myzhaopian","hidden":true,"children":null},{"name":"照片回收站","path":"/photos/delete","component":"/album/Delete.vue","icon":"el-icon-myhuishouzhan","hidden":true,"children":null}]},{"name":"说说管理","path":"/talk-submenu","component":"Layout","icon":"el-icon-mypinglun","hidden":false,"children":[{"name":"发布说说","path":"/talks","component":"/talk/Talk.vue","icon":"el-icon-myfabusekuai","hidden":false,"children":null},{"name":"说说列表","path":"/talk-list","component":"/talk/TalkList.vue","icon":"el-icon-myiconfontdongtaidianji","hidden":false,"children":null},{"name":"修改说说","path":"/talks/:talkId","component":"/talk/Talk.vue","icon":"el-icon-myshouye","hidden":true,"children":null}]},{"name":"日志管理","path":"/log-submenu","component":"Layout","icon":"el-icon-myguanyuwo","hidden":false,"children":[{"name":"操作日志","path":"/operation/log","component":"/log/Operation.vue","icon":"el-icon-myguanyuwo","hidden":false,"children":null}]},{"name":null,"path":"/setting","component":"Layout","icon":null,"hidden":false,"children":[{"name":"个人中心","path":"","component":"/setting/Setting.vue","icon":"el-icon-myuser","hidden":null,"children":null}]}]
}
前端代码分析
menu.js
- 下面的代码只遍历了2层,只处理了图标 和 路由的组件异步加载,和 Layout的字符串转为实际的Layout组件,这些都是vue-router的要求。
- 路由 和 菜单 用的 是同一份数据。此处可与vue3后台管理系统 # 调整路由处作对比学习,感觉的确他的更加灵活一点,他的可以不同path的路径都可以用Layout作为App.vue的路由出口展示的组件。我的是直接就当作Layout的子路由了,但更加简单,但有一点必须作为前提,那就是一点要跟着vue-router的用法走,这个是大前提,所以做的时候,肯定需要先把静态路由搭建出来,确认没问题之后,再搞动态路由。
import Layout from "@/layout/index.vue";
import router from "../../router";
import store from "../../store";
import axios from "axios";
import Vue from "vue";export function generaMenu() {// 查询用户菜单axios.get("/api/admin/user/menus").then(({ data }) => {if (data.flag) {var userMenuList = data.data;userMenuList.forEach(item => {if (item.icon != null) {item.icon = "iconfont " + item.icon;}if (item.component == "Layout") {item.component = Layout;}if (item.children && item.children.length > 0) {item.children.forEach(route => {route.icon = "iconfont " + route.icon;route.component = loadView(route.component);});}});// 添加侧边栏菜单store.commit("saveUserMenuList", userMenuList);// 添加菜单到路由router.addRoutes(userMenuList);} else {Vue.prototype.$message.error(data.message);router.push({ path: "/login" });}});
}export const loadView = view => {// 路由懒加载return resolve => require([`@/views${view}`], resolve);
};
SideBar.vue
- 此处可对照 vue3后台管理系统 # 使用el-menu创建侧边栏菜单
- 下面只做了2级遍历。多级菜单实现可参考: vue3后台管理系统 # 创建TreeMenu.vue递归组件
<template><div><el-menuclass="side-nav-bar"router:collapse="this.$store.state.collapse":default-active="this.$route.path"background-color="#304156"text-color="#BFCBD9"active-text-color="#409EFF"><template v-for="route of this.$store.state.userMenuList"><!-- 二级菜单 --><template v-if="route.name && route.children && !route.hidden"><el-submenu :key="route.path" :index="route.path"><!-- 二级菜单标题 --><template slot="title"><i :class="route.icon" /><span>{{ route.name }}</span></template><!-- 二级菜单选项 --><template v-for="(item, index) of route.children"><el-menu-item v-if="!item.hidden" :key="index" :index="item.path"><i :class="item.icon" /><span slot="title">{{ item.name }}</span></el-menu-item></template></el-submenu></template><!-- 一级菜单 --><template v-else-if="!route.hidden"><el-menu-item :index="route.path" :key="route.path"><i :class="route.children[0].icon" /><span slot="title">{{ route.children[0].name }}</span></el-menu-item></template></template></el-menu></div>
</template>
接口权限控制
资源表 及 分析

分析
- 将系统中的每一controller里面的接口,当作一个资源,接口名称就是资源名称、接口访问路径就是资源url。每一个controller类也是一个资源,它用来管理内部的接口(作为它们的父资源,父资源的parent_id为null),也就是说里面只会存在2级关系。
- 使用角色 去 关联 资源,用户 去 关联 角色,因此,就可以确定一个用户拥有哪些资源。一个角色如果关联了某个controller下面的某个或者某几个资源,那么它一定关联了这个controller资源(也就是子关联了,那么父也一定要关联)。那个菜单也应如此,但是我发现,角色分配菜单那里,选择了子菜单,却没有自动勾选对应的父级菜单,连父级菜单都没的话,返回的就是空菜单。资源那里是正常的。
- 当确定某个用户具有哪些角色,就可以确定这个用户拥有了哪些资源,其实,就是拥有了哪些接口的访问权限,接口的访问权限是通过security这个权限框架控制的,并且博客中是做到了
动态权限控制,即新增或者修改资源、更新角色 与 资源的关系时,项目不需要重启,用户也不需要退出再登录,即可按修改后的接口访问权限实时的生效,但是用户登录后,再修改这个用户的角色,这个是不能实时生效的,需要退出退出再登录。
WebSecurityConfig
package com.minzheng.blog.config;import com.minzheng.blog.handler.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.session.HttpSessionEventPublisher;/*** Security配置类** @author yezhiqiu* @date 2021/07/29*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate AuthenticationEntryPointImpl authenticationEntryPoint;@Autowiredprivate AccessDeniedHandlerImpl accessDeniedHandler;@Autowiredprivate AuthenticationSuccessHandlerImpl authenticationSuccessHandler;@Autowiredprivate AuthenticationFailHandlerImpl authenticationFailHandler;@Autowiredprivate LogoutSuccessHandlerImpl logoutSuccessHandler;@Beanpublic FilterInvocationSecurityMetadataSource securityMetadataSource() {return new FilterInvocationSecurityMetadataSourceImpl();}@Beanpublic AccessDecisionManager accessDecisionManager() {return new AccessDecisionManagerImpl();}@Beanpublic SessionRegistry sessionRegistry() {return new SessionRegistryImpl();}@Beanpublic HttpSessionEventPublisher httpSessionEventPublisher() {return new HttpSessionEventPublisher();}/*** 密码加密** @return {@link PasswordEncoder} 加密方式*/@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}/*** 配置权限** @param http http* @throws Exception 异常*/@Overrideprotected 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>() {@Overridepublic <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().sessionManagement().maximumSessions(20).sessionRegistry(sessionRegistry());}}
权限控制整体流程
先说登录
- 用户登录是通过Security配置的formLogin()配置的UsernamePasswordAuthenticationFilter这个过滤器来登录的,这个过滤器会提取登录请求中的"username","password"请求参数,交给认证管理器作认证,而认证管理器是security默认配置的,(具体可以看AbstractAuthenticationFilterConfigurer#configure(B http)这里是从sharedObject中拿到AuthenticationManager设置到UsernamePasswordAuthenticationFilter过滤器中)。而认证管理器是security它自己会默认创建一个,并且它默认会去寻找spring容器中定义的UserDetailsService这个类型的bean(具体见InitializeUserDetailsManagerConfigurer#configure(AuthenticationManagerBuilder auth)),里面的配置过程比较复杂(见Spring Security框架配置运行流程完整分析),
- 需要花费不少的时间才能看明白配置过程,但只需要知道它默认会去寻找定义的UserDetailsService实现类bean,设置到AuthenticationManager认证管理中,而formLogin()配置的UsernamePasswordAuthenticationFilter正是需要认证管理器,因此实际的查询用户操作就交给了我们配置的UserDetailsService这个bean中。
- 登录代码如下,实际上就是查询出了当前用户拥有的角色、当前用户点赞过哪些文章、评论、说说,以及ip地址和来源地和浏览器等
UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate UserAuthDao userAuthDao;@Autowiredprivate UserInfoDao userInfoDao;@Autowiredprivate RoleDao roleDao;@Autowiredprivate RedisService redisService;@Resourceprivate HttpServletRequest request;@Overridepublic UserDetails loadUserByUsername(String username) {if (StringUtils.isBlank(username)) {throw new BizException("用户名不能为空!");}// 查询账号是否存在UserAuth userAuth = userAuthDao.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 convertUserDetail(userAuth, request);}/*** 封装用户登录信息** @param user 用户账号* @param request 请求* @return 用户登录信息*/public UserDetailDTO convertUserDetail(UserAuth user, HttpServletRequest request) {// 查询账号信息UserInfo userInfo = userInfoDao.selectById(user.getUserInfoId());// 查询账号角色List<String> roleList = roleDao.listRolesByUserInfoId(userInfo.getId());// 查询账号点赞信息Set<Object> articleLikeSet = redisService.sMembers(ARTICLE_USER_LIKE + userInfo.getId());Set<Object> commentLikeSet = redisService.sMembers(COMMENT_USER_LIKE + userInfo.getId());Set<Object> talkLikeSet = redisService.sMembers(TALK_USER_LIKE + userInfo.getId());// 获取设备信息String ipAddress = IpUtils.getIpAddress(request);String ipSource = IpUtils.getIpSource(ipAddress);UserAgent userAgent = IpUtils.getUserAgent(request);// 封装权限集合return UserDetailDTO.builder().id(user.getId()).loginType(user.getLoginType()).userInfoId(userInfo.getId()).username(user.getUsername()).password(user.getPassword()).email(userInfo.getEmail()).roleList(roleList).nickname(userInfo.getNickname()).avatar(userInfo.getAvatar()).intro(userInfo.getIntro()).webSite(userInfo.getWebSite()).articleLikeSet(articleLikeSet).commentLikeSet(commentLikeSet).talkLikeSet(talkLikeSet).ipAddress(ipAddress).ipSource(ipSource).isDisable(userInfo.getIsDisable()).browser(userAgent.getBrowser().getName()).os(userAgent.getOperatingSystem().getName()).lastLoginTime(LocalDateTime.now(ZoneId.of(SHANGHAI.getZone()))).build();}}
再看权限控制
看这一部分之前,需要先搞懂security他的工作原理,它是基于filter过滤器实现的,可以先看:Security源码学习笔记&OAuth2 # 第十节部分 关于FilterIntercetor的介绍。配置的起源在于使用http.authorizeRequests()修改了其中默认配置的组件,而替换成了博客中使用的组件。
自定义FilterInvocationSecurityMetadataSourceImpl
security会把当前访问的资源请求对象,封装为FilterInvocation,把它交给SecurityMetadataSource#getAttributes方法,以获得访问当前资源请求对象所需要的权限。
下面代码的过程就是在通过ant-style的路径匹配,根据配置的资源url,查询到访问当前的资源可以是哪些角色,也就是说,用户必须要有返回中的任一角色,才能访问FilterInvocation,否则不允许访问。
下面还有一个返回指定“disable”固定字符串的意思是没有任何角色能够访问这个资源,除非你有一个disable的角色,但这个角色显然不存在,也就是没人可以访问这个资源。
还有一点是:在查询之前给了一个钩子,如果resourceRoleList为null(也就是有地方修改了这个属性为null),那就重新加载这个resourceList。
package com.minzheng.blog.handler;import com.minzheng.blog.dao.RoleDao;
import com.minzheng.blog.dto.ResourceRoleDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.CollectionUtils;import javax.annotation.PostConstruct;
import java.util.Collection;
import java.util.List;/*** 接口拦截规则** @author yezhiqiu* @date 2021/07/27*/
@Component
public class FilterInvocationSecurityMetadataSourceImpl implements FilterInvocationSecurityMetadataSource {/*** 资源角色列表*/private static List<ResourceRoleDTO> resourceRoleList;@Autowiredprivate RoleDao roleDao;/*** 加载资源角色信息*/@PostConstructprivate void loadDataSource() {resourceRoleList = roleDao.listResourceRoles();}/*** 清空接口角色信息*/public void clearDataSource() {resourceRoleList = null;}@Overridepublic Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {// 修改接口角色关系后重新加载if (CollectionUtils.isEmpty(resourceRoleList)) {this.loadDataSource();}FilterInvocation fi = (FilterInvocation) object;// 获取用户请求方式String method = fi.getRequest().getMethod();// 获取用户请求UrlString url = fi.getRequest().getRequestURI();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");}return SecurityConfig.createList(roleList.toArray(new String[]{}));}}return null;}@Overridepublic Collection<ConfigAttribute> getAllConfigAttributes() {return null;}@Overridepublic boolean supports(Class<?> aClass) {return FilterInvocation.class.isAssignableFrom(aClass);}}
自定义AccessDecisionManagerImpl
在上一步,获取到了访问某一资源需要的权限后,接下来,按照security的尿性,它会交给访问决策管理器,然后决策管理器会交给投票器,然后再根据投票结果确定是否能访问当前资源。
但是博客中是直接查询到当前用户拥有的权限,然后看这些权限有没有符合要求的,一旦发现有符合要求的,直接返回,否则,抛出异常。这里所说的权限,是要看UserDetailDTO#getAuthorities(它实现了UserDetails接口),从该方法中可以看出,就是指的角色。
@Component
public class AccessDecisionManagerImpl implements AccessDecisionManager {@Overridepublic void decide(Authentication authentication, Object o, 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("没有操作权限");}@Overridepublic boolean supports(ConfigAttribute configAttribute) {return true;}@Overridepublic boolean supports(Class<?> aClass) {return true;}
}
会话管理
用户登录完成后,在后面的请求中如何标识到这个用户呢?以前的时候用的是cookie,前后端分离项目中一般用的是请求头,如Authorization。但是在博客中使用的是cookie做的会话管理,后面的请求都是通过cookie来标识用户的,也就是如果用户仅用了cookie,那就无法登录(可以登录成功,但是后面再发的请求就报用户未登录),也就是说只要删除某个cookie,那就把对应的用户给踢下线了。Security当用户登录完成后,会把用户认证的对象存入SecurityContext中,而SecurityContext会被存入HttpSession中,在security遇到一个请求时,第二个处理的过滤器就是:SecurityContextPersistenceFilter,它就是负责从会话(前端须传入cookie)中获取登陆时存入的SecurityContext,然后把它设置到当前线程上下文中。这也就是说,如果,我们需要改成请求头的方式,而不是用cookie,则需要自己实现这一段逻辑。
明白了上面的逻辑后,可以参考再参考下会话管理的源码:Springsecurity会话管理与配置
它也是通过过滤器来实现的,通过http.sessionManagement()添加的,
- 一个SessionManagementFilter会话管理过滤器,它负责使用自己的securityContextRepository来对每个请求,验证是否有SecurityContext,如果有,直接放行;如果没有,则尝试获取绑定到当前线程的Authentcation,如果能获取到,则让会话认证策略处理,并存入自己的securityContextRepository。
- 一个ConcurrentSessionFilter过滤器。其中:SessionManagementFilter
限制单个用户登录人数(挤掉登录 或 阻止登录)
这里似乎发现了原博客中的一点小问题,需要重写UserDetailDTO的hashcode和equals方法。不重写的话,达不到控制单个用户会话数量的效果。
在SessionManagementConfigurer#init(H http)中,会去获取会话认证策略,如果用户有设置maximumSessions这个属性,那么就会添加一个会话并发控制的会话策略到会话认证策略中,并且把它设置到sharedObject中。
而在formLogin()配置的AbstractAuthenticationFilterConfigurer在configure方法中,是有去从sharedObject拿会话认证策略的,显然,init方法执行的顺序在configure方法之前,所以,是能拿到的,所以,用户在UsernamePasswordAuthenticationFilter的父类AbstractAuthenticationProcessingFilter登录之后,就会来到ConcurrentSessionControlAuthenticationStrategy#onAuthentication方法里面,去判断数量是否超过允许的最大会话数量,如果超过,是否阻止当前登录或者移除最近未使用的会话,并让它们失效,注意,这个失效只是标记了SessionInformation的expired属性为true,真正的会话失效,在ConcurrentSessionFilter的doFilter方法中。
踢人下线
踢人下线的道理,跟上面是一样的,只要从sessionRegistry中找到对应的用户id的SessionInformation会话信息,标记它们失效,下次这些用户请求时,在ConcurrentSessionFilter的doFilter方法中会去检查它们,如果被标记为失效,那就走失效逻辑。
注册会话
上面提到了会话删除,那么会话是什么时候注册进去的呢?同样是在SessionManagementConfigurer#init(H http)中获取会话认证策略时,除了会添加那个ConcurrentSessionControlAuthenticationStrategy,也会添加RegisterSessionAuthenticationStrategy,那么在登录的时候在AbstractAuthenticationProcessingFilter就会来到RegisterSessionAuthenticationStrategy中去注册一个会话,然后放入sessionRegistry中,这样就起到了管理会话的作用
会话监听
当用户长时间无操作时,用户的这个会话应当要失效。web中的HttpSession会话,当长时间无访问时(默认30分钟),就会自己invalidate失效掉。那么security中既然用SessionRegistryImpl去维护HttpSession,那么它应当要监听会话销毁时间,因此,在博客中,注册了HttpSessionEventPublisher这个bean,它实现了HttpSessionListener接口来监听httpsession的各种事件,然后把事件通过spring容器发布出来。而SessionRegistry则应当要监听这些会话事件,可以看下SessionRegistryImpl的实现,它实现了ApplicationListener接口,监听的泛型事件类型为AbstractSessionEvent。这里面其实就是用到了spring的事件机制。
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {protected final Log logger = LogFactory.getLog(SessionRegistryImpl.class);// <principal:Object,SessionIdSet>private final ConcurrentMap<Object, Set<String>> principals;// <sessionId:Object,SessionInformation>private final Map<String, SessionInformation> sessionIds;public SessionRegistryImpl() {this.principals = new ConcurrentHashMap<>();this.sessionIds = new ConcurrentHashMap<>();}public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals,Map<String, SessionInformation> sessionIds) {this.principals = principals;this.sessionIds = sessionIds;}@Overridepublic List<Object> getAllPrincipals() {return new ArrayList<>(this.principals.keySet());}@Overridepublic List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {Set<String> sessionsUsedByPrincipal = this.principals.get(principal);if (sessionsUsedByPrincipal == null) {return Collections.emptyList();}List<SessionInformation> list = new ArrayList<>(sessionsUsedByPrincipal.size());for (String sessionId : sessionsUsedByPrincipal) {SessionInformation sessionInformation = getSessionInformation(sessionId);if (sessionInformation == null) {continue;}if (includeExpiredSessions || !sessionInformation.isExpired()) {list.add(sessionInformation);}}return list;}@Overridepublic SessionInformation getSessionInformation(String sessionId) {Assert.hasText(sessionId, "SessionId required as per interface contract");return this.sessionIds.get(sessionId);}@Overridepublic void onApplicationEvent(AbstractSessionEvent event) {if (event instanceof SessionDestroyedEvent) {SessionDestroyedEvent sessionDestroyedEvent = (SessionDestroyedEvent) event;String sessionId = sessionDestroyedEvent.getId();removeSessionInformation(sessionId);}else if (event instanceof SessionIdChangedEvent) {SessionIdChangedEvent sessionIdChangedEvent = (SessionIdChangedEvent) event;String oldSessionId = sessionIdChangedEvent.getOldSessionId();if (this.sessionIds.containsKey(oldSessionId)) {Object principal = this.sessionIds.get(oldSessionId).getPrincipal();removeSessionInformation(oldSessionId);registerNewSession(sessionIdChangedEvent.getNewSessionId(), principal);}}}@Overridepublic void refreshLastRequest(String sessionId) {Assert.hasText(sessionId, "SessionId required as per interface contract");SessionInformation info = getSessionInformation(sessionId);if (info != null) {info.refreshLastRequest();}}@Overridepublic void registerNewSession(String sessionId, Object principal) {Assert.hasText(sessionId, "SessionId required as per interface contract");Assert.notNull(principal, "Principal required as per interface contract");if (getSessionInformation(sessionId) != null) {removeSessionInformation(sessionId);}if (this.logger.isDebugEnabled()) {this.logger.debug(LogMessage.format("Registering session %s, for principal %s", sessionId, principal));}this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {if (sessionsUsedByPrincipal == null) {sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();}sessionsUsedByPrincipal.add(sessionId);this.logger.trace(LogMessage.format("Sessions used by '%s' : %s", principal, sessionsUsedByPrincipal));return sessionsUsedByPrincipal;});}@Overridepublic void removeSessionInformation(String sessionId) {Assert.hasText(sessionId, "SessionId required as per interface contract");SessionInformation info = getSessionInformation(sessionId);if (info == null) {return;}if (this.logger.isTraceEnabled()) {this.logger.debug("Removing session " + sessionId + " from set of registered sessions");}this.sessionIds.remove(sessionId);this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {this.logger.debug(LogMessage.format("Removing session %s from principal's set of registered sessions", sessionId));sessionsUsedByPrincipal.remove(sessionId);if (sessionsUsedByPrincipal.isEmpty()) {// No need to keep object in principals Map anymorethis.logger.debug(LogMessage.format("Removing principal %s from registry", info.getPrincipal()));sessionsUsedByPrincipal = null;}this.logger.trace(LogMessage.format("Sessions used by '%s' : %s", info.getPrincipal(), sessionsUsedByPrincipal));return sessionsUsedByPrincipal;});}}相关文章:
学习风`宇博客用户权限菜单模块
文章目录 用户-角色-菜单-资源 各表关系图菜单 和 路由菜单表及分析分析 /api/admin/user/menus接口MenuServiceImpl#listUserMenus接口返回示例及分析 前端代码分析menu.jsSideBar.vue 接口权限控制资源表 及 分析分析 WebSecurityConfig权限控制整体流程先说登录UserDetailsS…...
centos7.6部署ELK集群(一)之elasticsearch7.7.0集群部署
32.3. 部署es7.7.0 32.3.1. 下载es(各节点都做) wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.7.0-linux-x86_64.tar.gz 32.3.2. 解压至安装目录(各节点都做) tar -xvf elasticsearch-7.7.0-li…...
leetcode142. 环形链表 II
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数…...
Linux: network: dummy 类型网络接口
文章目录 参考创建一个重要的用途是在container平台的应用dpdk相关的一个用途另一个用途ChatGPT的回复参考 https://tldp.org/LDP/nag/node72.html 这里举了一个例子,说为什么需要dummy类型的接口:就是一个类local loopback的一个接口,当应用需要给另一个本地的应用发送包的…...
java记录-lambda表达式、接口应用、方法引用
基本形式 (str)->{System.out.println(str) };调用作为参数的接口实例的方法 1、用一个类实现接口,然后使用该类实例调用方法 2、匿名内部类 3、在 接口(不能是抽象类) 有且只有一个抽象方法时,可以使用lamda表达式来重写这个…...
AI写作机器人-ai文章生成器在线
使用AI续写生成器,让内容创作事半功倍! 随着人工智能技术的不断进步和应用,AI续写生成器的出现为内容创作带来了全新的革命。这种技术可以让你的写作事半功倍,让你轻松生成高质量的文章和内容。在这篇文章中,我们将介绍…...
HarmonyOS原子化服务卡片整改、下架、升级失败部分原因及处理办法
随着HarmonyOS应用体系相关规则、团队的不断发展和完善,早期上架运营的HarmonyOS原子化服务卡片,很多都收到了整改、下架的通知,主要集中在用户协议、隐私声明、服务卡片的设计规范性等细节方面的问题;需要进行优化调整升级才行。…...
博客系统测试报告【可上线】
目录 1、测试概述 1.1、项目名称 1.2、测试时间 1.3、编写目的 1.4、测试范围 2、测试计划 2.1、测试用例 2.1.1、注册/登录模块 2.1.2、个人中心模块 2.1.3、找回密码模块 2.1.4、博客主列表模块 2.1.5、个人博客列表模块 2.1.6、个人草稿列表模块 2.1.7、博客详…...
shell中的for循环和if判断
一.编写脚本for1.sh,使用for循环创建20账户,账户名前缀由用户从键盘输入,账户初始密码由用户输入,例如: test1、test2、test3、.....、 test10 1.创建脚本for1.sh [rootserver ~]# vim for1.sh 2.编写脚本for1.sh 3.执行脚本for1.sh [roo…...
【Unity入门】16.脚本引用组件
【Unity入门】脚本引用组件 大家好,我是Lampard~~ 欢迎来到Unity入门系列博客,所学知识来自B站阿发老师~感谢 (一)脚本引用普通组件 (1)点击控制音频播放 还记得我们的车载音乐AudioSource吗?…...
无线蓝牙耳机哪款音质好?目前音质最好的无线蓝牙耳机推荐
现如今,蓝牙耳机已经是一个非常实用且常见的数码产品了,不少人喜欢戴着蓝牙耳机听歌,玩游戏。一款音质好的蓝牙耳机不止能听个响,还能给人极致的听觉享受。在此,我来给大家分享几款目前音质最好的无线蓝牙耳机…...
【云原生进阶之容器】第六章容器网络6.6.1--Cilium网络方案概述
《云原生进阶之容器》专题索引: 第一章Docker核心技术1.1节——Docker综述第一章Docker核心技术1.2节——Linux容器LXC第一章Docker核心技术1.3节——命名空间Namespace第一章Docker核心技术1.4节——chroot技术第一章Docker核心技术1.5.1节——cgroup综述...
集中式版本控制工具 —— SVN
一、简介 1️⃣ SVN 是什么? 代码版本管理工具他能记住每次的修改查看所有的修改记录恢复到任何历史版本恢复已经删除的文件 2️⃣ SVN 与 Git 相比有什么优势? 使用简单、上手快目录级权限控制,企业安全必备子目录 Checkout,…...
【Dom获取属性操作】JavaScript 全栈体系(十)
Web APIs 第四章 操作元素属性 一、操作元素常用属性 还可以通过 JS 设置/修改标签元素属性,比如通过 src更换 图片最常见的属性比如: href、title、src 等语法: 对象.属性 值 <!DOCTYPE html> <html lang"en">&…...
C# 中的多态和虚方法,如何实现多态和使用虚方法?
在 C# 中,多态(Polymorphism)是面向对象编程的基本特性之一,它允许使用不同的对象和方法来执行同一操作。C# 中实现多态的方式主要是通过虚方法和抽象类。 虚方法是一种允许子类覆盖的方法,它的实现是在运行时动态确定…...
R软件使用一些常见的问题
以下均是个人经验摸索的解决办法,使用 Rstudio 执行命令,如有高手能更好地解决问题,还望指教,提前感谢。 问题一: 有些 package 因为编辑得比较早又没有继续更新,所以需要用旧版本的 R 才能正常运行&#…...
为什么需要uboot?
一、先看概念 bootROM:一种固化在芯片内部的只读存储器(ROM),用于启动和初始化系统。BootROM 中通常包含了一些预先编写好的代码,用于完成系统启动前的基本初始化和配置,例如初始化时钟、GPIO控制器、中断…...
Qt布局实战:实现高效、美观的GUI应用程序
Qt布局实战:实现高效、美观的GUI应用程序 引言 (Introduction)1.1 Qt布局简介 (Brief introduction to Qt layouts)1.2 Qt布局的优势 (Advantages of Qt layouts) 2.布局类型 (Layout Types)2.1 水平布局 (QHBoxLayout)2.1.1 创建水平布局2.1.2 向水平布局中添加部件…...
推荐几款项目管理工具,提高你的团队协作效率
如何管理团队才能使团队发挥最大的价值,如果团队缺少协作,就会因为团队的内耗和冲突导致项目无法完成,如何提高团队协作效率呢?我们可以借助团队协作类的项目管理工具。 几个常见的项目管理工具: 1、进度猫 进度猫是…...
SQL101 检索每个顾客的名称和所有的订单号(一)
描述 Customers表代表顾客信息含有顾客id cust_id和 顾客名称 cust_name cust_idcust_namecust10andycust1bencust2tonycust22tomcust221ancust2217hex Orders表代表订单信息含有订单号order_num和顾客id cust_id order_numcust_ida1cust10a2cust1a3cust2a4cust22a5cust221…...
Java 语言特性(面试系列1)
一、面向对象编程 1. 封装(Encapsulation) 定义:将数据(属性)和操作数据的方法绑定在一起,通过访问控制符(private、protected、public)隐藏内部实现细节。示例: public …...
黑马Mybatis
Mybatis 表现层:页面展示 业务层:逻辑处理 持久层:持久数据化保存 在这里插入图片描述 Mybatis快速入门 模块,并采用分阶段微调策略的实践过程。通过这个过程,我不仅提升…...
【JVM】- 内存结构
引言 JVM:Java Virtual Machine 定义:Java虚拟机,Java二进制字节码的运行环境好处: 一次编写,到处运行自动内存管理,垃圾回收的功能数组下标越界检查(会抛异常,不会覆盖到其他代码…...
微服务商城-商品微服务
数据表 CREATE TABLE product (id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 商品id,cateid smallint(6) UNSIGNED NOT NULL DEFAULT 0 COMMENT 类别Id,name varchar(100) NOT NULL DEFAULT COMMENT 商品名称,subtitle varchar(200) NOT NULL DEFAULT COMMENT 商…...
【服务器压力测试】本地PC电脑作为服务器运行时出现卡顿和资源紧张(Windows/Linux)
要让本地PC电脑作为服务器运行时出现卡顿和资源紧张的情况,可以通过以下几种方式模拟或触发: 1. 增加CPU负载 运行大量计算密集型任务,例如: 使用多线程循环执行复杂计算(如数学运算、加密解密等)。运行图…...
Web 架构之 CDN 加速原理与落地实践
文章目录 一、思维导图二、正文内容(一)CDN 基础概念1. 定义2. 组成部分 (二)CDN 加速原理1. 请求路由2. 内容缓存3. 内容更新 (三)CDN 落地实践1. 选择 CDN 服务商2. 配置 CDN3. 集成到 Web 架构 …...
【Go语言基础【13】】函数、闭包、方法
文章目录 零、概述一、函数基础1、函数基础概念2、参数传递机制3、返回值特性3.1. 多返回值3.2. 命名返回值3.3. 错误处理 二、函数类型与高阶函数1. 函数类型定义2. 高阶函数(函数作为参数、返回值) 三、匿名函数与闭包1. 匿名函数(Lambda函…...
C# 表达式和运算符(求值顺序)
求值顺序 表达式可以由许多嵌套的子表达式构成。子表达式的求值顺序可以使表达式的最终值发生 变化。 例如,已知表达式3*52,依照子表达式的求值顺序,有两种可能的结果,如图9-3所示。 如果乘法先执行,结果是17。如果5…...
如何应对敏捷转型中的团队阻力
应对敏捷转型中的团队阻力需要明确沟通敏捷转型目的、提升团队参与感、提供充分的培训与支持、逐步推进敏捷实践、建立清晰的奖励和反馈机制。其中,明确沟通敏捷转型目的尤为关键,团队成员只有清晰理解转型背后的原因和利益,才能降低对变化的…...
