| // Copyright 2009 Google Inc. |
| // |
| // Licensed 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 com.google.enterprise.secmgr.servlets; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Predicate; |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Iterables; |
| import com.google.enterprise.secmgr.authncontroller.ExportedState; |
| import com.google.enterprise.secmgr.common.SecurityManagerUtil; |
| import com.google.enterprise.secmgr.modules.SamlClient; |
| import com.google.enterprise.secmgr.saml.OpenSamlUtil; |
| import com.google.enterprise.secmgr.saml.SamlLogUtil; |
| |
| import org.joda.time.DateTime; |
| import org.joda.time.DateTimeComparator; |
| import org.joda.time.DateTimeUtils; |
| import org.opensaml.saml2.core.Assertion; |
| import org.opensaml.saml2.core.Attribute; |
| import org.opensaml.saml2.core.AttributeStatement; |
| import org.opensaml.saml2.core.Audience; |
| import org.opensaml.saml2.core.AudienceRestriction; |
| import org.opensaml.saml2.core.Conditions; |
| import org.opensaml.saml2.core.Issuer; |
| import org.opensaml.saml2.core.NameIDType; |
| import org.opensaml.saml2.core.Response; |
| import org.opensaml.saml2.core.Subject; |
| import org.opensaml.saml2.core.SubjectConfirmation; |
| import org.opensaml.saml2.core.SubjectConfirmationData; |
| import org.opensaml.xml.XMLObject; |
| |
| import java.io.IOException; |
| import java.util.List; |
| import java.util.logging.Level; |
| import java.util.logging.Logger; |
| |
| import javax.annotation.concurrent.Immutable; |
| |
| /** |
| * A parser to disassemble and validate a SAML Response element. |
| */ |
| @Immutable |
| public final class ResponseParser { |
| private static final Logger LOGGER = Logger.getLogger(ResponseParser.class.getName()); |
| private static final DateTimeComparator dtComparator = DateTimeComparator.getInstance(); |
| |
| private final SamlClient client; |
| private final String recipient; |
| private final Response response; |
| private final String sessionId; |
| private final long now; |
| private final Assertion assertion; |
| |
| private ResponseParser(SamlClient client, String recipient, Response response, String sessionId) { |
| this.client = client; |
| this.recipient = recipient; |
| this.response = response; |
| this.sessionId = sessionId; |
| this.now = DateTimeUtils.currentTimeMillis(); |
| this.assertion = findSuitableAssertion(); |
| } |
| |
| public static ResponseParser make(SamlClient client, String recipient, Response response, |
| String sessionId) { |
| return new ResponseParser(client, recipient, response, sessionId); |
| } |
| |
| /** Log messages as info. */ |
| private void inform(String... messages) { |
| for (String message : messages) { |
| LOGGER.info(SecurityManagerUtil.sessionLogMessage(sessionId, message)); |
| } |
| } |
| |
| /** Log messages as warnings. */ |
| private void warn(String... messages) { |
| for (String message : messages) { |
| LOGGER.warning(SecurityManagerUtil.sessionLogMessage(sessionId, message)); |
| } |
| } |
| |
| /** |
| * If condition is false then log messages as warnings. |
| * <p> |
| * This method is used to modify a chain of && boolean conditions. |
| * For example: |
| * <pre> |
| * return name != null |
| * && isValidName(name) |
| * && hasSession(name); |
| * </pre> |
| * becomes: |
| * <pre> |
| * return warnIfFalse(name != null, "Name is null") |
| * && warnIfFalse(isValidName(name), "Invalid name: " + name) |
| * && warnIfFalse(hasSession(name), "Missing session for: " + name); |
| * </pre> |
| * <p> |
| * @return value of condition |
| */ |
| private boolean warnIfFalse(boolean condition, String ... messages) { |
| if (!condition) { |
| warn(messages); |
| } |
| return condition; |
| } |
| |
| // to avoid having 3 try/catch loops in findSuitableAssertionHelper |
| private Assertion findSuitableAssertion() { |
| try { |
| return findSuitableAssertionHelper(); |
| } catch (IOException e) { |
| LOGGER.log(Level.WARNING, "An error occurred but logger could not parse " |
| + "the SamlResponse object.", e); |
| return null; |
| } |
| } |
| |
| // TODO(cph): This logic is inadequate, but more or less the same as what the |
| // GSA does. Instead of trying to find a single valid assertion, we should |
| // analyze the assertions as a whole and combine their content. The SAML spec |
| // allows the IdP to include arbitrary numbers of assertions, and with each |
| // assertion an arbitrary number of statements. The spec explicitly states |
| // that an assertion with multiple statements is completely equivalent to |
| // multiple assertions, each with a single statement (provided the other parts |
| // of the assertions match one another). It's the responsibility of the |
| // relying party (us) to make sense of the information in whatever form the |
| // IdP sends it. |
| private Assertion findSuitableAssertionHelper() throws IOException { |
| if (response.getAssertions().isEmpty()) { |
| warn(SamlLogUtil.xmlMessage( |
| "Received no assertions in this response.", response)); |
| } |
| |
| for (Assertion assertion : response.getAssertions()) { |
| if (isAssertionValid(assertion)) { |
| return assertion; |
| } |
| warn(SamlLogUtil.xmlMessage( |
| "Rejected assertion because it was invalid", assertion)); |
| } |
| warn(SamlLogUtil.xmlMessage( |
| "Could not find a valid assertion for this response", response)); |
| return null; |
| } |
| |
| /** |
| * Is the response element valid? |
| */ |
| public boolean isResponseValid() { |
| Issuer issuer = response.getIssuer(); |
| return issuer == null || isValidIssuer(issuer); |
| } |
| |
| /** |
| * Get the response status code. |
| * Must satisfy {@link #isResponseValid} prior to calling. |
| */ |
| public String getResponseStatus() { |
| return response.getStatus().getStatusCode().getValue(); |
| } |
| |
| /** |
| * Are the assertions contained in this response valid? |
| * Meaningful only when response status is "success". |
| */ |
| public boolean areAssertionsValid() { |
| return assertion != null; |
| } |
| |
| /** |
| * Get the asserted subject. |
| * Must satisfy {@link #areAssertionsValid} prior to calling. |
| */ |
| public String getSubject() { |
| return assertion.getSubject().getNameID().getValue(); |
| } |
| |
| /** |
| * Get the expiration time for the subject verification. |
| * Must satisfy {@link #areAssertionsValid} prior to calling. |
| * |
| * @return The expiration time, or null if there is none. |
| */ |
| public DateTime getExpirationTime() { |
| DateTime time1 = assertion.getConditions().getNotOnOrAfter(); |
| List<SubjectConfirmation> confirmations = assertion.getSubject().getSubjectConfirmations(); |
| if (confirmations.isEmpty()) { |
| return time1; |
| } |
| DateTime time2 = Iterables.find(confirmations, bearerPredicate) |
| .getSubjectConfirmationData() |
| .getNotOnOrAfter(); |
| return (time1 == null || dtComparator.compare(time1, time2) > 0) ? time2 : time1; |
| } |
| |
| /** |
| * Gets an exported-state object. This information is a security manager |
| * extension. Must satisfy {@link #areAssertionsValid} prior to calling. |
| */ |
| public ExportedState getExportedState() { |
| return getExportedState(assertion); |
| } |
| |
| /** |
| * Gets an exported-state object from a given assertion. This information is |
| * a security manager extension. |
| * |
| * @param assertion The assertion to get the identities from. |
| * @return A exported-state object, or {@code null} if there's none. |
| */ |
| @VisibleForTesting |
| public static ExportedState getExportedState(Assertion assertion) { |
| for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) { |
| for (Attribute attribute : attributeStatement.getAttributes()) { |
| if (ExportedState.ATTRIBUTE_NAME.equals(attribute.getName())) { |
| List<XMLObject> attributeValues = attribute.getAttributeValues(); |
| if (attributeValues.size() == 1) { |
| XMLObject attributeValue = attributeValues.get(0); |
| String textContent = attributeValue.getDOM().getTextContent(); |
| return ExportedState.fromJsonString(textContent); |
| } |
| } |
| } |
| } |
| return null; |
| } |
| |
| /** |
| * Get any groups that are provided by the assertions. |
| * This information is a security manager extension. |
| * Must satisfy {@link #areAssertionsValid} prior to calling. |
| */ |
| public ImmutableSet<String> getGroups() { |
| return getGroups(assertion); |
| } |
| |
| /** |
| * Gets any groups that are provided by a given assertion. This information |
| * is a security manager extension. |
| * |
| * @param assertion An assertion to get the groups from. |
| * @return An immutable set of the groups found; may be empty. |
| */ |
| @VisibleForTesting |
| public static ImmutableSet<String> getGroups(Assertion assertion) { |
| ExportedState state = getExportedState(assertion); |
| return (state != null) |
| ? state.getPviCredentials().getGroups() |
| : ImmutableSet.<String>of(); |
| } |
| |
| // **************** Validation primitives **************** |
| |
| private boolean isAssertionValid(Assertion assertion) { |
| String validityDescription |
| = "is empty (of statements): " + assertion.getAuthnStatements().isEmpty() |
| + ", assertion issuer: " + assertion.getIssuer() |
| + ", is valid issuer: " + isValidIssuer(assertion.getIssuer()) |
| + ", is valid subject: " + isValidSubject(assertion.getSubject()) |
| + ", are valid conditions: " + isValidConditions(assertion.getConditions()); |
| inform(validityDescription); |
| |
| return !assertion.getAuthnStatements().isEmpty() |
| && (assertion.getIssuer() != null) |
| && isValidIssuer(assertion.getIssuer()) |
| && isValidSubject(assertion.getSubject()) |
| && isValidConditions(assertion.getConditions()); |
| } |
| |
| private boolean isValidIssuer(Issuer issuer) { |
| return warnIfFalse(issuer.getFormat() == null || NameIDType.ENTITY.equals(issuer.getFormat()), |
| "Issuer contains a format: " + issuer.getFormat() + |
| " but is not equal to expected format: " + NameIDType.ENTITY) |
| && warnIfFalse(client.getPeerEntity().getEntityID().equals(issuer.getValue()), |
| "Issuer value: " + issuer.getValue() + " is not equals to " |
| + "expected value: " + client.getPeerEntity().getEntityID()); |
| } |
| |
| private boolean isValidSubject(Subject subject) { |
| return warnIfFalse(subject != null, "Subject is null.") |
| && warnIfFalse(hasValidId(subject), "Subject has an invalid ID.") |
| && warnIfFalse(areValidSubjectConfirmations(subject.getSubjectConfirmations()), |
| "Subject does not have valid confirmations."); |
| } |
| |
| // This is a security manager requirement; it's not mandated by the spec. |
| private boolean hasValidId(Subject subject) { |
| return warnIfFalse(subject.getBaseID() == null, "Subject BaseID is not null.") |
| && warnIfFalse(subject.getNameID() != null, "Subject NameID is null") |
| && warnIfFalse(!Strings.isNullOrEmpty(subject.getNameID().getValue()), |
| "Subject NameID string is null or empty.") |
| && warnIfFalse(subject.getEncryptedID() == null, "Subject contains an EncryptedID."); |
| } |
| |
| private boolean areValidSubjectConfirmations(List<SubjectConfirmation> confirmations) { |
| if (confirmations.isEmpty()) { |
| // This violates the SAML spec, but the GSA has historically ignored this |
| // information, so we must allow it. |
| warn("SAML assertion received without subject confirmation"); |
| return true; |
| } |
| Iterable<SubjectConfirmation> bearers = Iterables.filter(confirmations, bearerPredicate); |
| return warnIfFalse(!Iterables.isEmpty(bearers), "SubjectConfirmations contains no bearers.") |
| && warnIfFalse(Iterables.all(bearers, validBearerPredicate), |
| "SubjectConfirmations were invalid."); |
| } |
| |
| private Predicate<SubjectConfirmation> bearerPredicate = |
| new Predicate<SubjectConfirmation>() { |
| public boolean apply(SubjectConfirmation confirmation) { |
| return OpenSamlUtil.BEARER_METHOD.equals(confirmation.getMethod()); |
| } |
| }; |
| |
| private Predicate<SubjectConfirmation> validBearerPredicate = |
| new Predicate<SubjectConfirmation>() { |
| public boolean apply(SubjectConfirmation bearer) { |
| return isValidSubjectConfirmationData(bearer.getSubjectConfirmationData()); |
| } |
| }; |
| |
| private boolean isValidSubjectConfirmationData(SubjectConfirmationData data) { |
| return warnIfFalse(recipient.equals(data.getRecipient()), |
| "SubjectConfirmationData - recipient not equals : " + recipient + |
| "but was instead: " + data.getRecipient()) |
| && warnIfFalse(isValidExpiration(data.getNotOnOrAfter()), "Invalid expiration.") |
| && warnIfFalse(client.getRequestId().equals((data.getInResponseTo())), |
| "Assertion inResponseTo: " + data.getInResponseTo() + |
| " was not equal to this client's RequestID: " + client.getRequestId()); |
| } |
| |
| private boolean isValidExpiration(DateTime expiration) { |
| return warnIfFalse(expiration != null, "Assertion expiration was null.") && |
| warnIfFalse(SecurityManagerUtil.isRemoteOnOrAfterTimeValid(expiration.getMillis(), now), |
| "The assertion's expiration time is invalid.", |
| "Security Manager Current Time: " + new DateTime(now).toString(), |
| "Assertion expiration:" + new DateTime(expiration.getMillis()).toString()); |
| } |
| |
| // TODO(cph): This code needs to handle <OneTimeUse> and <ProxyRestriction> |
| // conditions. |
| private boolean isValidConditions(Conditions conditions) { |
| return warnIfFalse(conditions != null, "Assertion conditions was null.") |
| && warnIfFalse(isValidConditionNotBefore(conditions.getNotBefore()), |
| "ConditionNotBefore is invalid: " + conditions.getNotBefore()) |
| && warnIfFalse(isValidConditionNotOnOrAfter(conditions.getNotOnOrAfter()), |
| "ConditionNotOnOrAfter is invalid: " + conditions.getNotOnOrAfter()) |
| && warnIfFalse(isValidConditionAudienceRestrictions(conditions.getAudienceRestrictions()), |
| "ConditionAudienceRestrictions is invalid: " + |
| conditions.getAudienceRestrictions()); |
| } |
| |
| private boolean isValidConditionNotBefore(DateTime notBefore) { |
| return notBefore == null || |
| SecurityManagerUtil.isRemoteBeforeTimeValid(notBefore.getMillis(), now); |
| } |
| |
| private boolean isValidConditionNotOnOrAfter(DateTime notOnOrAfter) { |
| return notOnOrAfter == null || |
| SecurityManagerUtil.isRemoteOnOrAfterTimeValid(notOnOrAfter.getMillis(), now); |
| } |
| |
| private boolean isValidConditionAudienceRestrictions(List<AudienceRestriction> restrictions) { |
| String localEntityId = client.getLocalEntity().getEntityID(); |
| for (AudienceRestriction restriction : restrictions) { |
| for (Audience audience : restriction.getAudiences()) { |
| if (localEntityId.equals(audience.getAudienceURI())) { |
| return true; |
| } |
| } |
| } |
| return false; |
| } |
| } |