1.简介 CAS:Yale 大学发起的一个开源项目,旨在为 Web 应用系统提供一种可靠的单点登录方法。 Shiro:Apache Shiro是一个Java安全框架,可以帮助我们完成认证、授权、会话管理、加密等,并且提供与web集成、缓存、rememberMed等功能。 *Shiro支持与CAS进行整合使用. 2.CAS Server搭建 参考:https://www.cnblogs.com/funyoung/p/9234947.html 3.CAS Client搭建 3.1 添加Shiro自身以及整合CAS的相关依赖 org.apache.shiroshiro-core1.4.0org.apache.shiroshiro-cas1.4.0 3.2 配置Spring提供的过滤器代理 在web.xml中配置DelegatingFilterProxy并指定targetBeanName。 shiroFilterorg.springframework.web.filter.DelegatingFilterProxy targetBeanName shiroFilter shiroFilter/* *DelegatingFilterProxy是一个标准的Filter代理,通过targetBeanName指定其要代理的Filter的bean的id(默认情况下将代理bean id为filter-name的Filter) 3.2 新增shiro.properties配置文件并设置相关属性 shiro.loginUrl=http://127.0.0.1:8080/cas/login?service=http://127.0.0.1:8080/A/shiro-cas shiro.logoutUrl=http://127.0.0.1:8080/cas/logout?service=http://127.0.0.1:8080/A/shiro-cas shiro.cas.serverUrlPrefix=http://127.0.0.1:8080/cas shiro.cas.service=http://127.0.0.1:8080/A/shiro-cas shiro.successUrl=http://127.0.0.1:8081/front/index shiro.failureUrl=http://127.0.0.1:8081/front/index *在spring-shiro.xml中需要使用到此文件的配置项. 3.3 创建自定义的CasRealm *Shiro为了与CAS进行整合,提供了CasRealm实现类,其已经对AuthozingRealm抽象类声明的doGetAuthenticationInfo(AuthenticationToken token)、doGetAuthorizationInfo(PrincipalCollection principals)方法进行实现。 认证方法: protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {//CasToken是AuthenticationToken的实现类,其principal为null,credential为ticket.CasToken casToken = (CasToken) token;if (token == null) {return null; } String ticket = (String)casToken.getCredentials();if (!StringUtils.hasText(ticket)) {return null; } //ticket检验器TicketValidator ticketValidator = ensureTicketValidator();try {// 去CAS服务端中验证ticket的合法性并获取用户名进行补全Assertion casAssertion = ticketValidator.validate(ticket, getCasService());// 从CAS服务端中获取相关属性,包括用户名、是否设置RememberMe等AttributePrincipal casPrincipal = casAssertion.getPrincipal(); String userId = casPrincipal.getName(); log.debug("Validate ticket : {} in CAS server : {} to retrieve user : {}", new Object[]{ ticket, getCasServerUrlPrefix(), userId }); Map attributes = casPrincipal.getAttributes();// refresh authentication token (user id + remember me)casToken.setUserId(userId); String rememberMeAttributeName = getRememberMeAttributeName(); String rememberMeStringValue = (String)attributes.get(rememberMeAttributeName);boolean isRemembered = rememberMeStringValue != null && Boolean.parseBoolean(rememberMeStringValue);if (isRemembered) { casToken.setRememberMe(true); }// 最终创建SimpleAuthencationInfo实体返回给SecurityManagerList principals = CollectionUtils.asList(userId, attributes); PrincipalCollection principalCollection = new SimplePrincipalCollection(principals, getName());return new SimpleAuthenticationInfo(principalCollection, ticket); } catch (TicketValidationException e) { throw new CasAuthenticationException("Unable to validate ticket [" + ticket + "]", e); } } *AuthenticationToken的Credential是ticket,而返回的AuthenticationInfo的Credential仍是ticket,之间仅经过一层ticket校验,并不需要经过分子系统的数据库校验,因为在CAS服务端进行认证时已经经过一次全局的数据库校验。 *若使用CasRealm,则在分子系统进行登录时(CasFilter),SecurityManager会调用该Realm的doGetAuthenticationInfo方法获取用户的安全信息,通过AuthenticationToken的Credential与从Realm中获取的AuthenticationInfo中的Credential进行密码的比对,若应用添加了黑白名单功能则需要自定义Realm继承CasRealm,重写其提供的doGetAuthenticationInfo方法。 授权方法: protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {// 恢复用户信息SimplePrincipalCollection principalCollection = (SimplePrincipalCollection) principals; List listPrincipals = principalCollection.asList(); Map attributes = (Map) listPrincipals.get(1); SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();// 为用户添加默认的角色addRoles(simpleAuthorizationInfo, split(defaultRoles));// 为用户添加默认的行为addPermissions(simpleAuthorizationInfo, split(defaultPermissions));// 为用户添加预设置的角色List attributeNames = split(roleAttributeNames);for (String attributeName : attributeNames) { String value = attributes.get(attributeName); addRoles(simpleAuthorizationInfo, split(value)); }// 为用户添加预设置的行为attributeNames = split(permissionAttributeNames);for (String attributeName : attributeNames) { String value = attributes.get(attributeName); addPermissions(simpleAuthorizationInfo, split(value)); }return simpleAuthorizationInfo; } *CasRealm实现的doGetAuthorizationInfo方法仅仅是为用户添加默认和预定义的角色与行为,并不符合实际的应用场景,因此也需要进行自定义。 自定义Realm继承CasRealm重写其doGetAuthenticationInfo和doGetAuthorizationInfo方法 /** * 用户登录和授权用的realm */public class CasUserRealm extends CasRealm { @Autowiredprivate UserService userService; @Autowiredprivate RoleService roleService; @Autowiredprivate MenuService menuService; /** * CAS认证 ,验证用户身份 * 将用户基本信息设置到会话中 */protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {//调用CasRealm实现的认证方法,其包含验证ticket、填充CasToken的principal等操作)AuthenticationInfo authc = super.doGetAuthenticationInfo(token); String username = (String) authc.getPrincipals().getPrimaryPrincipal(); User user = userService.findByUsername(username);if (user != null) {//黑名单限制if (Global.NO.equals(user.getLoginFlag())) {throw new AuthenticationException("msg:该账号禁止登录"); }//将用户信息放在sessionSecurityUtils.getSubject().getSession().setAttribute("user", user);return authc; } else {return null; } } /** * 设置角色和权限信息 */@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); User user = userService.findByUsername(username);if (user != null) { SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();//获取用户拥有的角色List roles = roleService.findByUserId(user.getId());for (Role role : roles) { authorizationInfo.addRole(role.getEnname());//获取用户拥有的权限List menus = menuService.findByRoleId(role.getId());for(Menu menu : menus){if(StringUtils.isNotBlank(menu.getPermission())){ authorizationInfo.addStringPermission(menu.getPermission()); } } }return authorizationInfo; } else {return null; } } } 3.4 完整的spring-shiro.xml配置 */shiro-cas必须可以在未认证的状态下被访问,可以将其排在第一位。 *当直接访问shiro.loginUrl进行登录时,若登录成功则跳转到casFilter配置的successUrl,若是访问受限的URL被Shiro重定向到shiro.loginUrl进行登录时,登录成功则会跳转到原访问的被Shiro拦截的URL。 *当在CAS服务端认证不通过时,并不会将请求重定向到casFilter,因此casFilter配置的failureUrl不会生效。 4.Shiro整合CAS原理分析 4.1 项目架构图 4.2 用户首次访问项目A 1.用户的请求首先到达项目A的ShiroFilter。 2.若用户访问需认证的URL(非user拦截器),由于未进行认证,Subject的isAuthenticated()方法返回false,则Shiro将请求重定向到ShiroFilter配置好的loginUrl进行登录处理。 3.由于TGC不能成功匹配TGT,因此CAS服务端认为用户未进行登录,将请求转发到登录页面。 4.输入用户名/密码进行登录,若CAS服务端认证成功,则生成TGC Cookie保存到客户端浏览器进程所占用的内存,生成TGT保存在CAS服务端位于的内存,通过TGT签发ST,最终回调service参数中的URL并携带ticket参数传递ST,若CAS服务端认证失败,则提示密码错误。 5.若CAS认证成功则请求最终到达项目A的CasFilter,执行其executeLogin方法(即执行分子系统的Shiro认证) 5.1 从HTTP请求中获取ticket参数,将其构造成CasToken实例。 5.2 执行Subject.login(AuthenticationToken token)方法。 5.3 SecurityManager首先判断缓存中是否存在用户对应的AuthenticationInfo实体,若不存在则调用CasRealm的doGetAuthenticationInfo方法进行获取并将其放入到缓存中,执行分子系统的认证操作,最终将authenticated属性设置为true标识用户已进行登录并将用户的身份信息放入Subject的PrincipalCollection实体。 5.4 若用户访问的资源需要权限,此时Shiro就会调用Subject的isPermitted(String str)方法来检验用户的权限,首先判断Subject中的PincipalCollection实体是否包用户的身份信息 (已登录才有),若包含则判断缓存中是否存在用户对应的AuthorizationInfo实体,若存在则从缓存中获取,否则将调用Realm的doGetAuthorizationInfo方法进行获取,最终将AuthorizationInfo实体放入缓存。 5.5 若用户具有特定的权限则允许访问资源,否则将跳转到ShiroFilter配置好的unauthorizedUrl。 *Shiro是通过Subject的isAuthenticated()方法判断当前用户是否已经登录的,当执行登录操作后会将Subject的authenticated属性设值为true并将用户的身份信息放入Subject的PrincipalCollection实体中。 *若用户访问的URL是user拦截器的,则Subject根据isAuthenticated()方法和isRememberMe()方法判断用户是否需要进行登录,若任意一个方法返回true则表示用户不需进行登录。 *当关闭浏览器重新访问时将产生新的Subject对象,isAuthenticated()方法返回false,除非设置了RememberMe否则都需要重新进行登录。 *loginUrl的值为CAS服务端的登录处理URL,并且需在URL后拼接Service参数传递当认证成功后的回调地址,回调地址必须进入预定义好的CasFilter过滤器且能支持匿名访问。 *若是通过访问URL被重定向到loginUrl的请求,当认证成功后将会跳转原访问的URL。 *若是直接访问loginUrl请求,当认证成功后会跳转casFilter配置好的successUrl。 CasFilter: protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {//获取AuthenticationToken实体AuthenticationToken token = createToken(request, response);if (token == null) { String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken " + "must be created in order to execute a login attempt.";throw new IllegalStateException(msg); }try { Subject subject = getSubject(request, response);//执行分子系统的Shrio认证与授权subject.login(token);return onLoginSuccess(token, subject, request, response); } catch (AuthenticationException e) {return onLoginFailure(token, e, request, response); } }protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {//获取CAS Server回调请求中的ticket参数,构造CasToken实体,其principal为null,credential为ticket.HttpServletRequest httpRequest = (HttpServletRequest) request; String ticket = httpRequest.getParameter(TICKET_PARAMETER);return new CasToken(ticket); } AuthorizingRealm的getAuthorizationIfno获取用户权限信息: protected AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals) {if (principals == null) {return null; } AuthorizationInfo info = null;if (log.isTraceEnabled()) { log.trace("Retrieving AuthorizationInfo for principals [" + principals + "]"); }//获取缓存实体CacheCache cache = getAvailableAuthorizationCache();if (cache != null) {if (log.isTraceEnabled()) { log.trace("Attempting to retrieve the AuthorizationInfo from cache."); } Object key = getAuthorizationCacheKey(principals);//从缓存中获取用户的权限信息,即AuthorizationInfo实体.info = cache.get(key);if (log.isTraceEnabled()) {if (info == null) { log.trace("No AuthorizationInfo found in cache for principals [" + principals + "]"); } else { log.trace("AuthorizationInfo found in cache for principals [" + principals + "]"); } } }//若缓存中没有用户的权限信息则调用Realm的doGetAuthorizationInfo方法通过数据库进行查询if (info == null) { info = doGetAuthorizationInfo(principals);if (info != null && cache != null) {if (log.isTraceEnabled()) { log.trace("Caching authorization info for principals: [" + principals + "]."); }//构造缓存Key,将用户的权限信息放入缓存中Object key = getAuthorizationCacheKey(principals); cache.put(key, info); } }return info; } *Subject的PrincipalCollection实体存放着用户的身份信息,当已登录的情况下访问需授权的资源时就会调用其isPermitted(String str)方法将通过缓存或Realm的doGetAuthorizationInfo方法获取用户的权限信息。 4.3 用户再次访问项目A 1.请求首先到达项目A的ShiroFilter,由于已进行认证,Subject的isAuthenticated()方法返回true(无关闭浏览器的前提下) 2.若用户访问需要权限的资源时,就会调用Subject的isPermitted(String str)方法来检验用户的权限,其底层调用AuthorizingRealm的getAuthorizationInfo(PrincipalCollection principals)方法获取用户的权限信息。 3.若用户具有特定的权限则允许访问资源,否则将跳转到ShiroFilter配置好的unauthorizedUrl。 *若用户访问的URL是user拦截器的,则Subject根据isAuthenticated()方法和isRememberMe()方法判断用户是否需要进行登录,若任意一个方法返回true则表示用户不需进行登录.。 4.4 用户第一次访问项目B 1.请求首先到达项目B的ShiroFilter。 2.若用户访问需认证的URL,由于未进行认证,Subject的isAuthenticated()方法返回false,则Shiro将请求重定向到ShiroFilter配置好的loginUrl。 3.由于之前已登录过,存在TGC Cookie,因此TGC能成功匹配TGT,因此CAS服务端认为用户已进行登录。 4.CAS服务端使用TGT签发ST,回调service参数中的URL并携带ticket参数传递ST。 5.请求到达CasFilter,执行其executeLogin方法( 即执行分子系统的Shiro认证 ) 5.1 从HTTP请求中获取ticket参数,将其构造成CasToken实例。 5.2 执行Subject.login(AuthenticationToken token)方法。 5.3 SecurityManager调用自定义的CasRealm的doGetAuthenticationInfo方法,执行分子系统的认证操作,最终将authenticated属性设置为true标识用户已进行登录并将用户的身份信息放入Subject的PrincipalCollection实体。 5.4 若用户访问的资源需要权限,此时Shiro就会调用Subject的isPermitted(String str)方法来检验用户的权限,其底层调用AuthorizingRealm的getAuthorizationIfno(PrincipalCollection principals)方法获取用户的权限信息。 5.5 若用户具有特定的权限则允许访问资源,否则将跳转到ShiroFilter配置好的unauthorizedUrl。 5.实现单点登录整合时需解决的问题 5.1 用户统一 各个分子系统的用户都需要统一,即项目A的admin要与项目B的admin是同一个用户,因为各个CAS Client是通过从CAS Server获取用户登录的用户名进行分子系统的认证与授权,如果用户不统一,若Admin在CAS Client1登录后其访问CAS Client2,CAS Client2将会使用admin用户名进行登录,但是此用户名可能是别人的。 *另外需要提供给CAS Server进行用户校验的用户表,表中的数据应与各个分子系统的用户表统一。 5.2 注册同步 各个分子系统注册时跳转到统一的注册页面,用户注册后需将用户信息同步到所有分子系统中(通过HTTP方式或分布式服务框架) 5.3 提供单点登录管理系统 统一管理用户信息(CRUD)、机构信息(CRUD)、各个分子系统的角色信息、菜单信息、角色菜单关联信息、为用户分配各个分子系统的角色。 *各个分子系统的角色信息、菜单信息、角色菜单关联信息等需与分子系统交互的都使用HTTP的方式或分布式服务框架调取接口实现。 *对于历史系统的整合可以在分子系统相关表中添加一个字段用于存储单点登录管理系统表中的id,避免因为id引起的冲突,在与分子系统进行交互时依赖该字段进行记录的定位。 5.4 分子系统取消登录页面 由于整合单点登录后需要在CAS Server中统一进行登录,因此不需要分子系统的登录页面,直接暴露欢迎页给用户,欢迎页中提供登录和注册的入口,登录进主页面后提供进入后台系统的入口。 5.5 单点登录下的权限控制 若各个分子系统都使用Shiro或其他的安全框架,其依赖sys_user、sys_role、sys_menu、sys_office等表中的数据,因此各个分子系统的权限由其自身安全框架进行管理。 5.6 历史系统整合单点登录 若要为历史分子系统整合成单点登录,则需要将各个分子系统的用户进行并集整合成统一的一张用户表供CAS Server进行校验( 此表也提供给单点登录后台系统使用 ),再将新增的用户分别录入到各个分子系统的用户表,但可能已存在A系统的admin与B系统的admin不是同一个人的情况,因此需要使用能代表用户的唯一标识作为整合标准,可以使用手机号以及邮箱等,那么就需要借助手机号或邮箱进行登录。 整合后的分子系统其用户表中的记录的id字段就与新整合后统一的用户表的id字段不一致,因此需要在各个分子系统的用户表中新增一个字段用于存储统一用户表的id,当要与分子系统进行交互时需要借助此id进行记录的定位。 6. 设计方案 6.1 前后端分离的设计方案 前后端分离模式,前端与后台使用Ajax的方式进行交互,当Ajax访问后台资源时,请求进入Shiro Filter,当用户未认证访问了需认证的资源时,Shiro将会把请求重定向到CAS登录处理,返回302状态码,但由于是通过AJax访问,因此无法处理302状态码,无法跳转到CAS登录处理,因此可以使用JSONP请求方式进行解决,JSONP请求方式能够响应浏览器返回的302状态码,因此当HTTP响应报文的状态码是302时,通过JSONP的方式能重定向到指定的地址。 1.提供欢迎页,当用户访问产品域名时直接进入欢迎页,欢迎页中提供登录和注册的入口,登录按钮是跳转到CAS Server登录处理的超链接,而注册按钮是跳转到统一单点登录系统的注册页面超链接。 2.当欢迎页渲染完毕后,前端需要通过JSONP请求方式,请求后台任意一个需要认证了才能访问的资源。 3.当JSONP请求,其对应的HTTP响应报文的状态码是200时,将页面跳转到产品的首页,在首页渲染完后通过Ajax访问getUser接口,用于获取用户的身份以及权限信息,通过getUser接口返回的身份和权限信息进行页面元素的控制。 4.当JSONP请求,其对应的HTTP响应报文的状态码非200时(即302),则不跳转到首页,用户访问产品域名后停留在产品的欢迎页,其可以通过点击登录和注册按钮进行相关操作。 5.当用户进入了首页,当点击首页中任意一个需认证后才能访问的接口时,前端需要预先通过Ajax访问getUser接口,若getUser接口能成功返回用户的身份和权限信息,则允许其进行下一步操作,跳转到新页面发送Ajax请求,若getUser接口返回空,则表示用户已经登录超时,此时前端需重新通过JSONP请求方式,走一遍CAS完整的登录处理,当JSONP原访问接口返回200状态码则允许进行下一步操作,否则手动跳转到CAS Server进行登录处理。 *在欢迎页渲染完通过JSONP访问任意一个需认证的接口,是为了让用户能走一遍完整的CAS登录处理,而且只有该一个请求使用JSONP方式,对于其他数据访问的请求仍然是使用Ajax。 用户第一次访问项目A 用户再次访问项目A 用户第一次访问项目B 6.2 前后端不分离的设计方案 前后端不分离的模式,则将由后台进行页面跳转,在产品域名对应后台首页跳转的Controller中需要通过Subject的isAuthenticate()方法判断当前用户是否已经登录,若未登录则将请求转发到欢迎页,否则重定向到首页中。 1.当用户访问产品域名时,请求到达后台页面跳转Controller,调用Subject的isAuthenticate()方法判断当前用户是否已经登录,若未登录则将请求转发到欢迎页,否则重定向到首页。 2.当用户进入欢迎页,欢迎页中内嵌一个 标签,其src属性是任意一个需认证后才能访问的资源, 3.当该请求对应的HTTP响应报文的状态码是200时,则表示用户已经进行登录,此时将会渲染 ,执行页面reload,那么将会重新访问后台首页跳转Controller,此时Subject的isAuthenticate()方法返回true,那么请求将会重定向到首页。 4.当该请求对应的HTTP响应报文的状态码非200时(即302),则不会渲染 ,页面停留在欢迎页中,用户可以点击登录和注册进行相关的操作。 5.当用户进入了首页,在首页渲染时将会发送Ajax请求访问getUser接口,获取用户的身份和权限信息进行页面元素的控制,当用户在首页访问需要认证后才能访问的接口时,不需要预先调用getUser()接口判断用户是否已经过期,直接访问地址即可,因为是使用前后端不分离的模式,当Shiro判断当前用户未进行登录,则会将请求重定向到CAS Server登录处理,返回302状态码,由于不是使用Ajax,因此能进行处理。 *在欢迎页中使用 的src属性访问任意一个需要认证后才能访问的资源,是为了让用户能走一遍完整的CAS登录处理,在此模式中也可以使用JSONP的方式,当JSONP访问接口返回的状态码为200时,手动reload页面即可。 用户第一次访问项目A 用户再次访问项目A 用户第一次访问项目B |
|