CAS单点登录的原理
1.首先了解几个概念
1)、TGC:Ticket-granting cookie,存放用户身份认证凭证的cookie,在浏览器和CAS Server间通讯时使用。
2)、TGT:ticket granting ticket,TGT对象的ID就是TGC的值,在服务器端,通过TGC查询TGT。TGT封装了TGC值以及此Cookie值对应的用户信息。
3)、ST:service ticket,CAS为用户签发的访问某一service的票据,ST是TGT签发的。
2.单点登录的流程
现在有系统A、系统B、认证中心。
用户首次访问系统A的时候:
1)用户通过浏览器访问系统A https://localhost:8443/spring-shiro-cas/index,系统A取不到局部session,这时候系统A需要做一个额外的操作,就是重定向到认证中心
2)请求 https://localhost:8443/cas-server/login?service=https://localhost:8443/spring-shiro-cas/cas,认证中心看浏览器有没有携带TGC,一看没有,返回cas login form让用户登录。
service
参数的作用其实可以认为是一个回调的 url(在cas客户端也就是系统A配置)
,将来通过服务端认证后,还要重定向到系统A。/cas是拦截器的地址,接收cas服务端票据,拦截之后交由casFilter处理验证票据
但是在这里还有一个作用就是注册服务,简单来说注册服务为的是让我们的认证中心能够知道有哪些系统在我们这里完成过登录,注册时会保存两个信息,一是service地址,二是对应的ticket。其重要目的是为了完成单点退出的功能。
3)用户输入用户名密码,验证正确,在服务端创建全局session TGT,并且set cookie TGC,path=/cas-server
重定向跳转到系统A,并且携带验证票据ST,https://localhost:8443/spring-shiro-cas/cas?ticket=ST-39-2Vh3Ee6xYUs4y0XTy4bw-cas01.example.org
系统A看访问地址是/cas,交给casFilter处理。
查看casFilter的源码可以知道,在casFillter中首先会将ticket包装成一个casToken,然后去执行登录executeLogin(request, response);这样执行认证的域就交到了casRealm中,
// contact CAS server to validate service ticket
Assertion casAssertion = ticketValidator.validate(ticket, getCasService()); //validate Attempts to validate a ticket for the provided service.; ticket the ticket to attempt to validate; service the service this ticket is valid for.
casRealm的ticketValidator会访问cas服务器验证ticket。CASRealm实际上充当的就是第一个CAS Client,让它将由ticket包装好的CAS token传送到CAS去验证。验证成功后就会返回登录信息(用户名和一些属性信息),这些信息将会包装成认证信息AuthenticationInfo,供后续使用。
casFilter源码:
/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.apache.shiro.cas; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.subject.Subject; import org.apache.shiro.web.filter.authc.AuthenticatingFilter; import org.apache.shiro.web.util.WebUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import java.io.IOException; /** * This filter validates the CAS service ticket to authenticate the user. It must be configured on the URL recognized * by the CAS server. For example, in {@code shiro.ini}: * <pre> * [main] * casFilter = org.apache.shiro.cas.CasFilter * ... * * [urls] * /shiro-cas = casFilter * ... * </pre> * (example : http://host:port/mycontextpath/shiro-cas) * * @since 1.2 */ public class CasFilter extends AuthenticatingFilter { private static Logger logger = LoggerFactory.getLogger(CasFilter.class); // the name of the parameter service ticket in url private static final String TICKET_PARAMETER = "ticket"; // the url where the application is redirected if the CAS service ticket validation failed (example : /mycontextpatch/cas_error.jsp) private String failureUrl; /** * The token created for this authentication is a CasToken containing the CAS service ticket received on the CAS service url (on which * the filter must be configured). * * @param request the incoming request * @param response the outgoing response * @throws Exception if there is an error processing the request. */ @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; String ticket = httpRequest.getParameter(TICKET_PARAMETER); return new CasToken(ticket); } /** * Execute login by creating {@link #createToken(javax.servlet.ServletRequest, javax.servlet.ServletResponse) token} and logging subject * with this token. * * @param request the incoming request * @param response the outgoing response * @throws Exception if there is an error processing the request. */ @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { return executeLogin(request, response); } /** * Returns <code>false</code> to always force authentication (user is never considered authenticated by this filter). * * @param request the incoming request * @param response the outgoing response * @param mappedValue the filter-specific config value mapped to this filter in the URL rules mappings. * @return <code>false</code> */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { return false; } /** * If login has been successful, redirect user to the original protected url. * * @param token the token representing the current authentication * @param subject the current authenticated subjet * @param request the incoming request * @param response the outgoing response * @throws Exception if there is an error processing the request. */ @Override protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception { issueSuccessRedirect(request, response); return false; } /** * If login has failed, redirect user to the CAS error page (no ticket or ticket validation failed) except if the user is already * authenticated, in which case redirect to the default success url. * * @param token the token representing the current authentication * @param ae the current authentication exception * @param request the incoming request * @param response the outgoing response */ @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException ae, ServletRequest request, ServletResponse response) { // is user authenticated or in remember me mode ? Subject subject = getSubject(request, response); if (subject.isAuthenticated() || subject.isRemembered()) { try { issueSuccessRedirect(request, response); } catch (Exception e) { logger.error("Cannot redirect to the default success url", e); } } else { try { WebUtils.issueRedirect(request, response, failureUrl); } catch (IOException e) { logger.error("Cannot redirect to failure url : {}", failureUrl, e); } } return false; } public void setFailureUrl(String failureUrl) { this.failureUrl = failureUrl; } }
casRealm源码:
/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.apache.shiro.cas; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.util.CollectionUtils; import org.apache.shiro.util.StringUtils; import org.jasig.cas.client.authentication.AttributePrincipal; import org.jasig.cas.client.validation.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * This realm implementation acts as a CAS client to a CAS server for authentication and basic authorization. * <p/> * This realm functions by inspecting a submitted {@link org.apache.shiro.cas.CasToken CasToken} (which essentially * wraps a CAS service ticket) and validates it against the CAS server using a configured CAS * {@link org.jasig.cas.client.validation.TicketValidator TicketValidator}. * <p/> * The {@link #getValidationProtocol() validationProtocol} is {@code CAS} by default, which indicates that a * a {@link org.jasig.cas.client.validation.Cas20ServiceTicketValidator Cas20ServiceTicketValidator} * will be used for ticket validation. You can alternatively set * or {@link org.jasig.cas.client.validation.Saml11TicketValidator Saml11TicketValidator} of CAS client. It is based on * {@link AuthorizingRealm AuthorizingRealm} for both authentication and authorization. User id and attributes are retrieved from the CAS * service ticket validation response during authentication phase. Roles and permissions are computed during authorization phase (according * to the attributes previously retrieved). * * @since 1.2 * @see <a href="https://github.com/bujiio/buji-pac4j">buji-pac4j</a> * @deprecated replaced with Shiro integration in <a href="https://github.com/bujiio/buji-pac4j">buji-pac4j</a>. */ @Deprecated public class CasRealm extends AuthorizingRealm { // default name of the CAS attribute for remember me authentication (CAS 3.4.10+) public static final String DEFAULT_REMEMBER_ME_ATTRIBUTE_NAME = "longTermAuthenticationRequestTokenUsed"; public static final String DEFAULT_VALIDATION_PROTOCOL = "CAS"; private static Logger log = LoggerFactory.getLogger(CasRealm.class); // this is the url of the CAS server (example : http://host:port/cas) private String casServerUrlPrefix; // this is the CAS service url of the application (example : http://host:port/mycontextpath/shiro-cas) private String casService; /* CAS protocol to use for ticket validation : CAS (default) or SAML : - CAS protocol can be used with CAS server version < 3.1 : in this case, no user attributes can be retrieved from the CAS ticket validation response (except if there are some customizations on CAS server side) - SAML protocol can be used with CAS server version >= 3.1 : in this case, user attributes can be extracted from the CAS ticket validation response */ private String validationProtocol = DEFAULT_VALIDATION_PROTOCOL; // default name of the CAS attribute for remember me authentication (CAS 3.4.10+) private String rememberMeAttributeName = DEFAULT_REMEMBER_ME_ATTRIBUTE_NAME; // this class from the CAS client is used to validate a service ticket on CAS server private TicketValidator ticketValidator; // default roles to applied to authenticated user private String defaultRoles; // default permissions to applied to authenticated user private String defaultPermissions; // names of attributes containing roles private String roleAttributeNames; // names of attributes containing permissions private String permissionAttributeNames; public CasRealm() { setAuthenticationTokenClass(CasToken.class); } @Override protected void onInit() { super.onInit(); ensureTicketValidator(); } protected TicketValidator ensureTicketValidator() { if (this.ticketValidator == null) { this.ticketValidator = createTicketValidator(); } return this.ticketValidator; } protected TicketValidator createTicketValidator() { String urlPrefix = getCasServerUrlPrefix(); if ("saml".equalsIgnoreCase(getValidationProtocol())) { return new Saml11TicketValidator(urlPrefix); } return new Cas20ServiceTicketValidator(urlPrefix); } /** * Authenticates a user and retrieves its information. * * @param token the authentication token * @throws AuthenticationException if there is an error during authentication. */ @Override @SuppressWarnings("unchecked") protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { CasToken casToken = (CasToken) token; if (token == null) { return null; } String ticket = (String)casToken.getCredentials(); if (!StringUtils.hasText(ticket)) { return null; } TicketValidator ticketValidator = ensureTicketValidator(); try { // contact CAS server to validate service ticket Assertion casAssertion = ticketValidator.validate(ticket, getCasService()); // get principal, user id and attributes 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); } // create simple authentication info 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); } } /** * Retrieves the AuthorizationInfo for the given principals (the CAS previously authenticated user : id + attributes). * * @param principals the primary identifying principals of the AuthorizationInfo that should be retrieved. * @return the AuthorizationInfo associated with this principals. */ @Override @SuppressWarnings("unchecked") protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // retrieve user information SimplePrincipalCollection principalCollection = (SimplePrincipalCollection) principals; List<Object> listPrincipals = principalCollection.asList(); Map<String, String> attributes = (Map<String, String>) listPrincipals.get(1); // create simple authorization info SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); // add default roles addRoles(simpleAuthorizationInfo, split(defaultRoles)); // add default permissions addPermissions(simpleAuthorizationInfo, split(defaultPermissions)); // get roles from attributes List<String> attributeNames = split(roleAttributeNames); for (String attributeName : attributeNames) { String value = attributes.get(attributeName); addRoles(simpleAuthorizationInfo, split(value)); } // get permissions from attributes attributeNames = split(permissionAttributeNames); for (String attributeName : attributeNames) { String value = attributes.get(attributeName); addPermissions(simpleAuthorizationInfo, split(value)); } return simpleAuthorizationInfo; } /** * Split a string into a list of not empty and trimmed strings, delimiter is a comma. * * @param s the input string * @return the list of not empty and trimmed strings */ private List<String> split(String s) { List<String> list = new ArrayList<String>(); String[] elements = StringUtils.split(s, ','); if (elements != null && elements.length > 0) { for (String element : elements) { if (StringUtils.hasText(element)) { list.add(element.trim()); } } } return list; } /** * Add roles to the simple authorization info. * * @param simpleAuthorizationInfo * @param roles the list of roles to add */ private void addRoles(SimpleAuthorizationInfo simpleAuthorizationInfo, List<String> roles) { for (String role : roles) { simpleAuthorizationInfo.addRole(role); } } /** * Add permissions to the simple authorization info. * * @param simpleAuthorizationInfo * @param permissions the list of permissions to add */ private void addPermissions(SimpleAuthorizationInfo simpleAuthorizationInfo, List<String> permissions) { for (String permission : permissions) { simpleAuthorizationInfo.addStringPermission(permission); } } public String getCasServerUrlPrefix() { return casServerUrlPrefix; } public void setCasServerUrlPrefix(String casServerUrlPrefix) { this.casServerUrlPrefix = casServerUrlPrefix; } public String getCasService() { return casService; } public void setCasService(String casService) { this.casService = casService; } public String getValidationProtocol() { return validationProtocol; } public void setValidationProtocol(String validationProtocol) { this.validationProtocol = validationProtocol; } public String getRememberMeAttributeName() { return rememberMeAttributeName; } public void setRememberMeAttributeName(String rememberMeAttributeName) { this.rememberMeAttributeName = rememberMeAttributeName; } public String getDefaultRoles() { return defaultRoles; } public void setDefaultRoles(String defaultRoles) { this.defaultRoles = defaultRoles; } public String getDefaultPermissions() { return defaultPermissions; } public void setDefaultPermissions(String defaultPermissions) { this.defaultPermissions = defaultPermissions; } public String getRoleAttributeNames() { return roleAttributeNames; } public void setRoleAttributeNames(String roleAttributeNames) { this.roleAttributeNames = roleAttributeNames; } public String getPermissionAttributeNames() { return permissionAttributeNames; } public void setPermissionAttributeNames(String permissionAttributeNames) { this.permissionAttributeNames = permissionAttributeNames; } }
4)认证中心验证票据合法,返回用户登录信息给系统A,这个登录信息包括之前输入的用户名和一些属性信息(需要在cas server中配置)参考这篇博客:CAS4.0 SERVER登录后用户信息的返回
系统A在本地创建局部会话session。并且set cookie,path=/spring-shiro-cas,再重定向请求资源。
5)请求https://localhost:8443/spring-shiro-cas/index,携带cookie,系统验证cookie session,通过后返回资源页面给浏览器。
用户第二次访问系统A的时候:
1)用户再次访问 https://localhost:8443/spring-shiro-cas/index,并且带着cookie,系统A通过cookie找到局部session,证明之前用户已经登录过系统。
2)系统A返回受限资源给用户。
用户首次访问系统B的时候:
1)用户通过浏览器访问系统B https://localhost:8443/spring-shiro-cas-2/index,系统A取不到局部session,这时候系统B需要做一个额外的操作,就是重定向到认证中心
2)请求 https://localhost:8443/cas-server/login?service=https://localhost:8443/spring-shiro-cas-2/cas,认证中心看浏览器有没有携带TGC,一看有,获取到全局session TGT,证明已经登录过。
3)重定向跳转到系统B,并且携带验证票据ST,https://localhost:8443/spring-shiro-cas-2/cas?ticket=ST-56-2bb3Ee6xYUskkdTy4bw-cas01.example.org
4)系统B将地址栏获取的ticket发送给认证中心,进行票据的验证。认证中心验证票据合法,返回用户登录信息给系统B,系统B在本地创建局部会话session。并且set cookie,path=/spring-shiro-cas-2
再重定向请求资源。
5)请求https://localhost:8443/spring-shiro-cas-2/index,携带cookie,系统验证cookie session,通过后返回资源页面给浏览器。
问题:CAS是如何解决cookie不能跨域问题的?
在前面描述的认证过程已经提到,认证中心验证用户登录通过后生成传给浏览器的cookie TGC的路径在/cas-server,只要请求的路径包括https://localhost:8443/cas-server,那么CAS就可以知道当前用户是已经登录过的,但是由于跨域的原因,客户端(应用系统)无法直接获取cookie,所以客户端是无法直接信任当前用户的。
所以过程就是浏览器访问客户端,客户端说我没法知道你是谁(没有cookie),你去找认证中心看他那有没有你的资料。
浏览器找到认证中心(这时携带身份凭证cookie TGC了),认证中心说:“我知道你是谁,你拿着这个登录票据ticket去找客户端。”
浏览器把ticket给了客户端,客户端说:“你稍等,我向认证中心确认下你这个票据是不是他给的”,得到确认后客户端终于把浏览器请求的资源返回给它。
这个地方不理解原理的时候很容易认为用户登录系统A之后,系统A会存放登录状态session,生成cookie给浏览器path=/spring-shiro-cas,但是由于跨域的原因,在访问系统B的时候,系统B又拿不到这个cookie.那怎么知道它有没有登录过呢?
这种想法就是错误的!!
要清楚首次单点登录的时候是会产生两个cookie的,一个是CAS和浏览器的全局会话,一个是应用系统和浏览器的局部会话。
系统A和浏览器的session-cookie是局部会话,确实访问系统B的时候不起作用了。
但是!!因为登录系统A的时候是去的认证中心,身份凭证是存放在CAS 服务器中,是全局会话,用户再首次访问任何一个系统都会要求去认证中心看有没有这个东西,所以只要登录过一次,后面都会带上。
我们再来回顾下这个过程
1. 单点登录的过程中,第一步应用服务器将请求重定向到认证服务器,用户输入账号密码认证成功后,只是在浏览器和认证服务器之间建立了信任(TGC),但是浏览器和应用系统之间并没有建立信任。
2. ST是CAS认证中心认证成功后返回给浏览器,浏览器带着它去访问应用系统,应用系统再凭它去认证中心验证你这个用户是否合法。只有这样,浏览器和应用系统才能建立信任的会话。
3. 而TGC的作用主要是用于实现单点登录,就是当浏览器要访问应用系统2时,应用系统2也会重定向到认证中心,但是此时由于TGC的存在,认证中心信任了该浏览器,就不需要用户再输入账号密码了,直接返回给浏览器ST,重复2中的步骤。
理解这个要清楚cookie的path是怎么回事,参考这篇博文
cookie的路径和域
最后来个官方详细图解:
3.单点登录的时候各个类运行的流程
cas单点的流程是由spring webflow来处理。
参考这篇博客CAS server 介绍