cas client与shiro集成框架pac4j源码分析

一、前言

pac4j-cas关键filter有3个,分别为io.buji.pac4j.filter.SecurityFilterio.buji.pac4j.filter.CallbackFilterio.buji.pac4j.filter.LogoutFilter
SecurityFilter负责对登录认证判断,重定向到cas服务的login页面等;
CallbackFilter负责在cas登录完成后,cas回调客户端,校验cas颁发的ST等;
LogoutFilter负责单点登出。

 二、安全过滤器

1
// cas 资源认证拦截器 SecurityFilter securityFilter = new SecurityFilter(); securityFilter.setConfig(config); securityFilter.setClients(casProperties.getCasClientName());

三、CallbackFilter

流程:
io.buji.pac4j.filter.CallbackFilter#doFilter
->org.pac4j.core.engine.DefaultCallbackLogic#perform 逻辑类
->org.pac4j.core.client.IndirectClient#getCredentials
->org.pac4j.core.client.BaseClient#retrieveCredentials
->org.pac4j.cas.credentials.authenticator.CasAuthenticator#validate 校验类
->org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#validate
->org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#constructValidationUrl

 CAS 协议
 java-cas-客户端
 网关和续订

CAS 是一种基于 HTTP 2 和 3 的协议,它要求其每个组件都可以通过特定的 URI 进行访问。本部分将讨论每个 URI:

URI Description
/login 凭据请求者/接受者
/logout 销毁 CAS 会话(注销)
/validate 服务票证验证
/serviceValidate 服务票证验证 [CAS 2.0]
/proxyValidate 服务/代理票证验证 [CAS 2.0]
/proxy 代理票务服务 [CAS 2.0]
/p3/serviceValidate 服务票证验证 [CAS 3.0]
/p3/proxyValidate 服务/代理票证验证 [CAS 3.0]

说明:建议根据CAS服务端版本设置使用相应的CAS Protocol ,CAS 5.x建议使用CasProtocol.CAS30,低版本建议使用CasProtocol.CAS20,以防获取额外属性信息org.pac4j.core.profile.UserProfile#getAttribute(java.lang.String)失败。
validate结果会以xml方式返回给cas客户端。

原因CasProtocol.CAS30在构造url时org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#constructValidationUrl时会拼接/p3/serviceValidate

1
@Bean public CasConfiguration casConfig(){ final CasConfiguration configuration = new CasConfiguration(); //CAS server登录地址 configuration.setLoginUrl(casProperties.getCasServerUrl() + "/login"); //CAS 版本,默认为 CAS30,CAS服务端版本较低时使用CasProtocol.CAS20 configuration.setProtocol(CasProtocol.CAS30); configuration.setAcceptAnyProxy(true); configuration.setPrefixUrl(casProperties.getCasServerUrl() + "/"); return configuration; }
1
final ShiroCallbackLogic<Object, J2EContext> shiroCallbackLogic = new ShiroCallbackLogic<>(); shiroCallbackLogic.setErrorUrl(casProperties.getCasClientUrl() + "/unauth"); // cas 认证后回调拦截器 CallbackFilter callbackFilter = new CallbackFilter(); callbackFilter.setCallbackLogic(shiroCallbackLogic); callbackFilter.setDefaultUrl(casProperties.getCasClientUrl()); callbackFilter.setConfig(config);

源码分析:
io.buji.pac4j.filter.CallbackFilter#doFilter

1
@Override public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException { assertNotNull("callbackLogic", callbackLogic); assertNotNull("config", config); final HttpServletRequest request = (HttpServletRequest) servletRequest; final HttpServletResponse response = (HttpServletResponse) servletResponse; final SessionStore<J2EContext> sessionStore = config.getSessionStore(); final J2EContext context = new J2EContext(request, response, sessionStore != null ? sessionStore : ShiroSessionStore.INSTANCE); final HttpActionAdapter<Object, J2EContext> adapter = httpActionAdapter != null ? httpActionAdapter : J2ENopHttpActionAdapter.INSTANCE; callbackLogic.perform(context, config, adapter, this.defaultUrl, this.saveInSession, this.multiProfile, false, this.defaultClient); }

org.pac4j.core.engine.DefaultCallbackLogic#perform

1
public R perform(C context, Config config, HttpActionAdapter<R, C> httpActionAdapter, String inputDefaultUrl, Boolean inputSaveInSession, Boolean inputMultiProfile, Boolean inputRenewSession, String client) { this.logger.debug("=== CALLBACK ==="); HttpAction action; try { String defaultUrl; if (inputDefaultUrl == null) { defaultUrl = "/"; } else { defaultUrl = inputDefaultUrl; } boolean saveInSession; if (inputSaveInSession == null) { saveInSession = true; } else { saveInSession = inputSaveInSession; } boolean multiProfile; if (inputMultiProfile == null) { multiProfile = false; } else { multiProfile = inputMultiProfile; } boolean renewSession; if (inputRenewSession == null) { renewSession = true; } else { renewSession = inputRenewSession; } CommonHelper.assertNotNull("clientFinder", this.clientFinder); CommonHelper.assertNotNull("context", context); CommonHelper.assertNotNull("config", config); CommonHelper.assertNotNull("httpActionAdapter", httpActionAdapter); CommonHelper.assertNotBlank("defaultUrl", defaultUrl); Clients clients = config.getClients(); CommonHelper.assertNotNull("clients", clients); List<Client> foundClients = this.clientFinder.find(clients, context, client); CommonHelper.assertTrue(foundClients != null && foundClients.size() == 1, "unable to find one indirect client for the callback: check the callback URL for a client name parameter or suffix path or ensure that your configuration defaults to one indirect client"); Client foundClient = (Client)foundClients.get(0); this.logger.debug("foundClient: {}", foundClient); CommonHelper.assertNotNull("foundClient", foundClient); Credentials credentials = foundClient.getCredentials(context); this.logger.debug("credentials: {}", credentials); CommonProfile profile = foundClient.getUserProfile(credentials, context); this.logger.debug("profile: {}", profile); this.saveUserProfile(context, config, profile, saveInSession, multiProfile, renewSession); action = this.redirectToOriginallyRequestedUrl(context, defaultUrl); } catch (RuntimeException var19) { return this.handleException(var19, httpActionAdapter, context); } return httpActionAdapter.adapt(action.getCode(), context); }

ShiroCallbackLogic类图
org.pac4j.core.client.IndirectClient#getCredentials

1
/** * <p>Get the credentials from the web context. In some cases, a {@link HttpAction} may be thrown:</p> * <ul> * <li>if the <code>CasClient</code> receives a logout request, it returns a 200 HTTP status code</li> * <li>for the <code>IndirectBasicAuthClient</code>, if no credentials are sent to the callback url, an unauthorized response * (401 HTTP status code) is returned to request credentials through a popup.</li> * </ul> * * @param context the current web context * @return the credentials */ @Override public final C getCredentials(final WebContext context) { init(); final C credentials = retrieveCredentials(context); // no credentials -> save this authentication has already been tried and failed if (credentials == null) { context.getSessionStore().set(context, getName() + ATTEMPTED_AUTHENTICATION_SUFFIX, "true"); } else { cleanAttemptedAuthentication(context); } return credentials; }

org.pac4j.core.client.BaseClient#retrieveCredentials

1
/** * Retrieve the credentials. * * @param context the web context * @return the credentials */ protected C retrieveCredentials(final WebContext context) { try { final C credentials = this.credentialsExtractor.extract(context); if (credentials == null) { return null; } final long t0 = System.currentTimeMillis(); try { this.authenticator.validate(credentials, context); } finally { final long t1 = System.currentTimeMillis(); logger.debug("Credentials validation took: {} ms", t1 - t0); } return credentials; } catch (CredentialsException e) { logger.info("Failed to retrieve or validate credentials: {}", e.getMessage()); logger.debug("Failed to retrieve or validate credentials", e); return null; } }

org.pac4j.cas.credentials.authenticator.CasAuthenticator#validate

1
@Override public void validate(final TokenCredentials credentials, final WebContext context) { init(); final String ticket = credentials.getToken(); try { final String finalCallbackUrl = callbackUrlResolver.compute(urlResolver, callbackUrl, clientName, context); final Assertion assertion = configuration.retrieveTicketValidator(context).validate(ticket, finalCallbackUrl); final AttributePrincipal principal = assertion.getPrincipal(); logger.debug("principal: {}", principal); final String id = principal.getName(); final Map<String, Object> newPrincipalAttributes = new HashMap<>(); final Map<String, Object> newAuthenticationAttributes = new HashMap<>(); // restore both sets of attributes final Map<String, Object> oldPrincipalAttributes = principal.getAttributes(); final Map<String, Object> oldAuthenticationAttributes = assertion.getAttributes(); final InternalAttributeHandler attrHandler = ProfileHelper.getInternalAttributeHandler(); if (oldPrincipalAttributes != null) { oldPrincipalAttributes.entrySet().stream() .forEach(e -> newPrincipalAttributes.put(e.getKey(), attrHandler.restore(e.getValue()))); } if (oldAuthenticationAttributes != null) { oldAuthenticationAttributes.entrySet().stream() .forEach(e -> newAuthenticationAttributes.put(e.getKey(), attrHandler.restore(e.getValue()))); } final CommonProfile profile; // in case of CAS proxy, don't restore the profile, just build a CAS one if (configuration.getProxyReceptor() != null) { profile = getProfileDefinition().newProfile(principal, configuration.getProxyReceptor()); profile.setId(ProfileHelper.sanitizeIdentifier(profile, id)); getProfileDefinition().convertAndAdd(profile, newPrincipalAttributes, newAuthenticationAttributes); } else { profile = ProfileHelper.restoreOrBuildProfile(getProfileDefinition(), id, newPrincipalAttributes, newAuthenticationAttributes, principal, configuration.getProxyReceptor()); } logger.debug("profile returned by CAS: {}", profile); credentials.setUserProfile(profile); } catch (final TicketValidationException e) { String message = "cannot validate CAS ticket: " + ticket; throw new TechnicalException(message, e); } }

org.pac4j.cas.config.CasConfiguration#retrieveTicketValidator

1
public TicketValidator retrieveTicketValidator(final WebContext context) { if (this.defaultTicketValidator != null) { return this.defaultTicketValidator; } else { if (this.protocol == CasProtocol.CAS10) { return buildCas10TicketValidator(context); } else if (this.protocol == CasProtocol.CAS20) { return buildCas20TicketValidator(context); } else if (this.protocol == CasProtocol.CAS20_PROXY) { return buildCas20ProxyTicketValidator(context); } else if (this.protocol == CasProtocol.CAS30) { return buildCas30TicketValidator(context); } else if (this.protocol == CasProtocol.CAS30_PROXY) { return buildCas30ProxyTicketValidator(context); } else if (this.protocol == CasProtocol.SAML) { return buildSAMLTicketValidator(context); } else { throw new TechnicalException("Unable to initialize the TicketValidator for protocol: " + this.protocol); } } }

org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#validate

1
public final Assertion validate(final String ticket, final String service) throws TicketValidationException { final String validationUrl = constructValidationUrl(ticket, service); logger.debug("Constructing validation url: {}", validationUrl); try { logger.debug("Retrieving response from server."); final String serverResponse = retrieveResponseFromServer(new URL(validationUrl), ticket); if (serverResponse == null) { throw new TicketValidationException("The CAS server returned no response."); } logger.debug("Server response: {}", serverResponse); return parseResponseFromServer(serverResponse); } catch (final MalformedURLException e) { throw new TicketValidationException(e); } }

org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#constructValidationUrl

1
protected final String constructValidationUrl(final String ticket, final String serviceUrl) { final Map<String, String> urlParameters = new HashMap<String, String>(); logger.debug("Placing URL parameters in map."); urlParameters.put("ticket", ticket); urlParameters.put("service", serviceUrl); if (this.renew) { urlParameters.put("renew", "true"); } logger.debug("Calling template URL attribute map."); populateUrlAttributeMap(urlParameters); logger.debug("Loading custom parameters from configuration."); if (this.customParameters != null) { urlParameters.putAll(this.customParameters); } final String suffix = getUrlSuffix(); final StringBuilder buffer = new StringBuilder(urlParameters.size() * 10 + this.casServerUrlPrefix.length() + suffix.length() + 1); int i = 0; buffer.append(this.casServerUrlPrefix); if (!this.casServerUrlPrefix.endsWith("/")) { buffer.append("/"); } buffer.append(suffix); for (Map.Entry<String, String> entry : urlParameters.entrySet()) { final String key = entry.getKey(); final String value = entry.getValue(); if (value != null) { buffer.append(i++ == 0 ? "?" : "&"); buffer.append(key); buffer.append("="); final String encodedValue = encodeUrl(value); buffer.append(encodedValue); } } return buffer.toString(); }

四、LogoutFilter

1
/** * 自定义存储 * 注意:不可使用{@link io.buji.pac4j.context.ShiroSessionStore},否则无法建立票据与session之间联系,导致单点退出失效! * @return */ @Bean public SessionStore sessionStore(){ return new J2ESessionStore(); } @Bean public Config config(CasClient casClient, SessionStore sessionStore) { Config config = new Config(casClient); config.setSessionStore(sessionStore); return config; }
1
// 登出拦截器 LogoutFilter logoutFilter = new LogoutFilter(); logoutFilter.setConfig(config); // 单点登出 logoutFilter.setCentralLogout(true); // 本地登出 logoutFilter.setLocalLogout(true); logoutFilter.setDefaultUrl(casProperties.getCasClientUrl() + "/callback?client_name=" + casProperties.getCasClientName());

参考:
利用pac4j的封装,实现自定义cas校验ST、集成jwt