/**
* Copyright (c) Codice Foundation
* <p>
* This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or any later version.
* <p>
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
*/
package ddf.security.samlp.impl;
import static org.apache.commons.lang.StringUtils.isBlank;
import static org.apache.commons.lang.StringUtils.isNotBlank;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URLEncoder;
import java.time.Duration;
import java.time.Instant;
import javax.validation.constraints.NotNull;
import org.codice.ddf.security.common.HttpUtils;
import org.joda.time.DateTime;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.saml.common.SAMLVersion;
import org.opensaml.saml.common.SignableSAMLObject;
import org.opensaml.saml.saml2.core.LogoutRequest;
import org.opensaml.saml.saml2.core.LogoutResponse;
import org.opensaml.xmlsec.signature.SignableXMLObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ddf.security.samlp.SamlProtocol;
import ddf.security.samlp.SimpleSign;
import ddf.security.samlp.ValidationException;
public abstract class SamlValidator {
private static final Logger LOGGER = LoggerFactory.getLogger(SamlValidator.class);
protected final Builder builder;
private SamlValidator(Builder builder) {
this.builder = builder;
}
public final void validate() throws ValidationException {
checkTimestamp();
checkSamlVersion();
checkId();
checkRequiredFields();
checkDestination();
additionalValidation();
}
protected void checkTimestamp() throws ValidationException {
DateTime issueInstant = getIssueInstant();
if (issueInstant == null) {
throw new ValidationException("Issue Instant cannot be null!");
}
Instant instant = Instant.ofEpochMilli(issueInstant.getMillis());
Instant now = Instant.now();
if (instant.minus(builder.clockSkew)
.isAfter(now)) {
throw new ValidationException("Issue Instant cannot be in the future");
}
if (instant.plus(builder.clockSkew)
.isBefore(now.minus(builder.timeout))) {
throw new ValidationException("Issue Instant was outside valid time range");
}
}
protected void checkSamlVersion() throws ValidationException {
SAMLVersion samlVersion = getSamlVersion();
if (samlVersion == null) {
throw new ValidationException("SAML Version cannot be null!");
}
if (!SAMLVersion.VERSION_20.equals(samlVersion)) {
throw new ValidationException("Invalid SAML Version!");
}
}
protected abstract void checkRequiredFields() throws ValidationException;
protected abstract void checkDestination() throws ValidationException;
protected abstract void additionalValidation() throws ValidationException;
protected void checkId() throws ValidationException {
// pass, default method
}
protected abstract DateTime getIssueInstant();
protected abstract SAMLVersion getSamlVersion();
void checkPostSignature(SignableSAMLObject samlObject) throws ValidationException {
if (samlObject.getSignature() != null) {
try {
builder.simpleSign.validateSignature(samlObject.getSignature(),
samlObject.getDOM()
.getOwnerDocument());
} catch (SimpleSign.SignatureException e) {
throw new ValidationException("Invalid or untrusted signature.");
}
}
}
void checkRedirectSignature(String reqres) throws ValidationException {
try {
String signedParts = String.format("%s=%s&RelayState=%s&SigAlg=%s",
reqres,
URLEncoder.encode(builder.samlString, "UTF-8"),
builder.relayState,
URLEncoder.encode(builder.sigAlgo, "UTF-8"));
if (!builder.simpleSign.validateSignature(signedParts,
builder.signature,
builder.signingCertificate)) {
throw new ValidationException("Signature verification failed for redirect binding.");
}
} catch (SimpleSign.SignatureException | UnsupportedEncodingException e) {
throw new ValidationException("Signature validation failed.", e);
}
}
/**
* Builder class for SamlValidator.
* <br/>
* Default <code>Timeout</code> of 10 minutes and <code>clockSkew</code> of 30 seconds.
* <br/>
* If validating a redirect saml type, the Signature, Signature Algorithm,
* Relay State, <b>original</b> Saml string, and signing certificate are
* required. For Post binding type, only the object is required.
*/
public static class Builder {
protected SimpleSign simpleSign;
protected SamlProtocol.Binding binding;
protected boolean isRequest;
protected XMLObject xmlObject;
protected Duration timeout = Duration.ofMinutes(10);
protected Duration clockSkew = Duration.ofSeconds(30);
protected String requestId;
protected String destination;
protected String relayState;
protected String signature;
protected String sigAlgo;
protected String samlString;
protected String signingCertificate;
/**
* Creates a new <code>SamlValidator.Builder</code> with the given SimpleSign.
* <br/>
* Create a new instance, set any optional arguments, and then finish
* by calling either <code>build()</code> or <code>buildAndValidate</code>.
*
* @param simpleSign an instance of {@link SimpleSign}
*/
public Builder(SimpleSign simpleSign) {
this.simpleSign = simpleSign;
}
/**
* Utility method that calls the
* {@link #build(String, SamlProtocol.Binding, SignableXMLObject)} method
* and then validates the object.
*
* @param destination The actual endpoint that the saml object was sent to,
* not the destination field on the object
* @param binding The binding of the object (POST or REDIRECT)
* @param xmlObject target object to validate
* @throws IllegalStateException
* @throws ValidationException
*/
public void buildAndValidate(@NotNull String destination,
@NotNull SamlProtocol.Binding binding, @NotNull SignableXMLObject xmlObject)
throws IllegalStateException, ValidationException {
SamlValidator validator = build(destination, binding, xmlObject);
validator.validate();
}
/**
* @param destination The actual endpoint that the saml object was sent to,
* not the destination field on the object
* @param binding The binding of the object (POST or REDIRECT)
* @param xmlObject target object to validate
* @return A {@link SamlValidator} object
* @throws IllegalStateException
*/
public SamlValidator build(@NotNull String destination,
@NotNull SamlProtocol.Binding binding, @NotNull SignableXMLObject xmlObject)
throws IllegalStateException, ValidationException {
if (binding == null) {
throw new IllegalArgumentException("Binding cannot be null!");
}
this.binding = binding;
if (isBlank(destination)) {
throw new IllegalArgumentException("The service destination cannot be null");
}
this.destination = destination;
if (xmlObject instanceof LogoutRequest) {
isRequest = true;
LOGGER.trace("xmlObject is a LogoutRequest [{}]", xmlObject);
} else if (xmlObject instanceof LogoutResponse) {
isRequest = false;
LOGGER.trace("xmlObject is a LogoutResponse [{}]", xmlObject);
} else {
throw new IllegalArgumentException("Could not determine type of xmlObject");
}
this.xmlObject = xmlObject;
if (binding == SamlProtocol.Binding.HTTP_POST) {
return isRequest ? new PostRequest(this) : new PostResponse(this);
}
if (binding == SamlProtocol.Binding.HTTP_REDIRECT) {
if (isBlank(signature) || isBlank(sigAlgo) || isBlank(samlString) || isBlank(
signingCertificate)) {
throw new UnsupportedOperationException("Cannot validate object with blank data");
}
return isRequest ? new RedirectRequest(this) : new RedirectResponse(this);
}
throw new UnsupportedOperationException("Binding not supported.");
}
public Builder setRedirectParams(String relayState, String signature, String sigAlgo,
String samlString, String signingCertificate) {
this.relayState = relayState;
this.signature = signature;
this.sigAlgo = sigAlgo;
this.samlString = samlString;
this.signingCertificate = signingCertificate;
return this;
}
public Builder setRequestId(@NotNull String requestId) {
if (isBlank(requestId)) {
throw new IllegalArgumentException("Logout Request Id cannot be blank!");
}
this.requestId = requestId;
return this;
}
public Builder setTimeout(@NotNull Duration timeout) {
if (timeout == null) {
throw new IllegalArgumentException("Timeout cannot be null!");
}
this.timeout = timeout;
return this;
}
public Builder setClockSkew(@NotNull Duration clockSkew) {
if (clockSkew == null) {
throw new IllegalArgumentException("clockSkew cannot be null!");
}
this.clockSkew = clockSkew;
return this;
}
}
public abstract static class Request extends SamlValidator {
protected final LogoutRequest logoutRequest;
private Request(Builder builder) {
super(builder);
logoutRequest = (LogoutRequest) builder.xmlObject;
}
@Override
protected SAMLVersion getSamlVersion() {
return logoutRequest.getVersion();
}
@Override
protected DateTime getIssueInstant() {
return logoutRequest.getIssueInstant();
}
@Override
protected void checkRequiredFields() throws ValidationException {
// Version existence covered in #checkSamlVersion
// IssueInstance existence covered in #checkTimestamp
if (isBlank(logoutRequest.getID())) {
throw new ValidationException("ID cannot be blank!");
}
}
@Override
protected void checkDestination() throws ValidationException {
if (isNotBlank(logoutRequest.getDestination())) {
try {
if (!HttpUtils.validateAndStripQueryString(logoutRequest.getDestination())
.equals(builder.destination)) {
throw new ValidationException("Destination validation failed");
}
} catch (MalformedURLException e) {
throw new ValidationException(String.format("Destination [%s]is not a valid URL",
logoutRequest.getDestination()), e);
}
}
}
}
public abstract static class Response extends SamlValidator {
protected final LogoutResponse logoutResponse;
private Response(Builder builder) {
super(builder);
logoutResponse = (LogoutResponse) builder.xmlObject;
}
@Override
protected SAMLVersion getSamlVersion() {
return logoutResponse.getVersion();
}
@Override
protected DateTime getIssueInstant() {
return logoutResponse.getIssueInstant();
}
@Override
protected void checkRequiredFields() throws ValidationException {
// Version existence covered in #checkSamlVersion
// IssueInstance existence covered in #checkTimestamp
if (isBlank(logoutResponse.getID())) {
throw new ValidationException("ID cannot be blank!");
}
}
@Override
protected void checkDestination() throws ValidationException {
if (isNotBlank(logoutResponse.getDestination())) {
try {
if (!builder.destination.equals(HttpUtils.validateAndStripQueryString(
logoutResponse.getDestination()))) {
throw new ValidationException("Destination validation failed");
}
} catch (MalformedURLException e) {
throw new ValidationException("Invalid Destination URL", e);
}
}
}
@Override
protected void checkId() throws ValidationException {
if (isNotBlank(builder.requestId)) {
if (!builder.requestId.equals(logoutResponse.getInResponseTo())) {
throw new ValidationException(
"The InResponseTo value did not match the Logout Request Id");
}
}
}
}
public static class PostRequest extends Request {
protected final LogoutRequest logoutRequest;
private PostRequest(Builder builder) {
super(builder);
logoutRequest = (LogoutRequest) builder.xmlObject;
}
@Override
protected void additionalValidation() throws ValidationException {
checkPostSignature(logoutRequest);
}
}
public static class PostResponse extends Response {
protected final LogoutResponse logoutResponse;
private PostResponse(Builder builder) {
super(builder);
logoutResponse = (LogoutResponse) builder.xmlObject;
}
@Override
protected void additionalValidation() throws ValidationException {
checkPostSignature(logoutResponse);
}
}
public static class RedirectRequest extends Request {
protected final LogoutRequest logoutRequest;
private RedirectRequest(Builder builder) {
super(builder);
logoutRequest = (LogoutRequest) builder.xmlObject;
}
@Override
protected void additionalValidation() throws ValidationException {
checkRedirectSignature("SAMLRequest");
}
}
public static class RedirectResponse extends Response {
protected final LogoutResponse logoutResponse;
private RedirectResponse(Builder builder) {
super(builder);
logoutResponse = (LogoutResponse) builder.xmlObject;
}
@Override
protected void additionalValidation() throws ValidationException {
checkRedirectSignature("SAMLResponse");
}
}
}