Merge pull request #212 from mmoayyed/json-validator

New Json ticket validator to accept CAS responses in JSON.
This commit is contained in:
Misagh Moayyed 2017-06-20 09:16:37 -07:00 committed by GitHub
commit f341f50cd5
13 changed files with 1825 additions and 1318 deletions

View File

@ -342,7 +342,15 @@ Validates the tickets using the CAS 2.0 protocol. If you provide either the `acc
| `hostnameVerifier` | Hostname verifier class name, used when making back-channel calls | No
#### org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter
Validates the tickets using the CAS 3.0 protocol. If you provide either the `acceptAnyProxy` or the `allowedProxyChains` parameters, a `Cas30ProxyTicketValidator` will be constructed. Otherwise a general `Cas30ServiceTicketValidator` will be constructed that does not accept proxy tickets. Supports all configurations that are available for `Cas20ProxyReceivingTicketValidationFilter`.
Validates the tickets using the CAS 3.0 protocol. If you provide either the `acceptAnyProxy` or the `allowedProxyChains` parameters,
a `Cas30ProxyTicketValidator` will be constructed. Otherwise a general `Cas30ServiceTicketValidator` will be constructed that does not
accept proxy tickets. Supports all configurations that are available for `Cas20ProxyReceivingTicketValidationFilter`.
#### org.jasig.cas.client.validation.json.Cas30JsonProxyReceivingTicketValidationFilter
Indentical to `Cas30ProxyReceivingTicketValidationFilter`, yet the filter is able to accept validation responses from CAS
that are formatted as JSON per guidelines laid out by the CAS protocol.
See the [protocol documentation](https://apereo.github.io/cas/5.1.x/protocol/CAS-Protocol-Specification.html)
for more info.
##### Proxy Authentication vs. Distributed Caching
The client has support for clustering and distributing the TGT state among application nodes that are behind a load balancer. In order to do so, the parameter needs to be defined as such for the filter.

View File

@ -35,6 +35,11 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>

View File

@ -50,9 +50,10 @@ public class Cas20ProxyTicketValidator extends Cas20ServiceTicketValidator {
return "proxyValidate";
}
@Override
protected void customParseResponse(final String response, final Assertion assertion)
throws TicketValidationException {
final List<String> proxies = XmlUtils.getTextForElements(response, "proxy");
final List<String> proxies = parseProxiesFromResponse(response);
if (proxies == null) {
throw new InvalidProxyChainTicketValidationException(
@ -61,7 +62,7 @@ public class Cas20ProxyTicketValidator extends Cas20ServiceTicketValidator {
);
}
// this means there was nothing in the proxy chain, which is okay
if ((this.allowEmptyProxyChain && proxies.isEmpty())) {
if (this.allowEmptyProxyChain && proxies.isEmpty()) {
logger.debug("Found an empty proxy chain, permitted by client configuration");
return;
}
@ -85,6 +86,10 @@ public class Cas20ProxyTicketValidator extends Cas20ServiceTicketValidator {
throw new InvalidProxyChainTicketValidationException("Invalid proxy chain: " + proxies.toString());
}
protected List<String> parseProxiesFromResponse(final String response) {
return XmlUtils.getTextForElements(response, "proxy");
}
public final void setAcceptAnyProxy(final boolean acceptAnyProxy) {
this.acceptAnyProxy = acceptAnyProxy;
}

View File

@ -77,15 +77,15 @@ public class Cas20ServiceTicketValidator extends AbstractCasProtocolUrlBasedTick
return "serviceValidate";
}
protected final Assertion parseResponseFromServer(final String response) throws TicketValidationException {
final String error = XmlUtils.getTextForElement(response, "authenticationFailure");
protected Assertion parseResponseFromServer(final String response) throws TicketValidationException {
final String error = parseAuthenticationFailureFromResponse(response);
if (CommonUtils.isNotBlank(error)) {
throw new TicketValidationException(error);
}
final String principal = XmlUtils.getTextForElement(response, "user");
final String proxyGrantingTicketIou = XmlUtils.getTextForElement(response, "proxyGrantingTicket");
final String principal = parsePrincipalFromResponse(response);
final String proxyGrantingTicketIou = parseProxyGrantingTicketFromResponse(response);
final String proxyGrantingTicket;
if (CommonUtils.isBlank(proxyGrantingTicketIou) || this.proxyGrantingTicketStorage == null) {
@ -113,6 +113,18 @@ public class Cas20ServiceTicketValidator extends AbstractCasProtocolUrlBasedTick
return assertion;
}
protected String parseProxyGrantingTicketFromResponse(final String response) {
return XmlUtils.getTextForElement(response, "proxyGrantingTicket");
}
protected String parsePrincipalFromResponse(final String response) {
return XmlUtils.getTextForElement(response, "user");
}
protected String parseAuthenticationFailureFromResponse(final String response) {
return XmlUtils.getTextForElement(response, "authenticationFailure");
}
/**
* Default attribute parsing of attributes that look like the following:
* &lt;cas:attributes&gt;

View File

@ -26,7 +26,7 @@ package org.jasig.cas.client.validation;
*/
public class Cas30ServiceTicketValidator extends Cas20ServiceTicketValidator {
public Cas30ServiceTicketValidator(String casServerUrlPrefix) {
public Cas30ServiceTicketValidator(final String casServerUrlPrefix) {
super(casServerUrlPrefix);
}

View File

@ -0,0 +1,35 @@
/*
* Licensed to Jasig under one or more contributor license
* agreements. See the NOTICE file distributed with this work
* for additional information regarding copyright ownership.
* Jasig 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 the following location:
*
* 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.jasig.cas.client.validation.json;
import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter;
/**
* Creates either a Cas30JsonServiceTicketValidator to validate tickets.
*
* @author Misagh Moayyed
*/
public class Cas30JsonProxyReceivingTicketValidationFilter extends Cas30ProxyReceivingTicketValidationFilter {
public Cas30JsonProxyReceivingTicketValidationFilter() {
super();
this.defaultServiceTicketValidatorClass = Cas30JsonServiceTicketValidator.class;
this.defaultProxyTicketValidatorClass = Cas30JsonProxyTicketValidator.class;
}
}

View File

@ -0,0 +1,43 @@
package org.jasig.cas.client.validation.json;
import org.jasig.cas.client.validation.Assertion;
import org.jasig.cas.client.validation.Cas30ProxyTicketValidator;
import org.jasig.cas.client.validation.TicketValidationException;
import java.util.Collections;
import java.util.List;
/**
* This is {@link Cas30JsonProxyTicketValidator} that attempts to parse the CAS validation response
* as JSON. Very similar to {@link Cas30JsonServiceTicketValidator}, it also honors proxies as the name suggests.
*
* @author Misagh Moayyed
*/
public class Cas30JsonProxyTicketValidator extends Cas30ProxyTicketValidator {
public Cas30JsonProxyTicketValidator(final String casServerUrlPrefix) {
super(casServerUrlPrefix);
setCustomParameters(Collections.singletonMap("format", "JSON"));
}
@Override
protected Assertion parseResponseFromServer(final String response) throws TicketValidationException {
try {
final TicketValidationJsonResponse json = new JsonValidationResponseParser().parse(response);
return json.getAssertion(getProxyGrantingTicketStorage(), getProxyRetriever());
} catch (final Exception e) {
logger.warn("Unable parse the JSON response");
return super.parseResponseFromServer(response);
}
}
@Override
protected List<String> parseProxiesFromResponse(final String response) {
try {
final TicketValidationJsonResponse json = new JsonValidationResponseParser().parse(response);
return json.getServiceResponse().getAuthenticationSuccess().getProxies();
} catch (final Exception e) {
logger.warn("Unable to locate proxies from the JSON response", e);
return super.parseProxiesFromResponse(response);
}
}
}

View File

@ -0,0 +1,44 @@
package org.jasig.cas.client.validation.json;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.jasig.cas.client.validation.Assertion;
import org.jasig.cas.client.validation.Cas30ServiceTicketValidator;
import org.jasig.cas.client.validation.TicketValidationException;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
/**
* This is {@link Cas30JsonServiceTicketValidator} that attempts to parse the CAS validation response
* as JSON. If the response is not formatted as JSON, it shall fallback to the XML default syntax.
* The JSON response provides advantages in terms of naming and parsing CAS attributes that have special
* names that otherwise may not be encoded as XML, such as the invalid {@code <cas:special:attribute>value</cas:special:attribute>}
*
* @author Misagh Moayyed
*/
public class Cas30JsonServiceTicketValidator extends Cas30ServiceTicketValidator {
public Cas30JsonServiceTicketValidator(final String casServerUrlPrefix) {
super(casServerUrlPrefix);
setCustomParameters(Collections.singletonMap("format", "JSON"));
}
@Override
protected Assertion parseResponseFromServer(final String response) throws TicketValidationException {
try {
final TicketValidationJsonResponse json = new JsonValidationResponseParser().parse(response);
return json.getAssertion(getProxyGrantingTicketStorage(), getProxyRetriever());
} catch (final JsonProcessingException e) {
logger.warn("Unable parse the JSON response. Falling back to XML", e);
return super.parseResponseFromServer(response);
} catch (final IOException e) {
throw new TicketValidationException(e.getMessage(), e);
}
}
@Override
protected Map<String, Object> extractCustomAttributes(final String xml) {
return Collections.emptyMap();
}
}

View File

@ -0,0 +1,48 @@
package org.jasig.cas.client.validation.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jasig.cas.client.util.CommonUtils;
import org.jasig.cas.client.validation.TicketValidationException;
import java.io.IOException;
/**
* This is {@link JsonValidationResponseParser}.
*
* @author Misagh Moayyed
*/
final class JsonValidationResponseParser {
private final ObjectMapper objectMapper;
public JsonValidationResponseParser() {
this.objectMapper = new ObjectMapper();
this.objectMapper.findAndRegisterModules();
}
public TicketValidationJsonResponse parse(final String response) throws TicketValidationException, IOException {
if (CommonUtils.isBlank(response)) {
throw new TicketValidationException("Invalid JSON response; The response is empty");
}
final TicketValidationJsonResponse json = this.objectMapper.readValue(response, TicketValidationJsonResponse.class);
final TicketValidationJsonResponse.CasServiceResponseAuthentication serviceResponse = json.getServiceResponse();
if (serviceResponse.getAuthenticationFailure() != null
&& serviceResponse.getAuthenticationSuccess() != null) {
throw new TicketValidationException("Invalid JSON response; It indicates both a success "
+ "and a failure event, which is indicative of a server error. The actual response is " + response);
}
if (serviceResponse.getAuthenticationFailure() != null) {
final String error = json.getServiceResponse().getAuthenticationFailure().getCode()
+ " - " + serviceResponse.getAuthenticationFailure().getDescription();
throw new TicketValidationException(error);
}
final String principal = json.getServiceResponse().getAuthenticationSuccess().getUser();
if (CommonUtils.isEmpty(principal)) {
throw new TicketValidationException("No principal was found in the response from the CAS server.");
}
return json;
}
}

View File

@ -0,0 +1,140 @@
package org.jasig.cas.client.validation.json;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.jasig.cas.client.authentication.AttributePrincipal;
import org.jasig.cas.client.authentication.AttributePrincipalImpl;
import org.jasig.cas.client.proxy.ProxyGrantingTicketStorage;
import org.jasig.cas.client.proxy.ProxyRetriever;
import org.jasig.cas.client.util.CommonUtils;
import org.jasig.cas.client.validation.Assertion;
import org.jasig.cas.client.validation.AssertionImpl;
import java.util.List;
import java.util.Map;
/**
* This is {@link TicketValidationJsonResponse}.
*
* @author Misagh Moayyed
*/
final class TicketValidationJsonResponse {
private final CasServiceResponseAuthentication serviceResponse;
@JsonCreator
public TicketValidationJsonResponse(@JsonProperty("serviceResponse")
final CasServiceResponseAuthentication serviceResponse) {
this.serviceResponse = serviceResponse;
}
public CasServiceResponseAuthentication getServiceResponse() {
return serviceResponse;
}
Assertion getAssertion(final ProxyGrantingTicketStorage proxyGrantingTicketStorage,
final ProxyRetriever proxyRetriever) {
final String proxyGrantingTicketIou = getServiceResponse().getAuthenticationSuccess().getProxyGrantingTicket();
final String proxyGrantingTicket;
if (CommonUtils.isBlank(proxyGrantingTicketIou) || proxyGrantingTicketStorage == null) {
proxyGrantingTicket = null;
} else {
proxyGrantingTicket = proxyGrantingTicketStorage.retrieve(proxyGrantingTicketIou);
}
final Assertion assertion;
final Map<String, Object> attributes = getServiceResponse().getAuthenticationSuccess().getAttributes();
final String principal = getServiceResponse().getAuthenticationSuccess().getUser();
if (CommonUtils.isNotBlank(proxyGrantingTicket)) {
final AttributePrincipal attributePrincipal = new AttributePrincipalImpl(principal, attributes,
proxyGrantingTicket, proxyRetriever);
assertion = new AssertionImpl(attributePrincipal);
} else {
assertion = new AssertionImpl(new AttributePrincipalImpl(principal, attributes));
}
return assertion;
}
static class CasServiceResponseAuthentication {
private final CasServiceResponseAuthenticationFailure authenticationFailure;
private final CasServiceResponseAuthenticationSuccess authenticationSuccess;
@JsonCreator
public CasServiceResponseAuthentication(@JsonProperty("authenticationFailure")
final CasServiceResponseAuthenticationFailure authenticationFailure,
@JsonProperty("authenticationSuccess")
final CasServiceResponseAuthenticationSuccess authenticationSuccess) {
this.authenticationFailure = authenticationFailure;
this.authenticationSuccess = authenticationSuccess;
}
public CasServiceResponseAuthenticationFailure getAuthenticationFailure() {
return this.authenticationFailure;
}
public CasServiceResponseAuthenticationSuccess getAuthenticationSuccess() {
return this.authenticationSuccess;
}
}
static class CasServiceResponseAuthenticationSuccess {
private String user;
private String proxyGrantingTicket;
private List proxies;
private Map attributes;
public String getUser() {
return this.user;
}
public void setUser(final String user) {
this.user = user;
}
public String getProxyGrantingTicket() {
return this.proxyGrantingTicket;
}
public void setProxyGrantingTicket(final String proxyGrantingTicket) {
this.proxyGrantingTicket = proxyGrantingTicket;
}
public List getProxies() {
return this.proxies;
}
public void setProxies(final List proxies) {
this.proxies = proxies;
}
public Map getAttributes() {
return this.attributes;
}
public void setAttributes(final Map attributes) {
this.attributes = attributes;
}
}
static class CasServiceResponseAuthenticationFailure {
private String code;
private String description;
public String getCode() {
return this.code;
}
public void setCode(final String code) {
this.code = code;
}
public String getDescription() {
return this.description;
}
public void setDescription(final String description) {
this.description = description;
}
}
}

View File

@ -0,0 +1,90 @@
package org.jasig.cas.client.validation.json;
import org.jasig.cas.client.PublicTestHttpServer;
import org.jasig.cas.client.proxy.ProxyGrantingTicketStorage;
import org.jasig.cas.client.proxy.ProxyGrantingTicketStorageImpl;
import org.jasig.cas.client.proxy.ProxyRetriever;
import org.jasig.cas.client.validation.AbstractTicketValidatorTests;
import org.jasig.cas.client.validation.Assertion;
import org.jasig.cas.client.validation.TicketValidationException;
import org.junit.Before;
import org.junit.Test;
import junit.framework.Assert;
public class Cas30JsonServiceTicketValidatorTests extends AbstractTicketValidatorTests {
private static final PublicTestHttpServer server = PublicTestHttpServer.instance(8088);
private ProxyGrantingTicketStorage proxyGrantingTicketStorage;
private Cas30JsonServiceTicketValidator ticketValidator;
@Before
public void setUp() throws Exception {
this.proxyGrantingTicketStorage = getProxyGrantingTicketStorage();
this.ticketValidator = new Cas30JsonServiceTicketValidator(CONST_CAS_SERVER_URL_PREFIX + "8088");
this.ticketValidator.setProxyCallbackUrl("test");
this.ticketValidator.setProxyGrantingTicketStorage(getProxyGrantingTicketStorage());
this.ticketValidator.setProxyRetriever(getProxyRetriever());
this.ticketValidator.setRenew(true);
}
private ProxyGrantingTicketStorage getProxyGrantingTicketStorage() {
return new ProxyGrantingTicketStorageImpl();
}
private ProxyRetriever getProxyRetriever() {
return new ProxyRetriever() {
/** Unique Id for serialization. */
private static final long serialVersionUID = 1L;
public String getProxyTicketIdFor(final String proxyGrantingTicketId, final String targetService) {
return "test";
}
};
}
@Test
public void testSuccessfulJsonResponse() throws Exception {
final String RESPONSE = "{ " +
"\"serviceResponse\" : { " +
"\"authenticationSuccess\" : { " +
"\"user\" : \"casuser\", " +
"\"proxyGrantingTicket\" : \"PGTIOU-84678-8a9d\" ," +
"\"attributes\" : { " +
"\"cn\" : [ \"Name\" ] " +
'}' +
'}' +
'}' +
'}';
server.content = RESPONSE.getBytes(server.encoding);
final Assertion assertion = ticketValidator.validate("test", "test");
Assert.assertEquals(assertion.getPrincipal().getName(), "casuser");
Assert.assertTrue(assertion.getPrincipal().getAttributes().containsKey("cn"));
}
@Test(expected = TicketValidationException.class)
public void testFailingJsonResponse() throws Exception {
final String RESPONSE = "{ " +
"\"serviceResponse\" : { " +
"\"authenticationFailure\" : { " +
"\"code\" : \"INVALID_TICKET\", " +
"\"description\" : \"Description\" " +
'}' +
'}' +
'}';
server.content = RESPONSE.getBytes(server.encoding);
ticketValidator.validate("test", "test");
}
@Test
public void testSuccessfulXmlResponseWithJson() throws Exception {
final String RESPONSE = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'><cas:authenticationSuccess><cas:user>"
+ "test</cas:user><cas:proxyGrantingTicket>PGTIOU</cas:proxyGrantingTicket></cas:authenticationSuccess></cas:serviceResponse>";
server.content = RESPONSE.getBytes(server.encoding);
ticketValidator.validate("test", "test");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -127,6 +127,7 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.mycila.maven-license-plugin</groupId>
<artifactId>maven-license-plugin</artifactId>
@ -206,6 +207,11 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
@ -261,5 +267,6 @@
<ehcache.version>2.2.0</ehcache.version>
<clover.version>3.0.2</clover.version>
<slf4j.version>1.7.1</slf4j.version>
<jackson.version>2.8.8.1</jackson.version>
</properties>
</project>