十年网站开发经验 + 多家企业客户 + 靠谱的建站团队
量身定制 + 运营维护+专业推广+无忧售后,网站问题一站解决
[[399043]]

创新互联建站坚持“要么做到,要么别承诺”的工作理念,服务领域包括:成都做网站、成都网站设计、成都外贸网站建设、企业官网、英文网站、手机端网站、网站推广等服务,满足客户于互联网时代的比如网站设计、移动媒体设计的需求,帮助企业找到有效的互联网解决方案。努力成为您成熟可靠的网络建设合作伙伴!
登录成功后,自动踢掉前一个登录用户,松哥第一次见到这个功能,就是在扣扣里边见到的,当时觉得挺好玩的。
自己做开发后,也遇到过一模一样的需求,正好最近的 Spring Security 系列正在连载,就结合 Spring Security 来和大家聊一聊这个功能如何实现。
在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑,松哥之前遇到的需求就是业务原因要求一个用户只能在一个设备上登录。
要实现一个用户不可以同时在两台设备上登录,我们有两种思路:
后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的效果。
如果用户已经登录,则不允许后来者登录。
这种思路都能实现这个功能,具体使用哪一个,还要看我们具体的需求。
在 Spring Security 中,这两种都很好实现,一个配置就可以搞定。
2.1 踢掉已经登录用户
想要用新的登录踢掉旧的登录,我们只需要将最大会话数设置为 1 即可,配置如下:
- @Override
 - protected void configure(HttpSecurity http) throws Exception {
 - http.authorizeRequests()
 - .anyRequest().authenticated()
 - .and()
 - .formLogin()
 - .loginPage("/login.html")
 - .permitAll()
 - .and()
 - .csrf().disable()
 - .sessionManagement()
 - .maximumSessions(1);
 - }
 
maximumSessions 表示配置最大会话数为 1,这样后面的登录就会自动踢掉前面的登录。这里其他的配置都是我们前面文章讲过的,我就不再重复介绍,文末可以下载案例完整代码。
配置完成后,分别用 Chrome 和 Firefox 两个浏览器进行测试(或者使用 Chrome 中的多用户功能)。
- This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).
 
可以看到,这里说这个 session 已经过期,原因则是由于使用同一个用户进行并发登录。
2.2 禁止新的登录
如果相同的用户已经登录了,你不想踢掉他,而是想禁止新的登录操作,那也好办,配置方式如下:
- @Override
 - protected void configure(HttpSecurity http) throws Exception {
 - http.authorizeRequests()
 - .anyRequest().authenticated()
 - .and()
 - .formLogin()
 - .loginPage("/login.html")
 - .permitAll()
 - .and()
 - .csrf().disable()
 - .sessionManagement()
 - .maximumSessions(1)
 - .maxSessionsPreventsLogin(true);
 - }
 
添加 maxSessionsPreventsLogin 配置即可。此时一个浏览器登录成功后,另外一个浏览器就登录不了了。
是不是很简单?
不过还没完,我们还需要再提供一个 Bean:
- @Bean
 - HttpSessionEventPublisher httpSessionEventPublisher() {
 - return new HttpSessionEventPublisher();
 - }
 
为什么要加这个 Bean 呢?因为在 Spring Security 中,它是通过监听 session 的销毁事件,来及时的清理 session 的记录。用户从不同的浏览器登录后,都会有对应的 session,当用户注销登录之后,session 就会失效,但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的,这一个失效事件无法被 Spring 容器感知到,进而导致当用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来(小伙伴们可以自行尝试不添加上面的 Bean,然后让用户注销登录之后再重新登录)。
为了解决这一问题,我们提供一个 HttpSessionEventPublisher ,这个类实现了 HttpSessionListener 接口,在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到,该类部分源码如下:
- public void sessionCreated(HttpSessionEvent event) {
 - HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession());
 - getContext(event.getSession().getServletContext()).publishEvent(e);
 - }
 - public void sessionDestroyed(HttpSessionEvent event) {
 - HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
 - getContext(event.getSession().getServletContext()).publishEvent(e);
 - }
 
OK,虽然多了一个配置,但是依然很简单!
3.实现原理
上面这个功能,在 Spring Security 中是怎么实现的呢?我们来稍微分析一下源码。
首先我们知道,在用户登录的过程中,会经过 UsernamePasswordAuthenticationFilter,而 UsernamePasswordAuthenticationFilter 中过滤方法的调用是在 AbstractAuthenticationProcessingFilter 中触发的,我们来看下 AbstractAuthenticationProcessingFilter#doFilter 方法的调用:
- public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
 - throws IOException, ServletException {
 - HttpServletRequest request = (HttpServletRequest) req;
 - HttpServletResponse response = (HttpServletResponse) res;
 - if (!requiresAuthentication(request, response)) {
 - chain.doFilter(request, response);
 - return;
 - }
 - Authentication authResult;
 - try {
 - authResult = attemptAuthentication(request, response);
 - if (authResult == null) {
 - return;
 - }
 - sessionStrategy.onAuthentication(authResult, request, response);
 - }
 - catch (InternalAuthenticationServiceException failed) {
 - unsuccessfulAuthentication(request, response, failed);
 - return;
 - }
 - catch (AuthenticationException failed) {
 - unsuccessfulAuthentication(request, response, failed);
 - return;
 - }
 - // Authentication success
 - if (continueChainBeforeSuccessfulAuthentication) {
 - chain.doFilter(request, response);
 - }
 - successfulAuthentication(request, response, chain, authResult);
 
在这段代码中,我们可以看到,调用 attemptAuthentication 方法走完认证流程之后,回来之后,接下来就是调用 sessionStrategy.onAuthentication 方法,这个方法就是用来处理 session 的并发问题的。具体在:
- public class ConcurrentSessionControlAuthenticationStrategy implements
 - MessageSourceAware, SessionAuthenticationStrategy {
 - public void onAuthentication(Authentication authentication,
 - HttpServletRequest request, HttpServletResponse response) {
 - final List
 sessions = sessionRegistry.getAllSessions( - authentication.getPrincipal(), false);
 - int sessionCount = sessions.size();
 - int allowedSessions = getMaximumSessionsForThisUser(authentication);
 - if (sessionCount < allowedSessions) {
 - // They haven't got too many login sessions running at present
 - return;
 - }
 - if (allowedSessions == -1) {
 - // We permit unlimited logins
 - return;
 - }
 - if (sessionCount == allowedSessions) {
 - HttpSession session = request.getSession(false);
 - if (session != null) {
 - // Only permit it though if this request is associated with one of the
 - // already registered sessions
 - for (SessionInformation si : sessions) {
 - if (si.getSessionId().equals(session.getId())) {
 - return;
 - }
 - }
 - }
 - // If the session is null, a new one will be created by the parent class,
 - // exceeding the allowed number
 - }
 - allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
 - }
 - protected void allowableSessionsExceeded(List
 sessions, - int allowableSessions, SessionRegistry registry)
 - throws SessionAuthenticationException {
 - if (exceptionIfMaximumExceeded || (sessions == null)) {
 - throw new SessionAuthenticationException(messages.getMessage(
 - "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
 - new Object[] {allowableSessions},
 - "Maximum sessions of {0} for this principal exceeded"));
 - }
 - // Determine least recently used sessions, and mark them for invalidation
 - sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
 - int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
 - List
 sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy); - for (SessionInformation session: sessionsToBeExpired) {
 - session.expireNow();
 - }
 - }
 - }
 
这段核心代码我来给大家稍微解释下:
如此,两行简单的配置就实现了 Spring Security 中 session 的并发管理。是不是很简单?不过这里还有一个小小的坑,松哥将在下篇文章中继续和大家分析。
本文案例大家可以从 GitHub 上下载:https://github.com/lenve/spring-security-samples
本文转载自微信公众号「江南一点雨」,可以通过以下二维码关注。转载本文请联系江南一点雨公众号。