一、前言

在Shiro中我们可以通过org.apache.shiro.session.mgt.eis.SessionDAO对象的getActiveSessions()方法方便的获取到当前所有有效的Session对象。通过这些Session对象,我们可以实现一些比较有趣的功能,比如查看当前系统的在线人数,查看这些在线用户的一些基本信息,强制让某个用户下线等。

为了达到这几个目标,我们在现有的Spring Boot Shiro项目基础上进行一些改造(缓存使用Ehcache)。

二、更改ShiroConfig

为了能够在Spring Boot中使用SessionDao,我们在ShiroConfig中配置该Bean:

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

如果使用的是Redis作为缓存实现,那么SessionDAO则为RedisSessionDAO:

1
2
3
4
5
6
@Bean
public RedisSessionDAO sessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
return redisSessionDAO;
}

在Shiro中,SessionDao通过org.apache.shiro.session.mgt.SessionManager进行管理,所以继续在ShiroConfig中配置SessionManager:

1
2
3
4
5
6
7
8
9
@Bean
public SessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
Collection<SessionListener> listeners = new ArrayList<SessionListener>();
listeners.add(new ShiroSessionListener());
sessionManager.setSessionListeners(listeners);
sessionManager.setSessionDAO(sessionDAO());
return sessionManager;
}

其中ShiroSessionListener为org.apache.shiro.session.SessionListener接口的手动实现,所以接下来定义一个该接口的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ShiroSessionListener implements SessionListener{
private final AtomicInteger sessionCount = new AtomicInteger(0);

@Override
public void onStart(Session session) {
sessionCount.incrementAndGet();
}

@Override
public void onStop(Session session) {
sessionCount.decrementAndGet();
}

@Override
public void onExpiration(Session session) {
sessionCount.decrementAndGet();
}
}

其维护着一个原子类型的Integer对象,用于统计在线Session的数量。

定义完SessionManager后,还需将其注入到SecurityManager中:

1
2
3
4
5
6
7
8
9
10
    @Bean
public SecurityManager securityManager() {
// 配置SecurityManager,并注入shiroRealm
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(shiroRealm());
securityManager.setRememberMeManager(rememberMeManager());
securityManager.setCacheManager(getEhCacheManager());
securityManager.setSessionManager(sessionManager());
return securityManager;
}

三、UserOnline

配置完ShiroConfig后,我们可以创建一个UserOnline实体类,用于描述每个在线用户的基本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Getter
@Setter
public class UserOnline implements Serializable {
private static final long serialVersionUID = -5440372534300871948L;
// session id
private String id;
// 用户id
private String userId;
// 用户名称
private String username;
// 用户主机地址
private String host;
// 用户登录时系统IP
private String systemHost;
// 状态
private String status;
// session创建时间
private Date startTimestamp;
// session最后访问时间
private Date lastAccessTime;
// 超时时间
private Long timeout;
}

四、Service

创建一个Service接口,包含查看所有在线用户和根据SessionId踢出用户抽象方法:

1
2
3
4
public interface SessionService {
List<UserOnline> list();
boolean forceLogout(String sessionId);
}

其具体实现:

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
@Service("sessionService")
public class SessionServiceImpl implements SessionService {
@Autowired
private SessionDAO sessionDAO;

@Override
public List<UserOnline> list() {
List<UserOnline> list = new ArrayList<>();
Collection<Session> sessions = sessionDAO.getActiveSessions();
for (Session session : sessions) {
UserOnline userOnline = new UserOnline();
User user = new User();
SimplePrincipalCollection principalCollection = new SimplePrincipalCollection();
if (session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY) == null) {
continue;
} else {
principalCollection = (SimplePrincipalCollection) session
.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
user = (User) principalCollection.getPrimaryPrincipal();
userOnline.setUsername(user.getUserName());
userOnline.setUserId(user.getId().toString());
}
userOnline.setId((String) session.getId());
userOnline.setHost(session.getHost());
userOnline.setStartTimestamp(session.getStartTimestamp());
userOnline.setLastAccessTime(session.getLastAccessTime());
Long timeout = session.getTimeout();
if (timeout == 0l) {
userOnline.setStatus("离线");
} else {
userOnline.setStatus("在线");
}
userOnline.setTimeout(timeout);
list.add(userOnline);
}
return list;
}

@Override
public boolean forceLogout(String sessionId) {
Session session = sessionDAO.readSession(sessionId);
session.setTimeout(0);
return true;
}
}

通过SessionDao的getActiveSessions()方法,我们可以获取所有有效的Session,通过该Session,我们还可以获取到当前用户的Principal信息。

值得说明的是,当某个用户被踢出后(Session Time置为0),该Session并不会立刻从ActiveSessions中剔除,所以我们可以通过其timeout信息来判断该用户在线与否。

如果使用的Redis作为缓存实现,那么,forceLogout()方法需要稍作修改:

1
2
3
4
5
6
@Override
public boolean forceLogout(String sessionId) {
Session session = sessionDAO.readSession(sessionId);
sessionDAO.delete(session);
return true;
}

五、Controller

定义一个SessionContoller,用于处理Session的相关操作:

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
@Controller
@RequestMapping("/online")
public class SessionController {
@Autowired
SessionService sessionService;

@RequestMapping("index")
public String online() {
return "online";
}

@ResponseBody
@RequestMapping("list")
public List<UserOnline> list() {
return sessionService.list();
}

@ResponseBody
@RequestMapping("forceLogout")
public ResponseBo forceLogout(String id) {
try {
sessionService.forceLogout(id);
return ResponseBo.ok();
} catch (Exception e) {
e.printStackTrace();
return ResponseBo.error("踢出用户失败");
}
}
}

六、页面

我们编写一个online.html页面,用于展示所有在线用户的信息:

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
57
58
59
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>在线用户管理</title>
<script th:src="@{/js/jquery-1.11.1.min.js}"></script>
<script th:src="@{/js/dateFormat.js}"></script>
</head>
<body>
<h3>在线用户数:<span id="onlineCount"></span></h3>
<table>
<tr>
<th>序号</th>
<th>用户名称</th>
<th>登录时间</th>
<th>最后访问时间</th>
<th>主机</th>
<th>状态</th>
<th>操作</th>
</tr>
</table>
<a th:href="@{/index}">返回</a>
</body>
<script th:inline="javascript">
var ctx = [[@{/}]];
$.get(ctx + "online/list", {}, function(r){
var length = r.length;
$("#onlineCount").text(length);
var html = "";
for(var i = 0; i < length; i++){
html += "<tr>"
+ "<td>" + (i+1) + "</td>"
+ "<td>" + r[i].username + "</td>"
+ "<td>" + new Date(r[i].startTimestamp).Format("yyyy-MM-dd hh:mm:ss") + "</td>"
+ "<td>" + new Date(r[i].lastAccessTime).Format("yyyy-MM-dd hh:mm:ss") + "</td>"
+ "<td>" + r[i].host + "</td>"
+ "<td>" + r[i].status + "</td>"
+ "<td><a href='#' onclick='offline(\"" + r[i].id + "\",\"" + r[i].status +"\")'>下线</a></td>"
+ "</tr>";
}
$("table").append(html);
},"json");

function offline(id,status){
if(status == "离线"){
alert("该用户已是离线状态!!");
return;
}
$.get(ctx + "online/forceLogout", {"id": id}, function(r){
if (r.code == 0) {
alert('该用户已强制下线!');
location.href = ctx + 'online/index';
} else {
alert(r.msg);
}
},"json");
}
</script>
</html>

在index.html中加入该页面的入口:

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
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:shiro="http://www.pollix.at/thymeleaf/shiro" >
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<style>
div {
border: 1px dashed #ddd;
padding: 10px;
margin: 10px 10px 10px 0px;
}
</style>
<body>
<p>你好![[${user.userName}]]</p>
<p shiro:hasRole="admin">你的角色为超级管理员</p>
<p shiro:hasRole="test">你的角色为测试账户</p>
<div>
<a shiro:hasPermission="user:user" th:href="@{/user/list}">获取用户信息</a>
<a shiro:hasPermission="user:add" th:href="@{/user/add}">新增用户</a>
<a shiro:hasPermission="user:delete" th:href="@{/user/delete}">删除用户</a>
</div>
<a shiro:hasRole="admin" th:href="@{/online/index}">在线用户管理</a>
<a th:href="@{/logout}">注销</a>
</body>
</html>

七、测试

启动项目,在FireFox浏览器中使用test账户访问:

在QQ浏览器中使用test账户访问:

然后在wno704主界面点击“在线用户管理”:

显示的信息符合我们的预期,点击test的下线按钮,强制将其踢出:

回到test用户的主界面,点击“查看用户信息”,会发现页面已经被重定向到login页面,因为其Session已经失效!

再次刷新wno704的online页面,显示如下: