Shiro 避免单个账号多处保持登录状态

公司内部管理系统使用 Shiro 做权限框架,Shiro 的缓存使用的是 Ehcache,一直存在个问题:单个账号可多处同时使用。出于安全考虑,这是不应该的,要实现的效果为避免单个账号多处保持登录状态。

解决方案挺简单,发生登录行为后使账号的上一次登录行为失效。在 Shiro 中我们可以通过 org.apache.shiro.session.mgt.eis.SessionDAO 实现会话管理,比如获取当前所有的活跃会话、更新单个会话信息。为了实现效果,我在现有的项目上做了些改造。

Shiro 配置

在 Shiro 的配置类中做以下操作。

注入 SessionDAO 实例:

1
2
3
4
@Bean
public SessionDAO sessionDAO() {
return new MemorySessionDAO();
}

注入 SessionManager 实例:

1
2
3
4
5
6
@Bean
public SessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(sessionDAO());
return sessionManager;
}

给 SecurityManager 设置 sessionManager 属性:

1
2
3
4
5
6
7
8
@Bean(name = "securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm());
securityManager.setCacheManager(cacheManager());
securityManager.setSessionManager(sessionManager());
return securityManager;
}

发生登录行为后推送事件异步化处理

发生登录行为后推送登录事件去异步化处理,这里我觉得没有必要去做同步化处理,一是会使登录逻辑更加复杂化,二是多多少少会对登录速度有些影响。

准备使用 Guava 工具包下的 EventBus 来实现这个异步化处理逻辑,主要有两个核心类:LoginEventBusCenter(登录事件总线中心)、LoginEventObserver(登录事件观察者),下面附上它们的源码。

LoginEventBusCenter 用来注册/注销观察者、推送事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import com.google.common.eventbus.EventBus;
import org.springframework.stereotype.Component;

/**
* @desc 登录事件总线中心
* @author HLJ
* @date 2020/4/21 15:58
*/
@Component
public class LoginEventBusCenter {

/**
* 事件总线
*/
private final EventBus eventBus = new EventBus();

/**
* 注册观察者
* @param observer 观察者实例
*/
public void register(Object observer) {
eventBus.register(observer);
}

/**
* 注销观察者
* @param observer 观察者实例
*/
public void unregister(Object observer) {
eventBus.unregister(observer);
}

/**
* 推送事件
* @param event 事件信息
*/
public void post(Object event) {
eventBus.post(event);
}

}

LoginEventObserverBo 作为一个中间业务对象,存储账号的会话 ID、用户 ID、用户名等信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

import java.io.Serializable;

/**
* @desc 登录事件观察者 BO
* @author HLJ
* @date 2020/4/21 16:34
*/
@NoArgsConstructor
@Getter
@Setter
@ToString
public class LoginEventObserverBo implements Serializable {
private static final long serialVersionUID = 1317296563624907994L;

/**
* 会话 ID
*/
private String sessionId;

/**
* 用户 ID
*/
private Integer userId;

/**
* 用户名
*/
private String username;

}

LoginEventObserver 用来进行登录后得处理,使用 Guava Cache 来暂存会话属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.eventbus.Subscribe;
import org.apache.shiro.session.InvalidSessionException;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
* @desc 登录事件观察者
* @author HLJ
* @date 2020/4/21 16:35
*/
@Component
public class LoginEventObserver {

/**
* key : 用户名,value : 会话 ID
* cache 生存时间取值大于会话的有效时间即可
*/
private final Cache<String, LoginEventObserverBo> cache = CacheBuilder.newBuilder()
.maximumSize(1000L)
.expireAfterWrite(6L, TimeUnit.HOURS)
.build();

@Autowired
private SessionDAO sessionDAO;

/**
* @desc 处理并接收登录事件
* @author HLJ
* @date 2020/4/21 16:51
* @param event 事件信息
*/
@Subscribe // 只有通过 @Subscribe 注解的方法才会被注册到 EventBus,且方法只能有一个参数
public void handle(LoginEventObserverBo event) {
if (event == null) {
return;
}

// 判断账号是否存在已登录行为,存在的话则使其下线
LoginEventObserverBo previous = cache.getIfPresent(event.getUsername());
if (previous != null) {
try {
sessionDAO.readSession(previous.getSessionId()).setTimeout(0L);
} catch (InvalidSessionException ignored) {
}
}

// 存储新登录账号信息
cache.put(event.getUsername(), event);
}

}

在登录事件总线中心注册观察者,这里我偷个懒,直接在 SpringBoot 的启动类注册:

1
2
3
4
5
6
7
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(App.class, args);
// 注册登录事件的观察者实例
LoginEventBusCenter loginEventBusCenter = context.getBean(LoginEventBusCenter.class);
LoginEventObserver loginEventObserver = context.getBean(LoginEventObserver.class);
loginEventBusCenter.register(loginEventObserver);
}

发生登录行为后推送事件,部分代码如下:

1
2
3
4
5
6
7
8
9
10
// 登录
SecurityUtils.getSubject().login(new UsernamePasswordToken(username, password));

// 推送登录事件
LoginEventObserverBo event = new LoginEventObserverBo();
event.setSessionId(SecurityUtils.getSubject().getSession().getId().toString());
UserBo user = getLoginUser();
event.setUsername(user.getUsername());
event.setUserId(user.getId());
loginEventBusCenter.post(event);

Shiro 避免单个账号多处保持登录状态
https://blog.yohlj.cn/posts/25476f49/
作者
Enoch
发布于
2020年4月21日
许可协议