功能描述:用户a登录了s账号,接着用户b也登录了s账号,此时用户a将被踢出。一个账号只能一个人登录,被别人登录了,那么你就要被踢下线。
本文目录
- shiro认证与授权理解
- 实现需求核心
- 以下是实现shiro用户踢出
- KickOutListener(登录成功后加入业务逻辑)
- kickOutFilter(进入controller的初级验证)
- 配置监听器、过滤器遇到的坑集合!!!
- 坑一: 监听器中的RedisTemplate注入不进来一直为null。
- 坑二:说realm没有配置
- 坑三:过滤器无效
- 坑四:不知道如何注入监听器
- 然后就是测试
- 吹毛求疵的优化
- 以下实现在线用户列表展示需求
- 添加session监听器
shiro认证与授权理解
用户在登录的时候只执行认证方法而没有马上去执行授权doGetAuthorizationInfo()方法。
- shiro并不是在认证之后就马上对用户授权,而是在用户认证通过之后,接下来要访问的资源或者目标方法需要权限的时候才会调用doGetAuthorizationInfo()方法,进行授权.
- 比如当认证通过后,访问@RequiresPermissions注解的目标方法,或者目标页面中有shiro的权限标签,这是shiro就会调用doGetAuthorizationInfo()方法.
实现需求核心
- 理解: 在loginOut的同时会删除session,退出浏览器不会删除session(我个人是配置了redis缓存session还有realm的)可以参考这篇博客shiro配置安全管理器之缓存管理器详解(底层用redis做存储,实现分布式session及分布式授权访问)
- AccessControlFilter(进入controller的过滤器):访问一些不需要权限的接口或者url时会进入AccessControlFilter进行过滤,当访问需要权限的接口或者url不会进入AccessControlFilter进行过滤,此时就会直接走授权。(doGetAuthorizationInfo),看是否有无权限进入此接口或者url。
- AuthenticationListener(认证监听器):通过实现这个接口我们可以在登录成功、失败、登出时进行一些对应的操作
- SessionListenerAdapter(session的监听器):通过继承这个接口,可以在session创建后、销毁后、超时后,写一些我们自己的业务逻辑。
以下是实现shiro用户踢出
KickOutListener(登录成功后加入业务逻辑)
往当前登录成功的session中存放一个当前user的唯一标识(方便在线用户列表功能的实现),登录成功且当前登录的用户没有被踢出那么把当前登录用户的sessionId作为value值存入redis的list集合中,key为当前登录用户的用户名。如果list的大小超过了1,那么踢出list之前的sessionId,进而拿到被剔出的session,往被踢的session中设置一个kisckOut的值。
/**
* @author 张子行
* @class 踢出监听器
*/
@Slf4j
public class KickOutListener implements AuthenticationListener {
private String KICK_OUT_KEY = "ZZH_KICK_OUT";
private String REDIS_LIST_KEY = null;
//最大人数限制
private Integer MAX_LOGIN_NUM = 1;
private Boolean enbleKickOut = true;
//默认当前用户登录了,是把上一个用户给踢掉
private Boolean kickOutOrder = true;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SessionManager sessionManager;
/**
* @param
* @method shiro登录成功后调用
*/
@Override
public void onSuccess(AuthenticationToken token, AuthenticationInfo info) {
//登录成功后的subject
Subject subject = SecurityUtils.getSubject();
ListOperations redisList = redisTemplate.opsForList();
Session session = subject.getSession();
session.setAttribute("loginUserName", info.getPrincipals().getPrimaryPrincipal());
Session kickOutSession;
Integer sessionId = (Integer) session.getId();
Object kickOutSessionId = null;
User user = new User();
try {
BeanUtils.copyProperties(user, info.getPrincipals().getPrimaryPrincipal());
REDIS_LIST_KEY = user.getUsername();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
//第一次登录,从当前session中获取KICK_OUT_KEY值
if (session.getAttribute(KICK_OUT_KEY) == null && REDIS_LIST_KEY != null) {
redisList.leftPush(REDIS_LIST_KEY, sessionId);
}
//队列里面超过MAX_LOGIN_NUM,开始踢人
//enbleKickOut也算一个扩展点叭,默认是true,可以踢人,当然自己也可以进行扩展设置
if (redisList.size(REDIS_LIST_KEY) > MAX_LOGIN_NUM && enbleKickOut) {
if (kickOutOrder) {
//踢出,返回值是踢出的value
kickOutSessionId = redisList.rightPop(REDIS_LIST_KEY);
} else {
kickOutSessionId = redisList.leftPop(REDIS_LIST_KEY);
}
}
try {
//获取被踢出用户的session
kickOutSession = sessionManager.getSession(new DefaultSessionKey((Serializable) kickOutSessionId));
kickOutSession.setAttribute(KICK_OUT_KEY, true);
log.info("踢出用户成功");
} catch (Exception e) {
log.info("踢出用户出错");
}
log.info("监听登录成功");
}
public KickOutListener setEnbleKickOut(Boolean enbleKickOut) {
this.enbleKickOut = enbleKickOut;
return this;
}
/**
* @param
* @method shiro登录失败后调用
*/
@Override
public void onFailure(AuthenticationToken token, AuthenticationException ae) {
log.info("监听登录失败");
}
/**
* @param
* @method shiro登出后调用
*/
@Override
public void onLogout(PrincipalCollection principals) {
log.info("监听出成功");
}
}
kickOutFilter(进入controller的初级验证)
根据当前的servletRequest、servletResponse获取里面的subject,进而获取subject中的session。如果此session里面有kickOut的值存在,登出当前用户,且跳转到登录页面。
/**
* @author 张子行
* @class 踢出过滤器
*/
@Slf4j
public class kickOutFilter extends AccessControlFilter {
private String KICK_OUT_URL;
private String KICK_OUT_KEY = "ZZH_KICK_OUT";
public kickOutFilter setKICK_OUT_URL(String KICK_OUT_URL) {
this.KICK_OUT_URL = KICK_OUT_URL;
return this;
}
/**
* @param
* @method return false接着进入onAccessDenied,反之不会
*/
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
log.info("isAccessAllowed");
return false;
}
/**
* @param
* @method return false直接给浏览器响应,return true正常请求
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
log.info("onAccessDenied");
Subject subject = getSubject(servletRequest, servletResponse);
User user = new User();
Session session;
try {
BeanUtils.copyProperties(user,subject.getPrincipals().getPrimaryPrincipal());
if (!subject.isAuthenticated() && !subject.isRemembered()) {
//如果没有登录,不做处理直接放行
return true;
}
} catch (Exception e) {
System.out.println(e);
//可能此次操作之前并没有进行过登录,那么subject.getPrincipals().getPrimaryPrincipal()此时为null会出现异常,直接放行就行
return true;
}
//当前登录的subject的session
session = subject.getSession();
if (user == null || session == null) {
//如果此次操作的subject中都没有userName、session的值,也不做处理直接放行
return true;
}
if (session.getAttribute(KICK_OUT_KEY) != null) {
subject.logout();
//此次会话已经被踢出,跳转到踢出页面
WebUtils.issueRedirect(servletRequest, servletResponse, KICK_OUT_URL);
return false;
}
return true;
}
}
配置监听器、过滤器遇到的坑集合!!!
坑一: 监听器中的RedisTemplate注入不进来一直为null。
原因:监听器加载于IOC之前,所以这个时候注入RedisTemplate时是null
解决办法: 将自定义监听器也加入到配置中,在拦截器执行的时候实例化监听器Bean
坑二:说realm没有配置
报如下错误:Configuration error: No realms have been configured! One or more
realms must be present to execute an authorization
operation.java.lang.IllegalStateException: Configuration error: No
realms have been configured! One or more realms must be present to
execute an authorization operation.()原因: 还不清除,可能和shiro的源码有关,会解决就谢谢了!
解决办法: 配置SecurityManager的时候,这里切记先添加Authenticator,在添加realm,否则报错。其他的顺序都会出岔子我也是醉了。感谢
/**
* @param
* @method 配置监听器bean
*/
@Bean
public AuthenticationListener KickOutListener() {
return new KickOutListener();
}
/**
* @param
* @method 这里设置了shiroCacheManager,表示要缓存loginRealm,以及会话session。
* 这里如果不设置shiroCacheManager,由于shiroSessionDao(shiroCacheManager)已经注入过shiroCacheManager
* session信息也是能被缓存的
*/
@Bean
public SecurityManager securityManager() {
ArrayList<AuthenticationListener> listeners = new ArrayList<>();
ModularRealmAuthenticator modularRealmAuthenticator = new ModularRealmAuthenticator();
//添加监听器
listeners.add(kickOutListener());
modularRealmAuthenticator.setAuthenticationListeners(listeners);
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 缓存授权信息,也就是realm
// securityManager.setCacheManager(shiroCacheManager);
securityManager.setSessionManager(sessionManager());
//这里切记先添加modularRealmAuthenticator,在添加loginRealm(),否则报错
//Configuration error: No realms have been configured! One or more realms must be present to execute an authorization operation.java.lang.IllegalStateException: Configuration error: No realms have been configured! One or more realms must be present to execute an authorization operation.
securityManager.setAuthenticator(modularRealmAuthenticator);
securityManager.setRealm(loginRealm());
return securityManager;
}
坑三:过滤器无效
我们注册了过滤器但是也要同时指定这个过滤器作用于哪些路径。
坑四:不知道如何注入监听器
看网上都是用xml配置的,并没有用springboot的配置,自己也是逐个看了一下SecurityManager下面有哪些方法,然后就知道怎么注入监听器咯。
然后就是测试
打开2个浏览器登录同一个账号,后登录的会把之前登录的给踢出,然后先前登录的刷新页面会重新跳转到登录页面。
吹毛求疵的优化
a用户把b用户给踢出了,但是如果此时b用户所在的界面是一个权限操作页面(这个页面中的任何一个按钮都是需要权限才能操作成功的)呢?b用户虽然被踢出了,但是此时b用户接着在这个页面上操作,或者是重新刷新此页面,并不会进入kickOutFilter,而是直接走realm中的授权 public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals),先授权,看是否有权限进行页面中的权限操作,而且此时的PrincipalCollection principals默认是被shiro缓存起来的,也就是说此时即使b用户被踢出了,但是授权还是可以继续成功的,在此时这个页面还是可以继续进行权限的操作,并不会走kickOutFilter,这不是有悖踢出嘛。
解决办法:
大前提:关闭自动缓存realm,如果缓存realm那么你就永远都执行不到授权的代码了,会直接从缓存中拉取realm
在授权这加一个判断,如果之前登录的subject中的session中拿得到KICK_OUT_KEY的值,直接登出,且返回一个空的权限的SimpleAuthorizationInfo 。这样此时b用户就啥也干不了了。
/**
* @param
* @return
* @function 授权
*/
@Override
public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();
if (session.getAttribute(KICK_OUT_KEY) != null) {
subject.logout();
log.info("被踢出的sessionId" + session.getId());
//此次会话已经被踢出,跳转到踢出页面
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
return simpleAuthorizationInfo;
}
return Authorization(principals);
}
private SimpleAuthorizationInfo Authorization(PrincipalCollection principals) {
log.info("授权。。。。。");
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
ArrayList<String> perms = new ArrayList<>();
Object key = principals.getPrimaryPrincipal();
User loginUser = new User();
try {
BeanUtils.copyProperties(loginUser, key);
} catch (Exception e) {
}
//TODO这里是模拟数据库中的权限表的情况
if (loginUser.getUsername().equals("shiro")) {
perms.add("shiro");
simpleAuthorizationInfo.addRole("shiro");
simpleAuthorizationInfo.addStringPermissions(perms);
}
if (loginUser.getUsername().equals("zzh")) {
simpleAuthorizationInfo.addRole("index");
perms.add("index");
simpleAuthorizationInfo.addStringPermissions(perms);
}
log.info("授权完成。。。。。");
return simpleAuthorizationInfo;
}
以下实现在线用户列表展示需求
也可以参考这片文章数据库中添加用户是否登录is_login字段,用来记录用户的登录状态,假设1表示在线;0表示未在线,则需要进行控制:
1)当用户登录成功时,设置用户登录状态为1
2)当用户退出登录时,设置用户登录状态为0
3)当session失效时,设置对应的用户的登录状态为0
自定义一个类ShiroSessionLinstener,继承SessionLinstenerAdapter类,同时重写监听函数
1)session创建时,输出sessionId,不进行任何操作
2)session停止时,修改数据库当前用户的登录状态字段为0
3)session失效时,修改数据库当前用户的登录状态字段为0
public class ShiroSessionListener extends SessionListenerAdapter {
// session创建
@Override
public void onStart(Session session) {
super.onStart(session);
System.out.println("session创建,sessionId:" + session.getId());
}
// session停止
@Override
public void onStop(Session session) {
//TODO 修改数据库当前用户的登录状态字段
//登录成功后设置过这个Attribute的
User loginUser = (User) session.getAttribute("loginUserName");
System.out.println("session停止,sessionId:" + session.getId() + ",用户:" + loginUser);
}
// session失效
@Override
public void onExpiration(Session session) {
//TODO 修改数据库当前用户的登录状态字段
//登录成功后设置过这个Attribute的
User loginUser = (User) session.getAttribute("loginUserName");
System.out.println("session失效,sessionId:" + session.getId() + ",用户:" + loginUser);
}
}
添加session监听器
SessionManager中配置即可
/**
* @param
* @method DefaultWebSessionManager的配置,设置了sessionDAO
*/
@Bean
public SessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
ArrayList<SessionListener> listeners = new ArrayList<>();
listeners.add(ShiroSessionListener());
sessionManager.setSessionListeners(listeners);
sessionManager.setSessionDAO(sessionDAO());
return sessionManager;
}