简介

保证同一时间内只有一个账户处于登录状态
在同一个系统中,我们只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑

要实现一个用户不可以同时在两台设备上登录,我们有两种思路:

  • 后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的效果。
  • 如果用户已经登录,则不允许后来者登录。

实现

方法一:踢掉前一个已登录账户

想要实现该功能,我们只需要在security中将session的最大数设置为1,即可实现

  1. @Override
  2. protected void configure(HttpSecurity http) throws Exception {
  3. http.authorizeRequests()
  4. .anyRequest().authenticated()
  5. .and()
  6. .formLogin()
  7. .loginPage("/login.html")
  8. .permitAll()
  9. .and()
  10. .csrf().disable()
  11. .sessionManagement()
  12. .maximumSessions(1);
  13. }

maximumSessions 表示配置最大会话数为 1,这样后面的登录就会自动踢掉前面的登录。

方法二:已登录账户不允许再登录

如果相同的用户已经登录了,你不想踢掉他,而是想禁止新的登录操作,那也好办,配置方式如下:

  1. @Override
  2. protected void configure(HttpSecurity http) throws Exception {
  3. http.authorizeRequests()
  4. .anyRequest().authenticated()
  5. .and()
  6. .formLogin()
  7. .loginPage("/login.html")
  8. .permitAll()
  9. .and()
  10. .csrf().disable()
  11. .sessionManagement()
  12. .maximumSessions(1)
  13. .maxSessionsPreventsLogin(true);
  14. }

添加 maxSessionsPreventsLogin 配置即可。此时一个浏览器登录成功后,另外一个浏览器就登录不了了。

配置实体

我们还需要再提供一个 Bean:

  1. @Bean
  2. HttpSessionEventPublisher httpSessionEventPublisher() {
  3. return new HttpSessionEventPublisher();
  4. }

为什么要加这个 Bean 呢?
因为在 Spring Security 中,它是通过监听 session 的销毁事件,来及时的清理 session 的记录。
用户从不同的浏览器登录后,都会有对应的 session,当用户注销登录之后,session 就会失效,
但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的,
这一个失效事件无法被 Spring 容器感知到,
进而导致当用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来

为了解决这一问题,我们提供一个 HttpSessionEventPublisher ,
这个类实现了 HttpSessionListener 接口,
在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,
并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到

  1. public void sessionCreated(HttpSessionEvent event) {
  2. HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession());
  3. getContext(event.getSession().getServletContext()).publishEvent(e);
  4. }
  5. public void sessionDestroyed(HttpSessionEvent event) {
  6. HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
  7. getContext(event.getSession().getServletContext()).publishEvent(e);
  8. }

前后端分离配置

对于前后端分离的情况下,我们的用户都是存在数据库中的,为此我们上面的配置很明显不会生效,

源码分析

Spring Security 中通过 SessionRegistryImpl 类来实现对会话信息的统一管理,我们来看下这个类的源码(部分):


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;
    }

    public List<Object> getAllPrincipals() {
        return new ArrayList(this.principals.keySet());
    }

    public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
        Set<String> sessionsUsedByPrincipal = (Set)this.principals.get(principal);
        if (sessionsUsedByPrincipal == null) {
            return Collections.emptyList();
        } else {
            List<SessionInformation> list = new ArrayList(sessionsUsedByPrincipal.size());
            Iterator var5 = sessionsUsedByPrincipal.iterator();

            while(true) {
                SessionInformation sessionInformation;
                do {
                    do {
                        if (!var5.hasNext()) {
                            return list;
                        }

                        String sessionId = (String)var5.next();
                        sessionInformation = this.getSessionInformation(sessionId);
                    } while(sessionInformation == null);
                } while(!includeExpiredSessions && sessionInformation.isExpired());

                list.add(sessionInformation);
            }
        }
    }

    public SessionInformation getSessionInformation(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        return (SessionInformation)this.sessionIds.get(sessionId);
    }

    public void onApplicationEvent(AbstractSessionEvent event) {
        String oldSessionId;
        if (event instanceof SessionDestroyedEvent) {
            SessionDestroyedEvent sessionDestroyedEvent = (SessionDestroyedEvent)event;
            oldSessionId = sessionDestroyedEvent.getId();
            this.removeSessionInformation(oldSessionId);
        } else if (event instanceof SessionIdChangedEvent) {
            SessionIdChangedEvent sessionIdChangedEvent = (SessionIdChangedEvent)event;
            oldSessionId = sessionIdChangedEvent.getOldSessionId();
            if (this.sessionIds.containsKey(oldSessionId)) {
                Object principal = ((SessionInformation)this.sessionIds.get(oldSessionId)).getPrincipal();
                this.removeSessionInformation(oldSessionId);
                this.registerNewSession(sessionIdChangedEvent.getNewSessionId(), principal);
            }
        }

    }

    public void refreshLastRequest(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        SessionInformation info = this.getSessionInformation(sessionId);
        if (info != null) {
            info.refreshLastRequest();
        }

    }

    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 (this.getSessionInformation(sessionId) != null) {
            this.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();
            }

            ((Set)sessionsUsedByPrincipal).add(sessionId);
            this.logger.trace(LogMessage.format("Sessions used by '%s' : %s", principal, sessionsUsedByPrincipal));
            return (Set)sessionsUsedByPrincipal;
        });
    }

    public void removeSessionInformation(String sessionId) {
        Assert.hasText(sessionId, "SessionId required as per interface contract");
        SessionInformation info = this.getSessionInformation(sessionId);
        if (info != null) {
            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()) {
                    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;
            });
        }
    }
}
  1. 首先大家看到,一上来声明了一个 principals 对象,这是一个支持并发访问的 map 集合,集合的 key 就是用户的主体(principal),正常来说,用户的 principal 其实就是用户对象,而集合的 value 则是一个 set 集合,这个 set 集合中保存了这个用户对应的 sessionid。
  2. 如有新的 session 需要添加,就在 registerNewSession 方法中进行添加,具体是调用 principals.compute 方法进行添加,key 就是 principal。
  3. 如果用户注销登录,sessionid 需要移除,相关操作在 removeSessionInformation 方法中完成,具体也是调用 principals.computeIfPresent 方法

ConcurrentMap 集合的 key 是 principal 对象,用对象做 key,一定要重写 equals 方法和 hashCode 方法,否则第一次存完数据,下次就找不到了

security内置User

public class User implements UserDetails, CredentialsContainer {
 private String password;
 private final String username;
 private final Set<GrantedAuthority> authorities;
 private final boolean accountNonExpired;
 private final boolean accountNonLocked;
 private final boolean credentialsNonExpired;
 private final boolean enabled;
 @Override
 public boolean equals(Object rhs) {
  if (rhs instanceof User) {
   return username.equals(((User) rhs).username);
  }
  return false;
 }
 @Override
 public int hashCode() {
  return username.hashCode();
 }
}

数据库用户

@Getter
@Setter
@ToString
@RequiredArgsConstructor
@Entity(name = "t_user")
public class UserEntity implements UserDetails {
    private static final long serialVersionUID = 5461613954799968213L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;
    @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
    private List<RoleEntity> roleEntities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for(RoleEntity RoleEntity : getRoleEntities()) {
            authorities.add(new SimpleGrantedAuthority(RoleEntity.getName()));
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    @Override
    public boolean equals(Object o) {
        if(this == o)
            return true;
        if(o == null || Hibernate.getClass(this) != Hibernate.getClass(o))
            return false;
        UserEntity that = (UserEntity) o;
        return Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return 0;
    }
}

注意重写equals和hashCode方法即可实现前后端分离下的剔除用户的功能