要形成右边这种菜单,需要2部分来做支撑。
@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());
}
菜单
和 路由
,那么前端势必就要自己组装 出合适的数据格式了。
异步组件
加载方式,把它导入进去就可以了。比如:下面的首页,当匹配到/,就会默认展示Layout,然后由于里面有一个path为空字符串的子路由,vue-router会把这个子路由渲染到Layout的路由出口的地方。里面还有个小问题
,比如说下面的文章管理,它的path是/article-submenu,那我直接在地址上输入这个路径的话,它是会渲染一个Layout组件,然后路由出口是空的,也就是主区域是空白的,此时也可以给文章管理加一个path为空字符串的子路由,让它显示一个默认的页面,当然这个目录是点击不了的,只是为了防止用户输入这个路劲而已。此处可以参考:vue3后台管理系统、vue2异步组件{
"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
}
]
}
]
}
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);
};
<template>
<div>
<el-menu
class="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>
动态权限控制
,即新增或者修改资源、更新角色 与 资源的关系时,项目不需要重启,用户也不需要退出再登录,即可按修改后的接口访问权限实时的生效
,但是用户登录后,再修改这个用户的角色,这个是不能实时生效的,需要退出退出再登录。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 {
@Autowired
private AuthenticationEntryPointImpl authenticationEntryPoint;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private AuthenticationSuccessHandlerImpl authenticationSuccessHandler;
@Autowired
private AuthenticationFailHandlerImpl authenticationFailHandler;
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
@Bean
public FilterInvocationSecurityMetadataSource securityMetadataSource() {
return new FilterInvocationSecurityMetadataSourceImpl();
}
@Bean
public AccessDecisionManager accessDecisionManager() {
return new AccessDecisionManagerImpl();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
/**
* 密码加密
*
* @return {@link PasswordEncoder} 加密方式
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 配置权限
*
* @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()
.sessionManagement()
.maximumSessions(20)
.sessionRegistry(sessionRegistry());
}
}
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserAuthDao userAuthDao;
@Autowired
private UserInfoDao userInfoDao;
@Autowired
private RoleDao roleDao;
@Autowired
private RedisService redisService;
@Resource
private HttpServletRequest request;
@Override
public 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()修改了其中默认配置的组件,而替换成了博客中使用的组件。
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;
@Autowired
private RoleDao roleDao;
/**
* 加载资源角色信息
*/
@PostConstruct
private void loadDataSource() {
resourceRoleList = roleDao.listResourceRoles();
}
/**
* 清空接口角色信息
*/
public void clearDataSource() {
resourceRoleList = null;
}
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 修改接口角色关系后重新加载
if (CollectionUtils.isEmpty(resourceRoleList)) {
this.loadDataSource();
}
FilterInvocation fi = (FilterInvocation) object;
// 获取用户请求方式
String method = fi.getRequest().getMethod();
// 获取用户请求Url
String 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;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass);
}
}
在上一步,获取到了访问某一资源需要的权限后,接下来,按照security的尿性,它会交给访问决策管理器,然后决策管理器会交给投票器,然后再根据投票结果确定是否能访问当前资源。
但是博客中是直接查询到当前用户拥有的权限,然后看这些权限有没有符合要求的,一旦发现有符合要求的,直接返回,否则,抛出异常。这里所说的权限,是要看UserDetailDTO#getAuthorities(它实现了UserDetails接口),从该方法中可以看出,就是指的角色。
@Component
public class AccessDecisionManagerImpl implements AccessDecisionManager {
@Override
public 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("没有操作权限");
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
用户登录完成后,在后面的请求中如何标识到这个用户呢?以前的时候用的是cookie,前后端分离项目中一般用的是请求头,如Authorization。但是在博客中使用的是cookie做的会话管理,后面的请求都是通过cookie来标识用户的,也就是如果用户仅用了cookie,那就无法登录(可以登录成功,但是后面再发的请求就报用户未登录),也就是说只要删除某个cookie,那就把对应的用户给踢下线了。Security当用户登录完成后,会把用户认证的对象存入SecurityContext中,而SecurityContext会被存入HttpSession中,在security遇到一个请求时,第二个处理的过滤器就是:SecurityContextPersistenceFilter,它就是负责从会话(前端须传入cookie)中获取登陆时存入的SecurityContext,然后把它设置到当前线程上下文中。这也就是说,如果,我们需要改成请求头的方式,而不是用cookie,则需要自己实现这一段逻辑。
明白了上面的逻辑后,可以参考再参考下会话管理的源码:Springsecurity会话管理与配置
它也是通过过滤器来实现的,通过http.sessionManagement()添加的,
这里似乎发现了原博客中的一点小问题,需要重写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);
//
private final ConcurrentMap<Object, Set<String>> principals;
//
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;
}
@Override
public List<Object> getAllPrincipals() {
return new ArrayList<>(this.principals.keySet());
}
@Override
public 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;
}
@Override
public SessionInformation getSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
return this.sessionIds.get(sessionId);
}
@Override
public 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);
}
}
}
@Override
public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = getSessionInformation(sessionId);
if (info != null) {
info.refreshLastRequest();
}
}
@Override
public 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;
});
}
@Override
public 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 anymore
this.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;
});
}
}