Shiro的登录拦截及单点登录实现 您所在的位置:网站首页 sso单点登录实现代码 Shiro的登录拦截及单点登录实现

Shiro的登录拦截及单点登录实现

2023-07-21 09:43| 来源: 网络整理| 查看: 265

示例代码链接:https://github.com/Winter730/springmvc-shiro-demo

Shiro组件 Web过滤器:shiroFilterFactoryBean 参数如下: securityManager loginUrl 登录拦截跳转的Url successUrl 登录成功跳转的Url filters authc过滤器 filterChainDefinitions 指定过滤规则,其中: anno:任何人都可以访问;authc:必须是登录之后才能进行访问,不包含remember me;user:登录用户才可以访问,包含remember me;perms:指定过滤规则,这个一般是扩展使用,不会使用原生的。 安全管理器:securityManager 负责对所有的subject进行安全管理。 通过SecurityManager可以完成subject的认证、授权等,实质上SecurityManager是通过Authenticator进行认证,通过Authorizer进行授权,通过SessionManager进行会话管理等。 参数如下: realms sessionManager rememberMeManager

领域:realms 相当于datasource数据源,securityManager进行安全认证需要通过Realm获取用户权限数据,比如:如果用户身份数据在数据库那么realm就需要从数据库获取用户身份信息。 注意:不要把realm理解成只是从数据源取数据,在realm中还有认证授权校验的相关的代码。

自动登录:rememberMeManager 参数如下:

cipherKey cookie加密密钥 rememberMeCookie 自动登录缓存cookie:rememberMeCookie 参数如下: httpOnly:是否暴露给客户端 maxAge:Cookie生效时间,-1表示关闭浏览器时过期Cookie 会话管理:sessionManager shiro框架定义了一套会话管理,它不依赖web容器的session,所以shiro可以使用在非web应用上,也可以将分布式应用的会话集中在一点管理,此特性可使它实现单点登录。 参数如下: globalSessionTimeout 全局session超时时间 sessionDAO sessionIdCookieEnabled 是否将sessionId保存到Cookie中 sessionIdCookie sessionValidationSchedulerEnabled 是否开启会话验证器 sessionListeners 会话监听器 sessionFactory session工厂 cacheManager

SessionDAO 会话dao 是对session会话操作的一套接口,比如要将session存储到数据库,可以通过jdbc将会话存储到数据库。

会话Cookie:sessionIdCookie 参数如下:

httpOnly:是否暴露给客户端 maxAge:Cookie生效时间,-1表示关闭浏览器时过期Cookie CacheManager:缓存管理 将用户权限数据存储在缓存,这样可以提高性能

补充:

rememberMeManager 主要针对单节点登录 sessionManager 针对分布式应用会话的集中式管理

以下展示shiro在SpringMVC中的使用,示例代码中分为client1、client2、common、single、sso5个模块,其中common属于公共模块,关于Shiro的封装都在common模块实现,single为登录拦截及接口权限的实例代码。client1、client2、sso为单点登录的示例代码。

shiro实现登录拦截

shiro实现登录拦截的执行流程如下:

用户访问系统的受保护资源,请求被shiroFilter拦截,shiroFilter拦截请求后,通过authc过滤器isAccessAllowed()方法进行访问验证。 若访问验证不通过,将执行onAccessDenied()方法,转到登录页面,进行用户登录 用户登录验证通过调用realms中的doGetAuthenticationInfo()方法实现,验证用户的用户名、密码是否正确;用户是否被锁定。 用户登录成功后,回跳登录前地址,此时请求仍然被shiroFilter拦截,但用户验证已通过,故登录成功,可顺利访问受访问资源。

根据执行流程所述详细实现代码实现如下:

继承AuthenticationFilter,重写authc过滤器的isAccessAllowed、onAccessDenied方法 public class SingleAuthenticationFilter extends AuthenticationFilter { @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { Subject subject = getSubject(request, response); return subject.isAuthenticated(); } @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { StringBuffer ssoServerUrl = new StringBuffer(PropertiesFileUtil.getInstance("client").get("pmi.sso.server.url")); ssoServerUrl.append("/sso/index").append("?").append("appid").append("=").append(PropertiesFileUtil.getInstance("client").get("app.name")); //回跳地址 HttpServletRequest httpServletRequest = WebUtils.toHttp(servletRequest); StringBuffer backUrl = httpServletRequest.getRequestURL(); String queryString = httpServletRequest.getQueryString(); if(StringUtils.isNotBlank(queryString)) { backUrl.append("?").append(queryString); } ssoServerUrl.append("&").append("backUrl").append("=").append(URLEncoder.encode(backUrl.toString(), "utf-8")); WebUtils.toHttp(servletResponse).sendRedirect(ssoServerUrl.toString()); return false; } } 登录被拒绝时请求转发到登录接口,登录Controller类如下: /** * 单机登录,非会话登录 * Created by winter on 2021/4/24 */ @Controller @RequestMapping("/sso") public class SingleController extends BaseController { private static final Logger logger = LoggerFactory.getLogger(SingleController.class); @RequestMapping(value = "/index", method = RequestMethod.GET) public String index(HttpServletRequest request) throws Exception{ String appId = request.getParameter("appid"); String backUrl = request.getParameter("backUrl"); if(StringUtils.isBlank(appId)) { throw new RuntimeException("无效访问"); } return "redirect:/sso/login?backUrl=" + URLEncoder.encode(backUrl, "UTF-8"); } @RequestMapping(value = "/login", method = RequestMethod.GET) public String login(HttpServletRequest request) { return "/sso/login"; } @RequestMapping(value = "/login", method = RequestMethod.POST) @ResponseBody public Object login(HttpServletRequest request, HttpServletResponse response, ModelMap modelMap) { Map map = request.getParameterMap(); String userName = request.getParameter("username"); String password = request.getParameter("password"); String rememberMe = request.getParameter("rememberMe"); if (StringUtils.isBlank(userName)) { return new WebResult(WebResultConstant.EMPTY_USERNAME, "帐号不能为空!"); } if(StringUtils.isBlank(password)) { return new WebResult(WebResultConstant.EMPTY_PASSWORD, "密码不能为空!"); } Subject subject = SecurityUtils.getSubject(); // 使用Shiro认证登录 UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userName, password); try { if(BooleanUtils.toBoolean(rememberMe)) { usernamePasswordToken.setRememberMe(true); } else { usernamePasswordToken.setRememberMe(false); } subject.login(usernamePasswordToken); } catch (UnknownAccountException e) { return new WebResult(WebResultConstant.INVALID_USERNAME, "帐号不存在!"); } catch (IncorrectCredentialsException e) { return new WebResult(WebResultConstant.INVALID_PASSWORD, "密码错误!"); } catch (LockedAccountException e) { return new WebResult(WebResultConstant.INVALID_ACCOUNT, "帐号已锁定!"); } //回跳登录前地址 String backUrl = request.getParameter("backUrl"); if(StringUtils.isBlank(backUrl)) { backUrl = request.getContextPath(); WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl); return webResult; } else { WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl); return webResult; } } @RequestMapping(value = "/logout", method = RequestMethod.GET) public String logout(HttpServletRequest request) { //shiro退出登录 SecurityUtils.getSubject().logout(); //跳回原地址 String redirectUrl = request.getHeader("Referer"); if(null == redirectUrl) { redirectUrl = "/"; } return "redirect:" + redirectUrl; } } realm实现,继承AuthorizingRealm 重写认证授权校验 /** * 领域:realms * 相当于datasource数据源,securityManager进行安全认证需要通过Realm获取用户权限数据,比如:如果用户身份数据在数据库那么realm就需要从数据库获取用户身份信息。 * 注意:不要把realm理解成只是从数据源取数据,在realm中还有认证授权校验的相关的代码。 * * 此处的角色、权限理论上应该从数据库中获取,作为demo采用默认枚举类 * Created by winter on 2021/4/26 */ public class MyRealm extends AuthorizingRealm { /** * 授权: 验证权限时调用 * @param principalCollection * @return */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { String userName = (String) principalCollection.getPrimaryPrincipal(); User user = User.getUser(userName); //当前用户所有角色 Role role = Role.getRole(userName); Set roles = new HashSet(); roles.add(role.getName()); //当前用户所有权限 List permissionList = Permission.getPermission(userName); Set permissions = new HashSet(); for(Permission permission : permissionList){ if(StringUtils.isNotBlank(permission.getPermissionValue())) { permissions.add(permission.getPermissionValue()); } } SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); simpleAuthorizationInfo.setStringPermissions(permissions); simpleAuthorizationInfo.setRoles(roles); return simpleAuthorizationInfo; } /** * 认证:登录时调用 * @param authenticationToken * @return * @throws AuthenticationException */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { String userName = (String) authenticationToken.getPrincipal(); String password = new String((char[]) authenticationToken.getCredentials()); // 查询用户信息 User user = User.getUser(userName); if(null == user) { throw new UnknownAccountException(); } if(!user.getPassword().equals(MD5Util.md5(password + user.getSalt()))){ throw new IncorrectCredentialsException(); } if(user.getLocked() == 1) { throw new LockedAccountException(); } return new SimpleAuthenticationInfo(userName, password, getName()); } } 进行SpringMVC与shiro的整合(后续会提供SpringBoot的实现,从原理上是同一回事,挖坑待填)配置web.xml文件 org.springframework.web.context.ContextLoaderListener contextConfigLocation classpath:applicationContext*.xml SpringMVC org.springframework.web.servlet.DispatcherServlet contextConfigLocation classpath:SpringMVC-servlet.xml 1 SpringMVC / CharacterEncodingFilter org.springframework.web.filter.CharacterEncodingFilter encoding UTF-8 CharacterEncodingFilter /* REQUEST FORWARD shiroFilter org.springframework.web.filter.DelegatingFilterProxy targetFilterLifecycle true shiroFilter /* 配置文件中,配置shiroWeb过滤器及securityManager(可使用@Configuration进行Bean注入,会比每次都需要写xml文件简单,挖坑待填) /manage/** = authc /manage/index = user /druid/** = user /resources/** = anon /** = anon Shiro+SpringAOP实现接口权限管理 Shiro配置文件中注入Bean 在需要配置权限的接口上加入权限注解@RequiresPermissions("xxx"),例 @Controller @RequestMapping("/manage") public class ManageController extends BaseController { @RequestMapping(value = "/index", method = RequestMethod.GET) public String index(ModelMap modelMap) { return "/manage/index"; } @RequiresPermissions("sso:permission2:read") @RequestMapping(value = "/permission", method = RequestMethod.GET) public String permission(ModelMap modelMap) { return "/manage/permission"; } } Spring容器(SpringMVC-servlet)中注入AOP

此时已经完成了使用SpringAOP+Shiro实现接口权限管理,但是存在一个优化点在于,当每次请求需要权限的接口时,都会调用MyRealm中的doGetAuthorizationInfo()方法,去数据库查询用户所拥有的权限,那么,能否将该权限进行缓存,下次查询时,直接从缓存中获取结果而不需要每次都去查询数据库呢? 在single模块中,我们采用ehcache进行数据的缓存。

ehcache.xml配置文件配置如下:

注:如果是使用的tomcat启动的SpringMVC项目,ehcache缓存的存储路径是在tomcat目录下,即:apache-tomcat-8.5.56\temp\client1\ehcache 而非项目目录.

Spring容器中注入ehcache Shiro中使用ehcache

通过以上配置,再次请求需要权限的接口时会直接从ehcache中取缓存,不必再经过doGetAuthorizationInfo()方法。

Shiro会话管理实现单点登录(使用redis缓存session) 什么是单点登录

单点登录全程是Single Sign On(SSO),是指在多系统应用群众登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录和单点注销两部分。(单点注销暂不做过多处理,待填坑)

登录

SSO需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。 间接授权通过令牌实现,SSO认证中心验证用户的用户名密码没问题,创建授权令牌。 在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。 其过程如图所示 image 上图描述及相关代码描述如下:

用户访问系统1的受保护资源(例/),系统1发现用户未登录,跳转至SSO认证中心,并将自己的地址作为参数。 拦截用户请求通过PMIAuthenticationFilter.isAccessAllowed()方法实现 @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { Subject subject = getSubject(request, response); Session session = subject.getSession(); //判断请求类型 String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type"); session.setAttribute(PMIConstant.PMI_TYPE, PMIType); if("client".equals(PMIType)) { return validateClient(request, response); } if("server".equals(PMIType)) { return subject.isAuthenticated(); } return false; }

判断用户是否登录通过PMIAuthenticationFilter.validateClient()实现,此时各参数都不存在,故判断为未登录,由PMIAuthenticationFilter.onAccessDenied()方法跳转至SSO认证中心。 PMIAuthenticationFilter.onAccessDenied()方法实现如下:

@Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { StringBuffer ssoServerUrl = new StringBuffer(PropertiesFileUtil.getInstance("client").get("pmi.sso.server.url")); //server需要登录 String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type"); if("server".equals(PMIType)) { WebUtils.toHttp(servletResponse).sendRedirect(ssoServerUrl.append("/sso/login").toString()); return false; } ssoServerUrl.append("/sso/index").append("?").append("appid").append("=").append(PropertiesFileUtil.getInstance("client").get("app.name")); //回跳地址 HttpServletRequest httpServletRequest = WebUtils.toHttp(servletRequest); StringBuffer backUrl = httpServletRequest.getRequestURL(); String queryString = httpServletRequest.getQueryString(); if(StringUtils.isNotBlank(queryString)) { backUrl.append("?").append(queryString); } ssoServerUrl.append("&").append("backUrl").append("=").append(URLEncoder.encode(backUrl.toString(), "utf-8")); WebUtils.toHttp(servletResponse).sendRedirect(ssoServerUrl.toString()); return false; } SSO认证中心发现用户未登录,将用户引导至登录页面。 通过onAccessDenied()方法首先跳转至sso系统下SSOController.index()方法,查询数据库验证系统是否已经注册,确保系统可用性,确保系统可用后再跳转至SSO登录界面。(此处省略了从数据库验证系统是否已经注册的过程) 登录通过sso系统下SSOController.login()方法实现,此时各参数为空,直接跳转至login.jsp页面进行登录 @RequestMapping(value = "/index", method = RequestMethod.GET) public String index(HttpServletRequest request) throws Exception{ String appId = request.getParameter("appid"); String backUrl = request.getParameter("backUrl"); if(StringUtils.isBlank(appId)) { throw new RuntimeException("无效访问"); } return "redirect:/sso/login?backUrl=" + URLEncoder.encode(backUrl, "UTF-8"); } @RequestMapping(value = "/login", method = RequestMethod.GET) public String login(HttpServletRequest request) { Subject subject = SecurityUtils.getSubject(); Session session = subject.getSession(); String serverSessionId = session.getId().toString(); //判断是否已登录,如果已登录,则回跳 String code = RedisUtil.get(PMI_SERVER_CODE + "-" + serverSessionId); String userName = (String) subject.getPrincipal(); //code校验值 if(StringUtils.isNotBlank(code)) { //回跳 String backUrl = request.getParameter("backUrl"); if (StringUtils.isBlank(backUrl)) { backUrl = "/"; } else { if (backUrl.contains("?")) { backUrl += "&pmi_code=" + code + "&pmi_username=" + userName; } else { backUrl += "?pmi_code=" + code + "&pmi_username=" + userName; } } logger.info("认证中心账号通过,带code回跳: {}", backUrl); return "redirect:" + backUrl; } return "/sso/login"; }

用户输入用户名密码提交登录申请

SSO认证中心校验用户信息,创建用户与SSO认证中心之间的会话,称为全局会话,同时创建授权令牌,授权令牌取全局会话的sessionId。 登录校验通过sso系统下SSOController.login()方法实现

@RequestMapping(value = "/login", method = RequestMethod.POST) @ResponseBody /** * 此处有以下可能: * 1.用户首次登录 * 2.用户非首次登录,来自同一台机器 * 3.用户非首次登录,来自不同机器 */ public Object login(HttpServletRequest request, HttpServletResponse response, ModelMap modelMap) { String userName = request.getParameter("username"); String password = request.getParameter("password"); String rememberMe = request.getParameter("rememberMe"); if (StringUtils.isBlank(userName)) { return new WebResult(WebResultConstant.EMPTY_USERNAME, "帐号不能为空!"); } if(StringUtils.isBlank(password)) { return new WebResult(WebResultConstant.EMPTY_PASSWORD, "密码不能为空!"); } Subject subject = SecurityUtils.getSubject(); Session session = subject.getSession(); String sessionId = session.getId().toString(); //判断是否已登录,如果已登录,则回跳,防止重复登录,同时需判断,是否为同一IP,如果不为同一IP,需删除原会话,重新登录 String oldSessionId = RedisUtil.get(PMI_SHIRO_USER + "-" + userName); if(!StringUtils.isBlank(oldSessionId) && ! sessionId.equals(oldSessionId)){ pmiSessionDao.deleteOldSession(oldSessionId); } if(StringUtils.isBlank(oldSessionId) || (StringUtils.isNotBlank(oldSessionId) && !sessionId.equals(oldSessionId))) { // 使用Shiro认证登录 UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userName, password); try { if(BooleanUtils.toBoolean(rememberMe)) { usernamePasswordToken.setRememberMe(true); } else { usernamePasswordToken.setRememberMe(false); } subject.login(usernamePasswordToken); } catch (UnknownAccountException e) { return new WebResult(WebResultConstant.INVALID_USERNAME, "帐号不存在!"); } catch (IncorrectCredentialsException e) { return new WebResult(WebResultConstant.INVALID_PASSWORD, "密码错误!"); } catch (LockedAccountException e) { return new WebResult(WebResultConstant.INVALID_ACCOUNT, "帐号已锁定!"); } //更新session状态 //全局会话sessionID列表,供会话管理 RedisUtil.set(PMI_SHIRO_USER + "-" + userName,sessionId); //code校验值,目前以server的sessionId作为校验值 RedisUtil.set(PMI_SERVER_CODE + "-" + sessionId, sessionId, (int)subject.getSession().getTimeout() / 1000); //更新会话状态 pmiSessionDao.updateStatus(sessionId, PMISession.OnlineStatus.on_line); } //回跳登录前地址 String backUrl = request.getParameter("backUrl"); if(StringUtils.isNotBlank(sessionId)) { if (backUrl.contains("?")) { backUrl += "&pmi_code=" + sessionId + "&pmi_username=" + userName; } else { backUrl += "?pmi_code=" + sessionId + "&pmi_username=" + userName; } } if(StringUtils.isBlank(backUrl)) { backUrl = request.getContextPath(); WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl); return webResult; } else { WebResult webResult = new WebResult(WebResultConstant.SUCCESS, backUrl); return webResult; } }

SSO认证中心带着令牌(pmi_code)及用户名(pmi_username)跳转回最初的请求地址(backUrl)

系统1拿到令牌,去SSO认证中心校验令牌是否有效 此时又跳转回最初的请求地址,依旧被Shiro拦截,回到第一步的PMIAuthenticationFilter.isAccessAllowed()方法,重新通过validateClient()方法进行验证,此时已拿到code,将会创建局部会话,返回true。 其中,validateClient()方法如下:

/** * 认证中心登录成功带回code * 只有从会话会经过这个方法 */ private boolean validateClient(ServletRequest request, ServletResponse response) { Subject subject = getSubject(request, response); Session session = subject.getSession(); String sessionId = session.getId().toString(); //判断局部会话是否登录 try{ String cacheClientSession = RedisUtil.get(PMI_SHIRO_SESSION_CLIENT + "-" + sessionId); if(StringUtils.isNotBlank(cacheClientSession)) { //更新有效期 RedisUtil.set(PMI_SHIRO_SESSION_CLIENT + "-" + sessionId, cacheClientSession, (int)session.getTimeout() / 1000); //移除url中的code参数 if(null != request.getParameter("code")){ String backUrl = RequestParameterUtil.getParameterWithOutCode(WebUtils.toHttp(request)); HttpServletResponse httpServletResponse = WebUtils.toHttp(response); try { httpServletResponse.sendRedirect(backUrl); } catch (IOException e) { logger.error("局部会话已登录,移除code参数跳转出错:", e); } } else { return true; } } } catch (Exception e){ logger.error(e.getMessage(), e); } // 判断是否有认证中心code String code = request.getParameter("pmi_code"); // 已拿到code if(StringUtils.isNotBlank(code)) { //HttpPost去校验code try { StringBuffer ssoServerUrl = new StringBuffer(PropertiesFileUtil.getInstance("client").get("pmi.sso.server.url")); HttpClient httpClient = new DefaultHttpClient(); HttpPost httpPost = new HttpPost(ssoServerUrl.toString() + "/sso/code"); List nameValuePairs = new ArrayList(); nameValuePairs.add(new BasicNameValuePair("code", code)); httpPost.setEntity(new UrlEncodedFormEntity(nameValuePairs)); HttpResponse httpResponse = httpClient.execute(httpPost); if(httpResponse.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { HttpEntity httpEntity = httpResponse.getEntity(); JSONObject result = JSONObject.parseObject(EntityUtils.toString(httpEntity)); if(1 == result.getIntValue("code") && result.getString("data").equals(code)){ Jedis jedis = RedisUtil.getJedis(); jedis.sadd(PMI_SHIRO_CONNECTIDS + "-" + code,PMI_SHIRO_SESSION_CLIENT + "-" + sessionId); jedis.close(); pmiSessionDao.updateStatus(sessionId, PMISession.OnlineStatus.on_line); jedis = RedisUtil.getJedis(); Long number = jedis.scard(PMI_SHIRO_CONNECTIDS + "-" + code); jedis.close(); logger.info("当前code={},对应的注册系统个数:{}个", code, number); // 返回请求资源 try { // 移除url中的token参数(此处会导致验证通过后,仍然要进行一次验证,不过如果去掉的话,将会暴露pmi_code参数,影响安全性,暂无其他方案,先搁置) String backUrl = RequestParameterUtil.getParameterWithOutCode(WebUtils.toHttp(request)); HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.sendRedirect(backUrl); return true; } catch (IOException e) { logger.error("已拿到code,移除code参数跳转出错:", e); } } else { logger.warn(result.getString("data")); } } } catch (IOException e) { logger.error("验证token失败:", e); } } return false; }

校验code方法如下:

@RequestMapping(value = "/code", method = RequestMethod.POST) @ResponseBody public Object code(HttpServletRequest request) { String codeParam = request.getParameter("code"); String code = RedisUtil.get(PMI_SERVER_CODE + "-" + codeParam); if(StringUtils.isBlank(codeParam) || !codeParam.equals(code)){ new WebResult(WebResultConstant.FAILED, "无效code"); } return new WebResult(WebResultConstant.SUCCESS, code); }

sso认证中心校验令牌,返回有效,注册系统1

系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源

用户访问系统2的受保护资源

系统2发现用户未登录,跳转至SSO认证中心,并将自己的地址作为参数 同系统1,先经过PMIAuthenticationFilter.isAccessAllowed(),验证失败后通过onAccessDenied()跳转到sso认证中心

SSO认证中心发现用户已登录,跳转回系统2的地址,并附上令牌 此时redis中已全局会话已存在,返回code校验值

系统2拿到令牌,去SSO认证中心校验令牌是否有效 同系统1,通过validateClient()方法验证令牌有效性

SSO认证中心校验令牌,返回有效,注册系统2

系统2使用该令牌创建与用户的局部会话,返回受保护资源

会话持久化

当用户请求Url时,无论用户是否登录,Shiro已经创建了相应的会话,而该会话在用户尚未登录时,属于无效会话,不需要进行持久化,当用户成功登录时,会话才作为一个有效会话保存至Redis中。 当该用户再次请求登录时,需检查旧sessionId与新sessionId是否一致,若sessionId不一致,说明现在不在同一台机器上,需要将原会话信息删除,保存新会话信息。

会话管理器sessionManager详解 自定义WebSessionManager,用于替代DefaultWebSessionManager 在shiro的一次认证过程中会调用10次左右的 doReadSession,如果使用内存缓存这个问题不大。 但是如果使用redis,而且在网络情况不是特别好的情况下这就成为问题了。 针对这个问题重写DefaultWebSessionManager,将缓存数据存放到request中,这样可以保证每次请求(可能会多次调用doReadSession方法)只请求一次redis。 代码过长,详细见github链接 session的创建过程 当发起一个请求时,即创建一个会话,shiro通过会话Dao中的doReadSession方法查询会话,此时若查询结果为空,则认为会话不存在,通过sessionFacotry中的createSession方法创建一个会话,再通过调用SessionIdGenerator中的generateId方法产生会话的sessionId。同时当会话创建时会通过SessionListener对会话创建的动作进行监听。再通过会话dao中的doCreate()方法,对session会话进行处理(由于我们是在用户登录成功后,才将session进行持久化,所以在doCreate方法中没有对session做处理)。 相关代码如下 自定义sessionFactory: public class PMISessionFactory implements SessionFactory { @Override public Session createSession(SessionContext sessionContext) { PMISession session = new PMISession(); if(null != sessionContext && sessionContext instanceof WebSessionContext) { WebSessionContext webSessionContext = (WebSessionContext) sessionContext; HttpServletRequest request = (HttpServletRequest) webSessionContext.getServletRequest(); if(null != request) { session.setHost(request.getRemoteAddr()); session.setUserAgent(request.getHeader("User-Agent")); } } return session; } }

自定义SessionIdGenerator

public class JavaUUIDSessionIdGenerator implements SessionIdGenerator { @Override public Serializable generateId(Session session) { return UUID.randomUUID().toString().replaceAll("-", ""); } }

自定义SessionListener

public class PMISessionListener implements SessionListener { private static final Logger logger = LoggerFactory.getLogger(PMISessionListener.class); @Override public void onStart(Session session) { logger.info("会话创建:" + session.getId()); } @Override public void onStop(Session session) { logger.info("会话停止:" + session.getId()); } @Override public void onExpiration(Session session) { logger.info("会话过期:" + session.getId()); } }

自定义sessionDao(重点)

/** * Created by winter on 2021/5/13 */ public class PMISessionDao extends EnterpriseCacheSessionDAO { private static final Logger logger = LoggerFactory.getLogger(PMISessionDao.class); // 会话key private final static String PMI_SHIRO_SESSION = "pmi-shiro-session"; // sso服务器授权令牌 private final static String PMI_SERVER_CODE = "pmi-server-code"; // 以sso服务器sessionId关联的从session列表 private final static String PMI_SHIRO_CONNECTIDS = "pmi-shiro-connectIds"; @Override //此时会话已创建,但仅是创建状态,并未登录用户,故在该步骤不需要进行会话持久化,直接保存在cookie中即可 protected Serializable doCreate(Session session) { Serializable sessionId = super.doCreate(session); String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type"); logger.info("doCreate >>>>> type = {}, sessionId={}", PMIType, session.getId()); return sessionId; } @Override //getSession,此时session可能有以下情况: //1.session在cache中存在,redis中不存在,取cache中存在的session即可 //2.session在cache中不存在(已过期),redis中存在,取redis中存在的session //3.session在cache和redis中都不存在,返回null,此时会创建新会话 //4.session在cache和redis中都存在,无需查询redis,取缓存中的即可。 protected Session doReadSession(Serializable sessionId) { //从缓存中取Session String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type"); Cache sessionCache = this.getActiveSessionsCache(); PMISession session = (PMISession) sessionCache.get(sessionId); if(session != null){ logger.info("doReadSession use cache >>>>> type = {}, sessionId={}", PMIType, sessionId); return session; } session = (PMISession) SerializableUtil.deserialize(RedisUtil.get(PMI_SHIRO_SESSION + "-" + PMIType + "-" +sessionId)); logger.info("doReadSession use redis >>>>> type = {}, sessionId={}", PMIType, sessionId); return session; } @Override protected void doUpdate(Session session) { //如果会话过期/停止 没必要再更新了 if(session instanceof ValidatingSession && !((ValidatingSession)session).isValid()) { return; } HttpServletRequest request = Servlets.getRequest(); if(request == null) { return; } //更新session的最后一次访问时间 PMISession pmiSession = (PMISession) session; PMISession cachePMISession = (PMISession) doReadSession(session.getId()); if(null != cachePMISession) { pmiSession.setStatus(cachePMISession.getStatus()); pmiSession.setAttribute("FORCE_LOGOUT", cachePMISession.getAttribute("FORCE_LOGOUT")); } //在线状态才更新 if(pmiSession.getStatus() == PMISession.OnlineStatus.on_line){ RedisUtil.set(PMI_SHIRO_SESSION + "_" + session.getId(), SerializableUtil.serialize(session), (int) session.getTimeout() / 1000); } logger.info("doUpdate >>>>> sessionId={}", session.getId()); } @Override protected void doDelete(Session session) { String sessionId = session.getId().toString(); String PMIType = ObjectUtils.toString(session.getAttribute(PMIConstant.PMI_TYPE)); } /** * 更改在线状态 */ public void updateStatus(Serializable sessionId, PMISession.OnlineStatus onlineStatus){ Cache sessionCache = this.getActiveSessionsCache(); String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type"); PMISession session = (PMISession) sessionCache.get(sessionId); if(null == session) { return; } session.setStatus(onlineStatus); try { RedisUtil.set(PMI_SHIRO_SESSION + "-" + PMIType + "-" + session.getId(), SerializableUtil.serialize(session), (int)session.getTimeout() / 1000); } catch (Exception e) { logger.error(e.getMessage(), e); } } /** * 删除旧会话信息 */ public void deleteOldSession(Serializable sessionId){ //删除旧code校验值 RedisUtil.remove(PMI_SERVER_CODE + "-" + sessionId); //删除旧会话 String PMIType = PropertiesFileUtil.getInstance("client").get("pmi.type"); RedisUtil.remove(PMI_SHIRO_SESSION + "-" + PMIType + "-" + sessionId); //根据sessionId获取关联的从服务器列表 Jedis jedis = RedisUtil.getJedis(); Set set = jedis.smembers(PMI_SHIRO_CONNECTIDS + "-" + sessionId); //删除关联的从服务器列表会话 for(String data : set) { RedisUtil.remove(data); } RedisUtil.remove(PMI_SHIRO_CONNECTIDS + "-" + sessionId); } }

自定义Session,将会话的登录状态写入Session中

public class PMISession extends SimpleSession { public enum OnlineStatus { on_line("在线"), off_line("离线"), force_logout("强制退出"); private final String info; OnlineStatus(String info) { this.info = info; } public String getInfo() { return info; } } // 用户浏览器类型 private String userAgent; // 在线状态 private OnlineStatus status = OnlineStatus.off_line; public String getUserAgent() { return userAgent; } public void setUserAgent(String userAgent) { this.userAgent = userAgent; } public OnlineStatus getStatus() { return status; } public void setStatus(OnlineStatus status) { this.status = status; } }

在shiro配置文件中(applicationContext-shiro.xml)配置会话管理器(sessionManager)

那么此时,如果经历了一次client服务器的登录请求,且登录成功了,应该在redis中创建多少条数据呢?

当请求Url时,由于当用户在从服务器对sso服务器发起请求时,同时创建了从服务器的session及sso服务器的session,故当用户登录成功后,sso服务器的session及从session都需要写入到redis中。(shiro在请求Url时就已经创建了会话,此刻的session,不包含用户信息,同时SimpleSession类中也不包含用户信息。) 即在登录后,shiro中需要存在两条数据:pmi-shiro-session-master-(sessionId)、pmi-shiro-session-client-(sessionId),value值为序列化的session。 如果sso服务器的session已存在,另外一个系统发起请求时,若为同一个用户。将只会创建新的pmi-shiro-session-client-(sessionId),而不会创建新的sso服务器session。

在用户尚未登录时,需要建立sso服务器与从服务器的关联关系,使用授权令牌来进行关联,此刻用户尚未登录,采用sso服务器的sessionId作为授权令牌,这个授权令牌通过sso服务器创建,但是从服务器可获取。由此需要在Redis中创建一个用于sso服务器与从服务器交互的授权令牌,同时还需要创建一个set数组,能直接通过sso服务器的sessionId查询到所有的的从服务器session,便于删除操作。 此处需要redis中再存储两条数据:pmi-server-code-(sso服务器sessionId),value值暂时以sessionId作为授权令牌。pmi-shiro-connectIds-(sso服务器sessionId),value值为以sso服务器sessionId建立的pmi-shiro-client-session-(sessionId)set数组,方便在主会话修改或删除时,对从会话进行统一的处理。

对于所有的主session,也需要创建一个集合,便于会话的管理,进行会话的强制退出等操作。集合的value值应该包含用户信息及用户对应的sessionId,当用户登录时,通过该集合查找该用户是否已登录,pmi-shiro-master-session是否一致(不一致则说明不是同一个登录的场景),但是如果数据量过大的话,如果用户信息和session作为一个value值存在,势必会极大的影响效率。此处的考虑是将用户信息写在key中,通过前缀检索查找所有用户,value仅保存sessionId。即存储的数据为pmi-shiro-user-(username),value值为sso服务器的sessionId。

即,在SSO认证中心校验用户信息时,若认证通过,此时pmi-server-code-(sso服务器sessionId)、pmi-shiro-user-(username)、pmi-shiro-session-master-(sessionId)三条数据写入Redis中。请求回到从服务器,从服务器通过返回的令牌(pmi_code)。验证pmi_code是否有效,验证通过后,创建pmi-shiro-connectIds-(sso服务器sessionId)、pmi-shiro-client-session-(sessionId)。

注:此处使用了Cookie做了缓存,所以可以通过getActiveSessionsCache()获取到尚未登录但已创建的会话,减少了对Redis的压力,但是使用缓存即会导致可见性问题,本文并未对可见性问题进行详细的阐述与处理,应根据自己的实际需要进行相应的处理。没有完美无缺的代码,只有在实际条件下最合适的方案。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有