cas client与shiro集成框架pac4j源码分析
一、前言
pac4j-cas关键filter有3个,分别为
io.buji.pac4j.filter.SecurityFilter
、io.buji.pac4j.filter.CallbackFilter
、io.buji.pac4j.filter.LogoutFilter
SecurityFilter负责对登录认证判断,重定向到cas服务的login页面等;
CallbackFilter负责在cas登录完成后,cas回调客户端,校验cas颁发的ST等;
LogoutFilter负责单点登出。
二、SecurityFilter
// 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 Protocol
java-cas-client
gateway & renew
CAS is an HTTP2,3-based protocol that requires each of its components to be accessible through specific URIs. This section will discuss each of the URIs:
URI | Description |
---|---|
/login | credential requestor / acceptor |
/logout | destroy CAS session (logout) |
/validate | service ticket validation |
/serviceValidate | service ticket validation [CAS 2.0] |
/proxyValidate | service/proxy ticket validation [CAS 2.0] |
/proxy | proxy ticket service [CAS 2.0] |
/p3/serviceValidate | service ticket validation [CAS 3.0] |
/p3/proxyValidate | service/proxy ticket validation [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
@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;
}
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
@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
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);
}
org.pac4j.core.client.IndirectClient#getCredentials
/**
* <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
/**
* 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
@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
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
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
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
/**
* 自定义存储
* 注意:不可使用{@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;
}
// 登出拦截器
LogoutFilter logoutFilter = new LogoutFilter();
logoutFilter.setConfig(config);
// 单点登出
logoutFilter.setCentralLogout(true);
// 本地登出
logoutFilter.setLocalLogout(true);
logoutFilter.setDefaultUrl(casProperties.getCasClientUrl() + "/callback?client_name=" + casProperties.getCasClientName());