主要实现用户同时在线人数控制
1.项目结构
我们在application.yml 实现对用户同时在线人数和踢出之前还是之后的控制。
1.1 application.yml
spring:
freemarker:
suffix: .html
redis:
host: 127.0.0.1
database: 0
port: 6379
jedis:
pool:
max-idle: 8
min-idle: 1
max-wait: -1
max-active: 8
shiro:
#踢出之前用户还是之后用户
kickoutAfter: true
# 最大同时在线人数,-1 表示无限制
maxSession: 1
1.2 pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!--Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--大佬写的shiro整合redis-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>
1.3 User实体类
此处注意实体类要序列化,否则session会报错
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {
private Integer userId;
private String userName;
private String password;
}
1.4 ShiroConfig配置
package com.zjl.config;
import com.zjl.realm.MyRealm;
import com.zjl.filter.KickoutSessionFilter;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.JavaUuidSessionIdGenerator;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Value("${shiro.kickoutAfter}")
private boolean kickoutAfter;
@Value("${shiro.maxSession}")
private Integer maxSession;
//@Autowired
//private RedisTemplate redisTemplate;
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// shiro核心安全接口
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 登陆地址
shiroFilterFactoryBean.setLoginUrl("/login");
// 未授权地址
shiroFilterFactoryBean.setUnauthorizedUrl("/login");
// 过滤地址
LinkedHashMap<String,String> filterChainDefinitionMap = new LinkedHashMap<>();
// shiro过滤链配置
filterChainDefinitionMap.put("/login","anon");
filterChainDefinitionMap.put("/loginUser","anon");
filterChainDefinitionMap.put("/index","anon");
//自定义过滤器
Map<String, Filter> filters = new LinkedHashMap<>();
filters.put("kickout",kickoutSessionFilter());
shiroFilterFactoryBean.setFilters(filters);
filterChainDefinitionMap.put("/**","kickout,authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
@Bean
public SecurityManager securityManager(MyRealm myRealm){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(myRealm);
securityManager.setCacheManager(redisCacheManager());
securityManager.setSessionManager(sessionManager());
return securityManager;
}
/**
* Redis缓存管理器
*
* @return {@link RedisCacheManager}
*/
@Bean
public RedisCacheManager redisCacheManager(){
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
redisCacheManager.setExpire(1800);
// 配置缓存的话要求放在session里面的实体类必须有个id标识 注:这里id为用户表中的主键,否-> 报:User must has getter for field: xx
redisCacheManager.setPrincipalIdFieldName("userId");
return redisCacheManager;
}
/**
* Redis管理器
* @return {@link RedisManager}
*/
@Bean
public RedisManager redisManager(){
RedisManager redisManager = new RedisManager();
redisManager.setTimeout(2000);
return redisManager;
}
/**
* Redis SessionDao
* @return {@link RedisSessionDAO}
*/
@Bean
public RedisSessionDAO redisSessionDAO(){
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
return redisSessionDAO;
}
@Bean
public MyRealm myRealm(){
return new MyRealm();
}
@Bean
public SessionManager sessionManager(){
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(this.redisSessionDAO());
//session过期时间 1800000为半小时
// sessionManager.setGlobalSessionTimeout(1800000);
return sessionManager;
}
@Bean
public KickoutSessionFilter kickoutSessionFilter(){
KickoutSessionFilter kickoutSessionFilter = new KickoutSessionFilter();
kickoutSessionFilter.setMaxSession(maxSession);
kickoutSessionFilter.setKickoutUrl("/login?kickout=1");
kickoutSessionFilter.setKickoutBefore(kickoutAfter);
kickoutSessionFilter.setSessionManager(sessionManager());
kickoutSessionFilter.setCacheManager(redisCacheManager());
return kickoutSessionFilter;
}
/**
* Session ID生成管理器
*
* @return
*/
@Bean(name = "sessionIdGenerator")
public JavaUuidSessionIdGenerator sessionIdGenerator() {
JavaUuidSessionIdGenerator sessionIdGenerator = new JavaUuidSessionIdGenerator();
return sessionIdGenerator;
}
}
1.5 KickoutSessionFilter过滤器
主要实现踢人功能。
package com.zjl.filter;
import com.zjl.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.*;
@Slf4j
public class KickoutSessionFilter extends AccessControlFilter {
// 用户被踢出后重定向的地址
private String kickoutUrl = "/";
// 使用RedisCacheManager 存储的cache前缀名
public static String ONLINE_USER = "online_user";
// 踢出当前登录用户还是之前用户
private boolean kickoutAfter = true;
// 同一个账号最大同时在线人数
private int maxSession = -1;
// SessionManager session管理器
private SessionManager sessionManager;
// 缓存
private Cache<String, LinkedList<Serializable>> cache;
public void setCacheManager(CacheManager cacheManager) {
this.cache = cacheManager.getCache(ONLINE_USER);
}
public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
public void setMaxSession(int maxSession) {
this.maxSession = maxSession;
}
public void setKickoutUrl(String kickoutUrl) {
this.kickoutUrl = kickoutUrl;
}
public void setKickoutBefore(boolean kickoutAfter) {
this.kickoutAfter = kickoutAfter;
}
/**
* 是否允许访问,true表示允许访问
* @param servletRequest
* @param servletResponse
* @param o
* @return
* @throws Exception
*/
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
return false;
}
/**
* 访问拒绝时是否自己处理,return false表示已经自己处理,true 表示自己不处理,继续由下一个拦截器执行
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request, response);
//如果用户未登录,跳过此过程
if(!subject.isAuthenticated() && !subject.isRemembered()) {
return true;
}
// -1表示无人数限制
if (maxSession == -1){
return true;
}
// 获取当前登录的sessionId
Session session = subject.getSession();
Serializable sessionId = session.getId();
// 获取当前登录用户信息
User user = (User) subject.getPrincipal();
String username = user.getUserName();
// 读取当前用户的Redis缓存,没有就创建一个空队列
LinkedList<Serializable> deque = cache.get(username);
if(deque==null){
deque = new LinkedList<>();
}
//如果队列里没有此sessionId,且用户没有被踢出;放入队列
if(!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
//将sessionId存入队列
deque.add(sessionId);
//将用户的sessionId队列缓存
cache.put(username, deque);
}
//如果队列里的sessionId数超出最大会话数,开始踢人
while(deque.size() > maxSession) {
// 保存待踢出用户的sessionId
Serializable kickoutSessionId = null;
//如果踢出后登录者,更新队列
if(kickoutAfter) {
kickoutSessionId = deque.removeFirst();
cache.put(username, deque);
} else {
//否则踢出前者,更新队列
kickoutSessionId = deque.removeLast();
cache.put(username, deque);
}
// 对即将踢出的用户标记
try {
Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
if(kickoutSession != null) {
//设置会话的kickout属性表示踢出
kickoutSession.setAttribute("kickout", true);
}
} catch (Exception e) {
log.error("踢出用户异常!");
}
}
//如果被踢出了,直接退出,重定向到踢出后的地址
if (session.getAttribute("kickout")!=null && (Boolean)session.getAttribute("kickout")) {
try {
//退出登录
subject.logout();
} catch (Exception e) { //ignore
log.error("用户退出异常!");
}
// 保存请求
saveRequest(request);
//若为Ajax请求
if ("XMLHttpRequest".equalsIgnoreCase(((HttpServletRequest) request).getHeader("X-Requested-With"))) {
// 自定义ajax返回结果
try {
response.setCharacterEncoding("UTF-8");
PrintWriter out = response.getWriter();
out.println("您已经在其他地方登录,请重新登录!");
out.flush();
out.close();
} catch (Exception e) {
log.error("未知异常,请联系管理员,或刷新重试!");
}
}else{
//重定向到踢出地址
WebUtils.issueRedirect(request, response, kickoutUrl);
}
return false;
}
return true;
}
}
此处值得注意的点主要如下:
- kickoutUrl: 踢出用户后,重定向到的地址;此处使用 /login?kickout=1 后续便于js判断用户是否被踢出
- maxSession: 同时最大在线人数;
- ONLINE_USER : redisCache 的前缀名
- kickoutAfter: 控制踢出前一个还是后一个。
1.6 MyRealm
package com.zjl.realm;
import com.zjl.entity.User;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
public class MyRealm extends AuthorizingRealm {
private static final List<User> users = new ArrayList<>();{
users.add(new User(1,"zhangsan","123456"));
users.add(new User(2,"lisi","123456"));
users.add(new User(3,"wangwu","123456"));
}
/**
* 授权
* @param principalCollection
* @return {@link AuthorizationInfo}
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
return info;
}
/**
* 认证
* @param authenticationToken 身份验证令牌
* @return {@link AuthenticationInfo}
* @throws AuthenticationException 身份验证异常
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
String username = token.getUsername();
String password = "";
if (token.getPassword() != null){
password = new String(token.getPassword());
}
// 登陆操作
User user = login(username,password);
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user,password,getName());
return info;
}
/**
* 登录
*
* @param userName 用户名
* @param password 密码
*/
public User login(String userName ,String password){
//判断用户名
for(User user:users){
if (userName.equals(user.getUserName())){
//判断密码
if (password.equals(user.getPassword())){
return user;
}else {
throw new IncorrectCredentialsException();
}
}
}
throw new UnknownAccountException();
}
}
1.7 LoginController
此处简单写了几个方法,不再过多阐述,后续测试页面有效果。
package com.zjl.controller;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
/**
* 登录控制器
* @author zjl
* @date 2021/07/09
*/
@Controller
public class LoginController {
@GetMapping("/login")
public String login()
{
return "index";
}
@GetMapping("/success")
public String success()
{
return "success";
}
@PostMapping("/loginUser")
@ResponseBody
public Map<String,Object> login(String userName, String password) {
Map<String,Object> map = new HashMap<>();
UsernamePasswordToken token = new UsernamePasswordToken(userName, password,false);
Subject subject = SecurityUtils.getSubject();
try {
subject.login(token);
map.put("msg","登陆成功");
map.put("code",0);
} catch (AuthenticationException e) {
e.printStackTrace();
map.put("msg","账号或密码错误");
}
return map;
}
@PostMapping("/logout")
@ResponseBody
public Map<String,Object> logout(String userName, String password) {
Map<String,Object> map = new HashMap<>();
Subject subject = SecurityUtils.getSubject();
subject.logout();
map.put("msg","退出成功");
map.put("code",0);
return map;
}
@RequestMapping("/toInfo")
public ModelAndView info(){
ModelAndView view = new ModelAndView();
view.setViewName("/info");
return view;
}
}
1.8 html页面
1.8.1 index.html (登录页)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆页</title>
<!-- CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div class="container">
<#--登陆框-->
<div style="width: 500px;margin:200px auto;vertical-align: middle">
<form>
<div class="form-group">
<label for="userName">用户名</label>
<input type="text" class="form-control" id="userName" aria-describedby="emailHelp">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" class="form-control" id="password">
</div>
<button type="button" class="btn btn-primary" style="float: right" onclick="login()">登陆</button>
</form>
</div>
</div>
</body>
<script>
$(function () {
var href=location.href;
if(href.indexOf("kickout")>0){
alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!");
}
})
function login() {
let userName = $('#userName').val();
let password = $('#password').val();
console.log()
$.ajax({
type : 'POST',
url: "/loginUser",
dataType:"json",
data : {
userName:userName,
password:password,
},
success: function(res) {
console.log(res)
alert(res.msg)
if (res.code === 0){
location.href = "/success"
}
}
});
}
</script>
</html>
此段代码:判断当前用户是否重复登陆:
1.8.2 success.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆页</title>
<!-- CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div class="container">
<h1>登陆成功</h1>
<button type="button" class="btn btn-primary" style="float: right" onclick="logout()">退出登陆</button>
<a href="/toInfo">账号多人登陆提示</a>
</div>
</body>
<script>
function logout() {
$.ajax({
type : 'POST',
url: "/logout",
dataType:"json",
data : {
},
success: function(res) {
if (res.code === 0){
location.href = "/"
}
}
});
}
</script>
</html>
1.8.3 info.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陆页</title>
<!-- CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div class="container">
<h1>测试页面</h1>
</div>
</body>
</html>
2.测试
启动项目,访问 localhost:8080 测试账号:zhangsan/123456
我们先打开一个浏览器登陆。再重新打开一个不同的浏览器。或再打开此浏览器,开启无痕窗口。
此时点击第一个浏览器的 账号多人登陆提示 提示重复登陆!
至此,shiro加redis的踢人功能完成!