SpringBoot+shiro+redis 一个账户只允许一处登录,强制用户下线
概况
前期引入了redis来解决session共享,但并么有限制一个账户多人同时在线,且shiro本身没有带这个控制功能。在网上找了其他人的思路,但效果都有点不太好,于是自己写一下自己的项目实战。(第一次分享,希望各位大神多多指教)。
于是乎,利用redis 来记录一个用户的sessionID,如果一个用户存在多个sessionId,则获取队列末尾的sessionId,用sessionId查询对应的session,给session设置被踢出的标记。
当对应session,重新发起请求到后端,后端先判断session中是否有踢出标记,如果没有则正常操作,如果有踢出标记,则提示“已被强制下线”,同时session过期时间设置为0,立即过期清除。
redis中保存用户对应sessionId
先调好redis数据的存取
RDeque<Serializable> deque = redissonClient.getDeque(ShiroUtil.getSessionUserName()));
deque.push(sessionId);
shiro登录成功则校验用户session是否已存在
用户登录成功,在login方法追加校验是否唯一登录,或自定义注解或直接入侵
//session 中key,是否被踢出
public final static String FINAL_OUT_FLAG = "kick_out";
private void checkUserSingle(Subject currentUser) {
//获取当前用户的SessionID
Serializable sessionId = currentUser.getSession().getId();
//利用redisson 获取redis中的用户名与session列表,我们系统是name唯一,则以name为key,其他系统也可以使用id
RDeque<Serializable> deque = redissonClient.getDeque(ShiroUtil.getSessionUserName()));
//第一次登录则队列中为空,把自己存进去
if (deque.isEmpty()) {
deque.push(sessionId);
}
// 自己不是队列最后一个 也需要把自己添加进入队列,准备踢别人
if (!sessionId.equals(deque.getLast())) {
deque.push(sessionId);
}
//开始踢人,踢到只剩一个为止。(没有加锁,当前用户登录的同时,也可能同一个用户正在登录)
while (deque.size() > 1) {
try {
Serializable kickoutSessionId = deque.removeLast();
//获取将要被踢出的sessionId的session对象
Session kickOutSession = SecurityUtils.getSecurityManager().getSession(new DefaultSessionKey(kickoutSessionId));
if (kickOutSession != null) {
//设置session对象的kickout属性,true表示踢出了
kickOutSession.setAttribute(FINAL_OUT_FLAG, true);
}
//我没有处理异常
} catch (Exception e) {
}
}
}
注意此处没有直接将session立即过期,是希望给前端一个提示信息。因为我们系统没有消息推送服务,如果系统有推送消息服务,则推送提示消息给前端后,session可以立即过期。也就不需要后一步取拦截校验session是否已被踢出
shiroFilter中拦截校验session是否已被踢出
public class ShiroFilter extends FormAuthenticationFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 判断请求是否是options请求
String method = WebUtils.toHttp(request).getMethod();
if (StringUtils.equalsIgnoreCase("OPTIONS", method)) {
return Boolean.TRUE;
}
//判断是否已下线
getKickOut(response);
return super.isAccessAllowed(request, response, mappedValue);
}
@SneakyThrows
private void getKickOut(ServletResponse response) {
Subject subject = SecurityUtils.getSubject();
//如果没有登录,直接进行登录的流程
if (!subject.isAuthenticated() && !subject.isRemembered()) {
return;
}
Session session = subject.getSession();
//如果session没有被踢出去的标记 则不做动作
if (ObjectUtil.isNull(session.getAttribute(FINAL_OUT_FLAG)) || !(Boolean) session.getAttribute(FINAL_OUT_FLAG)) {
return;
}
//如果被踢出了,直接退出
try {
// 使用response响应流返回数据到前台(因前端需要接受json数据,注意前后端跨域问题)
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setContentType("application/json;charset=utf-8");
//401 前端拦截所有401状态的返回,展示Message("账号已被强制下线!")即可
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
PrintWriter out = httpServletResponse.getWriter();
ErrorResponse errorResponse = new ErrorResponse();
errorResponse.setCode(ResultCode.UNAUTHORIZED.getCode());
errorResponse.setCodeStr(ResultCode.UNAUTHORIZED.getMessage());
errorResponse.setMessage("账号已被强制下线!");
out.println(JSONUtil.toJsonStr(errorResponse));
out.flush();
out.close();
} catch (Exception e) {
} finally {
//session 立即失效
session.setTimeout(0);
}
throw new UnauthorizedException(ResultCode.UNAUTHORIZED, "账号已被强制下线");
}
}
在shiroConfig初始化shiroFilterFactoryBean的时候,使用ShiroFilter来拦截。注意系统url拦截都需要配置authc,否则请求不进入isAccessAllowed();
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("securityManager") SecurityManager securityManager,@Qualifier("sessionManager") SessionManager sessionManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, Filter> map = Maps.newLinkedHashMapWithExpectedSize(3);
ShiroFilter shiroFilter = new ShiroFilter();
//限制跳转
map.put("logout", shiroFilter);
map.put("unauth", shiroFilter);
map.put("authc", shiroFilter);
shiroFilterFactoryBean.setFilters(map);
//下面省略各种url的权限校验配置
}