From 0b5b82cc486029e3759a33e9224e5ccc3923a039 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Tue, 21 Feb 2023 17:06:37 -0800 Subject: [PATCH] GUACAMOLE-839: Allow accepted subject DNs to be restricted via configuration. --- .../ssl/SSLClientAuthenticationResource.java | 185 +++++++++++++----- .../auth/ssl/conf/ConfigurationService.java | 63 ++++++ .../ssl/conf/LdapNameGuacamoleProperty.java | 50 +++++ 3 files changed, 247 insertions(+), 51 deletions(-) create mode 100644 extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java index 4c99394f5..21a2e0c25 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; import javax.naming.InvalidNameException; import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; import javax.ws.rs.GET; import javax.ws.rs.core.Response; import javax.ws.rs.HeaderParam; @@ -45,10 +46,11 @@ import javax.ws.rs.core.UriBuilder; import org.apache.guacamole.GuacamoleClientException; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleResourceNotFoundException; -import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.auth.ssl.conf.ConfigurationService; import org.apache.guacamole.auth.sso.NonceService; import org.apache.guacamole.auth.sso.SSOResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * REST API resource that allows the user to retrieve an opaque state value @@ -66,6 +68,25 @@ public class SSLClientAuthenticationResource extends SSOResource { */ private static final String CLIENT_VERIFIED_HEADER_SUCCESS_VALUE = "SUCCESS"; + /** + * The string value that the SSL termination service uses for its client + * verification header to represent that the client certificate is absent. + */ + private static final String CLIENT_VERIFIED_HEADER_NONE_VALUE = "NONE"; + + /** + * The string prefix that the SSL termination service uses for its client + * verification header to represent that the client certificate has failed + * validation. The error message describing the nature of the failure is + * provided by the SSL termination service after this prefix. + */ + private static final String CLIENT_VERIFIED_HEADER_FAILED_PREFIX = "FAILED:"; + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(SSLClientAuthenticationResource.class); + /** * Service for retrieving configuration information. */ @@ -143,6 +164,67 @@ public class SSLClientAuthenticationResource extends SSOResource { } } + /** + * Extracts a user's username from the X.509 subject name, which should be + * in LDAP DN format. If specific username attributes are configured, only + * those username attributes are used to determine the name. If a specific + * base DN is configured, only subject names that are formatted as LDAP DNs + * within that base DN will be accepted. + * + * @param name + * The subject name to extract the username from. + * + * @return + * The username of the user represented by the given subject name. + * + * @throws GuacamoleException + * If any configuration parameters related to retrieving certificates + * from HTTP request cannot be parsed, or if the provided subject name + * cannot be parsed or is not acceptable (wrong base DN or wrong + * username attribute). + */ + public String getUsername(String name) throws GuacamoleException { + + // Extract user's DN from their X.509 certificate + LdapName dn; + try { + dn = new LdapName(name); + } + catch (InvalidNameException e) { + throw new GuacamoleClientException("Subject \"" + name + "\" is " + + "not a valid DN: " + e.getMessage(), e); + } + + // Verify DN actually contains components + int numComponents = dn.size(); + if (numComponents < 1) + throw new GuacamoleClientException("Subject DN is empty."); + + // Verify DN is within configured base DN (if any) + LdapName baseDN = confService.getSubjectBaseDN(); + if (baseDN != null && !(numComponents > baseDN.size() && dn.startsWith(baseDN))) + throw new GuacamoleClientException("Subject DN \"" + dn + "\" is " + + "not within the configured base DN."); + + // Retrieve the least significant attribute from the parsed DN - this + // will be the username + Rdn nameRdn = dn.getRdn(numComponents - 1); + + // Verify that the username is specified with one of the allowed + // attributes + List usernameAttributes = confService.getSubjectUsernameAttributes(); + if (usernameAttributes != null && !usernameAttributes.stream().anyMatch(nameRdn.getType()::equalsIgnoreCase)) + throw new GuacamoleClientException("Subject DN \"" + dn + "\" " + + "does not contain an acceptable username attribute."); + + // The DN is valid - extract the username from the least significant + // component + String username = nameRdn.getValue().toString(); + logger.debug("Username \"{}\" extracted from subject DN \"{}\".", username, dn); + return username; + + } + /** * Authenticates a user using HTTP headers containing that user's verified * X.509 certificate. It is assumed that this certificate is being passed @@ -154,15 +236,13 @@ public class SSLClientAuthenticationResource extends SSOResource { * The raw bytes of the X.509 certificate retrieved from the request. * * @return - * A new SSOAuthenticatedUser representing the identity of the user - * asserted by the SSL termination service via that user's X.509 - * certificate. + * The username of the user asserted by the SSL termination service via + * that user's X.509 certificate. * * @throws GuacamoleException - * If the provided X.509 certificate is not valid or cannot be parsed. - * It is expected that the SSL termination service will already have - * validated the certificate; this function validates only the - * certificate timestamps. + * If any configuration parameters related to retrieving certificates + * from HTTP request cannot be parsed, or if the certificate is not + * valid/present. */ public String getUsername(byte[] certificate) throws GuacamoleException { @@ -179,36 +259,15 @@ public class SSLClientAuthenticationResource extends SSOResource { } catch (CertificateException e) { - throw new GuacamoleClientException("The X.509 certificate " - + "presented is not valid.", e); + throw new GuacamoleClientException("Certificate is not valid: " + e.getMessage(), e); } catch (IOException e) { - throw new GuacamoleServerException("Provided X.509 certificate " - + "could not be read.", e); + throw new GuacamoleClientException("Certificate could not be read: " + e.getMessage(), e); } // Extract user's DN from their X.509 certificate - LdapName dn; - try { - Principal principal = cert.getSubjectX500Principal(); - dn = new LdapName(principal.getName()); - } - catch (InvalidNameException e) { - throw new GuacamoleClientException("The X.509 certificate " - + "presented does not contain a valid subject DN.", e); - } - - // Verify DN actually contains components - int numComponents = dn.size(); - if (numComponents < 1) - throw new GuacamoleClientException("The X.509 certificate " - + "presented contains an empty subject DN."); - - // Simply use first component of DN as username (TODO: Enforce - // requirements on the attribute providing the username and the base DN, - // and consider using components following the username to determine - // group memberships) - return dn.getRdn(numComponents - 1).getValue().toString(); + Principal principal = cert.getSubjectX500Principal(); + return getUsername(principal.getName()); } @@ -216,7 +275,7 @@ public class SSLClientAuthenticationResource extends SSOResource { * Processes the X.509 certificate in the headers of the given HTTP * request, returning an authentication session token representing the * identity in that certificate. If the certificate is invalid or not - * present, null is returned. + * present, an invalid session token is returned. * * @param credentials * The credentials submitted in the HTTP request being processed. @@ -226,14 +285,10 @@ public class SSLClientAuthenticationResource extends SSOResource { * * @return * An authentication session token representing the identity in the - * certificate in the given HTTP request, or null if the request does - * not contain a valid certificate. - * - * @throws GuacamoleException - * If any configuration parameters related to retrieving certificates - * from HTTP request cannot be parsed. + * certificate in the given HTTP request, or an invalid session token + * if no valid identity was asserted. */ - private String processCertificate(HttpHeaders headers) throws GuacamoleException { + private String processCertificate(HttpHeaders headers) { // // NOTE: A result with an associated state is ALWAYS returned by @@ -245,18 +300,46 @@ public class SSLClientAuthenticationResource extends SSOResource { // failures. // - // Verify that SSL termination has already verified the certificate - String verified = getHeader(headers, confService.getClientVerifiedHeader()); - if (!CLIENT_VERIFIED_HEADER_SUCCESS_VALUE.equals(verified)) - return sessionManager.generateInvalid(); + try { - String certificate = getHeader(headers, confService.getClientCertificateHeader()); - if (certificate == null) - return sessionManager.generateInvalid(); + // Verify that SSL termination has already verified the certificate + String verified = getHeader(headers, confService.getClientVerifiedHeader()); + if (verified != null && verified.startsWith(CLIENT_VERIFIED_HEADER_FAILED_PREFIX)) { + String message = verified.substring(CLIENT_VERIFIED_HEADER_FAILED_PREFIX.length()); + throw new GuacamoleClientException("Client certificate did " + + "not pass validation. SSL termination reports the " + + "following failure: \"" + message + "\""); + } + else if (CLIENT_VERIFIED_HEADER_NONE_VALUE.equals(verified)) { + throw new GuacamoleClientException("No client certificate was presented."); + } + else if (!CLIENT_VERIFIED_HEADER_SUCCESS_VALUE.equals(verified)) { + throw new GuacamoleClientException("Client certificate did not pass validation."); + } - String username = getUsername(decode(certificate)); - long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxTokenValidity()); - return sessionManager.defer(new SSLAuthenticationSession(username, validityDuration)); + String certificate = getHeader(headers, confService.getClientCertificateHeader()); + if (certificate == null) + throw new GuacamoleClientException("Client certificate missing from request."); + + String username = getUsername(decode(certificate)); + long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxTokenValidity()); + return sessionManager.defer(new SSLAuthenticationSession(username, validityDuration)); + + } + catch (GuacamoleClientException e) { + logger.warn("SSL/TLS client authentication attempt rejected: {}", e.getMessage()); + logger.debug("SSL/TLS client authentication failed.", e); + } + catch (GuacamoleException e) { + logger.error("SSL/TLS client authentication attempt could not be processed: {}", e.getMessage()); + logger.debug("SSL/TLS client authentication failed.", e); + } + catch (RuntimeException | Error e) { + logger.error("SSL/TLS client authentication attempt failed internally: {}", e.getMessage()); + logger.debug("Internal failure processing SSL/TLS client authentication attempt.", e); + } + + return sessionManager.generateInvalid(); } diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java index 3f57bd864..48fc86b65 100644 --- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java @@ -22,12 +22,15 @@ package org.apache.guacamole.auth.ssl.conf; import com.google.inject.Inject; import java.net.URI; import java.net.URISyntaxException; +import java.util.List; +import javax.naming.ldap.LdapName; import javax.ws.rs.core.UriBuilder; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; +import org.apache.guacamole.properties.StringListProperty; import org.apache.guacamole.properties.URIGuacamoleProperty; /** @@ -136,6 +139,34 @@ public class ConfigurationService { }; + /** + * The property defining the LDAP attribute or attributes that may be used + * to represent a username within the subject DN of a user's X.509 + * certificate. If the least-significant attribute of the subject DN is not + * one of these attributes, the certificate will be rejected. By default, + * any attribute is accepted. + */ + private static final StringListProperty SSL_SUBJECT_USERNAME_ATTRIBUTE = + new StringListProperty () { + + @Override + public String getName() { return "ssl-subject-username-attribute"; } + + }; + + /** + * The property defining the base DN containing all valid subject DNs. If + * specified, only certificates asserting subject DNs beneath this base DN + * will be accepted. By default, all DNs are accepted. + */ + private static final LdapNameGuacamoleProperty SSL_SUBJECT_BASE_DN = + new LdapNameGuacamoleProperty () { + + @Override + public String getName() { return "ssl-subject-base-dn"; } + + }; + /** * The property representing the amount of time that the temporary, unique * subdomain generated for SSL/TLS authentication may remain valid, in @@ -374,4 +405,36 @@ public class ConfigurationService { return environment.getProperty(SSL_MAX_DOMAIN_VALIDITY, DEFAULT_MAX_DOMAIN_VALIDITY); } + /** + * Returns the base DN that contains all valid subject DNs. If there is no + * such base DN (and all subject DNs are valid), null is returned. + * + * @return + * The base DN that contains all valid subject DNs, or null if all + * subject DNs are valid. + * + * @throws GuacamoleException + * If the configured base DN cannot be read or is not a valid LDAP DN. + */ + public LdapName getSubjectBaseDN() throws GuacamoleException { + return environment.getProperty(SSL_SUBJECT_BASE_DN); + } + + /** + * Returns a list of all attributes that may be used to represent a user's + * username within their subject DN. If all attributes may be accepted, + * null is returned. + * + * @return + * A list of all attributes that may be used to represent a user's + * username within their subject DN, or null if any attribute may be + * used. + * + * @throws GuacamoleException + * If the configured set of username attributes cannot be read. + */ + public List getSubjectUsernameAttributes() throws GuacamoleException { + return environment.getProperty(SSL_SUBJECT_USERNAME_ATTRIBUTE); + } + } diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java new file mode 100644 index 000000000..0299d0163 --- /dev/null +++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java @@ -0,0 +1,50 @@ +/* + * 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.ssl.conf; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.properties.GuacamoleProperty; + +/** + * A GuacamoleProperty whose value is an LDAP name, such as a distinguished + * name. + */ +public abstract class LdapNameGuacamoleProperty implements GuacamoleProperty { + + @Override + public LdapName parseValue(String value) throws GuacamoleException { + + if (value == null) + return null; + + try { + return new LdapName(value); + } + catch (InvalidNameException e) { + throw new GuacamoleServerException("Value \"" + value + + "\" is not a valid LDAP name.", e); + } + + } + +}