这又是 一片 关于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());
}