最近在做项目的时候遇到需要管理活跃session的需求。框架是用的SpringBoot,做了redis的session共享,并重写了shiro的sessionDao,代码如下:
RedisSessionDao.java
import java.io.Serializable;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.eis.AbstractSessionDAO;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* 重写了shiro的sessionDao,可以用于redis全局共享session
* @author Mr.Tao
*
*/
public class RedisSessionDao extends AbstractSessionDAO {
// Session超时时间,单位为毫秒
private long expireTime = 120000;
private static final String KEY_PREFIX = "shiro_session:";
private RedisTemplate<String,Session> redisTemplate;
public RedisSessionDao() {
super();
}
public RedisSessionDao(long expireTime, RedisTemplate<String,Session> redisTemplate) {
super();
this.expireTime = expireTime;
StringRedisSerializer stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
this.redisTemplate = redisTemplate;
}
@Override
public void update(Session session) throws UnknownSessionException {
if (session == null || session.getId() == null) {
return;
}
session.setTimeout(expireTime);
redisTemplate.opsForValue().set(getKey(session.getId()), session, expireTime, TimeUnit.MILLISECONDS);
}
@Override // 删除session
public void delete(Session session) {
if (null == session) {
return;
}
redisTemplate.opsForValue().getOperations().delete(getKey(session.getId()));
}
@Override
public Collection<Session> getActiveSessions() {
Set<String> keys = redisTemplate.keys(KEY_PREFIX+"*");
Set<Session> sessions = new HashSet<Session>(keys.size());
for (Serializable key : keys) {
sessions.add(redisTemplate.opsForValue().get(key));
}
return sessions;
}
@Override // 加入session
protected Serializable doCreate(Session session) {
Serializable sessionId = this.generateSessionId(session);
this.assignSessionId(session, sessionId);
redisTemplate.opsForValue().set(getKey(session.getId()), session, expireTime, TimeUnit.MILLISECONDS);
return sessionId;
}
@Override // 读取session
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
return null;
}
Session session = redisTemplate.opsForValue().get(getKey(sessionId));
return session;
}
private String getKey(Serializable sessionId) {
return KEY_PREFIX + sessionId;
}
}
这样做了以后可以共享session,但是统计活跃session的时候出了问题,活跃session里面拿到的不是最新的user身份信息。问题复现如下:
在登录完成后以后,存入到Redis里面的session的信息:
{
"lastAccessTime": 1607589832072,
"expired": false,
"host": "127.0.0.1",
"attributes": {
"org.apache.shiro.subject.support.DefaultSubjectContext_AUTHENTICATED_SESSION_KEY": true,
"org.apache.shiro.subject.support.DefaultSubjectContext_PRINCIPALS_SESSION_KEY": [
{
"lastChgPwdTime": 1606891249000,
"lastLoginTime": 1607589548000,
"loginId": 222,
"loginCode": "ceshi",
"staffName": "测试",
"staffId": 10208
}
]
},
"id": "4f097d02-624f-43c4-9333-3777dee638b1",
"startTimestamp": 1607589832072,
"timeout": 900000
}
然后我重新去set了user的ip信息。
LoginController.java
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(req.getUserName(), req.getPassword());
try {
subject.login(token);
User user = (User)subject.getPrincipal();
log.info("用户{}登陆成功", user.getStaffName());
String loginIp = HttpUtil.getIpAddress(request);
// 写入用户登录IP
user.setLoginIp(loginIp);
// 在这里重新去更新身份信息
UserUtil.setUser(user);
} catch (IncorrectCredentialsException e) {
// 密码错误,记录当日错误次数
} catch (LockedAccountException e) {
// 账号被锁定
} catch (Exception e) {
// 其他异常
}
并且更新了身份信息,更新身份信息的代码是网上找的,如下:
UserUtil.java
public static void setUser(User userInfo) {
Subject subject = SecurityUtils.getSubject();
PrincipalCollection principals = subject.getPrincipals();
//realName认证信息的key,对应的value就是认证的user对象
String realName= principals.getRealmNames().iterator().next();
//创建一个PrincipalCollection对象,userInfo是更新后的user对象
PrincipalCollection newPrincipalCollection = new SimplePrincipalCollection(userInfo, realName);
//调用subject的runAs方法,把新的PrincipalCollection放到session里面
subject.runAs(newPrincipalCollection);
}
问题就在这里,用这种方法更新了身份信息后,session的信息如下:
{
"lastAccessTime": 1607589832072,
"expired": false,
"host": "127.0.0.1",
"attributes": {
"org.apache.shiro.subject.support.DelegatingSubject.RUN_AS_PRINCIPALS_SESSION_KEY": [
[
{
"lastChgPwdTime": 1606891249000,
"lastLoginTime": 1607589548000,
"loginId": 222,
"loginIp": "127.0.1.1",
"loginCode": "ceshi",
"staffName": "测试",
"staffId": 10208
}
]
],
"org.apache.shiro.subject.support.DefaultSubjectContext_AUTHENTICATED_SESSION_KEY": true,
"org.apache.shiro.subject.support.DefaultSubjectContext_PRINCIPALS_SESSION_KEY": [
{
"lastChgPwdTime": 1606891249000,
"lastLoginTime": 1607589548000,
"loginId": 222,
"loginCode": "ceshi",
"staffName": "测试",
"staffId": 10208
}
]
},
"id": "4f097d02-624f-43c4-9333-3777dee638b1",
"startTimestamp": 1607589832072,
"timeout": 900000
}
可以看到虽然身份信息已经更新进去了,但是更新以后新加了一个key,叫做org.apache.shiro.subject.support.DelegatingSubject.RUN_AS_PRINCIPALS_SESSION_KEY并且该元素是一个身份信息的集合对象,而这个跟我们之前的身份信息org.apache.shiro.subject.support.DelegatingSubject.RUN_AS_PRINCIPALS_SESSION_KEY并不是一个key,使用这种方法去更新身份信息会导致统计活跃session失效。
统计活跃session的代码如下
public static List<User> getOnlineUsers(){
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager();
Collection<Session> sessions = sessionManager.getSessionDAO().getActiveSessions();
List<User> users = new ArrayList<>();
for (Session session : sessions) {
// 判断用是否登录
SimplePrincipalCollection simplePrincipalCollection = (SimplePrincipalCollection) session
.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (simplePrincipalCollection != null) {
User user = (User) simplePrincipalCollection.getPrimaryPrincipal();
users.add(user);
}
}
return users;
}
这里主要是从session里面去取用户信息,这里用的key为DefaultSubjectContext.PRINCIPALS_SESSION_KEY,这个常量的值为org.apache.shiro.subject.support.DelegatingSubject.RUN_AS_PRINCIPALS_SESSION_KEY而非使用subject.runAs方法所更新的key。
通过追踪源码可以发现
Subject实现类DelegatingSubject的getPrincipal方法关键代码如下:
DelegatingSubject.class
/**
* @see Subject#getPrincipal()
*/
public Object getPrincipal() {
return getPrimaryPrincipal(getPrincipals());
}
public PrincipalCollection getPrincipals() {
List<PrincipalCollection> runAsPrincipals = getRunAsPrincipalsStack();
return CollectionUtils.isEmpty(runAsPrincipals) ? this.principals : runAsPrincipals.get(0);
}
@SuppressWarnings("unchecked")
private List<PrincipalCollection> getRunAsPrincipalsStack() {
Session session = getSession(false);
if (session != null) {
return (List<PrincipalCollection>) session.getAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);
}
return null;
}
这里是优先通过RUN_AS_PRINCIPALS_SESSION_KEY去拿subject.runAs方法存储的元素,如果没有获取到,才会用PRINCIPALS_SESSION_KEY里的身份信息。所以一旦调用了subject.runAs方法后PRINCIPALS_SESSION_KEY里面的信息就等于无效了。而RUN_AS_PRINCIPALS_SESSION_KEY是DelegatingSubject类私有的常量,我们并不能通过去更换key来获取身份信息。
解决的方法是在更新身份信息的时候,不能调用subject.runAs,直接通过session去更新PRINCIPALS_SESSION_KEY里面的身份信息,解决代码如下:
public static void setUser(User userInfo) {
Subject subject = SecurityUtils.getSubject();
PrincipalCollection principals = subject.getPrincipals();
//realName认证信息的key,对应的value就是认证的user对象
String realName= principals.getRealmNames().iterator().next();
//创建一个PrincipalCollection对象,userInfo是更新后的user对象
PrincipalCollection newPrincipalCollection = new SimplePrincipalCollection(userInfo, realName);
//调用subject的runAs方法,把新的PrincipalCollection放到session里面
subject.getSession().setAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY, newPrincipalCollection);
}