前言
上一节,了解spring security如何处理session并发。
今天将在微人事项目里处理session并发问题,实现踢掉已登录的用户。
分析
(1)获取session集合通过sessionRegistry对象
final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
authentication.getPrincipal(), false);
(2)通过阅读源码,sessionRegistry是一个接口,只有一个实现类SessionRegistryImpl,Spring Security中通过SessionRegistryImpl类来实现对会话信息的统一管理
public class SessionRegistryImpl implements SessionRegistry,
ApplicationListener<SessionDestroyedEvent> {
/** <principal:Object,SessionIdSet> */
private final ConcurrentMap<Object, Set<String>> principals;
/** <sessionId:Object,SessionInformation> */
private final Map<String, SessionInformation> sessionIds;
public void registerNewSession(String sessionId, Object principal) {
if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
sessionIds.put(sessionId,
new SessionInformation(principal, sessionId, new Date()));
principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
}
sessionsUsedByPrincipal.add(sessionId);
return sessionsUsedByPrincipal;
});
}
public void removeSessionInformation(String sessionId) {
SessionInformation info = getSessionInformation(sessionId);
if (info == null) {
return;
}
sessionIds.remove(sessionId);
principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
sessionsUsedByPrincipal = null;
}
return sessionsUsedByPrincipal;
});
}
}
- 首先大家看到,一上来声明了一个Principals对象,这是一个支持并发访问的map集合,集合的key就是用户的参与者(principal),正常来说,用户的Principal其实就是用户对象,而集合的值则是一个set集合,这个set集合中保存了这个用户对应的sessionid。
- 如有新的会话需要添加,就在registerNewSession方法中进行添加,具体是调用principles.compute方法进行添加,key就是principal。
3.如果用户初始化登录,sessionid需要删除,相关操作在removeSessionInformation方法中完成
看到这里,大家发现一个问题,ConcurrentMap集合的键是主体对象,用对象做键,一定要重写等于方法和hashCode方法,否则第一次存完数据,下次就找不到了,这是JavaSE方面的知识参考
参考: 为啥重写equals 方法和 hashCode 方法
实现
(1)微人事里我们用自定义的过滤器代替了UsernamePasswordAuthenticationFilter,导致前面所讲的关于session的配置,统统中断。所有相关的配置我们都要在新的过滤器LoginFilter中进行配置,包括SessionAuthenticationStrategy也需要我们自己手动配置了。
首先第一步,我们重写Hr类的equals和hashCode方法
public class Hr implements UserDetails {
...
...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Hr hr = (Hr) o;
return Objects.equals(username, hr.username);
}
@Override
public int hashCode() {
return Objects.hash(username);
}
...
...
}
(2)接下来在SecurityConfig中进行配置
这里我们要自己提供SessionAuthenticationStrategy,而前面处理会话并发的是ConcurrentSessionControlAuthenticationStrategy,则,我们需要自己提供一个ConcurrentSessionControlAuthenticationStrategy的实例,然后配置给LoginFilter,但是在创建ConcurrentSessionControlAuthenticationStrategy实例的过程中,还需要有一个SessionRegistryImpl对象。
@Bean
SessionRegistryImpl sessionRegistry() {
return new SessionRegistryImpl();
}
``
然后在LoginFilter中配置SessionAuthenticationStrategy
```java
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
//省略
}
);
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
//省略
}
);
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/doLogin");
ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
sessionStrategy.setMaximumSessions(1);
//调用的是AbstractAuthenticationProcessingFilter#setSessionAuthenticationStrategy方法
loginFilter.setSessionAuthenticationStrategy(sessionStrategy);
return loginFilter;
}
会话配置还有了一个关键的过滤器叫做ConcurrentSessionFilter,本来这个过滤器是不需要我们管的,但是这个过滤器中也用到了SessionRegistryImpl,而SessionRegistryImpl现在是由我们自己来定义的,所以,该过滤器我们也要重新配置一下,如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> {
HttpServletResponse resp = event.getResponse();
resp.setContentType("application/json;charset=utf-8");
resp.setStatus(401);
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(RespBean.error("您已在另一台设备登录,本次登录已下线!")));
out.flush();
out.close();
}), ConcurrentSessionFilter.class);
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
在这里,我们重新创建一个ConcurrentSessionFilter的实例,代替系统替换的即可。在创建的新的ConcurrentSessionFilter实例时,需要两个参数:
- sessionRegistry就是我们前面提供的SessionRegistryImpl实例。
- 第二个参数,是一个处理会话过期后的额外函数,相应的,当用户被另外一个登录踢下线之后,你要给某种的下线提示,就在这里来完成。
(3)最后,我们还需要在处理完登录数据之后,手动向SessionRegistryImpl中添加一条记录:
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Autowired
SessionRegistry sessionRegistry;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//省略
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
Hr principal = new Hr();
principal.setUsername(username);
//注册session
sessionRegistry.registerNewSession(request.getSession(true).getId(), principal);
return this.getAuthenticationManager().authenticate(authRequest);
}
...
...
}
}