近年无状态登录兴起,但sessionId方式仍是主流方案,借用类似redis集群等方案存储session信息使得它也足以跟上微服务的浪潮。相对来说session方式更具有服务端控制感,而无状态登录要想实现服务端控制就得存储些东西,这么一来无状态就得打上一个问号。本文记录的是shiro采用session作为登录方案时,对用户进行限制数量登录,以及剔除下线。
首先搭建好基于redis存储session的shiro鉴权框架底子,网上很容易找到各种实现代码。
找到spring中的ShiroConfig,应有类似如下代码
// 自定义授权缓存管理器
实现 CacheManager 的授权缓存管理器,改用redis存储授权信息。
@Bean
public JedisCacheManager shiroCacheManager() {
JedisCacheManager shiroCacheManager = new JedisCacheManager();
return shiroCacheManager;
}
// 自定义Session存储容器
继承 AbstractSessionDAO 实现 SessionDAO ,对session的curd的具体实现方法自定义编写,采用redis存储与操作。也是本文的主要修改类。
@Bean
public JedisSessionDAO sessionDAO(IdGen idGen) {
JedisSessionDAO sessionDAO = new JedisSessionDAO();
sessionDAO.setSessionIdGenerator(idGen);
sessionDAO.setSessionKeyPrefix(redis_keyPrefix + "_session:");
return sessionDAO;
}
// 自定义会话管理配置
继承 DefaultWebSessionManager 的自定义WEB会话管理类。
@Bean
public SessionManager sessionManager(JedisSessionDAO sessionDAO, SimpleCookie sessionIdCookie) {
SessionManager sessionManager = new SessionManager();
sessionManager.setSessionDAO(sessionDAO);
// 会话超时时间,单位:毫秒
sessionManager.setGlobalSessionTimeout(session_sessionTimeout);
sessionManager.setSessionValidationSchedulerEnabled(true);
sessionManager.setSessionIdCookie(sessionIdCookie);
sessionManager.setSessionIdCookieEnabled(true);
return sessionManager;
}
// 自定义Shiro安全管理配置
@Bean
public DefaultWebSecurityManager securityManager(SystemAuthorizingRealm systemAuthorizingRealm, SessionManager sessionManager
, JedisCacheManager shiroCacheManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(systemAuthorizingRealm);
securityManager.setSessionManager(sessionManager);
securityManager.setCacheManager(shiroCacheManager);
return securityManager;
}
这些配置一层套一层,其它的省略了。。。主要修改的就是JedisSessionDAO
如图,
上面一堆是session的存储,存储的字符串类型,key为前缀+sessionId,value为session内容;
下面一堆则是辅助session限制登陆的存储,key为前缀+userId,value则是map集合,map的key为sessionId,value可以存储一些我们需要的内容,此处我存的是session的最后活动时间。
这么设计即可少许的redis操作就达到我们的目的——限制登陆和踢人下线。
注:key的存储命名使用:
分隔是因为低版本的RDM默认使用:
符号分隔归档,方便我们的可视化查询,高版本以及其它工具是可以自定义分隔符的。
新增以下方法,并对实现的接口 SessionDAO 添加抽象方法。
这个方法在登录时调用,用于判断一个账号登录session的数量并剔除超出规则的账号。
@Override
public Collection<Session> limitSessions(Object principal){
// principal在这个方法指的就是userID
if (principal != null){
principal = principal.toString();
}
// 等会儿取出来的用户存活的session需要放入这个list进行时间排序,以剔除过旧的session。
ArrayList<Session> sessions = new ArrayList();
Jedis jedis = null;
try {
jedis = JedisUtils.getResource();
// 查询该userId的session map集合。
Map<String, String> map = jedis.hgetAll(sessionUserKeyPrefix + principal);
for (Map.Entry<String, String> e : map.entrySet()){
// 遍历集合,剔除不规范的内容,一般来说是不会出现的
if (StringUtils.isNotBlank(e.getKey()) && StringUtils.isNotBlank(e.getValue())){
// 最后活动时间
String expire = e.getValue();
// 因为session的具体存储在redis的字符串中,可以自动过期,
// 而这里session信息存储在map集合的其中一条键值对中无法设置自动过期,
// 所以需要借助SimpleSession类对session是否存活进行校验。
// 每当该账号有认证操作时就会更新一遍map。
if (StringUtils.isNotBlank(expire)){
SimpleSession session = new SimpleSession();
session.setId(e.getKey());
session.setAttribute("principalId", principal);
session.setTimeout(TokenUtils.cacheSeconds * 1000);
session.setLastAccessTime(new Date(Long.valueOf(expire)));
try{
// 验证SESSION
session.validate();
sessions.add(session);
}
// SESSION验证失败
catch (Exception e2) {
jedis.hdel(sessionUserKeyPrefix + principal, e.getKey());
}
}
// 存储的SESSION不符合规则
else{
jedis.hdel(sessionUserKeyPrefix + principal, e.getKey());
}
}
// 存储的SESSION无Value
else if (StringUtils.isNotBlank(e.getKey())){
jedis.hdel(sessionUserKeyPrefix + principal, e.getKey());
}
}
// 剔除过期的session后得到的 sessions.size() 才是当前账号所存活的session
logger.info("该账户 session 数量: {} ", sessions.size());
// 我定义的规则:如果存活的session大于某个值,就对sessions进行时间排序,并且剔除最后操作较早的session
if(sessions.size() > SESSIONLIMTI) {
sessions.sort(new Comparator<Session>() {
@Override
public int compare(Session o1, Session o2) {
return (int)(o1.getLastAccessTime().getTime() - o2.getLastAccessTime().getTime());
}
});
for (int i = 0; i < sessions.size() - SESSIONLIMTI; i++) {
Session session = sessions.get(i);
jedis.hdel(sessionUserKeyPrefix + principal, session.getId().toString());
jedis.del(JedisUtils.getBytesKey(sessionKeyPrefix + session.getId()));
}
}
} catch (Exception e) {
logger.error("limitSessions", e);
} finally {
JedisUtils.returnResource(jedis);
}
return sessions;
}
如下代码,doGetAuthenticationInfo 是shiro认证的回调函数,重写内容一般有登录校验、登录日志之类,在这里就可以追加限制登录数量和剔除session的操作,也就是调用前面编写的方法。
/**
* 认证回调函数, 登录时调用
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) {
UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
// 校验登录验证码
//业务校验。。。。。。省略
// 校验用户名密码以及账号是否冻结
User user = getSystemService().。。。。。。
if (user != null) {
if (Global.NO.equals(user.getLoginFlag())) {
throw new AuthenticationException("msg:该帐号已禁止登录.");
} else if (Global.YES.equals(user.getBlacklist())) {
throw new AuthenticationException("msg:该帐号已被加入黑名单.");
}
byte[] salt = Encodes.decodeHex(。。。);
Principal principal = new Principal(user, 。。。);
// 无痕登录 不打日志
if(token.isTraceless()) {
principal.setTraceless(true);
} else {
// 更新登录IP和时间
getSystemService().updateUserLoginInfo(user);
// 记录登录日志
LogUtils.saveLog(Servlets.getRequest(), "系统登录", user);
// 踢人
int limitSessionSize = getSystemService().getSessionDao().limitSessions(user.getId()).size();
}
return new SimpleAuthenticationInfo(principal, 。。。);
} else {
return null;
}
}
重写 preHandle 方法,如果退出登录,就从map中移除该session,我本来是打算写在 JedisSessionDAO 的delete方法中,但是执行到这个方法的时候已经清除了用户信息,所以无法获得userId,当然可以采用再设置一个sessionId所对应的redis存储辅助,有些冗余,可能有更好的切入点写入,我目前是写在这里。
public class ApiLogoutFilter extends LogoutFilter {
private static final Logger log = LoggerFactory.getLogger(ApiLogoutFilter.class);
private String sessionUserKeyPrefix = "jes_map:";
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = this.getSubject(request, response);
if (this.isPostOnlyLogout() && !WebUtils.toHttp(request).getMethod().toUpperCase(Locale.ENGLISH).equals("POST")) {
return this.onLogoutRequestNotAPost(request, response);
} else {
String redirectUrl = this.getRedirectUrl(request, response, subject);
try {
SystemAuthorizingRealm.Principal principal = (SystemAuthorizingRealm.Principal)subject.getPrincipal();
String sessionId = subject.getSession().getId().toString();
subject.logout();
JedisUtils.mapRemove(sessionUserKeyPrefix + principal, sessionId);
} catch (SessionException var6) {
log.debug("Encountered session exception during logout. This can generally safely be ignored.", var6);
}
this.issueRedirect(request, response, redirectUrl);
return false;
}
}
}
这个方法里就可以获取userId了,如下代码就可以设置与更新这个登录的map集合,以及更新session的生命周期。
@Override
public void update(Session session) throws UnknownSessionException {
if (session == null || session.getId() == null) {
return;
}
/** 现在项目基本前后端分离 这一段基本没用
HttpServletRequest request = Servlets.getRequest();
if (request != null){
String uri = request.getServletPath();
// 如果是静态文件,则不更新SESSION
if (Servlets.isStaticFile(uri)){
return;
}
// 如果是视图文件,则不更新SESSION
if (StringUtils.startsWith(uri, Global.getConfig("web.view.prefix"))
&& StringUtils.endsWith(uri, Global.getConfig("web.view.suffix"))){
return;
}
// 手动控制不更新SESSION
if (Global.NO.equals(request.getParameter("updateSession"))){
return;
}
}
**/
Jedis jedis = null;
try {
jedis = JedisUtils.getResource();
// 获取登录者编号
PrincipalCollection pc = (PrincipalCollection)session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
String principalId = pc != null ? pc.getPrimaryPrincipal().toString() : StringUtils.EMPTY;
if (StringUtils.isNotBlank(principalId)) {
jedis.hset(sessionUserKeyPrefix + principalId, session.getId().toString(), "" + session.getLastAccessTime().getTime());
jedis.expire(sessionUserKeyPrefix + principalId, TokenUtils.cacheSeconds);
}
jedis.set(JedisUtils.getBytesKey(sessionKeyPrefix + session.getId()), JedisUtils.toBytes(session));
// 设置超期时间
int timeoutSeconds = (int)(session.getTimeout() / 1000);
jedis.expire((sessionKeyPrefix + session.getId()), timeoutSeconds);
logger.debug("update {} {}", session.getId(), request != null ? request.getRequestURI() : "");
} catch (Exception e) {
logger.error("update {} {}", session.getId(), request != null ? request.getRequestURI() : "", e);
} finally {
JedisUtils.returnResource(jedis);
}
}
在此,我只是规定了固定数量规则,这个限制登录数量当然可以是存储于关系型数据库里和账号绑定的,甚至可以是花里胡哨的规则,例如——手机登录限制只能登录1个,浏览器登录限制10个。还可以通过ws推送,主动告知被剔除的那个客户端——您的账号在福建省XX市XX登录,您被踢下线,如有异常,申请冻结账号。甚至可以列出登录设备列表,让客户可以选择性的剔除哪个设备。只要在map里存储的时间戳修改为这些丰富的数据,就能实现这些很有趣的功能。