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的相关依赖
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> <version>1.4.0</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-cas</artifactId> <version>1.4.0</version> </dependency>
3.2 配置Spring提供的过滤器代理
在web.xml中配置DelegatingFilterProxy并指定targetBeanName.
<filter> <filter-name>shiroFilter</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> <init-param> <param-name>targetBeanName</param-name> <param-value>shiroFilter</param-value> </init-param> </filter> <filter-mapping> <filter-name>shiroFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
*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为username,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<String, Object> 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实体返回给SecurityManager List<Object> 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,则SecurityManager会调用该Realm的doGetAuthenticationInfo方法获取用户的安全信息,通过AuthenticationToken的Credential与从Realm中获取的AuthenticationInfo中的Credential进行密码的比对,若应用添加了黑白名单功能则需要自定义Realm继承CasRealm,重写其提供的doGetAuthenticationInfo方法.
授权方法:
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 恢复用户信息 SimplePrincipalCollection principalCollection = (SimplePrincipalCollection) principals; List<Object> listPrincipals = principalCollection.asList(); Map<String, String> attributes = (Map<String, String>) listPrincipals.get(1); SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); // 为用户添加默认的角色 addRoles(simpleAuthorizationInfo, split(defaultRoles)); // 为用户添加默认的行为 addPermissions(simpleAuthorizationInfo, split(defaultPermissions)); // 为用户添加预设置的角色 List<String> 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方法仅仅是为用户添加默认和预定义的角色与行为,并不符合实际的应用场景,因此也需要进行自定义.
自定义CasRealm重写其doGetAuthenticationInfo和doGetAuthorizationInfo方法
/** * 用户登录和授权用的realm */ public class CasUserRealm extends CasRealm { @Autowired private UserService userService; @Autowired private RoleService roleService; @Autowired private MenuService menuService; /** * CAS认证 ,验证用户身份 * 将用户基本信息设置到会话中 */ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { //调用CasRealm实现的认证方法,其包含验证ticket、填充CasToken等操作) 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:该帐号禁止登录"); } //将用户信息放在session SecurityUtils.getSubject().getSession().setAttribute("user", user); return authc; } else { return null; } } /** * 设置角色和权限信息 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); User user = userService.findByUsername(username); if (user != null) { SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); //获取用户拥有的角色 List<Role> roles = roleService.findByUserId(user.getId()); for (Role role : roles) { authorizationInfo.addRole(role.getEnname()); //获取用户拥有的权限 List<Menu> 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配置
<!-- 配置shiroFilterFactoryBean,bean的id默认情况下必须与web.xml文件中DelegatingFilterProxy过滤器的filter-name相同,可以通过filter的targetBeanName初始化参数进行修改 --> <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <!-- 注入securityManager --> <property name="securityManager" ref="securityManager"/> <!-- 设置登录URL,当用户未认证,但访问了需要认证后才能访问的页面,就会自动重定向到登录URL --> <property name="loginUrl" value="${shiro.loginUrl}"/> <!-- 设置没有权限的URL,当用户认证后,访问一个页面却没有权限时,就会自动重定向到没有权限的URL,若用户未认证访问一个需要权限的URL时,会跳转到登录URL --> <property name="unauthorizedUrl" value="/unauthorized.html"/> <!-- 将Filter添加到Shiro过滤器链中,用于对资源设置权限 --> <property name="filters"> <map> <entry key="casFilter" value-ref="casFilter"/> <entry key="logoutFilter" value-ref="logoutFilter"/> </map> </property> <!-- 配置哪些请求需要受保护,以及访问这些页面需要的权限 --> <property name="filterChainDefinitions"> <value> /shiro-cas=casFilter /logout = logoutFilter /** = authc </value> </property> </bean> <!-- 单点登录过滤器 --> <bean id="casFilter" class="org.apache.shiro.cas.CasFilter"> <!-- 配置验证成功是的URL --> <property name="successUrl" value="${shiro.successUrl}"/> <!-- 配置验证错误时的失败URL --> <property name="failureUrl" value="${shiro.failureUrl}"/> </bean> <!--单点登出过滤器--> <bean id="logoutFilter" class="org.apache.shiro.web.filter.authc.LogoutFilter"> <!-- 注销时重定向的URL --> <property name="redirectUrl" value="${shiro.logoutUrl}"/> </bean> <!-- 注册自定义CasRealm --> <bean id="casRealm" class="com.realm.CasUserRealm"> <!-- cas服务端地址前缀,作为ticket校验 --> <property name="casServerUrlPrefix" value="${shiro.cas.serverUrlPrefix}"/> <!-- 应用服务地址,用来接收CAS服务端的票据 --> <property name="casService" value="${shiro.cas.service}"/> </bean> <!-- 配置securityManager --> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="subjectFactory" ref="casSubjectFactory"/> <property name="realm" ref="casRealm"/> </bean> <bean id="casSubjectFactory" class="org.apache.shiro.cas.CasSubjectFactory"></bean> <!-- 配置lifecycleBeanPostProcessor,shiro bean的生命周期管理器,可以自动调用Spring IOC容器中shiro bean的生命周期方法(初始化/销毁)--> <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/> <!-- 为了支持Shiro的注解需要定义DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor两个bean --> <!-- 配置DefaultAdvisorAutoProxyCreator,必须配置了lifecycleBeanPostProcessor才能使用 --> <bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" depends-on="lifecycleBeanPostProcessor"/> <!-- 配置AuthorizationAttributeSourceAdvisor --> <bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor"> <property name="securityManager" ref="securityManager"/> </bean> <!-- 自动注入properties属性文件 --> <bean id="configProperties" class="org.springframework.beans.factory.config.PropertiesFactoryBean"> <property name="locations"> <list> <value>classpath:conf/shiro.properties</value> </list> </property> </bean>
*/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认证成功则请求最终到达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.
*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为username,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 + "]"); } //获取缓存实体Cache Cache<Object, AuthorizationInfo> 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的getAuthorizationIfno(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 前后端分离设计方案
若使用前后端分离的模式,前端与后台使用Ajax的方式进行交互,当Ajax访问后台资源时,请求进入Shiro Filter,当用户未认证访问了需认证的资源时,Shiro将会把请求重定向到CAS登录处理,但由于是通过AJax访问,因此无法成功跳转到CAS登录处理.
前台系统和后台系统使用同一个webapp的情况:
1.后台主页面入口设置在前台主页面,点击后直接跳转到后台主页面(若前台和后台的主页面都直接对外进行暴露,当用户点击登录按钮后经过CAS认证最终进入Shiro的CasFilter进行分子系统的认证,最终重定向时并不知道用户的操作是从前台还是后台发起,因此重定向回统一页面)
2.前台主页面获取渲染数据的接口都需支持匿名访问并且主页面渲染时调用可匿名访问的isLogin接口判断用户是否已登录,若已登录则返回用户名、头像直接显示在页面且不显示登录按钮、通过用户拥有的权限进行页面元素的控制,否则未登录,则显示登录按钮.
3.当在主页面点击登录按钮( 即未登录状态 ),直接通过a标签的形式跳转到CAS登录页面,当认证成功后进入CASFilter,最终跳转CASFilter配置的登录成功URL,直接重定向到主页面即可.
4.当需要通过Ajax访问需要认证后才能访问的资源时,前端需通过js变量判断当前用户是否已登录或者通过访问后台isLogin接口,若未登录,则前端手动跳转到CAS登录页面进行认证,当认证成功,最终请求将会到达Shiro的Cas Filter,跳转CASFilter配置的登录成功URL,直接重定向到主页面即可,若返回已登录则直接通过Ajax获取资源.
*前端可以通过js存储当前用户是否已登录.
*前端访问后台需认证的资源前需要判断当前用户是否已登录,可以根据js变量或者后台提供的可匿名访问的接口.
前台系统和后台系统不是使用同一个webapp的情况:
1.前台主页面以及后台主页面可以分别对外暴露链接,当用户点击登录按钮后经过CAS认证最终进入Shiro的CasFilter进行分子系统的认证,最终重定向到对应系统的主页面.
2.前台主页面获取渲染数据的接口都需支持匿名访问并且主页面渲染时调用可匿名访问的isLogin接口判断用户是否已登录,若已登录则返回用户名、头像直接显示在页面且不显示登录按钮、通过用户拥有的权限进行页面元素的控制,否则未登录,则显示登录按钮.
3.后台主页面渲染时可以直接调用可匿名访问的isLogin接口判断用户是否已登录,若已登录则返回用户名、头像直接显示在页面、通过用户拥有的权限进行页面元素的控制,否则手动跳转到CAS Server登录处理.
4.前台主页面访问需要认证后才能访问的资源时,前端需通过js变量判断当前用户是否已登录或者通过访问后台isLogin接口,若未登录,则前端手动跳转到CAS登录页面进行认证,当认证成功,最终请求将会到达Shiro的Cas Filter,跳转CASFilter配置的登录成功URL,直接重定向到主页面即可,若返回已登录则直接通过ajax获取资源.
5.7 历史系统整合单点登录
若要为历史分子系统整合成单点登录,则需要将各个分子系统的用户进行并集整合成统一的一张用户表供CAS Server进行校验( 此表也提供给单点登录后台系统使用 ),再将新增的用户分别录入到各个分子系统的用户表,但可能已存在A系统的admin与B系统的admin不是同一个人的情况,因此需要使用能代表用户的唯一标识作为整合标准,可以使用手机号以及邮箱等,那么就需要借助手机号或邮箱进行登录.
整合后的分子系统其用户表中的记录的id字段就与新整合后统一的用户表的id字段不一致,因此需要在各个分子系统的用户表中新增一个字段用于存储统一用户表的id,当要与分子系统进行交互时需要借助此id进行记录的定位.