跳到主要内容

07、Shiro 速成:SpringBoot+Shiro 实现会话管理

一、概述

Shiro 提供了完整的企业级会话管理功能,不依赖于底层容器(如 web 容器 tomcat),不管 JavaSE 还是 JavaEE 环境都可以使用,提供了会话管理、会话事件监听、会话存储 / 持久化、容器无关的集群、失效 / 过期支持、对 Web 的透明支持、SSO 单点登录的支持等特性,即直接使用 Shiro 的会话管理可以直接替换如 Web 容器的会话管理。

二、重要概念

会话

所谓会话,即用户访问应用时保持的连接关系,在多次交互中应用能够识别出当前访问的用户是谁,且可以在多次交互中保存一些数据。如访问一些网站时登录成功后,网站可以记住用户,且在退出之前都可以识别当前用户是谁。

subject.getSession(); 获取Session对象
session.getId(); 获取当前会话的唯一标识。
session.getHost(); 获取当前 Subject 的主机地址
session.getTimeout(); / session.setTimeout(毫秒); 获取 / 设置当前 Session 的过期时间;如果不设置默认是会话管理器的全局过期时间。
session.getStartTimestamp(); / session.getLastAccessTime(); 获取会话的启动时间及最后访问时间;
session.setAttribute("key", "123"); / session.removeAttribute("key"); 设置 / 获取 / 删除会话属性
session.touch(); / session.stop(); 更新会话最后访问时间及销毁会话

会话管理器

会话管理器管理着应用中所有 Subject 的会话的创建、维护、删除、失效、验证等工作,是 Shiro 的核心组件。Shiro 提供了三个默认实现:

DefaultSessionManager:DefaultSecurityManager 使用的默认实现,用于 JavaSE 环境;
ServletContainerSessionManager:DefaultWebSecurityManager 使用的默认实现,用于 Web 环境,其直接使用 Servlet 容器的会话;
DefaultWebSessionManager:用于 Web 环境的实现,可以替代 ServletContainerSessionManager,自己维护着会话,直接废弃了 Servlet 容器的会话管理。

会话监听器

会话监听器用于监听会话创建、过期及停止事件。

onStart: 会话创建时触发
onExpiration: 会话过期时触发
onStop: 退出/会话过期时触发

会话存储 / 持久化

Shiro 提供 SessionDAO 用于会话的 CRUD,即 DAO(Data Access Object)模式实现:

//如DefaultSessionManager在创建完session后会调用该方法;如保存到关系数据库/文件系统/NoSQL数据库;即可以实现会话的持久化;返回会话ID;主要此处返回的ID.equals(session.getId());
Serializable create(Session session);
//根据会话ID获取会话
Session readSession(Serializable sessionId) throws UnknownSessionException;
//更新会话;如更新会话最后访问时间/停止会话/设置超时时间/设置移除属性等会调用
void update(Session session) throws UnknownSessionException;
//删除会话;当会话过期/会话停止(如用户退出时)会调用
void delete(Session session);
//获取当前所有活跃用户,如果用户量多此方法影响性能
Collection<Session> getActiveSessions();
  • 会话验证

Shiro 提供了会话验证调度器,用于定期的验证会话是否已过期,如果过期将停止会话;出于性能考虑,一般情况下都是获取会话时来验证会话是否过期并停止会话的;但是如在 web 环境中,如果用户不主动退出是不知道会话是否过期的,因此需要定期的检测会话是否过期,Shiro 提供了会话验证调度器 SessionValidationScheduler 来做这件事情。

  • 在线会话管理

有时候需要显示当前在线人数、当前在线用户,有时候可能需要强制某个用户下线等,此时就需要获取相应的在线用户并进行一些操作。

下面通过一个Shiro在线会话管理统计当前系统在线人数,查询在线用户信息、强制让某个用户下线等等。

三、Shiro在线会话管理

此案例使用RedisSessionDAO结合Redis缓存实现Shiro在线会话管理。

【a】Shiro全局配置类注入RedisSessionDAO

/**
 * 配置redis管理器
 */
@Bean
public RedisManager redisManager() {
    RedisManager redisManager = new RedisManager();
    //设置一小时超时,单位是秒
    redisManager.setExpire(3600);
    return redisManager;
}

/**
 * 注册RedisSessionDAO
 */
@Bean
public SessionDAO sessionDAO() {
    RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
    redisSessionDAO.setRedisManager(redisManager());
    return redisSessionDAO;
}

【b】注册SessionManager会话管理器

/**
 * 注册SessionManager会话管理器
 */
@Bean
public SessionManager sessionManager() {
    DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
    List<SessionListener> listeners = new ArrayList<>();
    //需要添加自己实现的会话监听器
    listeners.add(new CustomShiroSessionListener());
    //添加会话监听器给sessionManager管理
    sessionManager.setSessionListeners(listeners);
    //添加SessionDAO给sessionManager管理
    sessionManager.setSessionDAO(sessionDAO());
    //设置全局(项目)session超时单位 毫秒   -1为永不超时
    sessionManager.setGlobalSessionTimeout(360000);
    return sessionManager;
}

因为SessionManager会话管理器需要添加个会话监听,所以我们还得自定义一个会话监听器,通过实现SessionListener接口实现。

import org.apache.shiro.session.Session;
import org.apache.shiro.session.SessionListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @version V1.0
 * @ClassName: com.wsh.springboot.springbootshiro.listener.CustomShiroSessionListener.java
 * @Description: 自定义会话监听器
 * @author DDKK.COM 弟弟快看,程序员编程资料站
 * @date: 2022/11/7 15:03
 */
public class CustomShiroSessionListener implements SessionListener {
    private static final Logger logger = LoggerFactory.getLogger(CustomShiroSessionListener.class);

    /**
     * 维护着个原子类型的Integer对象,用于统计在线Session的数量
     */
    private final AtomicInteger sessionCount = new AtomicInteger(0);

    @Override
    public void onStart(Session session) {
        sessionCount.getAndIncrement();
        logger.info("用户登录人数增加一人" + sessionCount.get());
    }

    @Override
    public void onStop(Session session) {
        sessionCount.decrementAndGet();
        logger.info("用户登录人数减少一人" + sessionCount.get());
    }

    @Override
    public void onExpiration(Session session) {
        sessionCount.decrementAndGet();
        logger.info("用户登录过期一人" + sessionCount.get());
    }
}

【c】将会话管理器交给SecurityManager进行管理

//设置会话管理器
defaultWebSecurityManager.setSessionManager(sessionManager());

【d】创建一个实体类用于保存用户在线信息

public class OnlineUser {
    // session id
    private String sessionId;
    // 用户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;

    public String getSessionId() {
        return sessionId;
    }

    public void setSessionId(String sessionId) {
        this.sessionId = sessionId;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getHost() {
        return host;
    }

    public void setHost(String host) {
        this.host = host;
    }

    public String getSystemHost() {
        return systemHost;
    }

    public void setSystemHost(String systemHost) {
        this.systemHost = systemHost;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }

    public Date getStartTimestamp() {
        return startTimestamp;
    }

    public void setStartTimestamp(Date startTimestamp) {
        this.startTimestamp = startTimestamp;
    }

    public Date getLastAccessTime() {
        return lastAccessTime;
    }

    public void setLastAccessTime(Date lastAccessTime) {
        this.lastAccessTime = lastAccessTime;
    }

    public Long getTimeout() {
        return timeout;
    }

    public void setTimeout(Long timeout) {
        this.timeout = timeout;
    }
}

【e】创建OnlineUserService

public interface OnlineUserService {

    /**
     * 获取所有在线用户信息
     */
    List<OnlineUser> getAllOnlineUserList();

    /**
     * 根据sessionId强制登出
     *
     * @param sessionId 会话ID
     * @return
     */
    boolean forceLogout(String sessionId);

}

【f】创建OnlineUserService的实现类创建OnlineUserServiceImpl

@Service
public class OnlineUserServiceImpl implements OnlineUserService {

    /**
     * 注入会话dao
     */
    @Autowired
    private SessionDAO sessionDAO;

    @Autowired
    private UserMapper userMapper;

    @Override
    public List<OnlineUser> getAllOnlineUserList() {
        List<OnlineUser> onlineUserList = new ArrayList<>();
        //获取到当前所有有效的Session对象
        Collection<Session> activeSessions = sessionDAO.getActiveSessions();
        OnlineUser userOnline;
        //循环遍历所有有效的Session
        for (Session session : activeSessions) {
            userOnline = new OnlineUser();
            User user;
            SimplePrincipalCollection principalCollection;
            if (session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY) == null) {
                continue;
            } else {
                principalCollection = (SimplePrincipalCollection) session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
                String username = (String) principalCollection.getPrimaryPrincipal();
                user = userMapper.findUserByName(username);
                userOnline.setUsername(user.getUsername());
                userOnline.setUserId(user.getId());
            }
            userOnline.setSessionId((String) session.getId());
            userOnline.setHost(session.getHost());
            userOnline.setStartTimestamp(session.getStartTimestamp());
            userOnline.setLastAccessTime(session.getLastAccessTime());
            Long timeout = session.getTimeout();
            userOnline.setStatus(timeout.equals(0L) ? "离线" : "在线");
            userOnline.setTimeout(timeout);
            onlineUserList.add(userOnline);
        }
        return onlineUserList;
    }

    @Override
    public boolean forceLogout(String sessionId) {
        Session session = sessionDAO.readSession(sessionId);
        //强制注销
        sessionDAO.delete(session);
        return true;
    }

}

【g】创建OnlineUserController对外暴露操作接口

@Controller
public class OnlineUserController {

    @Autowired
    private OnlineUserService onlineUserService;

    @RequestMapping("/onlineUserList")
    public ModelAndView list() {
        List<OnlineUser> onlineUserList = onlineUserService.getAllOnlineUserList();
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("onlineUserList");
        modelAndView.addObject("onlineUserList", onlineUserList);
        return modelAndView;
    }

    @RequestMapping("/forceLogout")
    @ResponseBody
    public Map<String, String> forceLogout(@RequestParam("sessionId") String sessionId) {
        Map<String, String> resultMap = new HashMap<>(16);
        try {
            boolean forceLogout = onlineUserService.forceLogout(sessionId);
            if (forceLogout) {
                resultMap.put("code", "1");
                resultMap.put("msg", "强制踢人成功!");
            }
        } catch (Exception e) {
            resultMap.put("code", "0");
            resultMap.put("msg", "强制踢人失败!");
            e.printStackTrace();
        }
        return resultMap;
    }

}

【h】新建onlineUserList.html,用于展示所有在线用户

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>在线用户管理</title>
</head>
<body>
<h3>在线用户数: <span th:text="${onlineUserList.size()}"></span></h3>
<table border="1px">
    <tr>
        <th>用户id</th>
        <th>用户名称</th>
        <th>登录时间</th>
        <th>最后访问时间</th>
        <th>主机</th>
        <th>状态</th>
        <th>会话ID</th>
    </tr>
    <tr th:each="user : ${onlineUserList}">
        <th th:text="${user.userId}"></th>
        <th th:text="${user.username}"></th>
        <th th:text="${#dates.format(user.startTimestamp, 'yyyy-MM-dd HH:mm:ss')}"></th>
        <th th:text="${#dates.format(user.lastAccessTime, 'yyyy-MM-dd HH:mm:ss')}"></th>
        <th th:text="${user.host}"></th>
        <th th:text="${user.status}"></th>
        <th th:text="${user.sessionId}"></th>
    </tr>
</table>
</body>
</html>

【i】success.html加入如下超链接,用于跳转查看所有在线用户列表

<div>跳转到onlineUserList.html: <a href="/onlineUserList">查看用户在线管理列表</a><br></div>

【j】测试

启动项目,分别使用两个浏览器,一个浏览器用admin/123456登录,一个浏览器使用user/123456进行登录。

然后点击查看在线用户列表:

 

可以看到,当前在线用户是两个,这就完成了在线用户管理功能。

接下来我们测试一下强制踢出某个用户,使用会话ID进行踢出,这里为了方便,使用postman方式,传入会话ID,去删除会话信息:

 

然后再次查看当前用户列表:

 

可以看到,admin用户已经被踢出了。

注意:由于使用外部直接调用接口的方式去踢出,所以在Shiro配置类中需要放行/forceLogout接口。

filterChainDefinitionMap.put("/forceLogout", "anon");

四、总结

本篇文章主要总结了Shiro结合Redis实现在线会话管理功能,并通过一个统计当前在线用户总人数、强制踢出用户的小案例,说明了相关API的使用方法,本文采用的是RedisSessionDAO,即用的redis缓存。

Shiro也支持使用Ehcache缓存实现,那么在Shiro配置类中就需要注入MemorySessionDAO对象,而不是RedisSessionDAO。

@Bean
public SessionDAO sessionDAO() {
    MemorySessionDAO sessionDAO = new MemorySessionDAO();
    return sessionDAO;
}

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