这又是 一片 关于security 的文章,用于解决 session 并发问题 ,同时只有一个用户在线。 有一个用户在线后其他的设备登录此用户失败。

本文有两个实现方法,第一种实现方法稍微繁琐。
第二种方法有个小bug 但是可以通过前端的配合解决此bug。

本文代码,是基于 springboot+security restful权限控制官方推荐(五) 的代码

方法一

1. 修改security配置

修改 WebSecurityConfig 文件
添加 SessionRegistry 和 httpSessionEventPublisher
在configure 方法中 添加 maximumSessions(1).maxSessionsPreventsLogin(true).sessionRegistry(sessionRegistry) 配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
     private  CustomUserService customUserService;
    @Autowired
    SessionRegistry sessionRegistry;

    @Autowired
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserService).passwordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/users/**")
                .authenticated()
                .antMatchers(HttpMethod.POST)
                .authenticated()
                .antMatchers(HttpMethod.PUT)
                .authenticated()
                .antMatchers(HttpMethod.DELETE)
                .authenticated()
                .antMatchers("/**")
                .permitAll()
                .and()
                .sessionManagement().maximumSessions(1).maxSessionsPreventsLogin(true).sessionRegistry(sessionRegistry);
        http.httpBasic();
    }

    @Bean
    public SessionRegistry getSessionRegistry(){
        SessionRegistry sessionRegistry=new SessionRegistryImpl();
        return sessionRegistry;
    }

    @Bean
    public ServletListenerRegistrationBean httpSessionEventPublisher() {
        return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());
    }
}

2. UserDetails 添加比较

修改 实现 UserDetails 接口的类,一般都是 user 实体。

因为SpringSecurity是通过管理UserDetails对象来实现用户管理的,按照上一步的原理,是需要进行比较实现效果的,然后呢,类的比较是不能用==比较的,类之间的比较是通过类的equals方法进行比较的。

我们现在开始重写equals方法吧,关于重写的规则是:如果要重写equals方法,那么就必须要重写toStirng方法,如果要重写toString方法就最好要重写hashCode方法,所以我们需要在自定义的User对象中重写三个方法,hashCode、toString和equals方法。

@Override
    public String toString() {
        return this.username;
    }

    @Override
    public int hashCode() {
        return username.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        return this.toString().equals(obj.toString());
    }

3. loadUserByUsername 方法 添加相同用户检测代码

@Service
public class CustomUserService implements UserDetailsService { //自定义UserDetailsService 接口

    @Autowired
    UserDao userDao;
    @Autowired
    private SessionRegistry sessionRegistry;
    private static final org.slf4j.Logger logger = LoggerFactory.getLogger(CustomUserService.class);

    @Override
    public UserDetails loadUserByUsername(String username) { //重写loadUserByUsername 方法获得 userdetails 类型用户

        SysUser user = userDao.findByUserName(username);
        if(user == null){
            throw new UsernameNotFoundException("用户名不存在");
        }
        //用户已经登录则此次登录失败
        List<Object> o = sessionRegistry.getAllPrincipals();
        for ( Object principal : o) {
            if (principal instanceof SysUser && (user.getUsername().equals(((SysUser) principal).getUsername()))) {
                throw new SessionAuthenticationException("当前用户已经在线,登录失败!!!");
            }
        }
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        //用于添加用户的权限。只要把用户权限添加到authorities 就万事大吉。
        for(SysRole role:user.getRoles())
        {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
            logger.info("loadUserByUsername: " + user);
        }
        user.setGrantedAuthorities(authorities); //用于登录时 @AuthenticationPrincipal 标签取值
        return user;
    }

}

4. 定时session失效

由于我们 添加了SessionRegistry 所以session 失效要自己维护一下。写一个定时事件,

/**
     * 超时事件检查
     */
    @Scheduled(cron = "0 0/1 * * * ?")
    public void ScanUserOnline() {
    //获取所有在线用户
        List<User> users = userDao.getUsersWithOnLine(4);
        users.stream().parallel().forEach(user -> {
        //通过时间判断是否session过期
            if (CommonUtil.CalculationData(user.getAccessLastTime(),30)) {
                //如果过期则设置用户为下下线状态
                updateOnline(user.getId(),4,null);
            //session 置为失效  SessionUtil.expireSession(null,user,sessionRegistry);
            }
        });
    }


 /**
     * session 失效
     * @param request
     * @param sessionRegistry
     */
    public static void expireSession(HttpServletRequest request,User user, SessionRegistry sessionRegistry){
        List<SessionInformation>  sessionsInfo = null;
        if (null != user) {
            List<Object> o = sessionRegistry.getAllPrincipals();
            for ( Object principal : o) {
                if (principal instanceof User && (user.getUsername().equals(((User) principal).getUsername()))) {
                    sessionsInfo = sessionRegistry.getAllSessions(principal, false);
                }
            }
        }else if (null != request ) {
            SecurityContext sc = (SecurityContext) request.getSession().getAttribute("SPRING_SECURITY_CONTEXT");
            if (null != sc.getAuthentication().getPrincipal()) {
                sessionsInfo = sessionRegistry.getAllSessions(sc.getAuthentication().getPrincipal(), false);
                sc.setAuthentication(null);
            }
        }
        if(null != sessionsInfo && sessionsInfo.size() > 0) {
            for (SessionInformation sessionInformation : sessionsInfo) {
                //当前session失效
                sessionInformation.expireNow();
                sessionRegistry.removeSessionInformation(sessionInformation.getSessionId());
            }
        }
    }

5. logout方法修改

@RequestMapping(value="/offline", method = RequestMethod.GET)
    @ResponseBody
    public String offline (HttpServletRequest request, HttpServletResponse response) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null){
            //设置为离线状态
            userService.updateOnline(((User)auth.getPrincipal()).getId(), 4 ,null);
            new SecurityContextLogoutHandler().logout(request, response, auth);
        }
        return "ok";
    }

方法二

方法二就是 博客写的方法。

bug

不过此方法有个 bug 就是 当用户A在设备1上登录, 在设备2上再次登录 用户A 会被拒绝, 但是 在设备2 上登录 用户B,在用户B不登出的情况下,在设备2 上登录用户A 。此时用户A会登录成功,系统中将会有两个用户A 的session 存在。

解决:这个bug 可以通过于前端的配合规避掉。当前端在login 页面时检测浏览器是否有session,如果有session 就强制跳转到 首页,不允许用户在有session 的情况下,通过在浏览器敲路由的方式进入 login 页面。

1.在WebSecurityConfig 文件中添加配置

.sessionManagement().maximumSessions(1).maxSessionsPreventsLogin(true).sessionRegistry(sessionRegistry)

2.在 UserDetails 添加比较

```
@Override
    public String toString() {
        return this.username;
    }

    @Override
    public int hashCode() {
        return username.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        return this.toString().equals(obj.toString());
    }