From d360d2a9ef645303276753bcdb6c62a93843f368 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Sat, 20 Nov 2021 22:25:05 -0800 Subject: [PATCH] GUACAMOLE-1364: Clean up overall logic of SAML authentication flow. --- .../saml/AuthenticationProviderService.java | 229 +++--------------- .../auth/saml/SAMLAuthenticationProvider.java | 20 +- .../SAMLAuthenticationProviderModule.java | 18 +- .../SAMLAuthenticationProviderResource.java | 167 ------------- .../guacamole/auth/saml/SAMLResponseMap.java | 134 ---------- .../auth/saml/acs/AssertedIdentity.java | 134 ++++++++++ .../acs/AssertionConsumerServiceResource.java | 134 ++++++++++ .../auth/saml/acs/AuthenticationSession.java | 113 +++++++++ .../acs/AuthenticationSessionManager.java | 152 ++++++++++++ .../auth/saml/acs/IdentifierGenerator.java | 54 +++++ .../guacamole/auth/saml/acs/SAMLService.java | 188 ++++++++++++++ .../auth/saml/conf/ConfigurationService.java | 34 +++ .../auth/saml/user/SAMLAuthenticatedUser.java | 105 ++++++-- 13 files changed, 949 insertions(+), 533 deletions(-) delete mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderResource.java delete mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLResponseMap.java create mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertedIdentity.java create mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java create mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java create mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java create mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java create mode 100644 extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java index 263928a0f..b579edc33 100644 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java @@ -21,30 +21,14 @@ package org.apache.guacamole.auth.saml; import com.google.inject.Inject; import com.google.inject.Provider; -import com.onelogin.saml2.authn.AuthnRequest; -import com.onelogin.saml2.authn.SamlResponse; -import com.onelogin.saml2.exception.SettingsException; -import com.onelogin.saml2.exception.ValidationError; -import com.onelogin.saml2.settings.Saml2Settings; -import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.UriBuilder; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.xpath.XPathExpressionException; -import org.apache.guacamole.auth.saml.conf.ConfigurationService; import org.apache.guacamole.auth.saml.user.SAMLAuthenticatedUser; import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.auth.saml.acs.AssertedIdentity; +import org.apache.guacamole.auth.saml.acs.AuthenticationSessionManager; +import org.apache.guacamole.auth.saml.acs.SAMLService; import org.apache.guacamole.form.Field; import org.apache.guacamole.form.RedirectField; import org.apache.guacamole.language.TranslatableMessage; @@ -52,40 +36,36 @@ import org.apache.guacamole.net.auth.AuthenticatedUser; import org.apache.guacamole.net.auth.Credentials; import org.apache.guacamole.net.auth.credentials.CredentialsInfo; import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException; -import org.apache.guacamole.token.TokenName; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.xml.sax.SAXException; /** - * Class that provides services for use by the SAMLAuthenticationProvider class. + * Service that authenticates Guacamole users by processing the responses of + * SAML identity providers. */ public class AuthenticationProviderService { /** - * Logger for this class. + * The name of the query parameter that identifies an active authentication + * session (in-progress SAML authentication attempt). */ - private static final Logger logger = LoggerFactory.getLogger(AuthenticationProviderService.class); - - /** - * Service for retrieving SAML configuration information. - */ - @Inject - private ConfigurationService confService; + public static final String AUTH_SESSION_QUERY_PARAM = "state"; /** * Provider for AuthenticatedUser objects. */ @Inject private Provider authenticatedUserProvider; - + /** - * The map used to track active SAML responses. + * Manager of active SAML authentication attempts. */ @Inject - private SAMLResponseMap samlResponseMap; - - private static final String SAML_ATTRIBUTE_TOKEN_PREFIX = "SAML_"; + private AuthenticationSessionManager sessionManager; + + /** + * Service for processing SAML requests/responses. + */ + @Inject + private SAMLService saml; /** * Returns an AuthenticatedUser representing the user authenticated by the @@ -104,117 +84,30 @@ public class AuthenticationProviderService { */ public AuthenticatedUser authenticateUser(Credentials credentials) throws GuacamoleException { - + + // No authentication can be attempted without a corresponding HTTP + // request HttpServletRequest request = credentials.getRequest(); + if (request == null) + return null; - // Initialize and configure SAML client. - Saml2Settings samlSettings = confService.getSamlSettings(); + // Use established SAML identity if already provided by the SAML IdP + AssertedIdentity identity = sessionManager.getIdentity(request.getParameter(AUTH_SESSION_QUERY_PARAM)); + if (identity != null) { - if (request != null) { - - // Look for the SAML Response parameter. - String responseHash = request.getParameter("responseHash"); + // Back-port the username to the credentials + credentials.setUsername(identity.getUsername()); - if (responseHash != null && samlResponseMap.hasSamlResponse(responseHash)) { + // Configure the AuthenticatedUser and return it + SAMLAuthenticatedUser authenticatedUser = authenticatedUserProvider.get(); + authenticatedUser.init(identity, credentials); + return authenticatedUser; - try { - - SamlResponse samlResponse = samlResponseMap.getSamlResponse(responseHash); - - if (!samlResponse.validateNumAssertions()) { - logger.warn("SAML response contained other than single assertion."); - logger.debug("validateNumAssertions returned false."); - throw new GuacamoleServerException("Unable to validate SAML assertions."); - } - - // Validate timestamps, generating ValidationException if this fails. - samlResponse.validateTimestamps(); - - // Grab the username, and, if present, finish authentication. - String username = samlResponse.getNameId(); - if (username != null) { - - // Canonicalize username as lowercase - username = username.toLowerCase(); - - // Retrieve any provided attributes - Map> attributes = - samlResponse.getAttributes(); - - // Back-port the username to the credentials - credentials.setUsername(username); - - // Configure the AuthenticatedUser and return it - SAMLAuthenticatedUser authenticatedUser = - authenticatedUserProvider.get(); - - authenticatedUser.init(username, credentials, - parseTokens(attributes), - parseGroups(attributes, confService.getGroupAttribute())); - - return authenticatedUser; - } - } - - // Catch errors and convert to a GuacamoleExcetion. - catch (IOException e) { - logger.warn("Error during I/O while parsing SAML response: {}", e.getMessage()); - logger.debug("Received IOException when trying to parse SAML response.", e); - throw new GuacamoleServerException("IOException received while processing SAML response.", e); - } - catch (ParserConfigurationException e) { - logger.warn("Error configuring XML parser: {}", e.getMessage()); - logger.debug("Received ParserConfigurationException when trying to parse SAML response.", e); - throw new GuacamoleServerException("XML ParserConfigurationException received while processing SAML response.", e); - } - catch (SAXException e) { - logger.warn("Bad XML when parsing SAML response: {}", e.getMessage()); - logger.debug("Received SAXException while parsing SAML response.", e); - throw new GuacamoleServerException("XML SAXException received while processing SAML response.", e); - } - catch (SettingsException e) { - logger.warn("Error with SAML settings while parsing response: {}", e.getMessage()); - logger.debug("Received SettingsException while parsing SAML response.", e); - throw new GuacamoleServerException("SAML SettingsException received while process SAML response.", e); - } - catch (ValidationError e) { - logger.warn("Error validating SAML response: {}", e.getMessage()); - logger.debug("Received ValidationError while parsing SAML response.", e); - throw new GuacamoleServerException("SAML ValidationError received while processing SAML response.", e); - } - catch (XPathExpressionException e) { - logger.warn("Problem with XML parsing response: {}", e.getMessage()); - logger.debug("Received XPathExpressionException while processing SAML response.", e); - throw new GuacamoleServerException("XML XPathExpressionExcetion received while processing SAML response.", e); - } - catch (Exception e) { - logger.warn("Exception while getting name from SAML response: {}", e.getMessage()); - logger.debug("Received Exception while retrieving name from SAML response.", e); - throw new GuacamoleServerException("Generic Exception received processing SAML response.", e); - } - } } - // No SAML Response is present, or hash is not present in map. - AuthnRequest samlReq = new AuthnRequest(samlSettings); - URI authUri; - try { - authUri = UriBuilder.fromUri(samlSettings.getIdpSingleSignOnServiceUrl().toURI()) - .queryParam("SAMLRequest", samlReq.getEncodedAuthnRequest()) - .build(); - } - catch (IOException e) { - logger.error("Error encoding authentication request to string: {}", e.getMessage()); - logger.debug("Got IOException encoding authentication request.", e); - throw new GuacamoleServerException("IOException received while generating SAML authentication URI.", e); - } - catch(URISyntaxException e) { - logger.error("Error generating URI for authentication redirect: {}", e.getMessage()); - logger.debug("Got URISyntaxException generating authentication URI", e); - throw new GuacamoleServerException("URISyntaxException received while generating SAML authentication URI.", e); - } - - // Redirect to SAML Identity Provider (IdP) + // Redirect to SAML IdP if no SAML identity is associated with the + // Guacamole authentication request + URI authUri = saml.createRequest(); throw new GuacamoleInsufficientCredentialsException("Redirecting to SAML IdP.", new CredentialsInfo(Arrays.asList(new Field[] { new RedirectField("samlRedirect", authUri, new TranslatableMessage("LOGIN.INFO_SAML_REDIRECT_PENDING")) @@ -223,58 +116,4 @@ public class AuthenticationProviderService { } - /** - * Generates Map of tokens that can be substituted within Guacamole - * parameters given a Map containing a List of attributes from the SAML IdP. - * Attributes that have multiple values will be reduced to a single value, - * taking the first available value and discarding the remaining values. - * - * @param attributes - * The Map containing the attributes retrieved from the SAML IdP. - * - * @return - * A Map of key and single value pairs that can be used as parameter - * tokens. - */ - private Map parseTokens(Map> attributes) { - - Map tokens = new HashMap<>(); - for (Entry> entry : attributes.entrySet()) { - - List values = entry.getValue(); - tokens.put(TokenName.canonicalize( - entry.getKey(), SAML_ATTRIBUTE_TOKEN_PREFIX), - values.get(0)); - - } - - return tokens; - - } - - /** - * Returns a list of groups found in the provided Map of attributes returned - * by the SAML IdP by searching the map for the provided group attribute. - * - * @param attributes - * The Map of attributes provided by the SAML IdP. - * - * @param groupAttribute - * The name of the attribute that may be present in the Map that - * will be used to parse group membership for the authenticated user. - * - * @return - * A Set of groups of which the user is a member. - */ - private Set parseGroups(Map> attributes, - String groupAttribute) { - - List samlGroups = attributes.get(groupAttribute); - if (samlGroups != null && !samlGroups.isEmpty()) - return Collections.unmodifiableSet(new HashSet<>(samlGroups)); - - return Collections.emptySet(); - } - } diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProvider.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProvider.java index eb173d814..6089e5ada 100644 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProvider.java +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProvider.java @@ -22,15 +22,17 @@ package org.apache.guacamole.auth.saml; import com.google.inject.Guice; import com.google.inject.Injector; import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.auth.saml.acs.AssertionConsumerServiceResource; +import org.apache.guacamole.auth.saml.acs.AuthenticationSessionManager; import org.apache.guacamole.net.auth.AuthenticatedUser; import org.apache.guacamole.net.auth.AbstractAuthenticationProvider; import org.apache.guacamole.net.auth.Credentials; /** - * Class when provides authentication for the Guacamole Client against a - * SAML SSO Identity Provider (IdP). This module does not provide any - * storage for connection information, and must be layered with other - * modules in order to retrieve connections. + * AuthenticationProvider implementation that authenticates Guacamole users + * against a SAML SSO Identity Provider (IdP). This module does not provide any + * storage for connection information, and must be layered with other modules + * for authenticated users to have access to Guacamole connections. */ public class SAMLAuthenticationProvider extends AbstractAuthenticationProvider { @@ -43,12 +45,8 @@ public class SAMLAuthenticationProvider extends AbstractAuthenticationProvider { /** * Creates a new SAMLAuthenticationProvider that authenticates users * against a SAML IdP. - * - * @throws GuacamoleException - * If a required property is missing, or an error occurs while parsing - * a property. */ - public SAMLAuthenticationProvider() throws GuacamoleException { + public SAMLAuthenticationProvider() { // Set up Guice injector. injector = Guice.createInjector( @@ -64,7 +62,7 @@ public class SAMLAuthenticationProvider extends AbstractAuthenticationProvider { @Override public Object getResource() throws GuacamoleException { - return injector.getInstance(SAMLAuthenticationProviderResource.class); + return injector.getInstance(AssertionConsumerServiceResource.class); } @Override @@ -80,7 +78,7 @@ public class SAMLAuthenticationProvider extends AbstractAuthenticationProvider { @Override public void shutdown() { - injector.getInstance(SAMLResponseMap.class).shutdown(); + injector.getInstance(AuthenticationSessionManager.class).shutdown(); } } diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java index 9405b1e86..feb61d6f6 100644 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java @@ -21,7 +21,10 @@ package org.apache.guacamole.auth.saml; import com.google.inject.AbstractModule; import org.apache.guacamole.auth.saml.conf.ConfigurationService; -import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.auth.saml.acs.AssertionConsumerServiceResource; +import org.apache.guacamole.auth.saml.acs.AuthenticationSessionManager; +import org.apache.guacamole.auth.saml.acs.IdentifierGenerator; +import org.apache.guacamole.auth.saml.acs.SAMLService; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.environment.LocalEnvironment; import org.apache.guacamole.net.auth.AuthenticationProvider; @@ -48,13 +51,8 @@ public class SAMLAuthenticationProviderModule extends AbstractModule { * * @param authProvider * The AuthenticationProvider for which injection is being configured. - * - * @throws GuacamoleException - * If an error occurs while retrieving the Guacamole server - * environment. */ - public SAMLAuthenticationProviderModule(AuthenticationProvider authProvider) - throws GuacamoleException { + public SAMLAuthenticationProviderModule(AuthenticationProvider authProvider) { // Get local environment this.environment = LocalEnvironment.getInstance(); @@ -72,9 +70,11 @@ public class SAMLAuthenticationProviderModule extends AbstractModule { bind(Environment.class).toInstance(environment); // Bind SAML-specific services + bind(AssertionConsumerServiceResource.class); + bind(AuthenticationSessionManager.class); bind(ConfigurationService.class); - bind(SAMLAuthenticationProviderResource.class); - bind(SAMLResponseMap.class); + bind(IdentifierGenerator.class); + bind(SAMLService.class); } diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderResource.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderResource.java deleted file mode 100644 index 1b4049456..000000000 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderResource.java +++ /dev/null @@ -1,167 +0,0 @@ -/* - * 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.guacamole.auth.saml; - -import com.google.inject.Inject; -import com.onelogin.saml2.authn.SamlResponse; -import com.onelogin.saml2.exception.SettingsException; -import com.onelogin.saml2.exception.ValidationError; -import com.onelogin.saml2.http.HttpRequest; -import com.onelogin.saml2.servlet.ServletUtils; -import com.onelogin.saml2.settings.Saml2Settings; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.core.Response; -import javax.ws.rs.FormParam; -import javax.ws.rs.Path; -import javax.ws.rs.POST; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.UriBuilder; -import javax.xml.bind.DatatypeConverter; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.xpath.XPathExpressionException; -import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.GuacamoleServerException; -import org.apache.guacamole.auth.saml.conf.ConfigurationService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.xml.sax.SAXException; - -/** - * A class that implements the REST API necessary for the - * SAML Idp to POST back its response to Guacamole. - */ -public class SAMLAuthenticationProviderResource { - - /** - * Logger for this class. - */ - private final Logger logger = - LoggerFactory.getLogger(SAMLAuthenticationProviderResource.class); - - /** - * The configuration service for this module. - */ - @Inject - private ConfigurationService confService; - - /** - * The map used to track active responses. - */ - @Inject - private SAMLResponseMap samlResponseMap; - - /** - * A REST endpoint that is POSTed to by the SAML IdP - * with the results of the SAML SSO Authentication. - * - * @param samlResponseString - * The encoded response returned by the SAML IdP. - * - * @param consumedRequest - * The HttpServletRequest associated with the SAML response. The - * parameters of this request may not be accessible, as the request may - * have been fully consumed by JAX-RS. - * - * @return - * A HTTP Response that will redirect the user back to the - * Guacamole home page, with the SAMLResponse encoded in the - * return URL. - * - * @throws GuacamoleException - * If the Guacamole configuration cannot be read or an error occurs - * parsing a URI. - */ - @POST - @Path("callback") - public Response processSamlResponse( - @FormParam("SAMLResponse") String samlResponseString, - @Context HttpServletRequest consumedRequest) - throws GuacamoleException { - - URI guacBase = confService.getCallbackUrl(); - Saml2Settings samlSettings = confService.getSamlSettings(); - try { - HttpRequest request = ServletUtils - .makeHttpRequest(consumedRequest) - .addParameter("SAMLResponse", samlResponseString); - SamlResponse samlResponse = new SamlResponse(samlSettings, request); - - String responseHash = hashSamlResponse(samlResponseString); - samlResponseMap.putSamlResponse(responseHash, samlResponse); - return Response.seeOther(UriBuilder.fromUri(guacBase) - .queryParam("responseHash", responseHash) - .build() - ).build(); - - } - catch (IOException e) { - throw new GuacamoleServerException("I/O exception processing SAML response.", e); - } - catch (NoSuchAlgorithmException e) { - throw new GuacamoleServerException("Unexpected missing SHA-256 support while generating SAML response hash.", e); - } - catch (ParserConfigurationException e) { - throw new GuacamoleServerException("Parser exception processing SAML response.", e); - } - catch (SAXException e) { - throw new GuacamoleServerException("SAX exception processing SAML response.", e); - } - catch (SettingsException e) { - throw new GuacamoleServerException("Settings exception processing SAML response.", e); - } - catch (ValidationError e) { - throw new GuacamoleServerException("Exception validating SAML response.", e); - } - catch (XPathExpressionException e) { - throw new GuacamoleServerException("XML Xpath exception validating SAML response.", e); - } - - } - - /** - * This is a utility method designed to generate a SHA-256 hash for the - * given string representation of the SAMLResponse, throwing an exception - * if, for some reason, the Java implementation in use doesn't support - * SHA-256, and returning a hex-formatted hash value. - * - * @param samlResponse - * The String representation of the SAML response. - * - * @return - * A hex-formatted string of the SHA-256 hash. - * - * @throws NoSuchAlgorithmException - * If Java does not support SHA-256. - */ - private String hashSamlResponse(String samlResponse) - throws NoSuchAlgorithmException { - - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return DatatypeConverter.printHexBinary( - digest.digest(samlResponse.getBytes(StandardCharsets.UTF_8))); - } - -} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLResponseMap.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLResponseMap.java deleted file mode 100644 index 90109961a..000000000 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLResponseMap.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * 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.guacamole.auth.saml; - -import com.google.inject.Singleton; -import com.onelogin.saml2.authn.SamlResponse; -import com.onelogin.saml2.exception.ValidationError; -import java.util.Collection; -import java.util.Iterator; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * A class that handles mapping of hashes to SAMLResponse objects. - */ -@Singleton -public class SAMLResponseMap { - - /** - * The internal data structure that holds a map of SHA-256 hashes to - * SAML responses. - */ - private final ConcurrentMap samlResponseMap = - new ConcurrentHashMap<>(); - - /** - * Executor service which runs the periodic cleanup task - */ - private final ScheduledExecutorService executor = - Executors.newScheduledThreadPool(1); - - /** - * Create a new instance of this response map and kick off the executor - * that schedules the response cleanup task to run every five minutes. - */ - public SAMLResponseMap() { - // Cleanup unclaimed responses every five minutes - executor.scheduleAtFixedRate(new SAMLResponseCleanupTask(), 5, 5, TimeUnit.MINUTES); - } - - /** - * Retrieve the SamlResponse from the map that is represented by the - * provided hash, or null if no such object exists. - * - * @param hash - * The SHA-256 hash of the SamlResponse. - * - * @return - * The SamlResponse object matching the hash provided. - */ - protected SamlResponse getSamlResponse(String hash) { - return samlResponseMap.remove(hash); - } - - /** - * Place the provided mapping of hash to SamlResponse into the map. - * - * @param hash - * The hash that will be the lookup key for this SamlResponse. - * - * @param samlResponse - * The SamlResponse object. - */ - protected void putSamlResponse(String hash, SamlResponse samlResponse) { - samlResponseMap.put(hash, samlResponse); - } - - /** - * Return true if the provided hash key exists in the map, otherwise false. - * - * @param hash - * The hash key to look for in the map. - * - * @return - * true if the provided hash is present, otherwise false. - */ - protected boolean hasSamlResponse(String hash) { - return samlResponseMap.containsKey(hash); - } - - /** - * Task which runs every five minutes and cleans up any expired SAML - * responses that haven't been claimed and removed from the map. - */ - private class SAMLResponseCleanupTask implements Runnable { - - @Override - public void run() { - - // Loop through responses in map and remove ones that are no longer valid. - Iterator responseIterator = samlResponseMap.values().iterator(); - while (responseIterator.hasNext()) { - try { - responseIterator.next().validateTimestamps(); - } - catch (ValidationError e) { - responseIterator.remove(); - } - } - - } - - } - - /** - * Shut down the executor service that periodically cleans out the - * SamlResponse Map. This must be invoked during webapp shutdown in order - * to avoid resource leaks. - */ - public void shutdown() { - executor.shutdownNow(); - } - -} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertedIdentity.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertedIdentity.java new file mode 100644 index 000000000..6fcaa58c6 --- /dev/null +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertedIdentity.java @@ -0,0 +1,134 @@ +/* + * 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.guacamole.auth.saml.acs; + +import com.onelogin.saml2.authn.SamlResponse; +import com.onelogin.saml2.exception.ValidationError; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import javax.xml.xpath.XPathExpressionException; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleSecurityException; + +/** + * Representation of a user's identity as asserted by a SAML IdP. + */ +public class AssertedIdentity { + + /** + * The original SAML response received from the SAML IdP that asserted + * the user's identity. + */ + private final SamlResponse response; + + /** + * The user's Guacamole username. + */ + private final String username; + + /** + * All attributes included in the original SAML response. Attributes may + * possibly be associated with multiple values. + */ + private final Map> attributes; + + /** + * Creates a new AssertedIdentity representing the identity asserted by the + * SAML IdP in the given response. + * + * @param response + * The response received from the SAML IdP. + * + * @throws GuacamoleException + * If the given SAML response cannot be parsed or is missing required + * information. + */ + public AssertedIdentity(SamlResponse response) throws GuacamoleException { + + // Parse user identity from SAML response + String nameId; + try { + nameId = response.getNameId(); + if (nameId == null) + throw new GuacamoleSecurityException("SAML response did not " + + "include the relevant user's identity (no name ID)."); + } + + // Unfortunately, getNameId() is declared as "throws Exception", so + // this error handling has to be pretty generic + catch (Exception e) { + throw new GuacamoleSecurityException("User identity (name ID) " + + "could not be retrieved from the SAML response: " + e.getMessage(), e); + } + + // Retrieve any provided attributes + Map> responseAttributes; + try { + responseAttributes = Collections.unmodifiableMap(response.getAttributes()); + } + catch (XPathExpressionException | ValidationError e) { + throw new GuacamoleSecurityException("SAML attributes could not " + + "be parsed from the SAML response: " + e.getMessage(), e); + } + + this.response = response; + this.username = nameId.toLowerCase(); // Canonicalize username as lowercase + this.attributes = responseAttributes; + + } + + /** + * Returns the username of the Guacamole user whose identity was asserted + * by the SAML IdP. + * + * @return + * The username of the Guacamole user whose identity was asserted by + * the SAML IdP. + */ + public String getUsername() { + return username; + } + + /** + * Returns a Map containing all attributes included in the original SAML + * response that asserted this user's identity. Attributes may possibly be + * associated with multiple values. + * + * @return + * A Map of all attributes included in the original SAML response. + */ + public Map> getAttributes() { + return attributes; + } + + /** + * Returns whether the identity asserted by the original SAML response is + * still valid. An asserted identity may cease to be valid after creation + * if it has expired according to the timestamps included in the response. + * + * @return + * true if the original SAML response is still valid, false otherwise. + */ + public boolean isValid() { + return response.isValid(); + } + +} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java new file mode 100644 index 000000000..ba99d75fb --- /dev/null +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java @@ -0,0 +1,134 @@ +/* + * 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.guacamole.auth.saml.acs; + +import com.google.inject.Inject; +import java.net.URI; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; +import javax.ws.rs.FormParam; +import javax.ws.rs.Path; +import javax.ws.rs.POST; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.UriBuilder; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.auth.saml.AuthenticationProviderService; +import org.apache.guacamole.auth.saml.conf.ConfigurationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * REST API resource that provides a SAML assertion consumer service (ACS) + * endpoint. SAML identity providers will issue an HTTP POST to this endpoint + * asserting the user's identity when the user has successfully authenticated. + */ +public class AssertionConsumerServiceResource { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(AssertionConsumerServiceResource.class); + + /** + * The configuration service for this module. + */ + @Inject + private ConfigurationService confService; + + /** + * Manager of active SAML authentication attempts. + */ + @Inject + private AuthenticationSessionManager sessionManager; + + /** + * Service for processing SAML requests/responses. + */ + @Inject + private SAMLService saml; + + /** + * Processes the SAML response submitted by the SAML IdP via an HTTP POST. + * If SSO has been successful, the user is redirected back to Guacamole to + * complete authentication. If SSO has failed, the user is redirected back + * to Guacamole to re-attempt authentication. + * + * @param relayState + * The "RelayState" value originally provided in the SAML request, + * which in our case is the transient the session identifier of the + * in-progress authentication attempt. The SAML standard requires that + * the identity provider include the "RelayState" value it received + * alongside its SAML response. + * + * @param samlResponse + * The encoded response returned by the SAML IdP. + * + * @param consumedRequest + * The HttpServletRequest associated with the SAML response. The + * parameters of this request may not be accessible, as the request may + * have been fully consumed by JAX-RS. + * + * @return + * An HTTP Response that will redirect the user back to Guacamole, + * with an internal state identifier included in the URL such that the + * the Guacamole side of authentication can complete. + * + * @throws GuacamoleException + * If configuration information required for processing SAML responses + * cannot be read. + */ + @POST + @Path("callback") + public Response processSamlResponse( + @FormParam("RelayState") String relayState, + @FormParam("SAMLResponse") String samlResponse, + @Context HttpServletRequest consumedRequest) + throws GuacamoleException { + + URI guacBase = confService.getCallbackUrl(); + + try { + + // Validate and parse identity asserted by SAML IdP + AuthenticationSession session = saml.processResponse( + consumedRequest.getRequestURL().toString(), + relayState, samlResponse); + + // Store asserted identity for future retrieval via redirect + String identifier = sessionManager.defer(session); + + // Redirect user such that Guacamole's authentication system can + // retrieve the relevant SAML identity (stored above) + return Response.seeOther(UriBuilder.fromUri(guacBase) + .queryParam(AuthenticationProviderService.AUTH_SESSION_QUERY_PARAM, identifier) + .build() + ).build(); + + } + + // If invalid, redirect back to main page to re-attempt authentication + catch (GuacamoleException e) { + logger.warn("Authentication attempted with an invalid SAML response: {}", e.getMessage()); + logger.debug("Received SAML response failed validation.", e); + return Response.seeOther(guacBase).build(); + } + + } + +} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java new file mode 100644 index 000000000..b73bc7adb --- /dev/null +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java @@ -0,0 +1,113 @@ +/* + * 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.guacamole.auth.saml.acs; + +/** + * Representation of an in-progress SAML authentication attempt. + */ +public class AuthenticationSession { + + /** + * The absolute point in time after which this authentication session is + * invalid. This value is a UNIX epoch timestamp, as may be returned by + * {@link System#currentTimeMillis()}. + */ + private final long expirationTimestamp; + + /** + * The request ID of the SAML request associated with the authentication + * attempt. + */ + private final String requestId; + + /** + * The identity asserted by the SAML IdP, or null if authentication has not + * yet completed successfully. + */ + private AssertedIdentity identity = null; + + /** + * Creates a new AuthenticationSession representing an in-progress SAML + * authentication attempt. + * + * @param requestId + * The request ID of the SAML request associated with the + * authentication attempt. + * + * @param expires + * The number of milliseconds that may elapse before this session must + * be considered invalid. + */ + public AuthenticationSession(String requestId, long expires) { + this.expirationTimestamp = System.currentTimeMillis() + expires; + this.requestId = requestId; + } + + /** + * Returns whether this authentication session is still valid (has not yet + * expired). If an identity has been asserted by the SAML IdP, this + * considers also whether the SAML response asserting that identity has + * expired. + * + * @return + * true if this authentication session is still valid, false if it has + * expired. + */ + public boolean isValid() { + return System.currentTimeMillis() < expirationTimestamp + && (identity == null || identity.isValid()); + } + + /** + * Returns the request ID of the SAML request associated with the + * authentication attempt. + * + * @return + * The request ID of the SAML request associated with the + * authentication attempt. + */ + public String getRequestID() { + return requestId; + } + + /** + * Marks this authentication attempt as completed and successful, with the + * user having been asserted as having the given identity by the SAML IdP. + * + * @param identity + * The identity asserted by the SAML IdP. + */ + public void setIdentity(AssertedIdentity identity) { + this.identity = identity; + } + + /** + * Returns the identity asserted by the SAML IdP. If authentication has not + * yet completed successfully, this will be null. + * + * @return + * The identity asserted by the SAML IdP, or null if authentication has + * not yet completed successfully. + */ + public AssertedIdentity getIdentity() { + return identity; + } + +} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java new file mode 100644 index 000000000..198347cda --- /dev/null +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java @@ -0,0 +1,152 @@ +/* + * 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.guacamole.auth.saml.acs; + +import com.google.common.base.Predicates; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Manager service that temporarily stores SAML authentication attempts while + * the authentication flow is underway. Authentication attempts are represented + * as temporary authentication sessions, allowing authentication attempts to + * span multiple requests and redirects. Invalid or stale authentication + * sessions are automatically purged from storage. + */ +@Singleton +public class AuthenticationSessionManager { + + /** + * Generator of arbitrary, unique, unpredictable identifiers. + */ + @Inject + private IdentifierGenerator idGenerator; + + /** + * Map of authentication session identifiers to their associated + * {@link AuthenticationSession}. + */ + private final ConcurrentMap sessions = + new ConcurrentHashMap<>(); + + /** + * Executor service which runs the periodic cleanup task + */ + private final ScheduledExecutorService executor = + Executors.newScheduledThreadPool(1); + + /** + * Creates a new AuthenticationSessionManager that manages in-progress + * SAML authentication attempts. Invalid, stale sessions are automatically + * cleaned up. + */ + public AuthenticationSessionManager() { + executor.scheduleAtFixedRate(() -> { + sessions.values().removeIf(Predicates.not(AuthenticationSession::isValid)); + }, 1, 1, TimeUnit.MINUTES); + } + + /** + * Resumes the Guacamole side of the authentication process that was + * previously deferred through a call to defer(). Once invoked, the + * provided value ceases to be valid for future calls to resume(). + * + * @param identifier + * The unique string returned by the call to defer(). For convenience, + * this value may safely be null. + * + * @return + * The {@link AuthenticationSession} originally provided when defer() + * was invoked, or null if the session is no longer valid or no such + * value was returned by defer(). + */ + public AuthenticationSession resume(String identifier) { + + if (identifier != null) { + AuthenticationSession session = sessions.remove(identifier); + if (session != null && session.isValid()) + return session; + } + + return null; + + } + + /** + * Returns the identity finally asserted by the SAML IdP at the end of the + * authentication process represented by the authentication session with + * the given identifier. If there is no such authentication session, or no + * valid identity has been asserted by the SAML IdP for that session, null + * is returned. + * + * @param identifier + * The unique string returned by the call to defer(). For convenience, + * this value may safely be null. + * + * @return + * The identity finally asserted by the SAML IdP at the end of the + * authentication process represented by the authentication session + * with the given identifier, or null if there is no such identity. + */ + public AssertedIdentity getIdentity(String identifier) { + + AuthenticationSession session = resume(identifier); + if (session != null) + return session.getIdentity(); + + return null; + + } + + /** + * Defers the Guacamole side of authentication for the user having the + * given authentication session such that it may be later resumed through a + * call to resume(). If authentication is never resumed, the session will + * automatically be cleaned up after it ceases to be valid. + * + * @param session + * The {@link AuthenticationSession} representing the in-progress SAML + * authentication attempt. + * + * @return + * A unique and unpredictable string that may be used to represent the + * given session when calling resume(). + */ + public String defer(AuthenticationSession session) { + String identifier = idGenerator.generateIdentifier(); + sessions.put(identifier, session); + return identifier; + } + + /** + * Shuts down the executor service that periodically removes all invalid + * authentication sessions. This must be invoked when the SAML extension is + * shut down in order to avoid resource leaks. + */ + public void shutdown() { + executor.shutdownNow(); + } + +} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java new file mode 100644 index 000000000..a2a3aae6a --- /dev/null +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java @@ -0,0 +1,54 @@ +/* + * 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.guacamole.auth.saml.acs; + +import com.google.common.io.BaseEncoding; +import com.google.inject.Singleton; +import java.security.SecureRandom; + +/** + * Generator of unique and unpredictable identifiers. Each generated identifier + * is an arbitrary, random string produced using a cryptographically-secure + * random number generator and consists of at least 256 bits. + */ +@Singleton +public class IdentifierGenerator { + + /** + * Cryptographically-secure random number generator for generating unique + * identifiers. + */ + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * Generates a unique and unpredictable identifier. Each identifier is at + * least 256-bit and produced using a cryptographically-secure random + * number generator. + * + * @return + * A unique and unpredictable identifier. + */ + public String generateIdentifier() { + byte[] bytes = new byte[33]; + secureRandom.nextBytes(bytes); + return BaseEncoding.base64().encode(bytes); + } + +} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java new file mode 100644 index 000000000..bd945203e --- /dev/null +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java @@ -0,0 +1,188 @@ +/* + * 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.guacamole.auth.saml.acs; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.onelogin.saml2.authn.AuthnRequest; +import com.onelogin.saml2.authn.SamlResponse; +import com.onelogin.saml2.exception.SettingsException; +import com.onelogin.saml2.exception.ValidationError; +import com.onelogin.saml2.settings.Saml2Settings; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import javax.ws.rs.core.UriBuilder; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleSecurityException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.auth.saml.conf.ConfigurationService; +import org.xml.sax.SAXException; + +/** + * Service which abstracts the internals of handling SAML requests and + * responses. + */ +@Singleton +public class SAMLService { + + /** + * Service for retrieving SAML configuration information. + */ + @Inject + private ConfigurationService confService; + + /** + * Manager of active SAML authentication attempts. + */ + @Inject + private AuthenticationSessionManager sessionManager; + + /** + * Creates a new SAML request, beginning the overall authentication flow + * that will ultimately result in an asserted user identity if the user is + * successfully authenticated by the SAML IdP. The URI of the SSO endpoint + * of the SAML IdP that the user must be redirected to for the + * authentication process to continue is returned. + * + * @return + * The URI of the SSO endpoint of the SAML IdP that the user must be + * redirected to. + * + * @throws GuacamoleException + * If an error prevents the SAML request and redirect URI from being + * generated. + */ + public URI createRequest() throws GuacamoleException { + + Saml2Settings samlSettings = confService.getSamlSettings(); + AuthnRequest samlReq = new AuthnRequest(samlSettings); + + // Create a new authentication session to represent this attempt while + // it is in progress + AuthenticationSession session = new AuthenticationSession(samlReq.getId(), + confService.getAuthenticationTimeout() * 60000L); + + // Produce redirect for continuing the authentication process with + // the SAML IdP + try { + return UriBuilder.fromUri(samlSettings.getIdpSingleSignOnServiceUrl().toURI()) + .queryParam("SAMLRequest", samlReq.getEncodedAuthnRequest()) + .queryParam("RelayState", sessionManager.defer(session)) + .build(); + } + catch (IOException e) { + throw new GuacamoleServerException("SAML authentication request " + + "could not be encoded: " + e.getMessage()); + } + catch (URISyntaxException e) { + throw new GuacamoleServerException("SAML IdP redirect could not " + + "be generated due to an error in the URI syntax: " + + e.getMessage()); + } + + } + + /** + * Processes the given SAML response, as received by the SAML ACS endpoint + * at the given URL, producing an {@link AuthenticationSession} that now + * includes a valid assertion of the user's identity. If the SAML response + * is invalid in any way, an exception is thrown. + * + * @param url + * The URL of the ACS endpoint that received the SAML response. This + * should be the URL pointing to the single POST-handling endpoint of + * {@link AssertionConsumerServiceResource}. + * + * @param relayState + * The "RelayState" value originally provided in the SAML request, + * which in our case is the transient the session identifier of the + * in-progress authentication attempt. The SAML standard requires that + * the identity provider include the "RelayState" value it received + * alongside its SAML response. + * + * @param encodedResponse + * The response received from the SAML IdP via the ACS endpoint at the + * given URL. + * + * @return + * The {@link AuthenticationSession} associated with the in-progress + * authentication attempt, now associated with the {@link AssertedIdentity} + * representing the identity of the user asserted by the SAML IdP. + * + * @throws GuacamoleException + * If the given SAML response is not valid, or if the configuration + * information required to validate or decrypt the response cannot be + * read. + */ + public AuthenticationSession processResponse(String url, String relayState, + String encodedResponse) throws GuacamoleException { + + if (relayState == null) + throw new GuacamoleSecurityException("\"RelayState\" value " + + "is missing from SAML response."); + + AuthenticationSession session = sessionManager.resume(relayState); + if (session == null) + throw new GuacamoleSecurityException("\"RelayState\" value " + + "included with SAML response is not valid."); + + try { + + // Decode received SAML response + SamlResponse response = new SamlResponse(confService.getSamlSettings(), + url, encodedResponse); + + // Validate SAML response timestamp, signature, etc. + if (!response.isValid(session.getRequestID())) { + Exception validationException = response.getValidationException(); + throw new GuacamoleSecurityException("SAML response did not " + + "pass validation: " + validationException.getMessage(), + validationException); + } + + // Parse identity asserted by SAML IdP + session.setIdentity(new AssertedIdentity(response)); + return session; + + } + catch (ValidationError e) { + throw new GuacamoleSecurityException("SAML response did not pass " + + "validation: " + e.getMessage(), e); + } + catch (SettingsException e) { + throw new GuacamoleServerException("Current SAML settings are " + + "insufficient to decrypt/parse the received SAML " + + "response.", e); + } + catch (ParserConfigurationException | SAXException | XPathExpressionException e) { + throw new GuacamoleServerException("XML contents of SAML " + + "response could not be parsed.", e); + } + catch (IOException e) { + throw new GuacamoleServerException("Contents of SAML response " + + "could not be decrypted/read.", e); + } + + } + +} diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java index b59bee9dc..048773729 100644 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java @@ -32,6 +32,7 @@ import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.properties.BooleanGuacamoleProperty; +import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; import org.apache.guacamole.properties.URIGuacamoleProperty; @@ -145,6 +146,21 @@ public class ConfigurationService { }; + /** + * The maximum amount of time to allow for an in-progress SAML + * authentication attempt to be completed, in minutes. A user that takes + * longer than this amount of time to complete authentication with their + * identity provider will be redirected back to the identity provider to + * try again. + */ + private static final IntegerGuacamoleProperty SAML_AUTH_TIMEOUT = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "saml-auth-timeout"; } + + }; + /** * The Guacamole server environment. */ @@ -295,6 +311,24 @@ public class ConfigurationService { return environment.getProperty(SAML_GROUP_ATTRIBUTE, "groups"); } + /** + * Returns the maximum amount of time to allow for an in-progress SAML + * authentication attempt to be completed, in minutes. A user that takes + * longer than this amount of time to complete authentication with their + * identity provider will be redirected back to the identity provider to + * try again. + * + * @return + * The maximum amount of time to allow for an in-progress SAML + * authentication attempt to be completed, in minutes. + * + * @throws GuacamoleException + * If the authentication timeout cannot be parsed. + */ + public int getAuthenticationTimeout() throws GuacamoleException { + return environment.getProperty(SAML_AUTH_TIMEOUT, 5); + } + /** * Returns the collection of SAML settings used to initialize the client. * diff --git a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/user/SAMLAuthenticatedUser.java b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/user/SAMLAuthenticatedUser.java index 5228c99a1..93d5ca6cb 100644 --- a/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/user/SAMLAuthenticatedUser.java +++ b/extensions/guacamole-auth-saml/src/main/java/org/apache/guacamole/auth/saml/user/SAMLAuthenticatedUser.java @@ -20,19 +20,39 @@ package org.apache.guacamole.auth.saml.user; import com.google.inject.Inject; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.auth.saml.acs.AssertedIdentity; +import org.apache.guacamole.auth.saml.conf.ConfigurationService; import org.apache.guacamole.net.auth.AbstractAuthenticatedUser; import org.apache.guacamole.net.auth.AuthenticationProvider; import org.apache.guacamole.net.auth.Credentials; +import org.apache.guacamole.token.TokenName; /** - * An SAML-specific implementation of AuthenticatedUser, associating a - * username and particular set of credentials with the SAML authentication + * A SAML-specific implementation of AuthenticatedUser, associating a SAML + * identity and particular set of credentials with the SAML authentication * provider. */ public class SAMLAuthenticatedUser extends AbstractAuthenticatedUser { + /** + * The prefix that should be prepended to all parameter tokens generated + * from SAML attributes. + */ + private static final String SAML_ATTRIBUTE_TOKEN_PREFIX = "SAML_"; + + /** + * Service for retrieving SAML configuration information. + */ + @Inject + private ConfigurationService confService; + /** * Reference to the authentication provider associated with this * authenticated user. @@ -56,27 +76,78 @@ public class SAMLAuthenticatedUser extends AbstractAuthenticatedUser { private Map tokens; /** - * Initializes this AuthenticatedUser using the given username and - * credentials. + * Returns a Map of all parameter tokens that should be made available for + * substitution based on the given {@link AssertedIdentity}. The resulting + * Map will contain one parameter token for each SAML attribute in the + * SAML response that originally asserted the user's identity. Attributes + * that have multiple values will be reduced to a single value, taking the + * first available value and discarding the remaining values. * - * @param username - * The username of the user that was authenticated. + * @param identity + * The {@link AssertedIdentity} representing the user identity + * asserted by the SAML IdP. + * + * @return + * A Map of key and single value pairs that should be made available + * for substitution as parameter tokens. + */ + private Map getTokens(AssertedIdentity identity) { + return identity.getAttributes().entrySet() + .stream() + .filter((entry) -> !entry.getValue().isEmpty()) + .collect(Collectors.toUnmodifiableMap( + (entry) -> TokenName.canonicalize(entry.getKey(), SAML_ATTRIBUTE_TOKEN_PREFIX), + (entry) -> entry.getValue().get(0) + )); + } + + /** + * Returns a set of all group memberships asserted by the SAML IdP. + * + * @param identity + * The {@link AssertedIdentity} representing the user identity + * asserted by the SAML IdP. + * + * @return + * A set of all groups that the SAML IdP asserts this user is a + * member of. + * + * @throws GuacamoleException + * If the configuration information necessary to retrieve group + * memberships from a SAML response cannot be read. + */ + private Set getGroups(AssertedIdentity identity) + throws GuacamoleException { + + List samlGroups = identity.getAttributes().get(confService.getGroupAttribute()); + if (samlGroups == null || samlGroups.isEmpty()) + return Collections.emptySet(); + + return Collections.unmodifiableSet(new HashSet<>(samlGroups)); + + } + + /** + * Initializes this AuthenticatedUser using the given + * {@link AssertedIdentity} and credentials. + * + * @param identity + * The {@link AssertedIdentity} representing the user identity + * asserted by the SAML IdP. * * @param credentials * The credentials provided when this user was authenticated. - * - * @param tokens - * The tokens available from this authentication provider. - * - * @param effectiveGroups - * The groups of which this user is a member. + * + * @throws GuacamoleException + * If configuration information required for processing the user's + * identity and group memberships cannot be read. */ - public void init(String username, Credentials credentials, - Map tokens, Set effectiveGroups) { + public void init(AssertedIdentity identity, Credentials credentials) + throws GuacamoleException { this.credentials = credentials; - this.effectiveGroups = effectiveGroups; - this.tokens = tokens; - setIdentifier(username); + this.effectiveGroups = getGroups(identity); + this.tokens = getTokens(identity); + setIdentifier(identity.getUsername()); } /**