SpringBoot整合Shiro(四)
在线人数以及并发登录人数控制
项目中有时候会遇到统计当前在线人数的需求,也有这种情况当A 用户在邯郸地区登录 ,然后A用户在北京地区再登录 ,要踢出邯郸登录的状态。如果用户在北京重新登录,那么又要踢出邯郸的用户,这样反复。
这样保证了一个帐号只能同时一个人使用。那么下面来讲解一下 Shiro 怎么实现在线人数统计 以及 并发人数控制这个功能。
并发人数控制
使用的技术其实是 shiro的自定义filter,在 springboot整合shiro -快速入门 中 我们已经了解到,在shiroConfig的ShiroFilterFactoryBean中使用的过滤规则,如:anon ,authc,user等本质上是通过调用各自对应的filter方式集成的,也就是说,它是遵循过滤器链规则的。
创建KickoutSessionControlFilter
写一个KickoutSessionControlFilter类继承AccessControlFilter类
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
|
public class KickoutSessionControlFilter extends AccessControlFilter {
private String kickoutUrl;
private boolean kickoutAfter = false;
private int maxSession = 1; private SessionManager sessionManager; private Cache<String, Deque<Serializable>> cache;
public void setKickoutUrl(String kickoutUrl) { this.kickoutUrl = kickoutUrl; }
public void setKickoutAfter(boolean kickoutAfter) { this.kickoutAfter = kickoutAfter; }
public void setMaxSession(int maxSession) { this.maxSession = maxSession; }
public void setSessionManager(SessionManager sessionManager) { this.sessionManager = sessionManager; }
public void setCacheManager(CacheManager cacheManager) { this.cache = cacheManager.getCache("shiro-activeSessionCache"); }
@Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { return false; }
@Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { Subject subject = getSubject(request, response); if (!subject.isAuthenticated() && !subject.isRemembered()) { return true; }
Session session = subject.getSession(); String username = ((User) subject.getPrincipal()).getUsername(); Serializable sessionId = session.getId();
Deque<Serializable> deque = cache.get(username); if (deque == null) { deque = new LinkedList<Serializable>(); cache.put(username, deque); }
if (!deque.contains(sessionId) && session.getAttribute("kickout") == null) { deque.push(sessionId); }
while (deque.size() > maxSession) { Serializable kickoutSessionId = null; if (kickoutAfter) { kickoutSessionId = deque.getFirst(); kickoutSessionId = deque.removeFirst(); } else { kickoutSessionId = deque.removeLast(); } try { Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId)); if (kickoutSession != null) { kickoutSession.setAttribute("kickout", true); } } catch (Exception e) { e.printStackTrace(); } }
if (session.getAttribute("kickout") != null) { try { subject.logout(); } catch (Exception e) { } WebUtils.issueRedirect(request, response, kickoutUrl); return false; } return true; } }
|
注意:我们首先看一下 isAccessAllowed() 方法,在这个方法中,如果返回 true,则表示“通过”,走到下一个过滤器。如果没有下一个过滤器的话,表示具有了访问某个资源的权限。如果返回 false,则会调用 onAccessDenied 方法,去实现相应的当过滤不通过的时候执行的操作,例如检查用户是否已经登陆过,如果登陆过,根据自定义规则选择踢出前一个用户 还是 后一个用户。
onAccessDenied方法 返回 true 表示 自己处理完成,然后继续拦截器链执行。
只有当两者都返回false时,才会终止后面的filter执行。
shiroConfig相关配置
并发登录控制配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
@Bean public KickoutSessionControlFilter kickoutSessionControlFilter(){ KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter(); kickoutSessionControlFilter.setSessionManager(sessionManager()); kickoutSessionControlFilter.setCacheManager(ehCacheManager()); kickoutSessionControlFilter.setKickoutAfter(false); kickoutSessionControlFilter.setMaxSession(1); kickoutSessionControlFilter.setKickoutUrl("/login?kickout=1"); return kickoutSessionControlFilter; }
|
修改shirFilter
修改shiroConfig中shirFilter中配置KickoutSessionControlFilter 并修改过滤规则
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
|
@Bean(name = "shirFilter") public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
......
LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>(); filtersMap.put("kickout", kickoutSessionControlFilter()); shiroFilterFactoryBean.setFilters(filtersMap);
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login", "kickout,anon");
......
filterChainDefinitionMap.put("/**", "kickout,user");
return shiroFilterFactoryBean; }
|
解释:
filterChainDefinitionMap.put("/**", "kickout,user");
表示 访问/**下的资源 首先要通过 kickout 后面的filter,然后再通过user后面对应的filter才可以访问。
修改login.html
修改login.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="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro"> <head> <meta charset="UTF-8"/> <title>Insert title here</title> </head> <body> <h1>欢迎登录</h1> <h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1> <form action="/login" method="post"> 用户名:<input type="text" name="username"/><br/> 密 码:<input type="password" name="password"/><br/> <input type="checkbox" name="rememberMe"/>记住我<br/> <input type="submit" value="提交"/> </form> </body> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.0/jquery.js"></script> <script type="text/javascript"> $(function () { var href = location.href; if (href.indexOf("kickout") > 0) { alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!"); } }); </script> </html>
|
测试
先通过admin登录,然后换一个浏览器 再次登录admin,然后再回到原来的 浏览器刷新页面,会弹出框提示已下线
统计在线人数
springboot整合shiro-session管理 博客中,我们有配置过一个监听类 ,在该类中有统计session创建个数,我们也就用session的个数来统计在线的人数,但是这个统计人数是不准确的,存在这样一种情况,用户登录之后,强制退出浏览器,再次打开浏览器重新登录,在线人数一直在增加。暂时也没有想到特别好的方案。
修改UserController
添加在线人数的返回
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
| @RestController @RequestMapping("userInfo") public class UserController {
@Autowired private UserService userService;
@Autowired private RoleService roleService;
@Autowired private ShiroSessionListener shiroSessionListener; .....
@RequestMapping(value = "/userCount", method = RequestMethod.GET) @ResponseBody public String getCurrentUserCount() { int userCount = shiroSessionListener.getSessionCount(); return "当前在线人数" + userCount + "人"; } }
|
测试
登录多个账户访问 http://localhost:8080/userInfo/userCount
登录失败次数限制
如何限制用户登录尝试次数,防止坏人多次尝试,恶意暴力破解密码的情况出现,要限制用户登录尝试次数,必然要对用户名密码验证失败做记录,Shiro中用户名密码的验证交给了CredentialsMatcher 所以在CredentialsMatcher里面检查,记录登录次数是最简单的做法。当登录失败次数达到限制,修改数据库中的状态字段,并返回前台错误信息。
因为之前的博客都是用的明文,这里就不对密码进行加密了,如果有需要加密,将自定义密码比较器从SimpleCredentialsMatcher改为HashedCredentialsMatcher 然后将对应的配置项打开就可以。
限制登录次数
自定义RetryLimitHashedCredentialsMatcher继承SimpleCredentialsMatcher
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 60 61 62
| public class RetryLimitHashedCredentialsMatcher extends SimpleCredentialsMatcher {
private static final Logger logger = LoggerFactory.getLogger(RetryLimitHashedCredentialsMatcher.class);
private UserServiceAgent userServiceAgent = new UserServiceAgent();
private Cache<String, AtomicInteger> passwordRetryCache;
public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) { passwordRetryCache = cacheManager.getCache("passwordRetryCache"); }
@Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
String username = (String) token.getPrincipal(); AtomicInteger retryCount = passwordRetryCache.get(username); if (retryCount == null) { retryCount = new AtomicInteger(0); passwordRetryCache.put(username, retryCount); } if (retryCount.incrementAndGet() > 5) { User user = userServiceAgent.findByUserName(username); if (user != null && "0".equals(user.getState())) { user.setState("1"); userServiceAgent.update(user); } logger.info("锁定用户" + user.getUsername()); throw new LockedAccountException(); } boolean matches = super.doCredentialsMatch(token, info); if (matches) { passwordRetryCache.remove(username); } return matches; }
public void unlockAccount(String username) { User user = userServiceAgent.findByUserName(username); if (user != null) { user.setState("0"); userServiceAgent.update(user); passwordRetryCache.remove(username); } } }
|
shiroConfig配置
配置密码比较器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
@Bean("credentialsMatcher") public RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher() { RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher(ehCacheManager());
return retryLimitHashedCredentialsMatcher; }
|
shiroRealm中配置密码比较器
1 2 3 4 5 6 7 8
| @Bean public ShiroRealm shiroRealm() { ShiroRealm shiroRealm = new ShiroRealm(); ..... shiroRealm.setCredentialsMatcher(retryLimitHashedCredentialsMatcher()); return shiroRealm; }
|
配置缓存
在ehcache-shiro.xml添加缓存项
1 2 3 4 5 6 7 8 9 10 11 12 13
|
<cache name="passwordRetryCache" maxEntriesLocalHeap="2000" eternal="false" timeToIdleSeconds="0" timeToLiveSeconds="300" overflowToDisk="false" statistics="true"> </cache>
|
修改LoginController
在LoginController中添加解除admin用户限制方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Controller public class LoginController {
@Autowired private RetryLimitHashedCredentialsMatcher retryLimitHashedCredentialsMatcher;
.....
@RequestMapping("/unlockAccount") public String unlockAccount(Model model) { model.addAttribute("msg", "用户解锁成功"); retryLimitHashedCredentialsMatcher.unlockAccount("admin"); return "login"; }
}
|
注意:
为了方便测试,记得将 unlockAccount 权限改为 任何人可访问。
修改login.html
在login.html页面 添加 解锁admin用户的按钮
1
| <a href="/unlockAccount">解锁admin用户</a></button>
|
测试
连续五次输错密码
实现验证码认证
验证码是有效防止暴力破解的一种手段,常用做法是在服务端产生一串随机字符串与当前用户会话关联(我们通常说的放入 Session),然后向终端用户展现一张经过“扰乱”的图片,只有当用户输入的内容与服务端产生的内容相同时才允许进行下一步操作.
shiro添加验证码
说的是shiro添加验证码,其实不如说是web服务登录功能添加验证码,因为这个功能和shiro完全没有任何关系,网上大都是 实现 FormAuthenticationFilter 然后 在filter中进行验证码校验,或者是 在shiroRealm中进行验证码校验,一大堆的代码要写,感觉很麻烦,下面进行最简单的代码实现验证码功能。直接在 login方法内,判断验证码是否正确。
创建验证码类
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| package com.demo.utils;
import java.awt.*; import java.awt.image.BufferedImage; import java.util.Random;
public class CaptchaUtil {
private static final String RANDOM_STRS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final String FONT_NAME = "Fixedsys"; private static final int FONT_SIZE = 18;
private Random random = new Random();
private int width = 80; private int height = 25; private int lineNum = 50; private int strNum = 4;
public BufferedImage genRandomCodeImage(StringBuffer randomCode) { BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_BGR); Graphics g = image.getGraphics(); g.setColor(getRandColor(200, 250)); g.fillRect(0, 0, width, height);
g.setColor(getRandColor(110, 120));
for (int i = 0; i <= lineNum; i++) { drowLine(g); } g.setFont(new Font(FONT_NAME, Font.ROMAN_BASELINE, FONT_SIZE)); for (int i = 1; i <= strNum; i++) { randomCode.append(drowString(g, i)); } g.dispose(); return image; }
private Color getRandColor(int fc, int bc) { if (fc > 255) { fc = 255; } if (bc > 255) { bc = 255; } int r = fc + random.nextInt(bc - fc); int g = fc + random.nextInt(bc - fc); int b = fc + random.nextInt(bc - fc); return new Color(r, g, b); }
private String drowString(Graphics g, int i) { g.setColor(new Color(random.nextInt(101), random.nextInt(111), random .nextInt(121))); String rand = String.valueOf(getRandomString(random.nextInt(RANDOM_STRS .length()))); g.translate(random.nextInt(3), random.nextInt(3)); g.drawString(rand, 13 * i, 16); return rand; }
private void drowLine(Graphics g) { int x = random.nextInt(width); int y = random.nextInt(height); int x0 = random.nextInt(16); int y0 = random.nextInt(16); g.drawLine(x, y, x + x0, y + y0); }
private String getRandomString(int num) { return String.valueOf(RANDOM_STRS.charAt(num)); } }
|
获取验证码Controller
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
| @Controller public class CaptchaController {
public static final String KEY_CAPTCHA = "KEY_CAPTCHA";
@RequestMapping("/Captcha.jpg") public void getCaptcha(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("image/jpeg"); response.setHeader("Pragma", "No-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expire", 0); try {
HttpSession session = request.getSession();
CaptchaUtil tool = new CaptchaUtil(); StringBuffer code = new StringBuffer(); BufferedImage image = tool.genRandomCodeImage(code); session.removeAttribute(KEY_CAPTCHA); session.setAttribute(KEY_CAPTCHA, code.toString());
ImageIO.write(image, "JPEG", response.getOutputStream());
} catch (Exception e) { e.printStackTrace(); } } }
|
修改shiroConfig
在shiroConfig中对 获取验证码的功能开放权限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Bean(name = "shirFilter") public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); .....
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/Captcha.jpg","anon"); filterChainDefinitionMap.put("/login", "kickout,anon"); filterChainDefinitionMap.put("/", "anon");
..... return shiroFilterFactoryBean; }
|
修改LoginController
在login方法内添加验证码校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @RequestMapping(value = "/login", method = RequestMethod.POST) public String loginUser(HttpServletRequest request, String username, String password, boolean rememberMe, String captcha, Model model, HttpSession session) {
String sessionCaptcha = (String) SecurityUtils.getSubject().getSession().getAttribute(CaptchaController.KEY_CAPTCHA); if (null == captcha || !captcha.equalsIgnoreCase(sessionCaptcha)) { model.addAttribute("msg", "验证码错误!"); return "login"; }
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password, rememberMe); ..... }
|
修改login.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
| <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro"> <head> <meta charset="UTF-8"/> <title>Insert title here</title> </head> <body> <h1>欢迎登录</h1> <h1 th:if="${msg != null }" th:text="${msg}" style="color: red"></h1> <form action="/login" method="post"> 用户名:<input type="text" name="username"/><br/> 密 码:<input type="password" name="password"/><br/> 验证码:<input type="text" name="captcha"/><img alt="验证码" th:src="@{/Captcha.jpg}" title="点击更换" id="captcha_img"/> (看不清<a id="refreshCaptcha" href="javascript:void(0)">换一张</a>)<br/> <input type="checkbox" name="rememberMe"/>记住我<br/> <input type="submit" value="提交"/> <a href="/unlockAccount">解锁admin用户</a></button> </form> </body> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.0/jquery.js"></script> <script type="text/javascript"> $(function () { var href = location.href; if (href.indexOf("kickout") > 0) { alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!"); }
$("#refreshCaptcha,#captcha_img").click(function () { $("#captcha_img").attr("src", "/Captcha.jpg?id=" + new Date() + Math.floor(Math.random() * 24)); }); }); </script> </html>
|
测试
故意输错验证码