blob: a69e0d0fefa2f6cf31337f2f48222809cec33187 [file] [log] [blame]
/*
* 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.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.lang.reflect.Method;
import java.text.ParseException;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.syncope.common.lib.Attr;
import org.apache.syncope.common.lib.SyncopeClientException;
import org.apache.syncope.common.lib.to.EntityTO;
import org.apache.syncope.common.lib.saml2.SAML2Request;
import org.apache.syncope.common.lib.saml2.SAML2LoginResponse;
import org.apache.syncope.common.lib.saml2.SAML2Response;
import org.apache.syncope.common.lib.to.UserTO;
import org.apache.syncope.common.lib.types.CipherAlgorithm;
import org.apache.syncope.common.lib.types.ClientExceptionType;
import org.apache.syncope.common.lib.types.SAML2BindingType;
import org.apache.syncope.common.lib.types.IdRepoEntitlement;
import org.apache.syncope.core.logic.init.SAML2SP4UILoader;
import org.apache.syncope.core.logic.saml2.NoOpSessionStore;
import org.apache.syncope.core.logic.saml2.SAML2ClientCache;
import org.apache.syncope.core.logic.saml2.SAML2SP4UIContext;
import org.apache.syncope.core.logic.saml2.SAML2SP4UIUserManager;
import org.apache.syncope.core.persistence.api.dao.NotFoundException;
import org.apache.syncope.core.provisioning.api.RequestedAuthnContextProvider;
import org.apache.syncope.core.provisioning.api.data.AccessTokenDataBinder;
import org.opensaml.saml.saml2.core.LogoutResponse;
import org.opensaml.saml.saml2.core.NameID;
import org.opensaml.saml.saml2.core.StatusCode;
import org.pac4j.core.context.session.SessionStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
import org.apache.syncope.core.spring.ImplementationManager;
import org.apache.syncope.core.spring.security.AuthContextUtils;
import org.apache.syncope.core.spring.security.AuthDataAccessor;
import org.apache.syncope.core.spring.security.Encryptor;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.opensaml.saml.saml2.core.RequestedAuthnContext;
import org.opensaml.saml.saml2.metadata.AssertionConsumerService;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.impl.AssertionConsumerServiceBuilder;
import org.pac4j.core.context.WebContext;
import org.pac4j.core.exception.http.RedirectionAction;
import org.pac4j.core.exception.http.WithContentAction;
import org.pac4j.core.exception.http.WithLocationAction;
import org.pac4j.core.logout.NoLogoutActionBuilder;
import org.pac4j.saml.client.SAML2Client;
import org.pac4j.saml.config.SAML2Configuration;
import org.pac4j.saml.context.SAML2MessageContext;
import org.pac4j.saml.credentials.SAML2Credentials;
import org.pac4j.saml.credentials.authenticator.SAML2Authenticator;
import org.pac4j.saml.metadata.SAML2ServiceProviderMetadataResolver;
import org.pac4j.saml.profile.SAML2Profile;
import org.pac4j.saml.redirect.SAML2RedirectionActionBuilder;
import org.pac4j.saml.sso.impl.SAML2AuthnRequestBuilder;
import org.springframework.beans.BeanUtils;
import org.springframework.util.ResourceUtils;
import org.apache.syncope.core.persistence.api.entity.SAML2SP4UIIdPItem;
import org.apache.syncope.core.persistence.api.entity.SAML2SP4UIIdP;
import org.apache.syncope.core.persistence.api.dao.SAML2SP4UIIdPDAO;
@Component
public class SAML2SP4UILogic extends AbstractTransactionalLogic<EntityTO> {
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 Encryptor ENCRYPTOR = Encryptor.getInstance();
@Autowired
private SAML2SP4UILoader loader;
@Autowired
private AccessTokenDataBinder accessTokenDataBinder;
@Autowired
private SAML2ClientCache saml2ClientCache;
@Autowired
private SAML2SP4UIUserManager userManager;
@Autowired
private SAML2SP4UIIdPDAO idpDAO;
@Autowired
private AuthDataAccessor authDataAccessor;
private final Map<String, String> metadataCache = new ConcurrentHashMap<>();
private static String validateUrl(final String url) {
boolean isValid = true;
if (url.contains("..")) {
isValid = false;
}
if (isValid) {
isValid = ResourceUtils.isUrl(url);
}
if (!isValid) {
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
sce.getElements().add("Invalid URL: " + url);
throw sce;
}
return url;
}
private static String getCallbackUrl(final String spEntityID, final String urlContext) {
return validateUrl(spEntityID + urlContext + "/assertion-consumer");
}
@PreAuthorize("isAuthenticated()")
public void getMetadata(final String spEntityID, final String urlContext, final OutputStream os) {
String metadata = metadataCache.get(spEntityID + urlContext);
if (metadata == null) {
SAML2Configuration cfg = loader.newSAML2Configuration();
cfg.setServiceProviderEntityId(spEntityID);
cfg.setCallbackUrl(getCallbackUrl(spEntityID, urlContext));
SAML2ClientCache.getSPMetadataPath(spEntityID).ifPresent(cfg::setServiceProviderMetadataResourceFilepath);
EntityDescriptor entityDescriptor =
(EntityDescriptor) new SAML2ServiceProviderMetadataResolver(cfg).getEntityDescriptorElement();
AssertionConsumerService postACS = entityDescriptor.getSPSSODescriptor(SAMLConstants.SAML20P_NS).
getAssertionConsumerServices().get(0);
AssertionConsumerService redirectACS = new AssertionConsumerServiceBuilder().buildObject();
BeanUtils.copyProperties(postACS, redirectACS);
postACS.setBinding(SAML2BindingType.REDIRECT.getUri());
postACS.setIndex(1);
entityDescriptor.getSPSSODescriptor(SAMLConstants.SAML20P_NS).
getAssertionConsumerServices().add(redirectACS);
entityDescriptor.getSPSSODescriptor(SAMLConstants.SAML20P_NS).getSingleLogoutServices().
removeIf(slo -> !SAML2BindingType.POST.getUri().equals(slo.getBinding())
&& !SAML2BindingType.REDIRECT.getUri().equals(slo.getBinding()));
entityDescriptor.getSPSSODescriptor(SAMLConstants.SAML20P_NS).getSingleLogoutServices().
forEach(slo -> slo.setLocation(
getCallbackUrl(spEntityID, urlContext).replace("/assertion-consumer", "/logout")));
try {
metadata = cfg.toMetadataGenerator().getMetadata(entityDescriptor);
metadataCache.put(spEntityID + urlContext, metadata);
} catch (Exception e) {
LOG.error("While generating SP metadata", e);
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
sce.getElements().add(e.getMessage());
throw sce;
}
}
try (OutputStreamWriter osw = new OutputStreamWriter(os)) {
osw.write(metadata);
} catch (Exception e) {
LOG.error("While getting SP metadata", e);
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
sce.getElements().add(e.getMessage());
throw sce;
}
}
private SAML2Client getSAML2Client(final SAML2SP4UIIdP idp, final String spEntityID, final String urlContext) {
return saml2ClientCache.get(idp.getEntityID(), spEntityID).
orElseGet(() -> saml2ClientCache.add(
idp, loader.newSAML2Configuration(), spEntityID, getCallbackUrl(spEntityID, urlContext)));
}
private SAML2Client getSAML2Client(final String idpEntityID, final String spEntityID, final String urlContext) {
SAML2SP4UIIdP idp = idpDAO.findByEntityID(idpEntityID);
if (idp == null) {
throw new NotFoundException("SAML 2.0 IdP '" + idpEntityID + '\'');
}
return getSAML2Client(idp, spEntityID, urlContext);
}
private static SAML2Request buildRequest(final String idpEntityID, final RedirectionAction action) {
SAML2Request requestTO = new SAML2Request();
requestTO.setIdpEntityID(idpEntityID);
if (action instanceof WithLocationAction) {
WithLocationAction withLocationAction = (WithLocationAction) action;
requestTO.setBindingType(SAML2BindingType.REDIRECT);
requestTO.setContent(withLocationAction.getLocation());
} else if (action instanceof WithContentAction) {
WithContentAction withContentAction = (WithContentAction) action;
requestTO.setBindingType(SAML2BindingType.POST);
requestTO.setContent(Base64.getMimeEncoder().encodeToString(withContentAction.getContent().getBytes()));
}
return requestTO;
}
@PreAuthorize("hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
public SAML2Request createLoginRequest(
final String spEntityID,
final String urlContext,
final String idpEntityID) {
// 0. look for IdP
SAML2SP4UIIdP idp = idpDAO.findByEntityID(idpEntityID);
if (idp == null) {
throw new NotFoundException("SAML 2.0 IdP '" + idpEntityID + '\'');
}
// 1. look for configured client
SAML2Client saml2Client = getSAML2Client(idp, spEntityID, urlContext);
if (idp.getRequestedAuthnContextProvider() != null) {
RequestedAuthnContextProvider requestedAuthnContextProvider = null;
try {
requestedAuthnContextProvider = ImplementationManager.build(idp.getRequestedAuthnContextProvider());
} catch (Exception e) {
LOG.warn("Cannot instantiate '{}', reverting to default behavior",
idp.getRequestedAuthnContextProvider(), e);
}
if (requestedAuthnContextProvider != null) {
RequestedAuthnContext requestedAuthnContext = requestedAuthnContextProvider.get();
saml2Client.setRedirectionActionBuilder(new SAML2RedirectionActionBuilder(saml2Client) {
@Override
public Optional<RedirectionAction> getRedirectionAction(
final WebContext wc, final SessionStore sessionStore) {
this.saml2ObjectBuilder = new SAML2AuthnRequestBuilder() {
@Override
public AuthnRequest build(final SAML2MessageContext context) {
AuthnRequest authnRequest = super.build(context);
authnRequest.setRequestedAuthnContext(requestedAuthnContext);
return authnRequest;
}
};
return super.getRedirectionAction(wc, sessionStore);
}
});
}
}
// 2. create AuthnRequest
SAML2SP4UIContext ctx = new SAML2SP4UIContext(
saml2Client.getConfiguration().getAuthnRequestBindingType(),
null);
RedirectionAction action = saml2Client.getRedirectionAction(ctx, NoOpSessionStore.INSTANCE).
orElseThrow(() -> {
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
sce.getElements().add("No RedirectionAction generated for AuthnRequest");
return sce;
});
return buildRequest(idpEntityID, action);
}
@PreAuthorize("hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
public SAML2LoginResponse validateLoginResponse(final SAML2Response saml2Response) {
// 0. look for IdP
SAML2SP4UIIdP idp = idpDAO.findByEntityID(saml2Response.getIdpEntityID());
if (idp == null) {
throw new NotFoundException("SAML 2.0 IdP '" + saml2Response.getIdpEntityID() + '\'');
}
// 1. look for configured client
SAML2Client saml2Client = getSAML2Client(idp, saml2Response.getSpEntityID(), saml2Response.getUrlContext());
// 2. validate the provided SAML response
SAML2Credentials credentials;
try {
SAML2SP4UIContext ctx = new SAML2SP4UIContext(
saml2Client.getConfiguration().getAuthnRequestBindingType(),
saml2Response);
credentials = (SAML2Credentials) saml2Client.getCredentialsExtractor().
extract(ctx, NoOpSessionStore.INSTANCE).
orElseThrow(() -> new IllegalStateException("No AuthnResponse found"));
saml2Client.getAuthenticator().validate(credentials, ctx, NoOpSessionStore.INSTANCE);
} catch (Exception e) {
LOG.error("While validating AuthnResponse", e);
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
sce.getElements().add(e.getMessage());
throw sce;
}
// 3. prepare the result: find matching user (if any) and return the received attributes
SAML2LoginResponse loginResp = new SAML2LoginResponse();
loginResp.setIdp(saml2Client.getIdentityProviderResolvedEntityId());
loginResp.setSloSupported(!(saml2Client.getLogoutActionBuilder() instanceof NoLogoutActionBuilder));
SAML2Credentials.SAMLNameID nameID = credentials.getNameId();
SAML2SP4UIIdPItem connObjectKeyItem = idp.getConnObjectKeyItem().orElse(null);
String keyValue = null;
if (StringUtils.isNotBlank(nameID.getValue())
&& connObjectKeyItem != null
&& connObjectKeyItem.getExtAttrName().equals(NameID.DEFAULT_ELEMENT_LOCAL_NAME)) {
keyValue = nameID.getValue();
}
loginResp.setNotOnOrAfter(new Date(credentials.getConditions().getNotOnOrAfter().toInstant().toEpochMilli()));
loginResp.setSessionIndex(credentials.getSessionIndex());
for (SAML2Credentials.SAMLAttribute attr : credentials.getAttributes()) {
if (!attr.getAttributeValues().isEmpty()) {
String attrName = attr.getFriendlyName() == null ? attr.getName() : attr.getFriendlyName();
if (connObjectKeyItem != null && attrName.equals(connObjectKeyItem.getExtAttrName())) {
keyValue = attr.getAttributeValues().get(0);
}
loginResp.getAttrs().add(new Attr.Builder(attrName).values(attr.getAttributeValues()).build());
}
}
List<String> matchingUsers = keyValue == null
? List.of()
: userManager.findMatchingUser(keyValue, idp.getKey());
LOG.debug("Found {} matching users for {}", matchingUsers.size(), keyValue);
String username;
if (matchingUsers.isEmpty()) {
if (idp.isCreateUnmatching()) {
LOG.debug("No user matching {}, about to create", keyValue);
username = AuthContextUtils.callAsAdmin(AuthContextUtils.getDomain(),
() -> userManager.create(idp, loginResp, nameID.getValue()));
} else if (idp.isSelfRegUnmatching()) {
loginResp.setNameID(nameID.getValue());
UserTO userTO = new UserTO();
userManager.fill(idp.getKey(), loginResp, userTO);
loginResp.getAttrs().clear();
loginResp.getAttrs().addAll(userTO.getPlainAttrs());
if (StringUtils.isNotBlank(userTO.getUsername())) {
loginResp.setUsername(userTO.getUsername());
} else {
loginResp.setUsername(keyValue);
}
loginResp.setSelfReg(true);
return loginResp;
} else {
throw new NotFoundException("User matching the provided value " + keyValue);
}
} else if (matchingUsers.size() > 1) {
throw new IllegalArgumentException("Several users match the provided value " + keyValue);
} else {
if (idp.isUpdateMatching()) {
LOG.debug("About to update {} for {}", matchingUsers.get(0), keyValue);
username = AuthContextUtils.callAsAdmin(AuthContextUtils.getDomain(),
() -> userManager.update(matchingUsers.get(0), idp, loginResp));
} else {
username = matchingUsers.get(0);
}
}
loginResp.setUsername(username);
loginResp.setNameID(nameID.getValue());
// 4. generate JWT for further access
Map<String, Object> claims = new HashMap<>();
claims.put(JWT_CLAIM_IDP_ENTITYID, idp.getEntityID());
claims.put(JWT_CLAIM_NAMEID_FORMAT, nameID.getFormat());
claims.put(JWT_CLAIM_NAMEID_VALUE, nameID.getValue());
claims.put(JWT_CLAIM_SESSIONINDEX, loginResp.getSessionIndex());
byte[] authorities = null;
try {
authorities = ENCRYPTOR.encode(POJOHelper.serialize(
authDataAccessor.getAuthorities(loginResp.getUsername(), null)), CipherAlgorithm.AES).getBytes();
} catch (Exception e) {
LOG.error("Could not fetch authorities", e);
}
Pair<String, Date> accessTokenInfo =
accessTokenDataBinder.create(loginResp.getUsername(), claims, authorities, true);
loginResp.setAccessToken(accessTokenInfo.getLeft());
loginResp.setAccessTokenExpiryTime(accessTokenInfo.getRight());
return loginResp;
}
@PreAuthorize("isAuthenticated() and not(hasRole('" + IdRepoEntitlement.ANONYMOUS + "'))")
public SAML2Request createLogoutRequest(
final String accessToken,
final String spEntityID,
final String urlContext) {
// 1. fetch the current JWT used for Syncope authentication
JWTClaimsSet claimsSet;
try {
SignedJWT jwt = SignedJWT.parse(accessToken);
claimsSet = jwt.getJWTClaimsSet();
} catch (ParseException e) {
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.InvalidAccessToken);
sce.getElements().add(e.getMessage());
throw sce;
}
// 2. look for SAML2Client
String idpEntityID = (String) claimsSet.getClaim(JWT_CLAIM_IDP_ENTITYID);
if (idpEntityID == null) {
throw new NotFoundException("No SAML 2.0 IdP information found in the access token");
}
SAML2Client saml2Client = getSAML2Client(idpEntityID, spEntityID, urlContext);
if (saml2Client.getLogoutActionBuilder() instanceof NoLogoutActionBuilder) {
throw new IllegalArgumentException("No SingleLogoutService available for "
+ saml2Client.getIdentityProviderResolvedEntityId());
}
// 3. create LogoutRequest
SAML2Profile saml2Profile = new SAML2Profile();
saml2Profile.setId((String) claimsSet.getClaim(JWT_CLAIM_NAMEID_VALUE));
saml2Profile.addAuthenticationAttribute(
SAML2Authenticator.SAML_NAME_ID_FORMAT,
claimsSet.getClaim(JWT_CLAIM_NAMEID_FORMAT));
saml2Profile.addAuthenticationAttribute(
SAML2Authenticator.SESSION_INDEX,
claimsSet.getClaim(JWT_CLAIM_SESSIONINDEX));
SAML2SP4UIContext ctx = new SAML2SP4UIContext(
saml2Client.getConfiguration().getSpLogoutRequestBindingType(), null);
RedirectionAction action = saml2Client.getLogoutAction(
ctx,
NoOpSessionStore.INSTANCE,
saml2Profile, null).
orElseThrow(() -> {
SyncopeClientException sce = SyncopeClientException.build(ClientExceptionType.Unknown);
sce.getElements().add("No RedirectionAction generated for LogoutRequest");
return sce;
});
return buildRequest(idpEntityID, action);
}
@PreAuthorize("hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
public void validateLogoutResponse(final SAML2Response saml2Response) {
// 1. look for SAML2Client
if (saml2Response.getIdpEntityID() == null) {
LOG.error("No SAML 2.0 IdP entityID provided, ignoring");
return;
}
SAML2Client saml2Client = getSAML2Client(
saml2Response.getIdpEntityID(), saml2Response.getSpEntityID(), saml2Response.getUrlContext());
SAML2SP4UIIdP idp = idpDAO.findByEntityID(saml2Client.getIdentityProviderResolvedEntityId());
if (idp == null) {
throw new NotFoundException("SAML 2.0 IdP '" + saml2Client.getIdentityProviderResolvedEntityId() + '\'');
}
// 2. validate the provided SAML response
SAML2SP4UIContext ctx = new SAML2SP4UIContext(
saml2Client.getConfiguration().getSpLogoutRequestBindingType(),
saml2Response);
LogoutResponse logoutResponse;
try {
SAML2MessageContext saml2Ctx = saml2Client.getContextProvider().
buildContext(saml2Client, ctx, NoOpSessionStore.INSTANCE);
saml2Client.getLogoutProfileHandler().receive(saml2Ctx);
logoutResponse = (LogoutResponse) saml2Ctx.getMessageContext().getMessage();
} catch (Exception e) {
LOG.error("Could not validate LogoutResponse", e);
return;
}
// 3. finally check for logout status
if (!StatusCode.SUCCESS.equals(logoutResponse.getStatus().getStatusCode().getValue())) {
LOG.warn("Logout from SAML 2.0 IdP '{}' was not successful",
saml2Client.getIdentityProviderResolvedEntityId());
}
}
@Override
protected EntityTO resolveReference(
final Method method, final Object... args) throws UnresolvedReferenceException {
throw new UnresolvedReferenceException();
}
}