GUACAMOLE-839: Ensure SSL/TLS client auth failures are reflected in the Guacamole UI.

This commit is contained in:
Michael Jumper
2023-01-27 16:38:45 -08:00
parent b6ce477625
commit 38f1360dec
15 changed files with 681 additions and 295 deletions

View File

@@ -68,6 +68,20 @@ public class AuthenticationSessionManager<T extends AuthenticationSession> {
}, 1, 1, TimeUnit.MINUTES);
}
/**
* Generates a cryptographically-secure value identical in form to the
* session tokens generated by {@link #defer(org.apache.guacamole.auth.sso.AuthenticationSession)}
* but invalid. The returned value is indistinguishable from a valid token,
* but is not a valid token.
*
* @return
* An invalid token value that is indistinguishable from a valid
* token.
*/
public String generateInvalid() {
return idGenerator.generateIdentifier();
}
/**
* Resumes the Guacamole side of the authentication process that was
* previously deferred through a call to defer(). Once invoked, the

View File

@@ -37,6 +37,56 @@
<relativePath>../../</relativePath>
</parent>
<build>
<plugins>
<!-- JS/CSS Minification Plugin -->
<plugin>
<groupId>com.github.buckelieg</groupId>
<artifactId>minify-maven-plugin</artifactId>
<executions>
<execution>
<id>default-cli</id>
<configuration>
<charset>UTF-8</charset>
<webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
<webappTargetDir>${project.build.directory}/classes</webappTargetDir>
<jsSourceDir>/</jsSourceDir>
<jsTargetDir>/</jsTargetDir>
<jsFinalFile>ssl.js</jsFinalFile>
<jsSourceFiles>
<jsSourceFile>license.txt</jsSourceFile>
</jsSourceFiles>
<jsSourceIncludes>
<jsSourceInclude>**/*.js</jsSourceInclude>
</jsSourceIncludes>
<!-- Do not minify and include tests -->
<jsSourceExcludes>
<jsSourceExclude>**/*.test.js</jsSourceExclude>
</jsSourceExcludes>
<jsEngine>CLOSURE</jsEngine>
<!-- Disable warnings for JSDoc annotations -->
<closureWarningLevels>
<misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
<nonStandardJsDocs>OFF</nonStandardJsDocs>
</closureWarningLevels>
</configuration>
<goals>
<goal>minify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<!-- Guacamole Extension API -->

View File

@@ -22,38 +22,16 @@ package org.apache.guacamole.auth.ssl;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.UriBuilder;
import org.apache.guacamole.GuacamoleClientException;
import org.apache.guacamole.auth.ssl.conf.ConfigurationService;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.GuacamoleServerException;
import org.apache.guacamole.auth.sso.NonceService;
import org.apache.guacamole.GuacamoleResourceNotFoundException;
import org.apache.guacamole.auth.sso.SSOAuthenticationProviderService;
import org.apache.guacamole.auth.sso.user.SSOAuthenticatedUser;
import org.apache.guacamole.form.Field;
import org.apache.guacamole.form.RedirectField;
import org.apache.guacamole.language.TranslatableMessage;
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.net.auth.credentials.GuacamoleInvalidCredentialsException;
/**
* Service that authenticates Guacamole users using SSL/TLS authentication
@@ -68,13 +46,6 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS
@Inject
private ConfigurationService confService;
/**
* Service for validating and generating unique nonce values. Here, these
* nonces are used specifically for generating unique domains.
*/
@Inject
private NonceService subdomainNonceService;
/**
* Session manager for generating and maintaining unique tokens to
* represent the authentication flow of a user who has only partially
@@ -92,124 +63,12 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS
@Inject
private Provider<SSOAuthenticatedUser> authenticatedUserProvider;
/**
* The string value that the SSL termination service uses for its client
* verification header to represent that the client certificate has been
* verified.
*/
private static final String CLIENT_VERIFIED_HEADER_SUCCESS_VALUE = "SUCCESS";
/**
* The name of the query parameter containing the temporary session token
* representing the current state of an in-progress authentication attempt.
*/
private static final String AUTH_SESSION_PARAMETER_NAME = "state";
/**
* Decodes the provided URL-encoded string as UTF-8, returning the result.
*
* @param value
* The URL-encoded string to decode.
*
* @return
* The decoded string.
*
* @throws GuacamoleException
* If the provided value is not a value URL-encoded string.
*/
private byte[] decode(String value) throws GuacamoleException {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8.name())
.getBytes(StandardCharsets.UTF_8);
}
catch (IllegalArgumentException e) {
throw new GuacamoleClientException("Invalid URL-encoded value.", e);
}
catch (UnsupportedEncodingException e) {
// This should never happen, as UTF-8 is a standard charset that
// the JVM is required to support
throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
}
}
/**
* Authenticates a user using HTTP headers containing that user's verified
* X.509 certificate. It is assumed that this certificate is being passed
* to Guacamole from an SSL termination service that has already verified
* that this certificate is valid and authorized for access to that
* Guacamole instance.
*
* @param credentials
* The credentials received by Guacamole in the authentication request.
*
* @param certificate
* 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.
*
* @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.
*/
private SSOAuthenticatedUser authenticateUser(Credentials credentials,
byte[] certificate) throws GuacamoleException {
// Parse and re-verify certificate is valid with respect to timestamps
X509Certificate cert;
try (InputStream input = new ByteArrayInputStream(certificate)) {
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
cert = (X509Certificate) certFactory.generateCertificate(input);
// Verify certificate is valid (it should be given pre-validation
// from SSL termination, but it's worth rechecking for sanity)
cert.checkValidity();
}
catch (CertificateException e) {
throw new GuacamoleClientException("The X.509 certificate "
+ "presented is not valid.", e);
}
catch (IOException e) {
throw new GuacamoleServerException("Provided X.509 certificate "
+ "could not be read.", 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)
String username = dn.getRdn(numComponents - 1).getValue().toString();
SSOAuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
authenticatedUser.init(username, credentials,
Collections.emptySet(), Collections.emptyMap());
return authenticatedUser;
}
/**
* Processes the given HTTP request, returning the identity represented by
* the auth session token present in that request. If no such token is
@@ -224,80 +83,17 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS
* or null if there is no such token or the token does not represent a
* valid identity.
*/
private SSOAuthenticatedUser processIdentity(HttpServletRequest request) {
private SSOAuthenticatedUser processIdentity(Credentials credentials, HttpServletRequest request) {
String state = request.getParameter(AUTH_SESSION_PARAMETER_NAME);
return sessionManager.getIdentity(state);
}
/**
* 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.
*
* @param credentials
* The credentials submitted in the HTTP request being processed.
*
* @param request
* The HTTP request to process.
*
* @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.
*/
private String processCertificate(Credentials credentials,
HttpServletRequest request) throws GuacamoleException {
// Verify that SSL termination has already verified the certificate
String verified = request.getHeader(confService.getClientVerifiedHeader());
if (!CLIENT_VERIFIED_HEADER_SUCCESS_VALUE.equals(verified))
String username = sessionManager.getIdentity(state);
if (username == null)
return null;
String certificate = request.getHeader(confService.getClientCertificateHeader());
if (certificate == null)
return null;
SSOAuthenticatedUser authenticatedUser = authenticateUser(credentials, decode(certificate));
long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxTokenValidity());
return sessionManager.defer(new SSLAuthenticationSession(authenticatedUser, validityDuration));
}
/**
* Redirects the current user back to the main URL of the Guacamole
* instance to continue the authentication process after having identified
* themselves using SSL/TLS client authentication.
*
* @param token
* The authentication session token generated for the current user's
* identity.
*
* @throws GuacamoleException
* To redirect the user to the main URL of the Guacamole instance.
*/
private void resumeAuthenticationAtRedirectURI(String token)
throws GuacamoleException {
URI redirectURI = UriBuilder.fromUri(confService.getRedirectURI())
.queryParam(AUTH_SESSION_PARAMETER_NAME, token)
.build();
// Request that the provided credentials, now tokenized, be
// resubmitted in that tokenized form to the original host for
// authentication
throw new GuacamoleInsufficientCredentialsException("Please "
+ "resubmit your tokenized credentials using the "
+ "following URI.",
new CredentialsInfo(Arrays.asList(new Field[] {
new RedirectField(AUTH_SESSION_PARAMETER_NAME, redirectURI,
new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING"))
}))
);
SSOAuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
authenticatedUser.init(username, credentials,
Collections.emptySet(), Collections.emptyMap());
return authenticatedUser;
}
@@ -308,21 +104,22 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS
//
// Overall flow:
//
// 1) Unauthenticated user is given a temporary auth session token
// and redirected to the SSL termination instance that provides
// SSL client auth. This redirect uses a unique and temporary
// subdomain to ensure each SSL client auth attempt is fresh and
// does not use cached auth details.
// 1) An unauthenticated user makes a GET request to
// ".../api/ext/ssl/identity". After a series of redirects
// intended to prevent that identity from being inadvertently
// cached and inherited by future authentication attempts on the
// same client machine, an external SSL termination service requests
// and validates the user's certificate, those details are passed
// back to Guacamole via HTTP headers, and Guacamole produces a JSON
// response containing an opaque state value.
//
// 2) Unauthenticated user with a temporary auth session token
// is validated by SSL termination, with that SSL termination
// adding HTTP headers containing the validated certificate to the
// user's HTTP request.
// 2) The user (still unauthenticated) resubmits the opaque state
// value from the received JSON as the "state" parameter of a
// standard Guacamole authentication request (".../api/tokens").
//
// 3) If valid, the user is assigned a temporary token and redirected
// back to the original URL. That temporary token is accepted by
// this extension at the original URL as proof of the user's
// identity.
// 3) If the certificate received was valid, the user is authenticated
// according to the identity asserted by that certificate. If not,
// authentication is refused.
//
// NOTE: All SSL termination endpoints in front of Guacamole MUST
// be configured to drop these headers from any inbound requests
@@ -345,50 +142,13 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS
return null;
//
// Handle only auth session tokens at the main redirect URI, using the
// Handle only auth session tokens at the primary URI, using the
// pre-verified information from those tokens to determine user
// identity.
//
String redirectHost = confService.getRedirectURI().getHost();
if (host.equals(redirectHost)) {
SSOAuthenticatedUser user = processIdentity(request);
if (user != null)
return user;
// Redirect unauthenticated requests to the endpoint requiring
// SSL client auth to request identity verification
throw new GuacamoleInvalidCredentialsException("Invalid login.",
new CredentialsInfo(Arrays.asList(new Field[] {
new RedirectField(AUTH_SESSION_PARAMETER_NAME, getLoginURI(), // <-- Each call to getLoginURI() produces a unique subdomain that is valid only for ONE use (see below)
new TranslatableMessage("LOGIN.INFO_IDP_REDIRECT_PENDING"))
}))
);
}
//
// Process certificates only at valid single-use subdomains dedicated
// to client authentication, redirecting back to the main redirect URI
// for final authentication if that processing is successful.
//
// NOTE: This is CRITICAL. If unique subdomains are not generated and
// tied to strictly one authentication attempt, then those subdomains
// could be reused by a user on a shared machine to assume the cached
// credentials of another user that used that machine earlier. The
// browser and/or OS may cache the certificate so that it can be reused
// for future SSL sessions to that same domain. Here, we ensure each
// generated domain is unique and only valid for certificate processing
// ONCE. The domain may still be valid with DNS, but will no longer be
// usable for certificate authentication.
//
else if (subdomainNonceService.isValid(confService.getClientAuthenticationSubdomain(host))) {
String token = processCertificate(credentials, request);
if (token != null)
resumeAuthenticationAtRedirectURI(token);
}
if (confService.isPrimaryHostname(host))
return processIdentity(credentials, request);
// All other requests are not allowed - refuse to authenticate
throw new GuacamoleClientException("Direct authentication against "
@@ -400,9 +160,7 @@ public class AuthenticationProviderService implements SSOAuthenticationProviderS
@Override
public URI getLoginURI() throws GuacamoleException {
long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxDomainValidity());
String uniqueSubdomain = subdomainNonceService.generate(validityDuration);
return confService.getClientAuthenticationURI(uniqueSubdomain);
throw new GuacamoleResourceNotFoundException("No such resource.");
}
@Override

View File

@@ -0,0 +1,65 @@
/*
* 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;
/**
* REST API response that reports the result of attempting to authenticate the
* user using SSL/TLS client authentication. The information within this
* result is intentionally opaque and must be resubmitted in a separate
* authentication request for authentication to finally succeed or fail.
*/
public class OpaqueAuthenticationResult {
/**
* An arbitrary value representing the result of authenticating the
* current user.
*/
private final String state;
/**
* Creates a new OpaqueAuthenticationResult containing the given opaque
* state value. Successful authentication results must be indistinguishable
* from unsuccessful results with respect to this value. Only using this
* value within ANOTHER authentication attempt can determine whether
* authentication is successful.
*
* @param state
* An arbitrary value representing the result of authenticating the
* current user.
*/
public OpaqueAuthenticationResult(String state) {
this.state = state;
}
/**
* Returns an arbitrary value representing the result of authenticating the
* current user. This value may be resubmitted as the "state" parameter of
* an authentication request beneath the primary URI of the web application
* to finalize the authentication procedure and determine whether the
* operation has succeeded or failed.
*
* @return
* An arbitrary value representing the result of authenticating the
* current user.
*/
public String getState() {
return state;
}
}

View File

@@ -20,7 +20,6 @@
package org.apache.guacamole.auth.ssl;
import org.apache.guacamole.auth.sso.SSOAuthenticationProvider;
import org.apache.guacamole.auth.sso.SSOResource;
/**
* Guacamole authentication backend which authenticates users using SSL/TLS
@@ -37,7 +36,7 @@ public class SSLAuthenticationProvider extends SSOAuthenticationProvider {
* an external SSL termination system using SSL/TLS client authentication.
*/
public SSLAuthenticationProvider() {
super(AuthenticationProviderService.class, SSOResource.class,
super(AuthenticationProviderService.class, SSLClientAuthenticationResource.class,
new SSLAuthenticationProviderModule());
}

View File

@@ -20,6 +20,7 @@
package org.apache.guacamole.auth.ssl;
import com.google.inject.AbstractModule;
import com.google.inject.Scopes;
import org.apache.guacamole.auth.ssl.conf.ConfigurationService;
import org.apache.guacamole.auth.sso.NonceService;
@@ -32,7 +33,7 @@ public class SSLAuthenticationProviderModule extends AbstractModule {
@Override
protected void configure() {
bind(ConfigurationService.class);
bind(NonceService.class);
bind(NonceService.class).in(Scopes.SINGLETON);
bind(SSLAuthenticationSessionManager.class);
}

View File

@@ -20,7 +20,6 @@
package org.apache.guacamole.auth.ssl;
import org.apache.guacamole.auth.sso.AuthenticationSession;
import org.apache.guacamole.auth.sso.user.SSOAuthenticatedUser;
/**
* Representation of an in-progress SSL/TLS authentication attempt.
@@ -30,7 +29,7 @@ public class SSLAuthenticationSession extends AuthenticationSession {
/**
* The identity asserted by the external SSL termination service.
*/
private final SSOAuthenticatedUser identity;
private final String identity;
/**
* Creates a new AuthenticationSession representing an in-progress SSL/TLS
@@ -44,7 +43,7 @@ public class SSLAuthenticationSession extends AuthenticationSession {
* The number of milliseconds that may elapse before this session must
* be considered invalid.
*/
public SSLAuthenticationSession(SSOAuthenticatedUser identity, long expires) {
public SSLAuthenticationSession(String identity, long expires) {
super(expires);
this.identity = identity;
}
@@ -58,7 +57,7 @@ public class SSLAuthenticationSession extends AuthenticationSession {
* @return
* The identity asserted by the external SSL termination service.
*/
public SSOAuthenticatedUser getIdentity() {
public String getIdentity() {
return identity;
}

View File

@@ -21,7 +21,6 @@ package org.apache.guacamole.auth.ssl;
import com.google.inject.Singleton;
import org.apache.guacamole.auth.sso.AuthenticationSessionManager;
import org.apache.guacamole.auth.sso.user.SSOAuthenticatedUser;
/**
* Manager service that temporarily stores SSL/TLS authentication attempts
@@ -48,7 +47,7 @@ public class SSLAuthenticationSessionManager
* session with the given identifier, or null if there is no such
* identity.
*/
public SSOAuthenticatedUser getIdentity(String identifier) {
public String getIdentity(String identifier) {
SSLAuthenticationSession session = resume(identifier);
if (session != null)

View File

@@ -0,0 +1,332 @@
/*
* 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;
import com.google.inject.Inject;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.concurrent.TimeUnit;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.ws.rs.GET;
import javax.ws.rs.core.Response;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
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;
/**
* REST API resource that allows the user to retrieve an opaque state value
* representing their identity as determined by SSL/TLS client authentication.
* The opaque value may represent a valid identity or an authentication
* failure, and must be resubmitted within a normal Guacamole authentication
* request to finalize the authentication process.
*/
public class SSLClientAuthenticationResource extends SSOResource {
/**
* The string value that the SSL termination service uses for its client
* verification header to represent that the client certificate has been
* verified.
*/
private static final String CLIENT_VERIFIED_HEADER_SUCCESS_VALUE = "SUCCESS";
/**
* Service for retrieving configuration information.
*/
@Inject
private ConfigurationService confService;
/**
* Session manager for generating and maintaining unique tokens to
* represent the authentication flow of a user who has only partially
* authenticated. Here, these tokens represent a user that has been
* validated by SSL termination and allow the Guacamole instance that
* doesn't require SSL/TLS authentication to retrieve the user's identity
* and complete the authentication process.
*/
@Inject
private SSLAuthenticationSessionManager sessionManager;
/**
* Service for validating and generating unique nonce values. Here, these
* nonces are used specifically for generating unique domains.
*/
@Inject
private NonceService subdomainNonceService;
/**
* Retrieves a single value from the HTTP header having the given name. If
* there are multiple HTTP headers present with this name, the first
* matching header in the request is used. If there are no such headers in
* the request, null is returned.
*
* @param headers
* The HTTP headers present in the request.
*
* @param name
* The name of the header to retrieve.
*
* @return
* The first value of the HTTP header having the given name, or null if
* there is no such header.
*/
private String getHeader(HttpHeaders headers, String name) {
List<String> values = headers.getRequestHeader(name);
if (values.isEmpty())
return null;
return values.get(0);
}
/**
* Decodes the provided URL-encoded string as UTF-8, returning the result.
*
* @param value
* The URL-encoded string to decode.
*
* @return
* The decoded string.
*
* @throws GuacamoleException
* If the provided value is not a value URL-encoded string.
*/
private byte[] decode(String value) throws GuacamoleException {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8.name())
.getBytes(StandardCharsets.UTF_8);
}
catch (IllegalArgumentException e) {
throw new GuacamoleClientException("Invalid URL-encoded value.", e);
}
catch (UnsupportedEncodingException e) {
// This should never happen, as UTF-8 is a standard charset that
// the JVM is required to support
throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
}
}
/**
* Authenticates a user using HTTP headers containing that user's verified
* X.509 certificate. It is assumed that this certificate is being passed
* to Guacamole from an SSL termination service that has already verified
* that this certificate is valid and authorized for access to that
* Guacamole instance.
*
* @param certificate
* 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.
*
* @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.
*/
public String getUsername(byte[] certificate) throws GuacamoleException {
// Parse and re-verify certificate is valid with respect to timestamps
X509Certificate cert;
try (InputStream input = new ByteArrayInputStream(certificate)) {
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
cert = (X509Certificate) certFactory.generateCertificate(input);
// Verify certificate is valid (it should be given pre-validation
// from SSL termination, but it's worth rechecking for sanity)
cert.checkValidity();
}
catch (CertificateException e) {
throw new GuacamoleClientException("The X.509 certificate "
+ "presented is not valid.", e);
}
catch (IOException e) {
throw new GuacamoleServerException("Provided X.509 certificate "
+ "could not be read.", 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();
}
/**
* 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.
*
* @param credentials
* The credentials submitted in the HTTP request being processed.
*
* @param request
* The HTTP request to process.
*
* @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.
*/
private String processCertificate(HttpHeaders headers) throws GuacamoleException {
//
// NOTE: A result with an associated state is ALWAYS returned by
// processCertificate(), even if the request does not actually contain
// a valid certificate. This is by design and ensures that the nature
// of a certificate (valid vs. invalid) cannot be determined except
// via Guacamole's authentication endpoint, thus allowing auth failure
// hooks to consider attempts to use invalid certificates as auth
// 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();
String certificate = getHeader(headers, confService.getClientCertificateHeader());
if (certificate == null)
return sessionManager.generateInvalid();
String username = getUsername(decode(certificate));
long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxTokenValidity());
return sessionManager.defer(new SSLAuthenticationSession(username, validityDuration));
}
/**
* Attempts to authenticate the current user using SSL/TLS client
* authentication, returning an opaque value that represents their
* authenticated status. If necessary, the user is first redirected to a
* unique endpoint that supports SSL/TLS client authentication.
*
* @param headers
* All HTTP headers submitted in the user's authentication request.
*
* @param host
* The hostname that the user specified in their HTTP request.
*
* @return
* A Response containing an opaque value representing the user's
* authenticated status, or a Response redirecting the user to a
* unique endpoint that can provide this.
*
* @throws GuacamoleException
* If any required configuration information is missing or cannot be
* parsed, or if the request was not received at a valid subdomain.
*/
@GET
@Path("identity")
public Response authenticateClient(@Context HttpHeaders headers,
@HeaderParam("Host") String host) throws GuacamoleException {
// Redirect any requests to the domain that does NOT require SSL/TLS
// client authentication to the same endpoint at a domain that does
// require SSL/TLS authentication
String subdomain = confService.getClientAuthenticationSubdomain(host);
if (subdomain == null) {
long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxDomainValidity());
String uniqueSubdomain = subdomainNonceService.generate(validityDuration);
URI clientAuthURI = UriBuilder.fromUri(confService.getClientAuthenticationURI(uniqueSubdomain))
.path("api/ext/ssl/identity")
.build();
return Response.seeOther(clientAuthURI).build();
}
//
// Process certificates only at valid single-use subdomains dedicated
// to client authentication, redirecting back to the main redirect URI
// for final authentication if that processing is successful.
//
// NOTE: This is CRITICAL. If unique subdomains are not generated and
// tied to strictly one authentication attempt, then those subdomains
// could be reused by a user on a shared machine to assume the cached
// credentials of another user that used that machine earlier. The
// browser and/or OS may cache the certificate so that it can be reused
// for future SSL sessions to that same domain. Here, we ensure each
// generated domain is unique and only valid for certificate processing
// ONCE. The domain may still be valid with DNS, but will no longer be
// usable for certificate authentication.
//
if (subdomainNonceService.isValid(subdomain))
return Response.ok(new OpaqueAuthenticationResult(processCertificate(headers)))
.header("Access-Control-Allow-Origin", confService.getPrimaryOrigin().toString())
.type(MediaType.APPLICATION_JSON)
.build();
throw new GuacamoleResourceNotFoundException("No such resource.");
}
}

View File

@@ -21,8 +21,10 @@ package org.apache.guacamole.auth.ssl.conf;
import com.google.inject.Inject;
import java.net.URI;
import java.net.URISyntaxException;
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;
@@ -80,11 +82,11 @@ public class ConfigurationService {
* to THIS instance of Guacamole, but behind SSL termination that DOES NOT
* require or request SSL/TLS client authentication.
*/
private static final URIGuacamoleProperty SSL_REDIRECT_URI =
private static final URIGuacamoleProperty SSL_PRIMARY_URI =
new URIGuacamoleProperty() {
@Override
public String getName() { return "ssl-redirect-uri"; }
public String getName() { return "ssl-primary-uri"; }
};
@@ -121,11 +123,10 @@ public class ConfigurationService {
* authentication token for SSL/TLS authentication may remain valid, in
* minutes. This token is used to represent the user's asserted identity
* after it has been verified by the SSL termination service. This interval
* must be long enough to allow for network delays in redirecting the user
* back to the main Guacamole URL, but short enough that unused tokens do
* not consume unnecessary server resources and cannot potentially be
* guessed while the token is still valid. These tokens are 256-bit secure
* random values.
* must be long enough to allow for network delays in receiving the token,
* but short enough that unused tokens do not consume unnecessary server
* resources and cannot potentially be guessed while the token is still
* valid. These tokens are 256-bit secure random values.
*/
private static final IntegerGuacamoleProperty SSL_MAX_TOKEN_VALIDITY =
new IntegerGuacamoleProperty() {
@@ -141,12 +142,12 @@ public class ConfigurationService {
* minutes. This subdomain is used to ensure each SSL/TLS authentication
* attempt is fresh and does not potentially reuse a previous
* authentication attempt that was cached by the browser or OS. This
* interval must be long enough to allow for network delays in redirecting
* the user to the SSL termination service enforcing SSL/TLS
* authentication, but short enough that an unused domain does not consume
* unnecessary server resources and cannot potentially be guessed while
* that subdomain is still valid. These subdomains are 128-bit secure
* random values.
* interval must be long enough to allow for network delays in
* authenticating the user with the SSL termination service that enforces
* SSL/TLS client authentication, but short enough that an unused domain
* does not consume unnecessary server resources and cannot potentially be
* guessed while that subdomain is still valid. These subdomains are
* 128-bit secure random values.
*/
private static final IntegerGuacamoleProperty SSL_MAX_DOMAIN_VALIDITY =
new IntegerGuacamoleProperty() {
@@ -212,6 +213,11 @@ public class ConfigurationService {
*/
public String getClientAuthenticationSubdomain(String hostname) throws GuacamoleException {
// Any hostname that matches the explicitly-specific primary URI is not
// a client auth subdomain
if (isPrimaryHostname(hostname))
return null;
URI authURI = environment.getRequiredProperty(SSL_CLIENT_AUTH_URI);
String baseHostname = authURI.getHost();
@@ -240,11 +246,57 @@ public class ConfigurationService {
* required.
*
* @throws GuacamoleException
* If the required property for configuring the redirect URI is missing
* If the required property for configuring the primary URI is missing
* or cannot be parsed.
*/
public URI getRedirectURI() throws GuacamoleException {
return environment.getRequiredProperty(SSL_REDIRECT_URI);
public URI getPrimaryURI() throws GuacamoleException {
return environment.getRequiredProperty(SSL_PRIMARY_URI);
}
/**
* Returns the HTTP request origin for requests originating from this
* instance via the primary URI (as returned by {@link #getPrimaryURI()}.
* This value is essentially the same as the primary URI but with only the
* scheme, host, and port present.
*
* @return
* The HTTP request origin for requests originating from this instance
* via the primary URI.
*
* @throws GuacamoleException
* If the required property for configuring the primary URI is missing
* or cannot be parsed.
*/
public URI getPrimaryOrigin() throws GuacamoleException {
URI primaryURI = getPrimaryURI();
try {
return new URI(primaryURI.getScheme(), null, primaryURI.getHost(), primaryURI.getPort(), null, null, null);
}
catch (URISyntaxException e) {
throw new GuacamoleServerException("Request origin could not be "
+ "derived from the configured primary URI.", e);
}
}
/**
* Returns whether the given hostname is the same as the hostname in the
* primary URI (as returned by {@link #getPrimaryURI()}. Hostnames are
* case-insensitive.
*
* @param hostname
* The hostname to test.
*
* @return
* true if the hostname is the same as the hostname in the primary URI,
* false otherwise.
*
* @throws GuacamoleException
* If the required property for configuring the primary URI is missing
* or cannot be parsed.
*/
public boolean isPrimaryHostname(String hostname) throws GuacamoleException {
URI primaryURI = getPrimaryURI();
return hostname.equalsIgnoreCase(primaryURI.getHost());
}
/**

View File

@@ -0,0 +1,68 @@
/*
* 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.
*/
/**
* A directive which automatically attempts to log the current user in using
* SSL/TLS client authentication when the associated element is clicked.
*/
angular.module('element').directive('guacSslAuth', ['$injector', function guacSslAuth($injector) {
// Required services
var requestService = $injector.get('requestService');
var authenticationService = $injector.get('authenticationService');
var directive = {
restrict: 'A'
};
directive.link = function linkGuacSslAuth($scope, $element) {
/**
* The element which will register the click.
*
* @type Element
*/
const element = $element[0];
// Attempt SSL/TLS client authentication upon click
element.addEventListener('click', function elementClicked() {
// Transform SSL/TLS identity into an opaque "state" value and
// attempt authentication using that value
authenticationService.authenticate(
requestService({
method: 'GET',
headers : {
'Cache-Control' : undefined, // Avoid sending headers that would result in a pre-flight OPTIONS request for CORS
'Pragma' : undefined
},
url: 'api/ext/ssl/identity'
})
.then(function identityRetrieved(data) {
return { 'state' : data.state || '' };
})
)['catch'](requestService.IGNORE);
});
};
return directive;
}]);

View File

@@ -13,6 +13,8 @@
"styles/sso-providers.css"
],
"js" : [ "ssl.min.js" ],
"html" : [
"html/sso-providers.html",
"html/sso-provider-ssl.html"

View File

@@ -1,4 +1,4 @@
<meta name="after-children" content=".login-ui .sso-provider-list:last-child">
<li class="sso-provider sso-provider-ssl"><a href="api/ext/ssl/login">{{
<li class="sso-provider sso-provider-ssl"><a guac-ssl-auth href="">{{
'LOGIN.NAME_IDP_SSL' | translate
}}</a></li>

View File

@@ -0,0 +1,18 @@
/*
* 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.
*/

View File

@@ -0,0 +1,29 @@
/*
* 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.
*/
/**
* The module for code implementing SSO using SSL/TLS client authentication.
*/
angular.module('guacSsoSsl', [
'auth',
'rest'
]);
// Ensure the guacSsoSsl module is loaded along with the rest of the app
angular.module('index').requires.push('guacSsoSsl');