http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2LoginResponseTO.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2LoginResponseTO.java b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2LoginResponseTO.java new file mode 100644 index 0000000..b6ece53 --- /dev/null +++ b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2LoginResponseTO.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.common.lib.to; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlElementWrapper; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlType; +import org.apache.syncope.common.lib.AbstractBaseBean; + +@XmlRootElement(name = "saml2LoginResponse") +@XmlType +public class SAML2LoginResponseTO extends AbstractBaseBean { + + private static final long serialVersionUID = 794772343787258010L; + + private String nameID; + + private String sessionIndex; + + private Date authInstant; + + private Date notOnOrAfter; + + private String accessToken; + + private String username; + + private final Set<AttrTO> attrs = new HashSet<>(); + + public String getNameID() { + return nameID; + } + + public void setNameID(final String nameID) { + this.nameID = nameID; + } + + public String getSessionIndex() { + return sessionIndex; + } + + public void setSessionIndex(final String sessionIndex) { + this.sessionIndex = sessionIndex; + } + + public Date getAuthInstant() { + if (authInstant != null) { + return new Date(authInstant.getTime()); + } + return null; + } + + public void setAuthInstant(final Date authInstant) { + if (authInstant != null) { + this.authInstant = new Date(authInstant.getTime()); + } else { + this.authInstant = null; + } + } + + public Date getNotOnOrAfter() { + if (notOnOrAfter != null) { + return new Date(notOnOrAfter.getTime()); + } + return null; + } + + public void setNotOnOrAfter(final Date notOnOrAfter) { + if (notOnOrAfter != null) { + this.notOnOrAfter = new Date(notOnOrAfter.getTime()); + } else { + this.notOnOrAfter = null; + } + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(final String accessToken) { + this.accessToken = accessToken; + } + + public String getUsername() { + return username; + } + + public void setUsername(final String username) { + this.username = username; + } + + @XmlElementWrapper(name = "attrs") + @XmlElement(name = "attr") + @JsonProperty("attrs") + public Set<AttrTO> getAttrs() { + return attrs; + } + +}
http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2RequestTO.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2RequestTO.java b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2RequestTO.java new file mode 100644 index 0000000..136b58e --- /dev/null +++ b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/to/SAML2RequestTO.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.common.lib.to; + +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlType; +import org.apache.syncope.common.lib.AbstractBaseBean; + +@XmlRootElement(name = "saml2request") +@XmlType +public class SAML2RequestTO extends AbstractBaseBean { + + private static final long serialVersionUID = -2454209295007372086L; + + private String idpServiceAddress; + + private String content; + + private String relayState; + + public String getIdpServiceAddress() { + return idpServiceAddress; + } + + public void setIdpServiceAddress(final String idpServiceAddress) { + this.idpServiceAddress = idpServiceAddress; + } + + public String getContent() { + return content; + } + + public void setContent(final String content) { + this.content = content; + } + + public String getRelayState() { + return relayState; + } + + public void setRelayState(final String relayState) { + this.relayState = relayState; + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/types/SAML2SPEntitlement.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/types/SAML2SPEntitlement.java b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/types/SAML2SPEntitlement.java new file mode 100644 index 0000000..985bf8b --- /dev/null +++ b/ext/saml2sp/common-lib/src/main/java/org/apache/syncope/common/lib/types/SAML2SPEntitlement.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.common.lib.types; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; + +public final class SAML2SPEntitlement { + + public static final String IDP_READ = "IDP_READ"; + + public static final String IDP_LIST = "IDP_LIST"; + + public static final String IDP_IMPORT = "IDP_IMPORT"; + + public static final String IDP_UPDATE = "IDP_UPDATE"; + + public static final String IDP_DELETE = "IDP_DELETE"; + + private static final Set<String> VALUES; + + static { + Set<String> values = new TreeSet<>(); + for (Field field : SAML2SPEntitlement.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) && String.class.equals(field.getType())) { + values.add(field.getName()); + } + } + VALUES = Collections.unmodifiableSet(values); + } + + public static Set<String> values() { + return VALUES; + } + + private SAML2SPEntitlement() { + // private constructor for static utility class + } +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/pom.xml ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/pom.xml b/ext/saml2sp/logic/pom.xml new file mode 100644 index 0000000..b0ffe56 --- /dev/null +++ b/ext/saml2sp/logic/pom.xml @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF 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 + + 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. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.syncope.ext</groupId> + <artifactId>syncope-ext-saml2sp</artifactId> + <version>2.0.3-SNAPSHOT</version> + </parent> + + <name>Apache Syncope Extensions: SAML 2.0 SP Logic</name> + <description>Apache Syncope Extensions: SAML 2.0 SP Logic</description> + <groupId>org.apache.syncope.ext.saml2sp</groupId> + <artifactId>syncope-ext-saml2sp-logic</artifactId> + <packaging>jar</packaging> + + <properties> + <rootpom.basedir>${basedir}/../../..</rootpom.basedir> + </properties> + + <dependencies> + <dependency> + <groupId>org.apache.syncope.core</groupId> + <artifactId>syncope-core-logic</artifactId> + <version>${project.version}</version> + </dependency> + + <dependency> + <groupId>org.apache.syncope.ext.saml2sp</groupId> + <artifactId>syncope-ext-saml2sp-provisioning-java</artifactId> + <version>${project.version}</version> + </dependency> + + <dependency> + <groupId>org.apache.cxf</groupId> + <artifactId>cxf-rt-rs-security-sso-saml</artifactId> + </dependency> + + <dependency> + <groupId>org.apache.wss4j</groupId> + <artifactId>wss4j-ws-security-dom</artifactId> + </dependency> + + <dependency> + <groupId>org.opensaml</groupId> + <artifactId>opensaml-saml-impl</artifactId> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-checkstyle-plugin</artifactId> + </plugin> + </plugins> + </build> +</project> http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2IdPLogic.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2IdPLogic.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2IdPLogic.java new file mode 100644 index 0000000..3f6b4a3 --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2IdPLogic.java @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.Transformer; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.syncope.common.lib.SyncopeClientException; +import org.apache.syncope.common.lib.to.MappingItemTO; +import org.apache.syncope.common.lib.to.SAML2IdPTO; +import org.apache.syncope.common.lib.types.ClientExceptionType; +import org.apache.syncope.common.lib.types.SAML2SPEntitlement; +import org.apache.syncope.core.logic.saml2.SAML2ReaderWriter; +import org.apache.syncope.core.logic.saml2.SAML2IdPCache; +import org.apache.syncope.core.logic.saml2.SAML2IdPEntity; +import org.apache.syncope.core.persistence.api.dao.NotFoundException; +import org.apache.syncope.core.persistence.api.dao.SAML2IdPDAO; +import org.apache.syncope.core.persistence.api.entity.SAML2IdP; +import org.apache.syncope.core.provisioning.api.data.SAML2IdPDataBinder; +import org.apache.wss4j.common.saml.OpenSAMLUtil; +import org.opensaml.saml.common.xml.SAMLConstants; +import org.opensaml.saml.saml2.metadata.EntitiesDescriptor; +import org.opensaml.saml.saml2.metadata.EntityDescriptor; +import org.opensaml.saml.saml2.metadata.IDPSSODescriptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +@Component +public class SAML2IdPLogic extends AbstractTransactionalLogic<SAML2IdPTO> { + + static { + OpenSAMLUtil.initSamlEngine(false); + } + + @Autowired + private SAML2IdPCache cache; + + @Autowired + private SAML2IdPDataBinder binder; + + @Autowired + private SAML2IdPDAO idpDAO; + + @Autowired + private SAML2ReaderWriter saml2rw; + + @PreAuthorize("hasRole('" + SAML2SPEntitlement.IDP_LIST + "')") + @Transactional(readOnly = true) + public List<SAML2IdPTO> list() { + return CollectionUtils.collect(idpDAO.findAll(), new Transformer<SAML2IdP, SAML2IdPTO>() { + + @Override + public SAML2IdPTO transform(final SAML2IdP input) { + return binder.getIdPTO(input); + } + }, new ArrayList<SAML2IdPTO>()); + } + + @PreAuthorize("hasRole('" + SAML2SPEntitlement.IDP_READ + "')") + @Transactional(readOnly = true) + public SAML2IdPTO read(final String key) { + SAML2IdP idp = idpDAO.find(key); + if (idp == null) { + throw new NotFoundException("SAML 2.0 IdP '" + key + "'"); + } + + return binder.getIdPTO(idp); + } + + private List<SAML2IdPTO> importIdPs(final InputStream input) throws Exception { + List<EntityDescriptor> idpEntityDescriptors = new ArrayList<>(); + + Element root = OpenSAMLUtil.getParserPool().parse(new InputStreamReader(input)).getDocumentElement(); + if (SAMLConstants.SAML20MD_NS.equals(root.getNamespaceURI()) + && EntityDescriptor.DEFAULT_ELEMENT_LOCAL_NAME.equals(root.getLocalName())) { + + idpEntityDescriptors.add((EntityDescriptor) OpenSAMLUtil.fromDom(root)); + } else if (SAMLConstants.SAML20MD_NS.equals(root.getNamespaceURI()) + && EntitiesDescriptor.DEFAULT_ELEMENT_LOCAL_NAME.equals(root.getLocalName())) { + + NodeList children = root.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (SAMLConstants.SAML20MD_NS.equals(child.getNamespaceURI()) + && EntityDescriptor.DEFAULT_ELEMENT_LOCAL_NAME.equals(child.getLocalName())) { + + NodeList descendants = child.getChildNodes(); + for (int j = 0; j < descendants.getLength(); j++) { + Node descendant = descendants.item(j); + if (SAMLConstants.SAML20MD_NS.equals(descendant.getNamespaceURI()) + && IDPSSODescriptor.DEFAULT_ELEMENT_LOCAL_NAME.equals(descendant.getLocalName())) { + + idpEntityDescriptors.add((EntityDescriptor) OpenSAMLUtil.fromDom((Element) child)); + } + } + } + } + } + + List<SAML2IdPTO> result = new ArrayList<>(idpEntityDescriptors.size()); + for (EntityDescriptor idpEntityDescriptor : idpEntityDescriptors) { + SAML2IdPTO idpTO = new SAML2IdPTO(); + idpTO.setEntityID(idpEntityDescriptor.getEntityID()); + idpTO.setUseDeflateEncoding(false); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + saml2rw.write(new OutputStreamWriter(baos), idpEntityDescriptor, false); + idpTO.setMetadata(Base64.encodeBase64String(baos.toByteArray())); + } + MappingItemTO connObjectKeyItem = new MappingItemTO(); + connObjectKeyItem.setIntAttrName("username"); + connObjectKeyItem.setExtAttrName("NameID"); + idpTO.setConnObjectKeyItem(connObjectKeyItem); + result.add(idpTO); + + cache.put(idpEntityDescriptor, connObjectKeyItem, false); + } + + return result; + } + + @PreAuthorize("hasRole('" + SAML2SPEntitlement.IDP_IMPORT + "')") + public List<String> importFromMetadata(final InputStream input) { + List<String> imported = new ArrayList<>(); + + try { + for (SAML2IdPTO idpTO : importIdPs(input)) { + SAML2IdP idp = idpDAO.save(binder.create(idpTO)); + imported.add(idp.getKey()); + } + } catch (SyncopeClientException e) { + throw e; + } catch (Exception e) { + LOG.error("Unexpected error while importing IdP metadata", e); + SyncopeClientException ex = SyncopeClientException.build(ClientExceptionType.InvalidEntity); + ex.getElements().add(e.getMessage()); + throw ex; + } + + return imported; + } + + @PreAuthorize("hasRole('" + SAML2SPEntitlement.IDP_UPDATE + "')") + public void update(final SAML2IdPTO saml2IdpTO) { + SAML2IdP saml2Idp = idpDAO.find(saml2IdpTO.getKey()); + if (saml2Idp == null) { + throw new NotFoundException("SAML 2.0 IdP '" + saml2IdpTO.getKey() + "'"); + } + + saml2Idp = idpDAO.save(binder.update(saml2Idp, saml2IdpTO)); + + SAML2IdPEntity idpEntity = cache.get(saml2Idp.getEntityID()); + if (idpEntity != null) { + idpEntity.setUseDeflateEncoding(saml2Idp.isUseDeflateEncoding()); + idpEntity.setConnObjectKeyItem(binder.getIdPTO(saml2Idp).getConnObjectKeyItem()); + } + } + + @PreAuthorize("hasRole('" + SAML2SPEntitlement.IDP_DELETE + "')") + public void delete(final String key) { + SAML2IdP idp = idpDAO.find(key); + if (idp == null) { + throw new NotFoundException("SAML 2.0 IdP '" + key + "'"); + } + + idpDAO.delete(key); + cache.remove(idp.getEntityID()); + } + + @Override + protected SAML2IdPTO resolveReference(final Method method, final Object... args) + throws UnresolvedReferenceException { + + String key = null; + + if (ArrayUtils.isNotEmpty(args)) { + for (int i = 0; key == null && i < args.length; i++) { + if (args[i] instanceof String) { + key = (String) args[i]; + } else if (args[i] instanceof SAML2IdPTO) { + key = ((SAML2IdPTO) args[i]).getKey(); + } + } + } + + if (key != null) { + try { + return binder.getIdPTO(idpDAO.find(key)); + } catch (Throwable ignore) { + LOG.debug("Unresolved reference", ignore); + throw new UnresolvedReferenceException(ignore); + } + } + + throw new UnresolvedReferenceException(); + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java new file mode 100644 index 0000000..f452208 --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/SAML2SPLogic.java @@ -0,0 +1,689 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic; + +import com.fasterxml.uuid.Generators; +import com.fasterxml.uuid.impl.RandomBasedGenerator; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.lang.reflect.Method; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.ws.rs.core.MultivaluedMap; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; +import org.apache.cxf.helpers.IOUtils; +import org.apache.cxf.jaxrs.utils.JAXRSUtils; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactConsumer; +import org.apache.cxf.rs.security.jose.jws.JwsSignatureVerifier; +import org.apache.cxf.rs.security.saml.sso.SSOConstants; +import org.apache.syncope.common.lib.AbstractBaseBean; +import org.apache.syncope.common.lib.SyncopeClientException; +import org.apache.syncope.common.lib.to.AttrTO; +import org.apache.syncope.common.lib.to.SAML2RequestTO; +import org.apache.syncope.common.lib.to.SAML2LoginResponseTO; +import org.apache.syncope.common.lib.to.MappingItemTO; +import org.apache.syncope.common.lib.types.AnyTypeKind; +import org.apache.syncope.common.lib.types.ClientExceptionType; +import org.apache.syncope.common.lib.types.StandardEntitlement; +import org.apache.syncope.core.logic.init.SAML2SPLoader; +import org.apache.syncope.core.logic.saml2.SAML2ReaderWriter; +import org.apache.syncope.core.logic.saml2.SAML2IdPCache; +import org.apache.syncope.core.logic.saml2.SAML2IdPEntity; +import org.apache.syncope.core.logic.saml2.SAML2Signer; +import org.apache.syncope.core.persistence.api.attrvalue.validation.ParsingValidationException; +import org.apache.syncope.core.persistence.api.dao.AccessTokenDAO; +import org.apache.syncope.core.persistence.api.dao.NotFoundException; +import org.apache.syncope.core.persistence.api.dao.PlainSchemaDAO; +import org.apache.syncope.core.persistence.api.dao.SAML2IdPDAO; +import org.apache.syncope.core.persistence.api.dao.UserDAO; +import org.apache.syncope.core.persistence.api.entity.EntityFactory; +import org.apache.syncope.core.persistence.api.entity.PlainAttrValue; +import org.apache.syncope.core.persistence.api.entity.PlainSchema; +import org.apache.syncope.core.persistence.api.entity.SAML2IdP; +import org.apache.syncope.core.persistence.api.entity.user.UPlainAttrValue; +import org.apache.syncope.core.persistence.api.entity.user.User; +import org.apache.syncope.core.provisioning.api.IntAttrName; +import org.apache.syncope.core.provisioning.api.data.AccessTokenDataBinder; +import org.apache.syncope.core.provisioning.api.data.MappingItemTransformer; +import org.apache.syncope.core.provisioning.api.utils.EntityUtils; +import org.apache.syncope.core.provisioning.java.IntAttrNameParser; +import org.apache.syncope.core.provisioning.java.utils.MappingUtils; +import org.apache.wss4j.common.saml.OpenSAMLUtil; +import org.joda.time.DateTime; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.schema.XSString; +import org.opensaml.saml.common.SAMLVersion; +import org.opensaml.saml.common.xml.SAMLConstants; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.Attribute; +import org.opensaml.saml.saml2.core.AttributeStatement; +import org.opensaml.saml.saml2.core.AuthnContext; +import org.opensaml.saml.saml2.core.AuthnContextClassRef; +import org.opensaml.saml.saml2.core.AuthnContextComparisonTypeEnumeration; +import org.opensaml.saml.saml2.core.AuthnRequest; +import org.opensaml.saml.saml2.core.AuthnStatement; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.NameIDPolicy; +import org.opensaml.saml.saml2.core.NameIDType; +import org.opensaml.saml.saml2.core.RequestedAuthnContext; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.SessionIndex; +import org.opensaml.saml.saml2.core.StatusCode; +import org.opensaml.saml.saml2.core.impl.AuthnContextClassRefBuilder; +import org.opensaml.saml.saml2.core.impl.AuthnRequestBuilder; +import org.opensaml.saml.saml2.core.impl.IssuerBuilder; +import org.opensaml.saml.saml2.core.impl.LogoutRequestBuilder; +import org.opensaml.saml.saml2.core.impl.NameIDBuilder; +import org.opensaml.saml.saml2.core.impl.NameIDPolicyBuilder; +import org.opensaml.saml.saml2.core.impl.RequestedAuthnContextBuilder; +import org.opensaml.saml.saml2.core.impl.SessionIndexBuilder; +import org.opensaml.saml.saml2.metadata.AssertionConsumerService; +import org.opensaml.saml.saml2.metadata.EntityDescriptor; +import org.opensaml.saml.saml2.metadata.KeyDescriptor; +import org.opensaml.saml.saml2.metadata.NameIDFormat; +import org.opensaml.saml.saml2.metadata.SPSSODescriptor; +import org.opensaml.saml.saml2.metadata.SingleLogoutService; +import org.opensaml.saml.saml2.metadata.impl.AssertionConsumerServiceBuilder; +import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorBuilder; +import org.opensaml.saml.saml2.metadata.impl.KeyDescriptorBuilder; +import org.opensaml.saml.saml2.metadata.impl.NameIDFormatBuilder; +import org.opensaml.saml.saml2.metadata.impl.SPSSODescriptorBuilder; +import org.opensaml.saml.saml2.metadata.impl.SingleLogoutServiceBuilder; +import org.opensaml.xmlsec.keyinfo.KeyInfoGenerator; +import org.opensaml.xmlsec.keyinfo.impl.X509KeyInfoGeneratorFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; + +@Component +public class SAML2SPLogic extends AbstractTransactionalLogic<AbstractBaseBean> { + + private static final Integer JWT_RELAY_STATE_DURATION = 5; + + private static final String JWT_CLAIM_IDP_DEFLATE = "IDP_DEFLATE"; + + private static final String JWT_CLAIM_IDP_ENTITYID = "IDP_ENTITYID"; + + private static final String JWT_CLAIM_NAMEID_FORMAT = "NAMEID_FORMAT"; + + private static final String JWT_CLAIM_NAMEID_VALUE = "NAMEID_VALUE"; + + private static final String JWT_CLAIM_SESSIONINDEX = "SESSIONINDEX"; + + private static final RandomBasedGenerator UUID_GENERATOR = Generators.randomBasedGenerator(); + + static { + OpenSAMLUtil.initSamlEngine(false); + } + + @Autowired + private JwsSignatureVerifier jwsSignatureCerifier; + + @Autowired + private AccessTokenDataBinder accessTokenDataBinder; + + @Autowired + private SAML2SPLoader loader; + + @Autowired + private SAML2IdPCache cache; + + @Autowired + private UserDAO userDAO; + + @Autowired + private SAML2IdPDAO saml2IdPDAO; + + @Autowired + private PlainSchemaDAO plainSchemaDAO; + + @Autowired + private AccessTokenDAO accessTokenDAO; + + @Autowired + private IntAttrNameParser intAttrNameParser; + + @Autowired + private EntityFactory entityFactory; + + @Autowired + private SAML2ReaderWriter saml2rw; + + @Autowired + private SAML2Signer saml2Signer; + + @PreAuthorize("hasRole('" + StandardEntitlement.ANONYMOUS + "')") + public void getMetadata(final String spEntityID, final OutputStream os) { + try { + EntityDescriptor spEntityDescriptor = new EntityDescriptorBuilder().buildObject(); + spEntityDescriptor.setEntityID(spEntityID); + + SPSSODescriptor spSSODescriptor = new SPSSODescriptorBuilder().buildObject(); + spSSODescriptor.setWantAssertionsSigned(true); + spSSODescriptor.setAuthnRequestsSigned(true); + + X509KeyInfoGeneratorFactory keyInfoGeneratorFactory = new X509KeyInfoGeneratorFactory(); + keyInfoGeneratorFactory.setEmitEntityCertificate(true); + KeyInfoGenerator keyInfoGenerator = keyInfoGeneratorFactory.newInstance(); + keyInfoGenerator.generate(loader.getCredential()); + + KeyDescriptor keyDescriptor = new KeyDescriptorBuilder().buildObject(); + keyDescriptor.setKeyInfo(keyInfoGenerator.generate(loader.getCredential())); + spSSODescriptor.getKeyDescriptors().add(keyDescriptor); + + SingleLogoutService singleLogoutService = new SingleLogoutServiceBuilder().buildObject(); + singleLogoutService.setBinding(SAMLConstants.SAML2_POST_BINDING_URI); + singleLogoutService.setLocation(spEntityID + "saml2sp/logout"); + singleLogoutService.setResponseLocation(spEntityID + "saml2sp/logout"); + spSSODescriptor.getSingleLogoutServices().add(singleLogoutService); + + NameIDFormat nameIDFormat = new NameIDFormatBuilder().buildObject(); + nameIDFormat.setFormat(NameIDType.PERSISTENT); + spSSODescriptor.getNameIDFormats().add(nameIDFormat); + nameIDFormat = new NameIDFormatBuilder().buildObject(); + nameIDFormat.setFormat(NameIDType.TRANSIENT); + spSSODescriptor.getNameIDFormats().add(nameIDFormat); + + AssertionConsumerService assertionConsumerService = new AssertionConsumerServiceBuilder().buildObject(); + assertionConsumerService.setIndex(0); + assertionConsumerService.setBinding(SAMLConstants.SAML2_POST_BINDING_URI); + assertionConsumerService.setLocation(spEntityID + "saml2sp/assertion-consumer"); + + spSSODescriptor.getAssertionConsumerServices().add(assertionConsumerService); + spSSODescriptor.addSupportedProtocol(SAMLConstants.SAML20P_NS); + + spEntityDescriptor.getRoleDescriptors().add(spSSODescriptor); + + saml2rw.write(new OutputStreamWriter(os), spEntityDescriptor, true); + } catch (Exception e) { + LOG.error("While getting SP metadata", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + } + + private SAML2IdPEntity getIdP(final String entityID) { + SAML2IdPEntity idp = null; + + SAML2IdP saml2IdP = saml2IdPDAO.findByEntityID(entityID); + if (saml2IdP != null) { + try { + idp = cache.put(saml2IdP); + } catch (Exception e) { + LOG.error("Could not build SAML 2.0 IdP with key ", entityID, e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + } + + if (idp == null) { + throw new NotFoundException("SAML 2.0 IdP '" + entityID + "'"); + } + return idp; + } + + @PreAuthorize("hasRole('" + StandardEntitlement.ANONYMOUS + "')") + public SAML2RequestTO createLoginRequest( + final String spEntityID, final String idpEntityID) { + + // 1. look for IdP + SAML2IdPEntity idp = StringUtils.isBlank(idpEntityID) ? cache.getFirst() : cache.get(idpEntityID); + if (idp == null) { + if (StringUtils.isBlank(idpEntityID)) { + List<SAML2IdP> all = saml2IdPDAO.findAll(); + if (!all.isEmpty()) { + idp = getIdP(all.get(0).getKey()); + } + } else { + idp = getIdP(idpEntityID); + } + } + if (idp == null) { + throw new NotFoundException(StringUtils.isBlank(idpEntityID) + ? "Any SAML 2.0 IdP" + : "SAML 2.0 IdP '" + idpEntityID + "'"); + } + + // 2. create AuthnRequest + Issuer issuer = new IssuerBuilder().buildObject(); + issuer.setValue(spEntityID); + + NameIDPolicy nameIDPolicy = new NameIDPolicyBuilder().buildObject(); + if (idp.supportsNameIDFormat(NameIDType.TRANSIENT)) { + nameIDPolicy.setFormat(NameIDType.TRANSIENT); + } else if (idp.supportsNameIDFormat(NameIDType.PERSISTENT)) { + nameIDPolicy.setFormat(NameIDType.PERSISTENT); + } else { + throw new IllegalArgumentException("Could not find supported NameIDFormat for IdP " + idpEntityID); + } + nameIDPolicy.setAllowCreate(true); + nameIDPolicy.setSPNameQualifier(spEntityID); + + AuthnContextClassRef authnContextClassRef = new AuthnContextClassRefBuilder().buildObject(); + authnContextClassRef.setAuthnContextClassRef(AuthnContext.PPT_AUTHN_CTX); + RequestedAuthnContext requestedAuthnContext = new RequestedAuthnContextBuilder().buildObject(); + requestedAuthnContext.setComparison(AuthnContextComparisonTypeEnumeration.EXACT); + requestedAuthnContext.getAuthnContextClassRefs().add(authnContextClassRef); + + AuthnRequest authnRequest = new AuthnRequestBuilder().buildObject(); + authnRequest.setID("_" + UUID_GENERATOR.generate().toString()); + authnRequest.setAssertionConsumerServiceURL(spEntityID + "saml2sp/assertion-consumer"); + authnRequest.setForceAuthn(false); + authnRequest.setIsPassive(false); + authnRequest.setVersion(SAMLVersion.VERSION_20); + authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI); + authnRequest.setIssueInstant(new DateTime()); + authnRequest.setIssuer(issuer); + authnRequest.setNameIDPolicy(nameIDPolicy); + authnRequest.setRequestedAuthnContext(requestedAuthnContext); + authnRequest.setDestination(idp.getSSOLocation(SAMLConstants.SAML2_POST_BINDING_URI).getLocation()); + + SAML2RequestTO requestTO = new SAML2RequestTO(); + requestTO.setIdpServiceAddress(authnRequest.getDestination()); + try { + // 3. sign and encode AuthnRequest + requestTO.setContent(saml2Signer.signAndEncode(authnRequest, idp.isUseDeflateEncoding())); + + // 4. generate relay state as JWT + Map<String, Object> claims = new HashMap<>(); + claims.put(JWT_CLAIM_IDP_DEFLATE, idp.isUseDeflateEncoding()); + Triple<String, String, Date> relayState = + accessTokenDataBinder.generateJWT(authnRequest.getID(), JWT_RELAY_STATE_DURATION, claims); + requestTO.setRelayState(relayState.getMiddle()); + } catch (Exception e) { + LOG.error("While generating AuthnRequest", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + return requestTO; + } + + private List<String> findMatchingUser(final String keyValue, final MappingItemTO connObjectKeyItem) { + List<String> result = new ArrayList<>(); + + String transformed = keyValue; + for (MappingItemTransformer transformer : MappingUtils.getMappingItemTransformers(connObjectKeyItem)) { + List<Object> output = transformer.beforePull( + null, + null, + Collections.<Object>singletonList(transformed)); + if (output != null && !output.isEmpty()) { + transformed = output.get(0).toString(); + } + } + + IntAttrName intAttrName = intAttrNameParser.parse(connObjectKeyItem.getIntAttrName(), AnyTypeKind.USER); + + if (intAttrName.getField() != null) { + switch (intAttrName.getField()) { + case "key": + User byKey = userDAO.find(transformed); + if (byKey != null) { + result.add(byKey.getKey()); + } + break; + + case "username": + User byUsername = userDAO.findByUsername(transformed); + if (byUsername != null) { + result.add(byUsername.getKey()); + } + break; + + default: + } + } else if (intAttrName.getSchemaType() != null) { + switch (intAttrName.getSchemaType()) { + case PLAIN: + PlainAttrValue value = entityFactory.newEntity(UPlainAttrValue.class); + + PlainSchema schema = plainSchemaDAO.find(intAttrName.getSchemaName()); + if (schema == null) { + value.setStringValue(transformed); + } else { + try { + value.parseValue(schema, transformed); + } catch (ParsingValidationException e) { + LOG.error("While parsing provided key value {}", transformed, e); + value.setStringValue(transformed); + } + } + + CollectionUtils.collect(userDAO.findByAttrValue(intAttrName.getSchemaName(), value), + EntityUtils.keyTransformer(), result); + break; + + case DERIVED: + CollectionUtils.collect(userDAO.findByDerAttrValue(intAttrName.getSchemaName(), transformed), + EntityUtils.keyTransformer(), result); + break; + + default: + } + } + + return result; + } + + private Pair<String, String> extract(final InputStream response) throws IOException { + String strForm = IOUtils.toString(response); + MultivaluedMap<String, String> params = JAXRSUtils.getStructuredParams(strForm, "&", false, false); + + String samlResponse = URLDecoder.decode( + params.getFirst(SSOConstants.SAML_RESPONSE), StandardCharsets.UTF_8.name()); + LOG.debug("Received SAML Response: {}", samlResponse); + + String relayState = params.getFirst(SSOConstants.RELAY_STATE); + LOG.debug("Received Relay State: {}", relayState); + + return Pair.of(samlResponse, relayState); + } + + @PreAuthorize("hasRole('" + StandardEntitlement.ANONYMOUS + "')") + public SAML2LoginResponseTO validateLoginResponse(final InputStream response) { + // 1. extract raw SAML response and relay state + Pair<String, String> extracted; + try { + extracted = extract(response); + } catch (Exception e) { + LOG.error("While reading AuthnResponse", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + // 2. first checks for the provided relay state + JwsJwtCompactConsumer relayState = new JwsJwtCompactConsumer(extracted.getRight()); + if (!relayState.verifySignatureWith(jwsSignatureCerifier)) { + throw new IllegalArgumentException("Invalid signature found in Relay State"); + } + Boolean useDeflateEncoding = Boolean.valueOf( + relayState.getJwtClaims().getClaim(JWT_CLAIM_IDP_DEFLATE).toString()); + + // 3. parse the provided SAML response + Response samlResponse; + try { + XMLObject responseObject = saml2rw.read(true, useDeflateEncoding, extracted.getLeft()); + if (!(responseObject instanceof Response)) { + throw new IllegalArgumentException("Expected " + Response.class.getName() + + ", got " + responseObject.getClass().getName()); + } + samlResponse = (Response) responseObject; + } catch (Exception e) { + LOG.error("While parsing AuthnResponse", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + // 4. further checks: + // 4a. the SAML Reponse's InResponseTo + if (!relayState.getJwtClaims().getSubject().equals(samlResponse.getInResponseTo())) { + throw new IllegalArgumentException("Unmatching request ID: " + samlResponse.getInResponseTo()); + } + // 4b. the SAML Response status + if (!StatusCode.SUCCESS.equals(samlResponse.getStatus().getStatusCode().getValue())) { + throw new BadCredentialsException("The SAML IdP replied with " + + samlResponse.getStatus().getStatusCode().getValue()); + } + + // 5. validate the SAML response and, if needed, decrypt the provided assertion(s) + SAML2IdPEntity idp = getIdP(samlResponse.getIssuer().getValue()); + if (idp.getConnObjectKeyItem() == null) { + throw new IllegalArgumentException("No mapping provided for SAML 2.0 IdP '" + idp.getId() + "'"); + } + try { + saml2rw.validate(samlResponse, idp.getTrustStore()); + } catch (Exception e) { + LOG.error("While validating AuthnResponse", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + // 6. prepare the result: find matching user (if any) and return the received attributes + SAML2LoginResponseTO responseTO = new SAML2LoginResponseTO(); + + NameID nameID = null; + String keyValue = null; + for (Assertion assertion : samlResponse.getAssertions()) { + nameID = assertion.getSubject().getNameID(); + if (StringUtils.isNotBlank(nameID.getValue()) + && idp.getConnObjectKeyItem().getExtAttrName().equals("NameID")) { + + keyValue = nameID.getValue(); + } + + if (assertion.getConditions().getNotOnOrAfter() != null) { + responseTO.setNotOnOrAfter(assertion.getConditions().getNotOnOrAfter().toDate()); + } + for (AuthnStatement authnStmt : assertion.getAuthnStatements()) { + responseTO.setSessionIndex(authnStmt.getSessionIndex()); + + responseTO.setAuthInstant(authnStmt.getAuthnInstant().toDate()); + if (authnStmt.getSessionNotOnOrAfter() != null) { + responseTO.setNotOnOrAfter(authnStmt.getSessionNotOnOrAfter().toDate()); + } + } + + for (AttributeStatement attrStmt : assertion.getAttributeStatements()) { + for (Attribute attr : attrStmt.getAttributes()) { + if (!attr.getAttributeValues().isEmpty()) { + String attrName = attr.getFriendlyName() == null ? attr.getName() : attr.getFriendlyName(); + if (attrName.equals(idp.getConnObjectKeyItem().getExtAttrName()) + && attr.getAttributeValues().get(0) instanceof XSString) { + + keyValue = ((XSString) attr.getAttributeValues().get(0)).getValue(); + } + + AttrTO attrTO = new AttrTO(); + attrTO.setSchema(attrName); + for (XMLObject value : attr.getAttributeValues()) { + if (value.getDOM() != null) { + attrTO.getValues().add(value.getDOM().getTextContent()); + } + } + responseTO.getAttrs().add(attrTO); + } + } + } + } + if (nameID == null) { + throw new IllegalArgumentException("NameID not found"); + } + + List<String> matchingUsers = keyValue == null + ? Collections.<String>emptyList() + : findMatchingUser(keyValue, idp.getConnObjectKeyItem()); + LOG.debug("Found {} matching users for NameID {}", matchingUsers.size(), nameID.getValue()); + + if (matchingUsers.isEmpty()) { + throw new NotFoundException("User matching the provided NameID value " + nameID.getValue()); + } else if (matchingUsers.size() > 1) { + throw new IllegalArgumentException("Several users match the provided NameID value " + nameID.getValue()); + } + responseTO.setUsername(userDAO.find(matchingUsers.get(0)).getUsername()); + + responseTO.setNameID(nameID.getValue()); + // 7. generate JWT for further access + Map<String, Object> claims = new HashMap<>(); + claims.put(JWT_CLAIM_IDP_ENTITYID, idp.getId()); + claims.put(JWT_CLAIM_NAMEID_FORMAT, nameID.getFormat()); + claims.put(JWT_CLAIM_NAMEID_VALUE, nameID.getValue()); + claims.put(JWT_CLAIM_SESSIONINDEX, responseTO.getSessionIndex()); + responseTO.setAccessToken(accessTokenDataBinder.create(responseTO.getUsername(), claims, true)); + + return responseTO; + } + + @PreAuthorize("isAuthenticated() and not(hasRole('" + StandardEntitlement.ANONYMOUS + "'))") + public SAML2RequestTO createLogoutRequest(final String accessToken, final String spEntityID) { + // 1. fetch the current JWT used for Syncope authentication + JwsJwtCompactConsumer consumer = new JwsJwtCompactConsumer(accessToken); + if (!consumer.verifySignatureWith(jwsSignatureCerifier)) { + throw new IllegalArgumentException("Invalid signature found in Access Token"); + } + + // 2. look for IdP + String idpEntityID = (String) consumer.getJwtClaims().getClaim(JWT_CLAIM_IDP_ENTITYID); + SAML2IdPEntity idp = cache.get(idpEntityID); + if (idp == null) { + throw new NotFoundException("SAML 2.0 IdP '" + idpEntityID + "'"); + } + if (idp.getSLOLocation(SAMLConstants.SAML2_POST_BINDING_URI) == null) { + throw new IllegalArgumentException("No SingleLogoutService available for " + idp.getId()); + } + + // 3. create LogoutRequest + LogoutRequest logoutRequest = new LogoutRequestBuilder().buildObject(); + logoutRequest.setID("_" + UUID_GENERATOR.generate().toString()); + logoutRequest.setDestination(idp.getSLOLocation(SAMLConstants.SAML2_POST_BINDING_URI).getLocation()); + + DateTime now = new DateTime(); + logoutRequest.setIssueInstant(now); + logoutRequest.setNotOnOrAfter(now.plusMinutes(5)); + + Issuer issuer = new IssuerBuilder().buildObject(); + issuer.setValue(spEntityID); + logoutRequest.setIssuer(issuer); + + NameID nameID = new NameIDBuilder().buildObject(); + nameID.setFormat((String) consumer.getJwtClaims().getClaim(JWT_CLAIM_NAMEID_FORMAT)); + nameID.setValue((String) consumer.getJwtClaims().getClaim(JWT_CLAIM_NAMEID_VALUE)); + logoutRequest.setNameID(nameID); + + SessionIndex sessionIndex = new SessionIndexBuilder().buildObject(); + sessionIndex.setSessionIndex((String) consumer.getJwtClaims().getClaim(JWT_CLAIM_SESSIONINDEX)); + logoutRequest.getSessionIndexes().add(sessionIndex); + + SAML2RequestTO requestTO = new SAML2RequestTO(); + requestTO.setIdpServiceAddress(logoutRequest.getDestination()); + try { + // 3. sign and encode LogoutRequest + requestTO.setContent(saml2Signer.signAndEncode(logoutRequest, idp.isUseDeflateEncoding())); + + // 4. generate relay state as JWT + Map<String, Object> claims = new HashMap<>(); + claims.put(JWT_CLAIM_IDP_DEFLATE, idp.isUseDeflateEncoding()); + Triple<String, String, Date> relayState = + accessTokenDataBinder.generateJWT(logoutRequest.getID(), JWT_RELAY_STATE_DURATION, claims); + requestTO.setRelayState(relayState.getMiddle()); + } catch (Exception e) { + LOG.error("While generating LogoutRequest", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + return requestTO; + } + + @PreAuthorize("isAuthenticated() and not(hasRole('" + StandardEntitlement.ANONYMOUS + "'))") + public void validateLogoutResponse(final String accessToken, final InputStream response) { + // 1. fetch the current JWT used for Syncope authentication + JwsJwtCompactConsumer consumer = new JwsJwtCompactConsumer(accessToken); + if (!consumer.verifySignatureWith(jwsSignatureCerifier)) { + throw new IllegalArgumentException("Invalid signature found in Access Token"); + } + + // 2. extract raw SAML response and relay state + Pair<String, String> extracted; + try { + extracted = extract(response); + } catch (Exception e) { + LOG.error("While reading LogoutResponse", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + JwsJwtCompactConsumer relayState = null; + Boolean useDeflateEncoding = false; + if (StringUtils.isNotBlank(extracted.getRight())) { + // first checks for the provided relay state, if available + relayState = new JwsJwtCompactConsumer(extracted.getRight()); + if (!relayState.verifySignatureWith(jwsSignatureCerifier)) { + throw new IllegalArgumentException("Invalid signature found in Relay State"); + } + useDeflateEncoding = Boolean.valueOf( + relayState.getJwtClaims().getClaim(JWT_CLAIM_IDP_DEFLATE).toString()); + } + + // 3. parse the provided SAML response + LogoutResponse logoutResponse; + try { + XMLObject responseObject = saml2rw.read(true, useDeflateEncoding, extracted.getLeft()); + if (!(responseObject instanceof LogoutResponse)) { + throw new IllegalArgumentException("Expected " + LogoutResponse.class.getName() + + ", got " + responseObject.getClass().getName()); + } + logoutResponse = (LogoutResponse) responseObject; + } catch (Exception e) { + LOG.error("While parsing LogoutResponse", e); + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + sce.getElements().add(e.getMessage()); + throw sce; + } + + // 4. if relay state was available, check the SAML Reponse's InResponseTo + if (relayState != null && !relayState.getJwtClaims().getSubject().equals(logoutResponse.getInResponseTo())) { + throw new IllegalArgumentException("Unmatching request ID: " + logoutResponse.getInResponseTo()); + } + + // 5. finally check for the logout status + if (StatusCode.SUCCESS.equals(logoutResponse.getStatus().getStatusCode().getValue())) { + accessTokenDAO.delete(consumer.getJwtClaims().getTokenId()); + } else { + SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown); + if (logoutResponse.getStatus().getStatusMessage() == null) { + sce.getElements().add(logoutResponse.getStatus().getStatusCode().getValue()); + } else { + sce.getElements().add(logoutResponse.getStatus().getStatusMessage().getMessage()); + } + throw sce; + } + } + + @Override + protected AbstractBaseBean resolveReference( + final Method method, final Object... args) throws UnresolvedReferenceException { + + throw new UnresolvedReferenceException(); + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/init/SAML2SPLoader.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/init/SAML2SPLoader.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/init/SAML2SPLoader.java new file mode 100644 index 0000000..dee85ef --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/init/SAML2SPLoader.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic.init; + +import java.io.File; +import java.io.InputStream; +import java.security.KeyStore; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import net.shibboleth.utilities.java.support.resolver.CriteriaSet; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.syncope.core.persistence.api.SyncopeLoader; +import org.apache.syncope.core.provisioning.api.EntitlementsHolder; +import org.apache.syncope.common.lib.types.SAML2SPEntitlement; +import org.apache.syncope.core.spring.ApplicationContextProvider; +import org.apache.syncope.core.spring.ResourceWithFallbackLoader; +import org.opensaml.core.criterion.EntityIdCriterion; +import org.opensaml.security.credential.Credential; +import org.opensaml.security.credential.impl.KeyStoreCredentialResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class SAML2SPLoader implements SyncopeLoader { + + private static final Logger LOG = LoggerFactory.getLogger(SAML2SPLoader.class); + + private static final String SAML2SP_LOGIC_PROPERTIES = "saml2sp-logic.properties"; + + private static <T> T assertNotNull(final T argument, final String name) { + if (argument == null) { + throw new IllegalArgumentException("Argument '" + name + "' may not be null."); + } + return argument; + } + + private KeyStore keystore; + + private String keyPass; + + private Credential credential; + + @Override + public Integer getPriority() { + return 1000; + } + + @Override + public void load() { + EntitlementsHolder.getInstance().init(SAML2SPEntitlement.values()); + + String confDirectory = null; + + Properties props = new Properties(); + try (InputStream is = getClass().getResourceAsStream("/" + SAML2SP_LOGIC_PROPERTIES)) { + props.load(is); + confDirectory = props.getProperty("conf.directory"); + + File confDir = new File(confDirectory); + if (confDir.exists() && confDir.canRead() && confDir.isDirectory()) { + File confDirProps = FileUtils.getFile(confDir, SAML2SP_LOGIC_PROPERTIES); + if (confDirProps.exists() && confDirProps.canRead() && confDirProps.isFile()) { + props.clear(); + props.load(FileUtils.openInputStream(confDirProps)); + confDirectory = props.getProperty("conf.directory"); + } + } + } catch (Exception e) { + throw new RuntimeException("Could not read " + SAML2SP_LOGIC_PROPERTIES, e); + } + + assertNotNull(confDirectory, "<conf.directory>"); + + String name = props.getProperty("keystore.name"); + assertNotNull(name, "<keystore.name>"); + String type = props.getProperty("keystore.type"); + assertNotNull(type, "<keystore.type>"); + String storePass = props.getProperty("keystore.storepass"); + assertNotNull(storePass, "<keystore.storepass>"); + keyPass = props.getProperty("keystore.keypass"); + assertNotNull(keyPass, "<keystore.keypass>"); + String certAlias = props.getProperty("sp.cert.alias"); + assertNotNull(certAlias, "<sp.cert.alias>"); + + LOG.debug("Attempting to load the provided keystore..."); + try { + ResourceWithFallbackLoader loader = new ResourceWithFallbackLoader(); + loader.setResourceLoader(ApplicationContextProvider.getApplicationContext()); + loader.setPrimary(StringUtils.appendIfMissing("file:" + confDirectory, "/") + name); + loader.setFallback("classpath:" + name); + + keystore = KeyStore.getInstance(type); + try (InputStream inputStream = loader.getResource().getInputStream()) { + keystore.load(inputStream, storePass.toCharArray()); + LOG.debug("Keystore loaded"); + } + + Map<String, String> passwordMap = new HashMap<>(); + passwordMap.put(certAlias, keyPass); + KeyStoreCredentialResolver resolver = new KeyStoreCredentialResolver(keystore, passwordMap); + + this.credential = resolver.resolveSingle(new CriteriaSet(new EntityIdCriterion(certAlias))); + LOG.debug("SAML 2.0 Service Provider certificate loaded"); + } catch (Exception e) { + throw new RuntimeException("Could not initialize the SAML 2.0 Service Provider certificate", e); + } + } + + public KeyStore getKeyStore() { + return keystore; + } + + public String getKeyPass() { + return keyPass; + } + + public Credential getCredential() { + return credential; + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCache.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCache.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCache.java new file mode 100644 index 0000000..21e185d --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCache.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic.saml2; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import net.shibboleth.utilities.java.support.xml.XMLParserException; +import org.apache.syncope.common.lib.to.MappingItemTO; +import org.apache.syncope.core.logic.init.SAML2SPLoader; +import org.apache.syncope.core.persistence.api.entity.SAML2IdP; +import org.apache.syncope.core.provisioning.api.data.SAML2IdPDataBinder; +import org.apache.wss4j.common.ext.WSSecurityException; +import org.apache.wss4j.common.saml.OpenSAMLUtil; +import org.opensaml.saml.saml2.metadata.EntityDescriptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.w3c.dom.Element; + +/** + * Basic in-memory cache for available {@link SAML2IdPEntity} identity providers. + */ +@Component +public class SAML2IdPCache { + + private final Map<String, SAML2IdPEntity> cache = + Collections.synchronizedMap(new HashMap<String, SAML2IdPEntity>()); + + @Autowired + private SAML2SPLoader loader; + + @Autowired + private SAML2IdPDataBinder binder; + + public SAML2IdPEntity get(final String entityID) { + return cache.get(entityID); + } + + public SAML2IdPEntity getFirst() { + return cache.isEmpty() ? null : cache.entrySet().iterator().next().getValue(); + } + + public SAML2IdPEntity put( + final EntityDescriptor entityDescriptor, + final MappingItemTO connObjectKeyItem, + final boolean useDeflateEncoding) + throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException { + + return cache.put(entityDescriptor.getEntityID(), + new SAML2IdPEntity(entityDescriptor, connObjectKeyItem, useDeflateEncoding, loader.getKeyPass())); + } + + @Transactional(readOnly = true) + public SAML2IdPEntity put(final SAML2IdP idp) + throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException, WSSecurityException, + XMLParserException { + + Element element = OpenSAMLUtil.getParserPool().parse( + new InputStreamReader(new ByteArrayInputStream(idp.getMetadata()))).getDocumentElement(); + EntityDescriptor entityDescriptor = (EntityDescriptor) OpenSAMLUtil.fromDom(element); + return put(entityDescriptor, binder.getIdPTO(idp).getConnObjectKeyItem(), idp.isUseDeflateEncoding()); + } + + public SAML2IdPEntity remove(final String entityID) { + return cache.remove(entityID); + } +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCallbackHandler.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCallbackHandler.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCallbackHandler.java new file mode 100644 index 0000000..17cf6f0 --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPCallbackHandler.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic.saml2; + +import java.io.IOException; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import org.apache.wss4j.common.ext.WSPasswordCallback; + +public class SAML2IdPCallbackHandler implements CallbackHandler { + + private final String keyPass; + + public SAML2IdPCallbackHandler(final String keyPass) { + this.keyPass = keyPass; + } + + @Override + public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof WSPasswordCallback) { + WSPasswordCallback wspc = (WSPasswordCallback) callback; + wspc.setPassword(keyPass); + } + } + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPEntity.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPEntity.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPEntity.java new file mode 100644 index 0000000..35eacaf --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2IdPEntity.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic.saml2; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.codec.binary.Base64; +import org.apache.syncope.common.lib.to.MappingItemTO; +import org.opensaml.saml.common.xml.SAMLConstants; +import org.opensaml.saml.saml2.metadata.Endpoint; +import org.opensaml.saml.saml2.metadata.EntityDescriptor; +import org.opensaml.saml.saml2.metadata.IDPSSODescriptor; +import org.opensaml.saml.saml2.metadata.KeyDescriptor; +import org.opensaml.saml.saml2.metadata.NameIDFormat; +import org.opensaml.saml.saml2.metadata.SingleLogoutService; +import org.opensaml.saml.saml2.metadata.SingleSignOnService; +import org.opensaml.xmlsec.signature.X509Data; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SAML2IdPEntity { + + private static final Logger LOG = LoggerFactory.getLogger(SAML2IdPEntity.class); + + private final String id; + + private boolean useDeflateEncoding; + + private MappingItemTO connObjectKeyItem; + + private final Map<String, Endpoint> ssoBindings = new HashMap<>(); + + private final Map<String, SingleLogoutService> sloBindings = new HashMap<>(); + + private final List<String> nameIDFormats = new ArrayList<>(); + + private final KeyStore trustStore; + + public SAML2IdPEntity( + final EntityDescriptor entityDescriptor, + final MappingItemTO connObjectKeyItem, + final boolean useDeflateEncoding, + final String keyPass) + throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException { + + this.id = entityDescriptor.getEntityID(); + this.connObjectKeyItem = connObjectKeyItem; + this.useDeflateEncoding = useDeflateEncoding; + + IDPSSODescriptor idpdescriptor = entityDescriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS); + + for (SingleSignOnService sso : idpdescriptor.getSingleSignOnServices()) { + LOG.debug("[{}] Add SSO binding {}({})", id, sso.getBinding(), sso.getLocation()); + this.ssoBindings.put(sso.getBinding(), sso); + } + + for (SingleLogoutService slo : idpdescriptor.getSingleLogoutServices()) { + LOG.debug("[{}] Add SLO binding '{}'\n\tLocation: '{}'\n\tResponse Location: '{}'", + id, slo.getBinding(), slo.getLocation(), slo.getResponseLocation()); + this.sloBindings.put(slo.getBinding(), slo); + } + + for (NameIDFormat nameIDFormat : idpdescriptor.getNameIDFormats()) { + LOG.debug("[{}] Add NameIDFormat '{}'", id, nameIDFormat.getFormat()); + nameIDFormats.add(nameIDFormat.getFormat()); + } + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + + List<X509Certificate> chain = new ArrayList<>(); + for (KeyDescriptor key : idpdescriptor.getKeyDescriptors()) { + for (X509Data x509Data : key.getKeyInfo().getX509Datas()) { + for (org.opensaml.xmlsec.signature.X509Certificate cert : x509Data.getX509Certificates()) { + try (ByteArrayInputStream bais = new ByteArrayInputStream(Base64.decodeBase64(cert.getValue()))) { + chain.add(X509Certificate.class.cast(cf.generateCertificate(bais))); + } + } + } + } + + this.trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + this.trustStore.load(null, keyPass.toCharArray()); + if (!chain.isEmpty()) { + for (X509Certificate cert : chain) { + LOG.debug("[{}] Add X.509 certificate {}", id, cert.getSubjectX500Principal().getName()); + this.trustStore.setCertificateEntry(cert.getSubjectX500Principal().getName(), cert); + } + LOG.debug("[{}] Set default X.509 certificate {}", id, chain.get(0).getSubjectX500Principal().getName()); + this.trustStore.setCertificateEntry(id, chain.get(0)); + } + } + + public String getId() { + return id; + } + + public boolean isUseDeflateEncoding() { + return useDeflateEncoding; + } + + public void setUseDeflateEncoding(final boolean useDeflateEncoding) { + this.useDeflateEncoding = useDeflateEncoding; + } + + public MappingItemTO getConnObjectKeyItem() { + return connObjectKeyItem; + } + + public void setConnObjectKeyItem(final MappingItemTO connObjectKeyItem) { + this.connObjectKeyItem = connObjectKeyItem; + } + + public Endpoint getSSOLocation(final String binding) { + return ssoBindings.get(binding); + } + + public Endpoint getSLOLocation(final String binding) { + return sloBindings.get(binding); + } + + public boolean supportsNameIDFormat(final String nameIDFormat) { + return nameIDFormats.contains(nameIDFormat); + } + + public KeyStore getTrustStore() { + return trustStore; + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java new file mode 100644 index 0000000..23b3a38 --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2ReaderWriter.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic.saml2; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.io.Writer; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.zip.DataFormatException; +import javax.xml.stream.XMLStreamException; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.apache.commons.codec.binary.Base64; +import org.apache.cxf.rs.security.saml.DeflateEncoderDecoder; +import org.apache.cxf.rs.security.saml.sso.SAMLProtocolResponseValidator; +import org.apache.cxf.staxutils.StaxUtils; +import org.apache.syncope.core.logic.init.SAML2SPLoader; +import org.apache.wss4j.common.crypto.Merlin; +import org.apache.wss4j.common.ext.WSSecurityException; +import org.apache.wss4j.common.saml.OpenSAMLUtil; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.saml.saml2.core.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; + +@Component +public class SAML2ReaderWriter implements InitializingBean { + + private static final Logger LOG = LoggerFactory.getLogger(SAML2ReaderWriter.class); + + private static final TransformerFactory TRANSFORMER_FACTORY = TransformerFactory.newInstance(); + + static { + OpenSAMLUtil.initSamlEngine(false); + } + + @Autowired + private SAML2SPLoader loader; + + private SAMLProtocolResponseValidator protocolValidator; + + private SAML2IdPCallbackHandler callbackHandler; + + @Override + public void afterPropertiesSet() throws Exception { + protocolValidator = new SAMLProtocolResponseValidator(); + protocolValidator.setKeyInfoMustBeAvailable(true); + + callbackHandler = new SAML2IdPCallbackHandler(loader.getKeyPass()); + } + + public void write(final Writer writer, final XMLObject object, final boolean signObject) + throws TransformerConfigurationException, WSSecurityException, TransformerException { + + Transformer transformer = TRANSFORMER_FACTORY.newTransformer(); + StreamResult streamResult = new StreamResult(writer); + DOMSource source = new DOMSource(OpenSAMLUtil.toDom(object, null, signObject)); + transformer.transform(source, streamResult); + } + + public XMLObject read(final boolean postBinding, final boolean useDeflateEncoding, final String response) + throws DataFormatException, UnsupportedEncodingException, XMLStreamException, WSSecurityException { + + String decodedResponse = response; + // URL Decoding only applies for the redirect binding + if (!postBinding) { + decodedResponse = URLDecoder.decode(response, StandardCharsets.UTF_8.name()); + } + + InputStream tokenStream; + byte[] deflatedToken = Base64.decodeBase64(decodedResponse); + tokenStream = !postBinding && useDeflateEncoding + ? new DeflateEncoderDecoder().inflateToken(deflatedToken) + : new ByteArrayInputStream(deflatedToken); + + // parse the provided SAML response + Document responseDoc = StaxUtils.read(new InputStreamReader(tokenStream, StandardCharsets.UTF_8)); + XMLObject responseObject = OpenSAMLUtil.fromDom(responseDoc.getDocumentElement()); + + if (LOG.isDebugEnabled()) { + try { + StringWriter writer = new StringWriter(); + write(writer, responseObject, false); + writer.close(); + + LOG.debug("Parsed SAML response: {}", writer.toString()); + } catch (Exception e) { + LOG.error("Could not log the received SAML response", e); + } + } + + return responseObject; + } + + public void validate(final Response samlResponse, final KeyStore idpTrustStore) throws WSSecurityException { + // validate the SAML response and, if needed, decrypt the provided assertion(s) + Merlin crypto = new Merlin(); + crypto.setKeyStore(loader.getKeyStore()); + crypto.setTrustStore(idpTrustStore); + + protocolValidator.validateSamlResponse(samlResponse, crypto, callbackHandler); + + if (LOG.isDebugEnabled()) { + try { + StringWriter writer = new StringWriter(); + write(writer, samlResponse, false); + writer.close(); + + LOG.debug("SAML response with decrypted assertions: {}", writer.toString()); + } catch (Exception e) { + LOG.error("Could not log the SAML response with decrypted assertions", e); + } + } + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2Signer.java ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2Signer.java b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2Signer.java new file mode 100644 index 0000000..9a03627 --- /dev/null +++ b/ext/saml2sp/logic/src/main/java/org/apache/syncope/core/logic/saml2/SAML2Signer.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 + * + * 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.apache.syncope.core.logic.saml2; + +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import javax.xml.transform.TransformerException; +import org.apache.commons.codec.binary.Base64; +import org.apache.cxf.rs.security.saml.DeflateEncoderDecoder; +import org.apache.syncope.core.logic.init.SAML2SPLoader; +import org.apache.wss4j.common.ext.WSSecurityException; +import org.apache.wss4j.common.saml.OpenSAMLUtil; +import org.opensaml.saml.common.SignableSAMLObject; +import org.opensaml.saml.saml2.core.RequestAbstractType; +import org.opensaml.security.SecurityException; +import org.opensaml.xmlsec.keyinfo.KeyInfoGenerator; +import org.opensaml.xmlsec.keyinfo.impl.X509KeyInfoGeneratorFactory; +import org.opensaml.xmlsec.signature.Signature; +import org.opensaml.xmlsec.signature.support.SignatureConstants; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class SAML2Signer implements InitializingBean { + + static { + OpenSAMLUtil.initSamlEngine(false); + } + + @Autowired + private SAML2SPLoader loader; + + @Autowired + private SAML2ReaderWriter saml2rw; + + private KeyInfoGenerator keyInfoGenerator; + + private String signatureAlgorithm; + + @Override + public void afterPropertiesSet() throws Exception { + X509KeyInfoGeneratorFactory keyInfoGeneratorFactory = new X509KeyInfoGeneratorFactory(); + keyInfoGeneratorFactory.setEmitEntityCertificate(true); + keyInfoGenerator = keyInfoGeneratorFactory.newInstance(); + + signatureAlgorithm = SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA1; + String pubKeyAlgo = loader.getCredential().getPublicKey().getAlgorithm(); + if (pubKeyAlgo.equalsIgnoreCase("DSA")) { + signatureAlgorithm = SignatureConstants.ALGO_ID_SIGNATURE_DSA_SHA1; + } + } + + public String signAndEncode(final RequestAbstractType request, final boolean useDeflateEncoding) + throws SecurityException, WSSecurityException, TransformerException, IOException { + + // 1. sign request + Signature signature = OpenSAMLUtil.buildSignature(); + signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS); + signature.setSignatureAlgorithm(signatureAlgorithm); + signature.setSigningCredential(loader.getCredential()); + signature.setKeyInfo(keyInfoGenerator.generate(loader.getCredential())); + + SignableSAMLObject signableObject = (SignableSAMLObject) request; + signableObject.setSignature(signature); + signableObject.releaseDOM(); + signableObject.releaseChildrenDOM(true); + + // 2. serialize and encode request + StringWriter writer = new StringWriter(); + saml2rw.write(writer, request, true); + writer.close(); + + String requestMessage = writer.toString(); + byte[] deflatedBytes; + // not correct according to the spec but required by some IdPs. + if (useDeflateEncoding) { + deflatedBytes = new DeflateEncoderDecoder(). + deflateToken(requestMessage.getBytes(StandardCharsets.UTF_8)); + } else { + deflatedBytes = requestMessage.getBytes(StandardCharsets.UTF_8); + } + + return Base64.encodeBase64String(deflatedBytes); + } + +} http://git-wip-us.apache.org/repos/asf/syncope/blob/d9079e13/ext/saml2sp/logic/src/main/resources/saml2sp-logic.properties ---------------------------------------------------------------------- diff --git a/ext/saml2sp/logic/src/main/resources/saml2sp-logic.properties b/ext/saml2sp/logic/src/main/resources/saml2sp-logic.properties new file mode 100644 index 0000000..2d7e918 --- /dev/null +++ b/ext/saml2sp/logic/src/main/resources/saml2sp-logic.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF 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 +# +# 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. +conf.directory=${conf.directory} + +keystore.name=keystore +keystore.type=jks +keystore.storepass=changeit +keystore.keypass=changeit +sp.cert.alias=sp
