/* * (C) Copyright 2014 Nuxeo SA (http://nuxeo.com/) and others. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Contributors: * Nelson Silva <nelson.silva@inevo.pt> */ package org.nuxeo.ecm.platform.auth.saml; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.common.utils.i18n.I18NUtils; import org.nuxeo.ecm.platform.api.login.UserIdentificationInfo; import org.nuxeo.ecm.platform.auth.saml.binding.HTTPPostBinding; import org.nuxeo.ecm.platform.auth.saml.binding.HTTPRedirectBinding; import org.nuxeo.ecm.platform.auth.saml.binding.SAMLBinding; import org.nuxeo.ecm.platform.auth.saml.key.KeyManager; import org.nuxeo.ecm.platform.auth.saml.slo.SLOProfile; import org.nuxeo.ecm.platform.auth.saml.slo.SLOProfileImpl; import org.nuxeo.ecm.platform.auth.saml.sso.WebSSOProfile; import org.nuxeo.ecm.platform.auth.saml.sso.WebSSOProfileImpl; import org.nuxeo.ecm.platform.auth.saml.user.EmailBasedUserResolver; import org.nuxeo.ecm.platform.auth.saml.user.UserMapperBasedResolver; import org.nuxeo.ecm.platform.auth.saml.user.AbstractUserResolver; import org.nuxeo.ecm.platform.auth.saml.user.UserResolver; import org.nuxeo.ecm.platform.ui.web.auth.LoginScreenHelper; import org.nuxeo.ecm.platform.ui.web.auth.NXAuthConstants; import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPlugin; import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPluginLogoutExtension; import org.nuxeo.ecm.platform.ui.web.auth.service.LoginProviderLinkComputer; import org.nuxeo.runtime.api.Framework; import org.nuxeo.usermapper.service.UserMapperService; import org.opensaml.DefaultBootstrap; import org.opensaml.common.SAMLException; import org.opensaml.common.SAMLObject; import org.opensaml.common.binding.BasicSAMLMessageContext; import org.opensaml.common.binding.SAMLMessageContext; import org.opensaml.common.xml.SAMLConstants; import org.opensaml.saml2.core.AuthnRequest; import org.opensaml.saml2.core.LogoutRequest; import org.opensaml.saml2.core.LogoutResponse; import org.opensaml.saml2.core.NameID; import org.opensaml.saml2.encryption.Decrypter; import org.opensaml.saml2.encryption.EncryptedElementTypeEncryptedKeyResolver; import org.opensaml.saml2.metadata.EntityDescriptor; import org.opensaml.saml2.metadata.IDPSSODescriptor; import org.opensaml.saml2.metadata.RoleDescriptor; import org.opensaml.saml2.metadata.SPSSODescriptor; import org.opensaml.saml2.metadata.SingleLogoutService; import org.opensaml.saml2.metadata.SingleSignOnService; import org.opensaml.saml2.metadata.provider.AbstractMetadataProvider; import org.opensaml.saml2.metadata.provider.FilesystemMetadataProvider; import org.opensaml.saml2.metadata.provider.HTTPMetadataProvider; import org.opensaml.saml2.metadata.provider.MetadataProvider; import org.opensaml.saml2.metadata.provider.MetadataProviderException; import org.opensaml.security.MetadataCredentialResolver; import org.opensaml.ws.message.decoder.MessageDecodingException; import org.opensaml.ws.transport.InTransport; import org.opensaml.ws.transport.http.HttpServletRequestAdapter; import org.opensaml.ws.transport.http.HttpServletResponseAdapter; import org.opensaml.xml.Configuration; import org.opensaml.xml.ConfigurationException; import org.opensaml.xml.encryption.ChainingEncryptedKeyResolver; import org.opensaml.xml.encryption.InlineEncryptedKeyResolver; import org.opensaml.xml.encryption.SimpleRetrievalMethodEncryptedKeyResolver; import org.opensaml.xml.parse.BasicParserPool; import org.opensaml.xml.security.credential.Credential; import org.opensaml.xml.security.keyinfo.KeyInfoCredentialResolver; import org.opensaml.xml.security.keyinfo.StaticKeyInfoCredentialResolver; import org.opensaml.xml.signature.SignatureTrustEngine; import org.opensaml.xml.signature.impl.ExplicitKeySignatureTrustEngine; import javax.servlet.ServletException; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.nuxeo.ecm.platform.ui.web.auth.NXAuthConstants.LOGIN_ERROR; /** * A SAML2 authentication provider. * * @since 6.0 */ public class SAMLAuthenticationProvider implements NuxeoAuthenticationPlugin, LoginProviderLinkComputer, NuxeoAuthenticationPluginLogoutExtension { private static final Log log = LogFactory.getLog(SAMLAuthenticationProvider.class); private static final String ERROR_PAGE = "saml/error.jsp"; private static final String ERROR_AUTH = "error.saml.auth"; private static final String ERROR_USER = "error.saml.userMapping"; // User Resolver private static final Class<? extends UserResolver> DEFAULT_USER_RESOLVER_CLASS = EmailBasedUserResolver.class; private static final Class<? extends UserResolver> USERMAPPER_USER_RESOLVER_CLASS = UserMapperBasedResolver.class; // SAML Constants static final String SAML_SESSION_KEY = "SAML_SESSION"; // Supported SAML Bindings // TODO: Allow registering new bindings static List<SAMLBinding> bindings = new ArrayList<>(); static { bindings.add(new HTTPPostBinding()); bindings.add(new HTTPRedirectBinding()); } // Decryption key resolver private static ChainingEncryptedKeyResolver encryptedKeyResolver = new ChainingEncryptedKeyResolver(); static { encryptedKeyResolver.getResolverChain().add(new InlineEncryptedKeyResolver()); encryptedKeyResolver.getResolverChain().add(new EncryptedElementTypeEncryptedKeyResolver()); encryptedKeyResolver.getResolverChain().add(new SimpleRetrievalMethodEncryptedKeyResolver()); } // Profiles supported by the IdP private Map<String, AbstractSAMLProfile> profiles = new HashMap<>(); private UserResolver userResolver; private KeyManager keyManager; private SignatureTrustEngine trustEngine; private Decrypter decrypter; private MetadataProvider metadataProvider; @Override public void initPlugin(Map<String, String> parameters) { // Initialize the User Resolver String userResolverClassname = parameters.get("userResolverClass"); Class<? extends UserResolver> userResolverClass = null; if (StringUtils.isBlank(userResolverClassname)) { UserMapperService ums = Framework.getService(UserMapperService.class); if (ums!=null) { userResolverClass = USERMAPPER_USER_RESOLVER_CLASS; } else { userResolverClass = DEFAULT_USER_RESOLVER_CLASS; } } else { try { userResolverClass = Class.forName(userResolverClassname).asSubclass(AbstractUserResolver.class); } catch (ClassNotFoundException e) { log.error("Failed get user resolver class " + userResolverClassname); } } try { userResolver = userResolverClass.newInstance(); userResolver.init(parameters); } catch (InstantiationException | IllegalAccessException e) { log.error("Failed to initialize user resolver " + userResolverClassname); } // Initialize the OpenSAML library try { DefaultBootstrap.bootstrap(); } catch (ConfigurationException e) { log.error("Failed to bootstrap OpenSAML", e); } // Read the IdP metadata and initialize the supported profiles try { // Read the IdP metadata initializeMetadataProvider(parameters); // Setup Signature Trust Engine MetadataCredentialResolver metadataCredentialResolver = new MetadataCredentialResolver(metadataProvider); trustEngine = new ExplicitKeySignatureTrustEngine( metadataCredentialResolver, org.opensaml.xml.Configuration.getGlobalSecurityConfiguration().getDefaultKeyInfoCredentialResolver()); // Setup decrypter Credential encryptionCredential = getKeyManager().getEncryptionCredential(); if (encryptionCredential != null) { KeyInfoCredentialResolver resolver = new StaticKeyInfoCredentialResolver(encryptionCredential); decrypter = new Decrypter(null, resolver, encryptedKeyResolver); decrypter.setRootInNewDocument(true); } // Process IdP roles for (RoleDescriptor roleDescriptor : getIdPDescriptor().getRoleDescriptors()) { // Web SSO role if (roleDescriptor.getElementQName().equals(IDPSSODescriptor.DEFAULT_ELEMENT_NAME) && roleDescriptor.isSupportedProtocol(org.opensaml.common.xml.SAMLConstants.SAML20P_NS)) { IDPSSODescriptor idpSSO = (IDPSSODescriptor) roleDescriptor; // SSO for (SingleSignOnService sso : idpSSO.getSingleSignOnServices()) { if (sso.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) { addProfile(new WebSSOProfileImpl(sso)); break; } } // SLO for (SingleLogoutService slo : idpSSO.getSingleLogoutServices()) { if (slo.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) { addProfile(new SLOProfileImpl(slo)); break; } } } } } catch (MetadataProviderException e) { log.warn("Failed to register IdP: " + e.getMessage()); } // contribute icon and link to the Login Screen if (StringUtils.isNotBlank(parameters.get("name"))) { LoginScreenHelper.registerLoginProvider(parameters.get("name"), parameters.get("icon"), null, parameters.get("label"), parameters.get("description"), this); } } private void addProfile(AbstractSAMLProfile profile) { profile.setTrustEngine(trustEngine); profile.setDecrypter(decrypter); profiles.put(profile.getProfileIdentifier(), profile); } private void initializeMetadataProvider(Map<String, String> parameters) throws MetadataProviderException { AbstractMetadataProvider metadataProvider; String metadataUrl = parameters.get("metadata"); if (metadataUrl == null) { throw new MetadataProviderException("No metadata URI set for provider " + ((parameters.containsKey("name")) ? parameters.get("name") : "")); } int requestTimeout = parameters.containsKey("timeout") ? Integer.parseInt(parameters.get("timeout")) : 5; if (metadataUrl.startsWith("http:") || metadataUrl.startsWith("https:")) { metadataProvider = new HTTPMetadataProvider(metadataUrl, requestTimeout * 1000); } else { // file metadataProvider = new FilesystemMetadataProvider(new File(metadataUrl)); } metadataProvider.setParserPool(new BasicParserPool()); metadataProvider.initialize(); this.metadataProvider = metadataProvider; } private EntityDescriptor getIdPDescriptor() throws MetadataProviderException { return (EntityDescriptor) metadataProvider.getMetadata(); } /** * Returns a Login URL to use with HTTP Redirect */ protected String getSSOUrl(HttpServletRequest request, HttpServletResponse response) { WebSSOProfile sso = (WebSSOProfile) profiles.get(WebSSOProfile.PROFILE_URI); if (sso == null) { return null; } // Create and populate the context SAMLMessageContext context = new BasicSAMLMessageContext(); populateLocalContext(context); // Store the requested URL in the Relay State String requestedUrl = getRequestedUrl(request); if (requestedUrl != null) { context.setRelayState(requestedUrl); } // Build Uri HTTPRedirectBinding binding = (HTTPRedirectBinding) getBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI); String loginURL = sso.getEndpoint().getLocation(); try { AuthnRequest authnRequest = sso.buildAuthRequest(request); authnRequest.setDestination(sso.getEndpoint().getLocation()); context.setOutboundSAMLMessage(authnRequest); loginURL = binding.buildRedirectURL(context, sso.getEndpoint().getLocation()); } catch (SAMLException e) { log.error("Failed to build redirect URL", e); } return loginURL; } private String getRequestedUrl(HttpServletRequest request) { String requestedUrl = (String) request.getAttribute(NXAuthConstants.REQUESTED_URL); if (requestedUrl == null) { HttpSession session = request.getSession(false); if (session != null) { requestedUrl = (String) session.getAttribute(NXAuthConstants.START_PAGE_SAVE_KEY); } } return requestedUrl; } @Override public String computeUrl(HttpServletRequest request, String requestedUrl) { return getSSOUrl(request, null); } @Override public Boolean handleLoginPrompt(HttpServletRequest request, HttpServletResponse response, String baseURL) { String loginError = (String) request.getAttribute(LOGIN_ERROR); if (loginError != null) { try { request.getRequestDispatcher(ERROR_PAGE).forward(request, response); return true; } catch (ServletException | IOException e) { log.error("Failed to redirect to error page", e); return false; } } String loginURL = getSSOUrl(request, response); try { response.sendRedirect(loginURL); } catch (IOException e) { String errorMessage = String.format("Unable to send redirect on %s", loginURL); log.error(errorMessage, e); return false; } return true; } // Retrieves user identification information from the request. @Override public UserIdentificationInfo handleRetrieveIdentity(HttpServletRequest request, HttpServletResponse response) { HttpServletRequestAdapter inTransport = new HttpServletRequestAdapter(request); SAMLBinding binding = getBinding(inTransport); // Check if we support this binding if (binding == null) { return null; } HttpServletResponseAdapter outTransport = new HttpServletResponseAdapter(response, request.isSecure()); // Create and populate the context SAMLMessageContext context = new BasicSAMLMessageContext(); context.setInboundMessageTransport(inTransport); context.setOutboundMessageTransport(outTransport); populateLocalContext(context); // Decode the message try { binding.decode(context); } catch (org.opensaml.xml.security.SecurityException | MessageDecodingException e) { log.error("Error during SAML decoding", e); return null; } // Set Peer context info if needed try { if (context.getPeerEntityId() == null) { context.setPeerEntityId(getIdPDescriptor().getEntityID()); } if (context.getPeerEntityMetadata() == null) { context.setPeerEntityMetadata(getIdPDescriptor()); } if (context.getPeerEntityRole() == null) { context.setPeerEntityRole(IDPSSODescriptor.DEFAULT_ELEMENT_NAME); } } catch (MetadataProviderException e) { // } // Check for a response processor for this profile AbstractSAMLProfile processor = getProcessor(context); if (processor == null) { log.warn("Unsupported profile encountered in the context " + context.getCommunicationProfileId()); return null; } // Set the communication profile context.setCommunicationProfileId(processor.getProfileIdentifier()); // Delegate handling the message to the processor SAMLObject message = context.getInboundSAMLMessage(); // Handle SLO // TODO - Try to handle IdP initiated SLO somewhere else if (processor instanceof SLOProfile) { SLOProfile slo = (SLOProfile) processor; try { // Handle SLO response if (message instanceof LogoutResponse) { slo.processLogoutResponse(context); // Handle SLO request } else if (message instanceof LogoutRequest) { SAMLCredential credential = getSamlCredential(request); slo.processLogoutRequest(context, credential); } } catch (SAMLException e) { log.debug("Error processing SAML message", e); } return null; } // Handle SSO SAMLCredential credential; try { credential = ((WebSSOProfile) processor).processAuthenticationResponse(context); } catch (SAMLException e) { log.error("Error processing SAML message", e); sendError(request, ERROR_AUTH); return null; } String userId = userResolver.findOrCreateNuxeoUser(credential); if (userId == null) { log.warn("Failed to resolve user with NameID \"" + credential.getNameID().getValue() + "\"."); sendError(request, ERROR_USER); return null; } // Store session id in a cookie if (credential.getSessionIndexes() != null && !credential.getSessionIndexes().isEmpty()) { String nameValue = credential.getNameID().getValue(); String nameFormat = credential.getNameID().getFormat(); String sessionId = credential.getSessionIndexes().get(0); addCookie(response, SAML_SESSION_KEY, sessionId + "|" + nameValue + "|" + nameFormat); } // Redirect to URL in relay state if any HttpSession session = request.getSession(!response.isCommitted()); if (session != null) { if (StringUtils.isNotEmpty(credential.getRelayState())) { session.setAttribute(NXAuthConstants.START_PAGE_SAVE_KEY, credential.getRelayState()); } } return new UserIdentificationInfo(userId, userId); } protected AbstractSAMLProfile getProcessor(SAMLMessageContext context) { String profileId; SAMLObject message = context.getInboundSAMLMessage(); if (message instanceof LogoutResponse || message instanceof LogoutRequest) { profileId = SLOProfile.PROFILE_URI; } else { profileId = WebSSOProfile.PROFILE_URI; } return profiles.get(profileId); } protected SAMLBinding getBinding(String bindingURI) { for (SAMLBinding binding : bindings) { if (binding.getBindingURI().equals(bindingURI)) { return binding; } } return null; } protected SAMLBinding getBinding(InTransport transport) { for (SAMLBinding binding : bindings) { if (binding.supports(transport)) { return binding; } } return null; } private void populateLocalContext(SAMLMessageContext context) { // Set local info context.setLocalEntityId(SAMLConfiguration.getEntityId()); context.setLocalEntityRole(SPSSODescriptor.DEFAULT_ELEMENT_NAME); context.setMetadataProvider(metadataProvider); // Set the signing key keyManager = Framework.getLocalService(KeyManager.class); if (getKeyManager().getSigningCredential() != null) { context.setOutboundSAMLMessageSigningCredential(getKeyManager().getSigningCredential()); } } @Override public Boolean needLoginPrompt(HttpServletRequest httpRequest) { return true; } @Override public List<String> getUnAuthenticatedURLPrefix() { return null; } /** * Returns a Logout URL to use with HTTP Redirect */ protected String getSLOUrl(HttpServletRequest request, HttpServletResponse response) { SLOProfile slo = (SLOProfile) profiles.get(SLOProfile.PROFILE_URI); if (slo == null) { return null; } String logoutURL = slo.getEndpoint().getLocation(); SAMLCredential credential = getSamlCredential(request); // Create and populate the context SAMLMessageContext context = new BasicSAMLMessageContext(); populateLocalContext(context); try { LogoutRequest logoutRequest = slo.buildLogoutRequest(context, credential); logoutRequest.setDestination(slo.getEndpoint().getLocation()); context.setOutboundSAMLMessage(logoutRequest); HTTPRedirectBinding binding = (HTTPRedirectBinding) getBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI); logoutURL = binding.buildRedirectURL(context, slo.getEndpoint().getLocation()); } catch (SAMLException e) { log.error("Failed to get SAML Logout request", e); } return logoutURL; } private SAMLCredential getSamlCredential(HttpServletRequest request) { SAMLCredential credential = null; // Retrieve the SAMLCredential credential from cookie Cookie cookie = getCookie(request, SAML_SESSION_KEY); if (cookie != null) { String[] parts = cookie.getValue().split("\\|"); String sessionId = parts[0]; String nameValue = parts[1]; String nameFormat = parts[2]; NameID nameID = (NameID) Configuration.getBuilderFactory().getBuilder(NameID.DEFAULT_ELEMENT_NAME).buildObject( NameID.DEFAULT_ELEMENT_NAME); nameID.setValue(nameValue); nameID.setFormat(nameFormat); List<String> sessionIndexes = new ArrayList<>(); sessionIndexes.add(sessionId); credential = new SAMLCredential(nameID, sessionIndexes); } return credential; } @Override public Boolean handleLogout(HttpServletRequest request, HttpServletResponse response) { String logoutURL = getSLOUrl(request, response); if (logoutURL == null) { return false; } if (log.isDebugEnabled()) { log.debug("Send redirect to " + logoutURL); } try { response.sendRedirect(logoutURL); } catch (IOException e) { String errorMessage = String.format("Unable to send redirect on %s", logoutURL); log.error(errorMessage, e); return false; } Cookie cookie = getCookie(request, SAML_SESSION_KEY); if (cookie != null) { removeCookie(response, cookie); } return true; } private void sendError(HttpServletRequest req, String key) { String msg = I18NUtils.getMessageString("messages", key, null, req.getLocale()); req.setAttribute(LOGIN_ERROR, msg); } private KeyManager getKeyManager() { if (keyManager == null) { keyManager = Framework.getLocalService(KeyManager.class); } return keyManager; } private void addCookie(HttpServletResponse httpResponse, String name, String value) { Cookie cookie = new Cookie(name, value); httpResponse.addCookie(cookie); } private Cookie getCookie(HttpServletRequest httpRequest, String cookieName) { Cookie cookies[] = httpRequest.getCookies(); if (cookies != null) { for (Cookie cooky : cookies) { if (cookieName.equals(cooky.getName())) { return cooky; } } } return null; } private void removeCookie(HttpServletResponse httpResponse, Cookie cookie) { log.debug(String.format("Removing cookie %s.", cookie.getName())); cookie.setMaxAge(0); cookie.setValue(""); httpResponse.addCookie(cookie); } }