diff --git a/doc/licenses/bouncycastle-fips-1.0.2.1/LICENSE b/doc/licenses/bouncycastle-fips-1.0.2.1/LICENSE new file mode 100644 index 000000000..a02bc176b --- /dev/null +++ b/doc/licenses/bouncycastle-fips-1.0.2.1/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2000 - 2021 The Legion of the Bouncy Castle Inc. +(https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/doc/licenses/bouncycastle-fips-1.0.2.1/README b/doc/licenses/bouncycastle-fips-1.0.2.1/README new file mode 100644 index 000000000..4d92a89ee --- /dev/null +++ b/doc/licenses/bouncycastle-fips-1.0.2.1/README @@ -0,0 +1,8 @@ +BouncyCastle FIPS Distribution (https://www.bouncycastle.org/fips-java) +----------------------------------------------------------------------- + + Version: 1.0.2.1 + From: 'The Legion of Bouncy Castle' (https://www.bouncycastle.org) + License(s): + MIT (bundled/bouncycastle-fips-1.0.2.1/LICENSE) + diff --git a/doc/licenses/bouncycastle-fips-1.0.2.1/dep-coordinates.txt b/doc/licenses/bouncycastle-fips-1.0.2.1/dep-coordinates.txt new file mode 100644 index 000000000..854f49a58 --- /dev/null +++ b/doc/licenses/bouncycastle-fips-1.0.2.1/dep-coordinates.txt @@ -0,0 +1 @@ +org.bouncycastle:bc-fips:jar:1.0.2.1 diff --git a/doc/licenses/jetbrains-annotations-13.0/README b/doc/licenses/jetbrains-annotations-13.0/README new file mode 100644 index 000000000..e09618cab --- /dev/null +++ b/doc/licenses/jetbrains-annotations-13.0/README @@ -0,0 +1,9 @@ +Annotations for JVM-based languages +(https://github.com/JetBrains/java-annotations) +----------------------------------------------- + + Version: 13.0 + From: 'JetBrains s.r.o.' (http://www.jetbrains.com) + License(s): + Apache v2.0 + diff --git a/doc/licenses/jetbrains-annotations-13.0/dep-coordinates.txt b/doc/licenses/jetbrains-annotations-13.0/dep-coordinates.txt new file mode 100644 index 000000000..bb558cd6c --- /dev/null +++ b/doc/licenses/jetbrains-annotations-13.0/dep-coordinates.txt @@ -0,0 +1 @@ +org.jetbrains:annotations:jar:13.0 diff --git a/doc/licenses/kotlin-1.5.30/NOTICE.txt b/doc/licenses/kotlin-1.5.30/NOTICE.txt new file mode 100644 index 000000000..3efb9a198 --- /dev/null +++ b/doc/licenses/kotlin-1.5.30/NOTICE.txt @@ -0,0 +1,2 @@ +Kotlin Compiler +Copyright 2010-2020 JetBrains s.r.o and respective authors and developers diff --git a/doc/licenses/kotlin-1.5.30/README b/doc/licenses/kotlin-1.5.30/README new file mode 100644 index 000000000..f2de926e9 --- /dev/null +++ b/doc/licenses/kotlin-1.5.30/README @@ -0,0 +1,8 @@ +Kotlin (https://kotlinlang.org/) +-------------------------------- + + Version: 1.5.30 + From: 'JetBrains s.r.o and respective authors and developers' + License(s): + Apache v2.0 + diff --git a/doc/licenses/kotlin-1.5.30/dep-coordinates.txt b/doc/licenses/kotlin-1.5.30/dep-coordinates.txt new file mode 100644 index 000000000..474a373f0 --- /dev/null +++ b/doc/licenses/kotlin-1.5.30/dep-coordinates.txt @@ -0,0 +1,5 @@ +org.jetbrains.kotlin:kotlin-reflect:jar:1.5.30 +org.jetbrains.kotlin:kotlin-stdlib:jar:1.5.30 +org.jetbrains.kotlin:kotlin-stdlib-common:jar:1.5.30 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:1.5.30 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:1.5.30 diff --git a/doc/licenses/kotlinx-serialization-1.2.1/NOTICE.txt b/doc/licenses/kotlinx-serialization-1.2.1/NOTICE.txt new file mode 100644 index 000000000..16f7e9bee --- /dev/null +++ b/doc/licenses/kotlinx-serialization-1.2.1/NOTICE.txt @@ -0,0 +1,2 @@ +kotlinx.serialization library. +Copyright 2017-2019 JetBrains s.r.o and respective authors and developers diff --git a/doc/licenses/kotlinx-serialization-1.2.1/README b/doc/licenses/kotlinx-serialization-1.2.1/README new file mode 100644 index 000000000..5ded4539e --- /dev/null +++ b/doc/licenses/kotlinx-serialization-1.2.1/README @@ -0,0 +1,8 @@ +Kotlin Serialization (https://github.com/Kotlin/kotlinx.serialization) +---------------------------------------------------------------------- + + Version: 1.2.1 + From: 'JetBrains s.r.o and respective authors and developers' + License(s): + Apache v2.0 + diff --git a/doc/licenses/kotlinx-serialization-1.2.1/dep-coordinates.txt b/doc/licenses/kotlinx-serialization-1.2.1/dep-coordinates.txt new file mode 100644 index 000000000..e6fe5522d --- /dev/null +++ b/doc/licenses/kotlinx-serialization-1.2.1/dep-coordinates.txt @@ -0,0 +1,2 @@ +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:jar:1.2.1 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:jar:1.2.1 diff --git a/doc/licenses/ksm-sdk-16.2.1/LICENSE b/doc/licenses/ksm-sdk-16.2.1/LICENSE new file mode 100644 index 000000000..b63322a17 --- /dev/null +++ b/doc/licenses/ksm-sdk-16.2.1/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Keeper Security + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/doc/licenses/ksm-sdk-16.2.1/README b/doc/licenses/ksm-sdk-16.2.1/README new file mode 100644 index 000000000..4bf0527cf --- /dev/null +++ b/doc/licenses/ksm-sdk-16.2.1/README @@ -0,0 +1,9 @@ +Keeper Secrets Manager Java SDK +(https://github.com/Keeper-Security/secrets-manager) +---------------------------------------------------- + + Version: 16.2.1 + From: 'Keeper Security' (https://www.keepersecurity.com/) + License(s): + MIT (bundled/ksm-sdk-16.2.1/LICENSE) + diff --git a/doc/licenses/ksm-sdk-16.2.1/dep-coordinates.txt b/doc/licenses/ksm-sdk-16.2.1/dep-coordinates.txt new file mode 100644 index 000000000..5b9330316 --- /dev/null +++ b/doc/licenses/ksm-sdk-16.2.1/dep-coordinates.txt @@ -0,0 +1 @@ +com.keepersecurity.secrets-manager:core:jar:16.2.1 diff --git a/doc/licenses/snakeyaml-1.27/README b/doc/licenses/snakeyaml-1.27/README new file mode 100644 index 000000000..3fcd837d6 --- /dev/null +++ b/doc/licenses/snakeyaml-1.27/README @@ -0,0 +1,8 @@ +SnakeYAML (https://bitbucket.org/asomov/snakeyaml/) +--------------------------------------------------- + + Version: 1.27 + From: 'Andrey Somov' (https://bitbucket.org/asomov/) + License(s): + Apache v2.0 + diff --git a/doc/licenses/snakeyaml-1.27/dep-coordinates.txt b/doc/licenses/snakeyaml-1.27/dep-coordinates.txt new file mode 100644 index 000000000..d7cbad91a --- /dev/null +++ b/doc/licenses/snakeyaml-1.27/dep-coordinates.txt @@ -0,0 +1 @@ +org.yaml:snakeyaml:jar:1.27 diff --git a/extensions/guacamole-vault/.ratignore b/extensions/guacamole-vault/.ratignore new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-base/.ratignore new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml new file mode 100644 index 000000000..96fe1180a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml @@ -0,0 +1,74 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault-base + jar + guacamole-vault-base + http://guacamole.apache.org/ + + + UTF-8 + + + + org.apache.guacamole + guacamole-vault + 1.4.0 + ../../ + + + + + + + org.apache.guacamole + guacamole-ext + provided + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + + com.google.inject + guice + + + com.google.inject.extensions + guice-assistedinject + + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/VaultAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/VaultAuthenticationProvider.java new file mode 100644 index 000000000..440ef95d1 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/VaultAuthenticationProvider.java @@ -0,0 +1,77 @@ +/* + * 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.vault; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.net.auth.AbstractAuthenticationProvider; +import org.apache.guacamole.net.auth.AuthenticatedUser; +import org.apache.guacamole.net.auth.Credentials; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.user.VaultUserContextFactory; + +/** + * AuthenticationProvider implementation which automatically injects tokens + * containing the values of secrets retrieved from a vault. + */ +public abstract class VaultAuthenticationProvider + extends AbstractAuthenticationProvider { + + /** + * Factory for creating instances of the relevant vault-specific + * UserContext implementation. + */ + private final VaultUserContextFactory userContextFactory; + + /** + * Creates a new VaultAuthenticationProvider which uses the given module to + * configure dependency injection. + * + * @param module + * The module to use to configure dependency injection. + * + * @throws GuacamoleException + * If the properties file containing vault-mapped Guacamole + * configuration properties exists but cannot be read. + */ + protected VaultAuthenticationProvider(VaultAuthenticationProviderModule module) + throws GuacamoleException { + + Injector injector = Guice.createInjector(module); + this.userContextFactory = injector.getInstance(VaultUserContextFactory.class); + + // Automatically pull properties from vault + Environment environment = injector.getInstance(Environment.class); + VaultConfigurationService confService = injector.getInstance(VaultConfigurationService.class); + environment.addGuacamoleProperties(confService.getProperties()); + + } + + @Override + public UserContext decorate(UserContext context, + AuthenticatedUser authenticatedUser, Credentials credentials) + throws GuacamoleException { + return userContextFactory.create(context); + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/VaultAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/VaultAuthenticationProviderModule.java new file mode 100644 index 000000000..a790d0119 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/VaultAuthenticationProviderModule.java @@ -0,0 +1,99 @@ +/* + * 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.vault; + +import com.google.inject.AbstractModule; +import com.google.inject.assistedinject.FactoryModuleBuilder; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.environment.LocalEnvironment; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.vault.user.VaultUserContext; +import org.apache.guacamole.vault.user.VaultUserContextFactory; + +/** + * Guice module which configures injections specific to the base support for + * key vaults. When adding support for a key vault provider, a subclass + * specific to that vault implementation will need to be created. + * + * @see KsmAuthenticationProviderModule + */ +public abstract class VaultAuthenticationProviderModule extends AbstractModule { + + /** + * Guacamole server environment. + */ + private final Environment environment; + + /** + * Creates a new VaultAuthenticationProviderModule which configures + * dependency injection for the authentication provider of a vault + * implementation. + * + * @throws GuacamoleException + * If an error occurs while retrieving the Guacamole server + * environment. + */ + public VaultAuthenticationProviderModule() throws GuacamoleException { + this.environment = LocalEnvironment.getInstance(); + } + + /** + * Configures injections for interfaces which are implementation-specific + * to the vault service in use. Subclasses MUST provide a version of this + * function which binds concrete implementations to the following + * interfaces: + * + * - VaultConfigurationService + * - VaultSecretService + * + * @see KsmAuthenticationProviderModule + */ + protected abstract void configureVault(); + + /** + * Returns the instance of the Guacamole server environment which will be + * exposed to other classes via dependency injection. + * + * @return + * The instance of the Guacamole server environment which will be + * exposed via dependency injection. + */ + protected Environment getEnvironment() { + return environment; + } + + @Override + protected void configure() { + + // Bind Guacamole server environment + bind(Environment.class).toInstance(environment); + + // Bind factory for creating UserContexts + install(new FactoryModuleBuilder() + .implement(UserContext.class, VaultUserContext.class) + .build(VaultUserContextFactory.class)); + + // Bind all other implementation-specific interfaces + configureVault(); + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java new file mode 100644 index 000000000..a666a7b97 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java @@ -0,0 +1,190 @@ +/* + * 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.vault.conf; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.inject.Inject; +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.FileGuacamoleProperties; +import org.apache.guacamole.properties.GuacamoleProperties; +import org.apache.guacamole.properties.PropertiesGuacamoleProperties; +import org.apache.guacamole.vault.VaultAuthenticationProviderModule; +import org.apache.guacamole.vault.secret.VaultSecretService; + +/** + * Base class for services which retrieve key vault configuration information. + * A concrete implementation of this class must be defined and bound for key + * vault support to work. + * + * @see VaultAuthenticationProviderModule + */ +public abstract class VaultConfigurationService { + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + @Inject + private VaultSecretService secretService; + + /** + * ObjectMapper for deserializing YAML. + */ + private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + + /** + * The name of the file containing a YAML mapping of Guacamole parameter + * token to vault secret name. + */ + private final String tokenMappingFilename; + + /** + * The name of the properties file containing Guacamole configuration + * properties. Unlike guacamole.properties, the values of these properties + * are read from the vault. Each property is expected to contain a secret + * name instead of a property value. + */ + private final String propertiesFilename; + + /** + * Creates a new VaultConfigurationService which retrieves the token/secret + * mappings and Guacamole configuration properties from the files with the + * given names. + * + * @param tokenMappingFilename + * The name of the YAML file containing the token/secret mapping. + * + * @param propertiesFilename + * The name of the properties file containing Guacamole configuration + * properties whose values are the names of corresponding secrets. + */ + protected VaultConfigurationService(String tokenMappingFilename, + String propertiesFilename) { + this.tokenMappingFilename = tokenMappingFilename; + this.propertiesFilename = propertiesFilename; + } + + /** + * Returns a mapping dictating the name of the secret which maps to each + * parameter token. In the returned mapping, the value of each entry is the + * name of the secret to use to populate the value of the parameter token, + * and the key of each entry is the name of the parameter token which + * should receive the value of the secret. + * + * The name of the secret may contain its own tokens, which will be + * substituted using values from the given filter. See the definition of + * VaultUserContext for the names of these tokens and the contexts in which + * they can be applied to secret names. + * + * @return + * A mapping dictating the name of the secret which maps to each + * parameter token. + * + * @throws GuacamoleException + * If the YAML file defining the token/secret mapping cannot be read. + */ + public Map getTokenMapping() throws GuacamoleException { + + // Get configuration file from GUACAMOLE_HOME + File confFile = new File(environment.getGuacamoleHome(), tokenMappingFilename); + if (!confFile.exists()) + return Collections.emptyMap(); + + // Deserialize token mapping from YAML + try { + + Map mapping = mapper.readValue(confFile, new TypeReference>() {}); + if (mapping == null) + return Collections.emptyMap(); + + return mapping; + + } + + // Fail if YAML is invalid/unreadable + catch (IOException e) { + throw new GuacamoleServerException("Unable to read token mapping " + + "configuration file \"" + tokenMappingFilename + "\".", e); + } + + } + + /** + * Returns a GuacamoleProperties instance which automatically reads the + * values of requested properties from the vault. The name of the secret + * corresponding to a property stored in the vault is defined via the + * properties filename supplied at construction time. + * + * @return + * A GuacamoleProperties instance which automatically reads property + * values from the vault. + * + * @throws GuacamoleException + * If the properties file containing the property/secret mappings + * exists but cannot be read. + */ + public GuacamoleProperties getProperties() throws GuacamoleException { + + // Use empty properties if file cannot be found + File propFile = new File(environment.getGuacamoleHome(), propertiesFilename); + if (!propFile.exists()) + return new PropertiesGuacamoleProperties(new Properties()); + + // Automatically pull properties from vault + return new FileGuacamoleProperties(propFile) { + + @Override + public String getProperty(String name) throws GuacamoleException { + try { + + String secretName = super.getProperty(name); + if (secretName == null) + return null; + + return secretService.getValue(secretName).get(); + + } + catch (InterruptedException | ExecutionException e) { + + if (e.getCause() instanceof GuacamoleException) + throw (GuacamoleException) e; + + throw new GuacamoleServerException(String.format("Property " + + "\"%s\" could not be retrieved from the vault.", name), e); + } + } + + }; + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/CachedVaultSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/CachedVaultSecretService.java new file mode 100644 index 000000000..b26fdba94 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/CachedVaultSecretService.java @@ -0,0 +1,200 @@ +/* + * 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.vault.secret; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Caching implementation of VaultSecretService. Requests for the values of + * secrets will automatically be cached for a duration determined by the + * implementation. Subclasses must implement refreshCachedSecret() to provide + * a mechanism for CachedVaultSecretService to explicitly retrieve a value + * which is missing from the cache or has expired. + */ +public abstract class CachedVaultSecretService implements VaultSecretService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(CachedVaultSecretService.class); + + /** + * The cached value of a secret. + */ + protected class CachedSecret { + + /** + * A Future which contains or will contain the value of the secret at + * the time it was last retrieved. + */ + private final Future value; + + /** + * The time the value should be considered out-of-date, in milliseconds + * since midnight of January 1, 1970 UTC. + */ + private final long expires; + + /** + * Creates a new CachedSecret which represents a cached snapshot of the + * value of a secret. Each CachedSecret has a limited lifespan after + * which it should be considered out-of-date. + * + * @param value + * A Future which contains or will contain the current value of the + * secret. If no such secret exists, the given Future should + * complete with null. + * + * @param ttl + * The maximum number of milliseconds that this value should be + * cached. + */ + public CachedSecret(Future value, int ttl) { + this.value = value; + this.expires = System.currentTimeMillis() + ttl; + } + + /** + * Returns the value of the secret at the time it was last retrieved. + * The actual value of the secret may have changed. + * + * @return + * A Future which will eventually complete with the value of the + * secret at the time it was last retrieved. If no such secret + * exists, the Future will be completed with null. If an error + * occurs which prevents retrieval of the secret, that error will + * be exposed through an ExecutionException when an attempt is made + * to retrieve the value from the Future. + */ + public Future getValue() { + return value; + } + + /** + * Returns whether this specific cached value has expired. Expired + * values will be automatically refreshed by CachedVaultSecretService. + * + * @return + * true if this cached value has expired, false otherwise. + */ + public boolean isExpired() { + return System.currentTimeMillis() >= expires; + } + + } + + /** + * Cache of past requests to retrieve secrets. Expired secrets are lazily + * removed. + */ + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + /** + * Explicitly retrieves the value of the secret having the given name, + * returning a result that can be cached. The length of time that this + * specific value will be cached is determined by the TTL value provided to + * the returned CachedSecret. This function will be automatically invoked + * in response to calls to getValue() when the requested secret is either + * not cached or has expired. Expired secrets are not removed from the + * cache until another request is made for that secret. + * + * @param name + * The name of the secret to retrieve. + * + * @return + * A CachedSecret which defines the current value of the secret and the + * point in time that value should be considered potentially + * out-of-date. + * + * @throws GuacamoleException + * If an error occurs while retrieving the secret from the vault. + */ + protected abstract CachedSecret refreshCachedSecret(String name) + throws GuacamoleException; + + @Override + public Future getValue(String name) throws GuacamoleException { + + CompletableFuture refreshEntry; + + try { + + // Attempt to use cached result of previous call + Future cachedEntry = cache.get(name); + if (cachedEntry != null) { + + // Use cached result if not yet expired + CachedSecret secret = cachedEntry.get(); + if (!secret.isExpired()) { + logger.debug("Using cached secret for \"{}\".", name); + return secret.getValue(); + } + + // Evict if expired + else { + logger.debug("Cached secret for \"{}\" is expired.", name); + cache.remove(name, cachedEntry); + } + + } + + // If no cached result, or result is too old, race with other + // threads to be the thread which refreshes the entry + refreshEntry = new CompletableFuture<>(); + cachedEntry = cache.putIfAbsent(name, refreshEntry); + + // If a refresh operation is already in progress, wait for that + // operation to complete and use its value + if (cachedEntry != null) + return cachedEntry.get().getValue(); + + } + catch (InterruptedException | ExecutionException e) { + throw new GuacamoleServerException("Attempt to retrieve secret " + + "failed.", e); + } + + // If we reach this far, the cache entry is stale or missing, and it's + // this thread's responsibility to refresh the entry + try { + CachedSecret secret = refreshCachedSecret(name); + refreshEntry.complete(secret); + logger.debug("Cached secret for \"{}\" will be refreshed.", name); + return secret.getValue(); + } + + // Abort the refresh operation if an error occurs + catch (Error | RuntimeException | GuacamoleException e) { + refreshEntry.completeExceptionally(e); + cache.remove(name, refreshEntry); + logger.debug("Cached secret for \"{}\" could not be refreshed.", name); + throw e; + } + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java new file mode 100644 index 000000000..76349bad9 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java @@ -0,0 +1,105 @@ +/* + * 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.vault.secret; + +import java.util.Map; +import java.util.concurrent.Future; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.protocol.GuacamoleConfiguration; +import org.apache.guacamole.token.TokenFilter; + +/** + * Generic service for retrieving the value of a secret stored in a vault. + */ +public interface VaultSecretService { + + /** + * Translates an arbitrary string, which may contain characters not allowed + * by the vault implementation, into a string which is valid within a + * secret name. The type of transformation performed on the string, if any, + * will depend on the specific requirements of the vault provider. + * + * NOTE: It is critical that this transformation is deterministic and + * reasonably predictable for users. If an implementation must apply a + * transformation to secret names, that transformation needs to be + * documented. + * + * @param nameComponent + * An arbitrary string intended for use within a secret name, but which + * may contain characters not allowed by the vault implementation. + * + * @return + * A string containing essentially the same content as the provided + * string, but transformed deterministically such that it is acceptable + * as a component of a secret name by the vault provider. + */ + String canonicalize(String nameComponent); + + /** + * Returns a Future which eventually completes with the value of the secret + * having the given name. If no such secret exists, the Future will be + * completed with null. + * + * @param name + * The name of the secret to retrieve. + * + * @return + * A Future which completes with value of the secret having the given + * name. If no such secret exists, the Future will be completed with + * null. If an error occurs asynchronously which prevents retrieval of + * the secret, that error will be exposed through an ExecutionException + * when an attempt is made to retrieve the value from the Future. + * + * @throws GuacamoleException + * If the secret cannot be retrieved due to an error. + */ + Future getValue(String name) throws GuacamoleException; + + /** + * Returns a map of token names to corresponding Futures which eventually + * complete with the value of that token, where each token is dynamically + * defined based on connection parameters. If a vault implementation allows + * for predictable secrets based on the parameters of a connection, this + * function should be implemented to provide automatic tokens for those + * secrets and remove the need for manual mapping via YAML. + * + * @param config + * The configuration of the Guacamole connection for which tokens are + * being generated. This configuration may be empty or partial, + * depending on the underlying implementation. + * + * @param filter + * A TokenFilter instance that applies any tokens already available to + * be applied to the configuration of the Guacamole connection. These + * tokens will consist of tokens already supplied to connect(). + * + * @return + * A map of token names to their corresponding future values, where + * each token and value may be dynamically determined based on the + * connection configuration. + * + * @throws GuacamoleException + * If an error occurs producing the tokens and values required for the + * given configuration. + */ + Map> getTokens(GuacamoleConfiguration config, + TokenFilter filter) throws GuacamoleException; + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java new file mode 100644 index 000000000..53901483e --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java @@ -0,0 +1,406 @@ +/* + * 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.vault.user; + +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.TokenInjectingUserContext; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.protocol.GuacamoleConfiguration; +import org.apache.guacamole.token.GuacamoleTokenUndefinedException; +import org.apache.guacamole.token.TokenFilter; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * UserContext implementation which automatically injects tokens containing the + * values of secrets retrieved from a vault. + */ +public class VaultUserContext extends TokenInjectingUserContext { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(VaultUserContext.class); + + /** + * The name of the token which will be replaced with the username of the + * current user if specified within the name of a secret. Unlike the + * standard GUAC_USERNAME token, the username stored with the object + * representing the user is used here, not necessarily the username + * provided during authentication. This token applies to both connections + * and connection groups. + */ + private static final String USERNAME_TOKEN = "USERNAME"; + + /** + * The name of the token which will be replaced with the name of the + * current connection group if specified within the name of a secret. This + * token only applies only to connection groups. + */ + private static final String CONNECTION_GROUP_NAME_TOKEN = "CONNECTION_GROUP_NAME"; + + /** + * The name of the token which will be replaced with the identifier of the + * current connection group if specified within the name of a secret. This + * token only applies only to connection groups. + */ + private static final String CONNECTION_GROUP_IDENTIFIER_TOKEN = "CONNECTION_GROUP_ID"; + + /** + * The name of the token which will be replaced with the \"hostname\" + * connection parameter of the current connection if specified within the + * name of a secret. If the \"hostname\" parameter cannot be retrieved, or + * if the parameter is blank, the token will not be replaced and any + * secrets involving that token will not be retrieved. This token only + * applies only to connections. + */ + private static final String CONNECTION_HOSTNAME_TOKEN = "CONNECTION_HOSTNAME"; + + /** + * The name of the token which will be replaced with the \"username\" + * connection parameter of the current connection if specified within the + * name of a secret. If the \"username\" parameter cannot be retrieved, or + * if the parameter is blank, the token will not be replaced and any + * secrets involving that token will not be retrieved. This token only + * applies only to connections. + */ + private static final String CONNECTION_USERNAME_TOKEN = "CONNECTION_USERNAME"; + + /** + * The name of the token which will be replaced with the name of the + * current connection if specified within the name of a secret. This token + * only applies only to connections. + */ + private static final String CONNECTION_NAME_TOKEN = "CONNECTION_NAME"; + + /** + * The name of the token which will be replaced with the identifier of the + * current connection if specified within the name of a secret. This token + * only applies only to connections. + */ + private static final String CONNECTION_IDENTIFIER_TOKEN = "CONNECTION_ID"; + + /** + * Service for retrieving configuration information. + */ + @Inject + private VaultConfigurationService confService; + + /** + * Service for retrieving the values of secrets stored in a vault. + */ + @Inject + private VaultSecretService secretService; + + /** + * Creates a new VaultUserContext which automatically injects tokens + * containing values of secrets retrieved from a vault. The given + * UserContext is decorated such that connections and connection groups + * will receive additional tokens during the connection process. + * + * Note that this class depends on concrete implementations of the + * following classes to be provided via dependency injection: + * + * - VaultConfigurationService + * - VaultSecretService + * + * Bindings providing these concrete implementations will need to be + * provided by subclasses of VaultAuthenticationProviderModule for each + * supported vault. + * + * @param userContext + * The UserContext instance to decorate. + */ + @AssistedInject + public VaultUserContext(@Assisted UserContext userContext) { + super(userContext); + } + + /** + * Creates a new TokenFilter instance with token values set for all tokens + * which are not specific to connections or connection groups. Currently, + * this is only the vault-specific username token ("USERNAME"). Each token + * stored within the returned TokenFilter via setToken() will be + * automatically canonicalized for use within secret names. + * + * @return + * A new TokenFilter instance with token values set for all tokens + * which are not specific to connections or connection groups. + */ + private TokenFilter createFilter() { + + // Create filter that automatically canonicalizes all token values + TokenFilter filter = new TokenFilter() { + + @Override + public void setToken(String name, String value) { + super.setToken(name, secretService.canonicalize(value)); + } + + @Override + public void setTokens(Map tokens) { + tokens.entrySet().forEach((entry) -> setToken(entry.getKey(), entry.getValue())); + } + + }; + + filter.setToken(USERNAME_TOKEN, self().getIdentifier()); + return filter; + } + + /** + * Initiates asynchronous retrieval of all applicable tokens and + * corresponding values from the vault, using the given TokenFilter to + * filter tokens within the secret names prior to retrieving those secrets. + * + * @param tokenMapping + * The mapping dictating the name of the secret which maps to each + * parameter token, where the key is the name of the parameter token + * and the value is the name of the secret. The name of the secret + * may contain its own tokens, which will be substituted using values + * from the given filter. + * + * @param secretNameFilter + * The filter to use to substitute values for tokens in the names of + * secrets to be retrieved from the vault. + * + * @param config + * The GuacamoleConfiguration of the connection for which tokens are + * being retrieved, if available. This may be null. + * + * @param configFilter + * A TokenFilter instance that applies any tokens already available to + * be applied to the configuration of the Guacamole connection. These + * tokens will consist of tokens already supplied to connect(). + * + * @return + * A Map of token name to Future, where each Future represents the + * pending retrieval operation which will ultimately be completed with + * the value of all secrets mapped to that token. + * + * @throws GuacamoleException + * If the value for any applicable secret cannot be retrieved from the + * vault due to an error. + */ + private Map> getTokens(Map tokenMapping, + TokenFilter secretNameFilter, GuacamoleConfiguration config, + TokenFilter configFilter) throws GuacamoleException { + + // Populate map with pending secret retrieval operations corresponding + // to each mapped token + Map> pendingTokens = new HashMap<>(tokenMapping.size()); + for (Map.Entry entry : tokenMapping.entrySet()) { + + // Translate secret pattern into secret name, ignoring any + // secrets which cannot be translated + String secretName; + try { + secretName = secretNameFilter.filterStrict(entry.getValue()); + } + catch (GuacamoleTokenUndefinedException e) { + logger.debug("Secret for token \"{}\" will not be retrieved. " + + "Token \"{}\" within mapped secret name has no " + + "defined value in the current context.", + entry.getKey(), e.getTokenName()); + continue; + } + + // Initiate asynchronous retrieval of the token value + String tokenName = entry.getKey(); + Future secret = secretService.getValue(secretName); + pendingTokens.put(tokenName, secret); + + } + + // Additionally include any dynamic, parameter-based tokens + pendingTokens.putAll(secretService.getTokens(config, configFilter)); + + return pendingTokens; + + } + + /** + * Waits for all pending secret retrieval operations to complete, + * transforming each Future within the given Map into its contained String + * value. + * + * @param pendingTokens + * A Map of token name to Future, where each Future represents the + * pending retrieval operation which will ultimately be completed with + * the value of all secrets mapped to that token. + * + * @return + * A Map of token name to the corresponding String value retrieved for + * that token from the vault. + * + * @throws GuacamoleException + * If the value for any applicable secret cannot be retrieved from the + * vault due to an error. + */ + private Map resolve(Map> pendingTokens) throws GuacamoleException { + + // Populate map with tokens containing the values of their + // corresponding secrets + Map tokens = new HashMap<>(pendingTokens.size()); + for (Map.Entry> entry : pendingTokens.entrySet()) { + + // Complete secret retrieval operation, blocking if necessary + String secretValue; + try { + secretValue = entry.getValue().get(); + } + catch (InterruptedException | ExecutionException e) { + throw new GuacamoleServerException("Retrieval of secret value " + + "failed.", e); + } + + // If a value is defined for the secret in question, store that + // value under the mapped token + String tokenName = entry.getKey(); + if (secretValue != null) { + tokens.put(tokenName, secretValue); + logger.debug("Token \"{}\" populated with value from " + + "secret.", tokenName); + } + else + logger.debug("Token \"{}\" not populated. Mapped " + + "secret has no value.", tokenName); + + } + + return tokens; + + } + + @Override + protected void addTokens(ConnectionGroup connectionGroup, + Map tokens) throws GuacamoleException { + + String name = connectionGroup.getName(); + String identifier = connectionGroup.getIdentifier(); + logger.debug("Injecting tokens from vault for connection group " + + "\"{}\" (\"{}\").", identifier, name); + + // Add general and connection-group-specific tokens + TokenFilter filter = createFilter(); + filter.setToken(CONNECTION_GROUP_NAME_TOKEN, name); + filter.setToken(CONNECTION_GROUP_IDENTIFIER_TOKEN, identifier); + + // Substitute tokens producing secret names, retrieving and storing + // those secrets as parameter tokens + tokens.putAll(resolve(getTokens(confService.getTokenMapping(), filter, + null, new TokenFilter(tokens)))); + + } + + /** + * Retrieves the GuacamoleConfiguration of the given Connection. If + * possible, privileged access to the configuration is obtained first. Note + * that the underlying extension is not required to allow privileged + * access, nor is it required to expose the underlying configuration at + * all. + * + * @param connection + * The connection to retrieve the configuration from. + * + * @return + * The GuacamoleConfiguration associated with the given connection, + * which may be partial or empty. + * + * @throws GuacamoleException + * If an error prevents privileged retrieval of the configuration. + */ + private GuacamoleConfiguration getConnectionConfiguration(Connection connection) + throws GuacamoleException { + + String identifier = connection.getIdentifier(); + + // Obtain privileged access to parameters if possible (note that the + // UserContext returned by getPrivileged() is not guaranteed to + // actually be privileged) + Connection privilegedConnection = getPrivileged().getConnectionDirectory().get(identifier); + if (privilegedConnection != null) + return privilegedConnection.getConfiguration(); + + // Fall back to unprivileged access if not implemented/allowed by + // extension + return connection.getConfiguration(); + + } + + @Override + protected void addTokens(Connection connection, Map tokens) + throws GuacamoleException { + + String name = connection.getName(); + String identifier = connection.getIdentifier(); + logger.debug("Injecting tokens from vault for connection \"{}\" " + + "(\"{}\").", identifier, name); + + // Add general and connection-specific tokens + TokenFilter filter = createFilter(); + filter.setToken(CONNECTION_NAME_TOKEN, connection.getName()); + filter.setToken(CONNECTION_IDENTIFIER_TOKEN, identifier); + + // Add hostname and username tokens if available (implementations are + // not required to expose connection configuration details) + + GuacamoleConfiguration config = getConnectionConfiguration(connection); + Map parameters = config.getParameters(); + + String hostname = parameters.get("hostname"); + if (hostname != null && !hostname.isEmpty()) + filter.setToken(CONNECTION_HOSTNAME_TOKEN, hostname); + else + logger.debug("Hostname for connection \"{}\" (\"{}\") not " + + "available. \"{}\" token will not be populated in " + + "secret names.", identifier, name, + CONNECTION_HOSTNAME_TOKEN); + + String username = parameters.get("username"); + if (username != null && !username.isEmpty()) + filter.setToken(CONNECTION_USERNAME_TOKEN, username); + else + logger.debug("Username for connection \"{}\" (\"{}\") not " + + "available. \"{}\" token will not be populated in " + + "secret names.", identifier, name, + CONNECTION_USERNAME_TOKEN); + + // Substitute tokens producing secret names, retrieving and storing + // those secrets as parameter tokens + tokens.putAll(resolve(getTokens(confService.getTokenMapping(), filter, + config, new TokenFilter(tokens)))); + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContextFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContextFactory.java new file mode 100644 index 000000000..03e6c3a9b --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContextFactory.java @@ -0,0 +1,46 @@ +/* + * 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.vault.user; + +import org.apache.guacamole.net.auth.UserContext; + +/** + * Factory for creating UserContext instances which automatically inject tokens + * containing the values of secrets retrieved from a vault. + */ +public interface VaultUserContextFactory { + + /** + * Returns a new instance of a UserContext implementation which + * automatically injects tokens containing values of secrets retrieved from + * a vault. The given UserContext is decorated such that connections and + * connection groups will receive additional tokens during the connection + * process. + * + * @param userContext + * The UserContext instance to decorate. + * + * @return + * A new UserContext instance which automatically injects tokens + * containing values of secrets retrieved from a vault. + */ + UserContext create(UserContext userContext); + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/resources/translations/en.json new file mode 100644 index 000000000..c96ec2821 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/resources/translations/en.json @@ -0,0 +1,7 @@ +{ + + "DATA_SOURCE_AZURE_KEYVAULT" : { + "NAME" : "Azure Key Vault" + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-dist/.ratignore new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml new file mode 100644 index 000000000..1b2cb5996 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml @@ -0,0 +1,62 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault-dist + pom + guacamole-vault-dist + http://guacamole.apache.org/ + + + UTF-8 + + + + org.apache.guacamole + guacamole-vault + 1.4.0 + ../../ + + + + + + + org.apache.guacamole + guacamole-vault-ksm + 1.4.0 + + + + + + + + ${project.parent.artifactId}-${project.parent.version} + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml new file mode 100644 index 000000000..6f41e5278 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml @@ -0,0 +1,54 @@ + + + + + dist + ${project.parent.artifactId}-${project.parent.version} + + + + tar.gz + + + + + + + + ksm + + org.apache.guacamole:guacamole-vault-ksm + + + + + + + + + + target/licenses + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-ksm/.ratignore new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-ksm/pom.xml new file mode 100644 index 000000000..ac3b75f9a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/pom.xml @@ -0,0 +1,98 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault-ksm + jar + 1.4.0 + guacamole-vault-ksm + http://guacamole.apache.org/ + + + org.apache.guacamole + guacamole-vault + 1.4.0 + ../../ + + + + 1.5.30 + + + + + + + org.apache.guacamole + guacamole-ext + provided + + + + + org.apache.guacamole + guacamole-vault-base + 1.4.0 + + + + com.keepersecurity.secrets-manager + core + 16.2.1 + + + + + org.jetbrains.kotlin + kotlin-stdlib + + + org.jetbrains.kotlin + kotlin-stdlib-common + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProvider.java new file mode 100644 index 000000000..a24dfc875 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProvider.java @@ -0,0 +1,47 @@ +/* + * 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.vault.ksm; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProvider; + +/** + * VaultAuthenticationProvider implementation which reads secrets from Keeper + * Secrets Manager + */ +public class KsmAuthenticationProvider extends VaultAuthenticationProvider { + + /** + * Creates a new KsmKeyVaultAuthenticationProvider which reads secrets + * from a configured Keeper Secrets Manager. + * + * @throws GuacamoleException + * If configuration details cannot be read from guacamole.properties. + */ + public KsmAuthenticationProvider() throws GuacamoleException { + super(new KsmAuthenticationProviderModule()); + } + + @Override + public String getIdentifier() { + return "keeper-secrets-manager"; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java new file mode 100644 index 000000000..bcc5a784e --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java @@ -0,0 +1,58 @@ +/* + * 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.vault.ksm; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProviderModule; +import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; +import org.apache.guacamole.vault.ksm.secret.KsmSecretService; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.ksm.secret.KsmClient; +import org.apache.guacamole.vault.ksm.secret.KsmRecordService; +import org.apache.guacamole.vault.secret.VaultSecretService; + +/** + * Guice module which configures injections specific to Keeper Secrets + * Manager support. + */ +public class KsmAuthenticationProviderModule + extends VaultAuthenticationProviderModule { + + /** + * Creates a new KsmAuthenticationProviderModule which + * configures dependency injection for the Keeper Secrets Manager + * authentication provider and related services. + * + * @throws GuacamoleException + * If configuration details in guacamole.properties cannot be parsed. + */ + public KsmAuthenticationProviderModule() throws GuacamoleException {} + + @Override + protected void configureVault() { + + // Bind services specific to Keeper Secrets Manager + bind(KsmClient.class); + bind(KsmRecordService.class); + bind(VaultConfigurationService.class).to(KsmConfigurationService.class); + bind(VaultSecretService.class).to(KsmSecretService.class); + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java new file mode 100644 index 000000000..aaddb0de0 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java @@ -0,0 +1,53 @@ +/* + * 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.vault.ksm.conf; + +import com.keepersecurity.secretsManager.core.InMemoryStorage; +import com.keepersecurity.secretsManager.core.KeyValueStorage; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.properties.GuacamoleProperty; + +/** + * A GuacamoleProperty whose value is Keeper Secrets Manager {@link KeyValueStorage} + * object. The value of this property must be base64-encoded JSON, as output by + * the Keeper Commander CLI tool via the "sm client add" command. + */ +public abstract class KsmConfigProperty implements GuacamoleProperty { + + @Override + public KeyValueStorage parseValue(String value) throws GuacamoleException { + + // If no property provided, return null. + if (value == null) + return null; + + // Parse base64 value as KSM config storage + try { + return new InMemoryStorage(value); + } + catch (IllegalArgumentException e) { + throw new GuacamoleServerException("Invalid base64 configuration " + + "for Keeper Secrets Manager.", e); + } + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java new file mode 100644 index 000000000..38bcaaef1 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java @@ -0,0 +1,126 @@ +/* + * 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.vault.ksm.conf; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.BooleanGuacamoleProperty; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import com.keepersecurity.secretsManager.core.SecretsManagerOptions; + +/** + * Service for retrieving configuration information regarding the Keeper + * Secrets Manager authentication extension. + */ +@Singleton +public class KsmConfigurationService extends VaultConfigurationService { + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * The name of the file which contains the YAML mapping of connection + * parameter token to secrets within Keeper Secrets Manager. + */ + private static final String TOKEN_MAPPING_FILENAME = "ksm-token-mapping.yml"; + + /** + * The name of the properties file containing Guacamole configuration + * properties whose values are the names of corresponding secrets within + * Keeper Secrets Manager. + */ + private static final String PROPERTIES_FILENAME = "guacamole.properties.ksm"; + + /** + * The base64-encoded configuration information generated by the Keeper + * Commander CLI tool. + */ + private static final KsmConfigProperty KSM_CONFIG = new KsmConfigProperty() { + + @Override + public String getName() { + return "ksm-config"; + } + }; + + /** + * Whether unverified server certificates should be accepted. + */ + private static final BooleanGuacamoleProperty ALLOW_UNVERIFIED_CERT = new BooleanGuacamoleProperty() { + + @Override + public String getName() { + return "ksm-allow-unverified-cert"; + } + }; + + /** + * Creates a new KsmConfigurationService which reads the configuration + * from "ksm-token-mapping.yml" and properties from + * "guacamole.properties.ksm". The token mapping is a YAML file which lists + * each connection parameter token and the name of the secret from which + * the value for that token should be read, while the properties file is an + * alternative to guacamole.properties where each property value is the + * name of a secret containing the actual value. + */ + public KsmConfigurationService() { + super(TOKEN_MAPPING_FILENAME, PROPERTIES_FILENAME); + } + + /** + * Return whether unverified server certificates should be accepted when + * communicating with Keeper Secrets Manager. + * + * @return + * true if unverified server certificates should be accepted, false + * otherwise. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed. + */ + public boolean getAllowUnverifiedCertificate() throws GuacamoleException { + return environment.getProperty(ALLOW_UNVERIFIED_CERT, false); + } + + /** + * Returns the options required to authenticate with Keeper Secrets Manager + * when retrieving secrets. These options are read from the contents of + * base64-encoded JSON configuration data generated by the Keeper Commander + * CLI tool. + * + * @return + * The options that should be used when connecting to Keeper Secrets + * Manager when retrieving secrets. + * + * @throws GuacamoleException + * If required properties are not specified within + * guacamole.properties or cannot be parsed. + */ + public SecretsManagerOptions getSecretsManagerOptions() throws GuacamoleException { + return new SecretsManagerOptions(environment.getRequiredProperty(KSM_CONFIG), null, + getAllowUnverifiedCertificate()); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java new file mode 100644 index 000000000..2372dcb29 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java @@ -0,0 +1,478 @@ +/* + * 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.vault.ksm.secret; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.keepersecurity.secretsManager.core.Hosts; +import com.keepersecurity.secretsManager.core.KeeperFile; +import com.keepersecurity.secretsManager.core.KeeperRecord; +import com.keepersecurity.secretsManager.core.KeeperSecrets; +import com.keepersecurity.secretsManager.core.Login; +import com.keepersecurity.secretsManager.core.Notation; +import com.keepersecurity.secretsManager.core.SecretsManager; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Client which retrieves records from Keeper Secrets Manager, allowing + * content-based record retrieval. Note that because KSM is zero-knowledge, + * searching or indexing based on content can only be accomplished by + * retrieving and indexing everything. Except for record UIDs (which contain no + * information), it's not possible for the server to perform a search of + * content on the client's behalf. The client has to perform its own search. + */ +@Singleton +public class KsmClient { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(KsmClient.class); + + /** + * Service for retrieving configuration information. + */ + @Inject + private KsmConfigurationService confService; + + /** + * Service for retrieving data from records. + */ + @Inject + private KsmRecordService recordService; + + /** + * The publicly-accessible URL for Keeper's documentation covering Keeper + * notation. + */ + private static final String KEEPER_NOTATION_DOC_URL = + "https://docs.keeper.io/secrets-manager/secrets-manager/about/keeper-notation"; + + /** + * The regular expression that Keeper notation must match to be related to + * file retrieval. As the Keeper SDK provides mutually-exclusive for + * retrieving secret values and files via notation, the notation must first + * be tested to determine whether it refers to a file. + */ + private static final Pattern KEEPER_FILE_NOTATION = Pattern.compile("^(keeper://)?[^/]*/file/.+"); + + /** + * The maximum amount of time that an entry will be stored in the cache + * before being refreshed, in milliseconds. + */ + private static final long CACHE_INTERVAL = 5000; + + /** + * Read/write lock which guards access to all cached data, including the + * timestamp recording the last time the cache was refreshed. Readers of + * the cache must first acquire (and eventually release) the read lock, and + * writers of the cache must first acquire (and eventually release) the + * write lock. + */ + private final ReadWriteLock cacheLock = new ReentrantReadWriteLock(); + + /** + * The timestamp that the cache was last refreshed, in milliseconds, as + * returned by System.currentTimeMillis(). This value is automatically + * updated if {@link #validateCache()} refreshes the cache. This value must + * not be accessed without {@link #cacheLock} acquired appropriately. + */ + private volatile long cacheTimestamp = 0; + + /** + * The full cached set of secrets last retrieved from Keeper Secrets + * Manager. This value is automatically updated if {@link #validateCache()} + * refreshes the cache. This value must not be accessed without + * {@link #cacheLock} acquired appropriately. + */ + private KeeperSecrets cachedSecrets = null; + + /** + * All records retrieved from Keeper Secrets Manager, where each key is the + * UID of the corresponding record. The contents of this Map are + * automatically updated if {@link #validateCache()} refreshes the cache. + * This Map must not be accessed without {@link #cacheLock} acquired + * appropriately. + */ + private final Map cachedRecordsByUid = new HashMap<>(); + + /** + * All records retrieved from Keeper Secrets Manager, where each key is the + * hostname or IP address of the corresponding record. The hostname or IP + * address of a record is determined by {@link Hosts} fields, thus a record + * may be associated with multiple hosts. If a record is associated with + * multiple hosts, there will be multiple references to that record within + * this Map. The contents of this Map are automatically updated if + * {@link #validateCache()} refreshes the cache. This Map must not be + * accessed without {@link #cacheLock} acquired appropriately. Before using + * a value from this Map, {@link #cachedAmbiguousHosts} must first be + * checked to verify that there is indeed only one record associated with + * that host. + */ + private final Map cachedRecordsByHost = new HashMap<>(); + + /** + * The set of all hostnames or IP addresses that are associated with + * multiple records, and thus cannot uniquely identify a record. The + * contents of this Set are automatically updated if + * {@link #validateCache()} refreshes the cache. This Set must not be + * accessed without {@link #cacheLock} acquired appropriately.This Set + * must be checked before using a value retrieved from + * {@link #cachedRecordsByHost}. + */ + private final Set cachedAmbiguousHosts = new HashSet<>(); + + /** + * All records retrieved from Keeper Secrets Manager, where each key is the + * username of the corresponding record. The username of a record is + * determined by {@link Login} fields, thus a record may be associated with + * multiple users. If a record is associated with multiple users, there + * will be multiple references to that record within this Map. The contents + * of this Map are automatically updated if {@link #validateCache()} + * refreshes the cache. This Map must not be accessed without + * {@link #cacheLock} acquired appropriately. Before using a value from + * this Map, {@link #cachedAmbiguousUsernames} must first be checked to + * verify that there is indeed only one record associated with that user. + */ + private final Map cachedRecordsByUsername = new HashMap<>(); + + /** + * The set of all usernames that are associated with multiple records, and + * thus cannot uniquely identify a record. The contents of this Set are + * automatically updated if {@link #validateCache()} refreshes the cache. + * This Set must not be accessed without {@link #cacheLock} acquired + * appropriately.This Set must be checked before using a value retrieved + * from {@link #cachedRecordsByUsername}. + */ + private final Set cachedAmbiguousUsernames = new HashSet<>(); + + /** + * Validates that all cached data is current with respect to + * {@link #CACHE_INTERVAL}, refreshing data from the server as needed. + * + * @throws GuacamoleException + * If an error occurs preventing the cached data from being refreshed. + */ + private void validateCache() throws GuacamoleException { + + long currentTime = System.currentTimeMillis(); + + // Perform a read-only check that the cache has actually expired before + // continuing + cacheLock.readLock().lock(); + try { + if (currentTime - cacheTimestamp < CACHE_INTERVAL) + return; + } + finally { + cacheLock.readLock().unlock(); + } + + cacheLock.writeLock().lock(); + try { + + // Cache may have been updated since the read-only check. Re-verify + // that the cache has expired before continuing with a full refresh + if (currentTime - cacheTimestamp < CACHE_INTERVAL) + return; + + // Attempt to pull all records first, allowing that operation to + // succeed/fail BEFORE we clear out the last cached success + KeeperSecrets secrets = SecretsManager.getSecrets(confService.getSecretsManagerOptions()); + List records = secrets.getRecords(); + + // Store all secrets within cache + cachedSecrets = secrets; + + // Clear unambiguous cache of all records by UID + cachedRecordsByUid.clear(); + + // Clear cache of host-based records + cachedAmbiguousHosts.clear(); + cachedRecordsByHost.clear(); + + // Clear cache of login-based records + cachedAmbiguousUsernames.clear(); + cachedRecordsByUsername.clear(); + + // Store all records, sorting each into host-based and login-based + // buckets + records.forEach(record -> { + + // Store based on UID ... + cachedRecordsByUid.put(record.getRecordUid(), record); + + // ... and hostname/address + String hostname = recordService.getHostname(record); + addRecordForHost(record, hostname); + + // Store based on username ONLY if no hostname (will otherwise + // result in ambiguous entries for servers tied to identical + // accounts) + if (hostname == null) + addRecordForLogin(record, recordService.getUsername(record)); + + }); + + // Cache has been refreshed + this.cacheTimestamp = System.currentTimeMillis(); + + } + finally { + cacheLock.writeLock().unlock(); + } + + } + + /** + * Associates the given record with the given hostname. The hostname may be + * null. Both {@link #cachedRecordsByHost} and {@link #cachedAmbiguousHosts} + * are updated appropriately. The write lock of {@link #cacheLock} must + * already be acquired before invoking this function. + * + * @param record + * The record to associate with the hosts in the given field. + * + * @param hostname + * The hostname/address that the given record should be associated + * with. This may be null. + */ + private void addRecordForHost(KeeperRecord record, String hostname) { + + if (hostname == null) + return; + + KeeperRecord existing = cachedRecordsByHost.putIfAbsent(hostname, record); + if (existing != null && record != existing) + cachedAmbiguousHosts.add(hostname); + + } + + /** + * Associates the given record with the given username. The given username + * may be null. Both {@link #cachedRecordsByUsername} and + * {@link #cachedAmbiguousUsernames} are updated appropriately. The write + * lock of {@link #cacheLock} must already be acquired before invoking this + * function. + * + * @param record + * The record to associate with the given username. + * + * @param username + * The username that the given record should be associated with. This + * may be null. + */ + private void addRecordForLogin(KeeperRecord record, String username) { + + if (username == null) + return; + + KeeperRecord existing = cachedRecordsByUsername.putIfAbsent(username, record); + if (existing != null && record != existing) + cachedAmbiguousUsernames.add(username); + + } + + /** + * Returns all records accessible via Keeper Secrets Manager. The records + * returned are arbitrarily ordered. + * + * @return + * An unmodifiable Collection of all records accessible via Keeper + * Secrets Manager, in no particular order. + * + * @throws GuacamoleException + * If an error occurs that prevents records from being retrieved. + */ + public Collection getRecords() throws GuacamoleException { + validateCache(); + cacheLock.readLock().lock(); + try { + return Collections.unmodifiableCollection(cachedRecordsByUid.values()); + } + finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Returns the record having the given UID. If no such record exists, null + * is returned. + * + * @param uid + * The UID of the record to return. + * + * @return + * The record having the given UID, or null if there is no such record. + * + * @throws GuacamoleException + * If an error occurs that prevents the record from being retrieved. + */ + public KeeperRecord getRecord(String uid) throws GuacamoleException { + validateCache(); + cacheLock.readLock().lock(); + try { + return cachedRecordsByUid.get(uid); + } + finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Returns the record associated with the given hostname or IP address. If + * no such record exists, or there are multiple such records, null is + * returned. + * + * @param hostname + * The hostname of the record to return. + * + * @return + * The record associated with the given hostname, or null if there is + * no such record or multiple such records. + * + * @throws GuacamoleException + * If an error occurs that prevents the record from being retrieved. + */ + public KeeperRecord getRecordByHost(String hostname) throws GuacamoleException { + validateCache(); + cacheLock.readLock().lock(); + try { + + if (cachedAmbiguousHosts.contains(hostname)) { + logger.debug("The hostname/address \"{}\" is referenced by " + + "multiple Keeper records and cannot be used to " + + "locate individual secrets.", hostname); + return null; + } + + return cachedRecordsByHost.get(hostname); + + } + finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Returns the record associated with the given username. If no such record + * exists, or there are multiple such records, null is returned. + * + * @param username + * The username of the record to return. + * + * @return + * The record associated with the given username, or null if there is + * no such record or multiple such records. + * + * @throws GuacamoleException + * If an error occurs that prevents the record from being retrieved. + */ + public KeeperRecord getRecordByLogin(String username) throws GuacamoleException { + validateCache(); + cacheLock.readLock().lock(); + try { + + if (cachedAmbiguousUsernames.contains(username)) { + logger.debug("The username \"{}\" is referenced by multiple " + + "Keeper records and cannot be used to locate " + + "individual secrets.", username); + return null; + } + + return cachedRecordsByUsername.get(username); + + } + finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Returns the value of the secret stored within Keeper Secrets Manager and + * represented by the given Keeper notation. Keeper notation locates the + * value of a specific field, custom field, or file associated with a + * specific record. See: https://docs.keeper.io/secrets-manager/secrets-manager/about/keeper-notation + * + * @param notation + * The Keeper notation of the secret to retrieve. + * + * @return + * A Future which completes with the value of the secret represented by + * the given Keeper notation, or null if there is no such secret. + * + * @throws GuacamoleException + * If the requested secret cannot be retrieved or the Keeper notation + * is invalid. + */ + public Future getSecret(String notation) throws GuacamoleException { + validateCache(); + cacheLock.readLock().lock(); + try { + + // Retrieve any relevant file asynchronously + Matcher fileNotationMatcher = KEEPER_FILE_NOTATION.matcher(notation); + if (fileNotationMatcher.matches()) + return recordService.download(Notation.getFile(cachedSecrets, notation)); + + // Retrieve string values synchronously + return CompletableFuture.completedFuture(Notation.getValue(cachedSecrets, notation)); + + } + + // Unfortunately, the notation parser within the Keeper SDK throws + // plain Errors for retrieval failures ... + catch (Error e) { + logger.warn("Record \"{}\" does not exist.", notation); + logger.debug("Retrieval of record by Keeper notation failed.", e); + return CompletableFuture.completedFuture(null); + } + + // ... and plain Exceptions for parse failures (no subclasses) + catch (Exception e) { + logger.warn("\"{}\" is not valid Keeper notation. Please check " + + "the documentation at {} for valid formatting.", + notation, KEEPER_NOTATION_DOC_URL); + logger.debug("Provided Keeper notation could not be parsed.", e); + return CompletableFuture.completedFuture(null); + } + finally { + cacheLock.readLock().unlock(); + } + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java new file mode 100644 index 000000000..e2543ba1b --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java @@ -0,0 +1,511 @@ +/* + * 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.vault.ksm.secret; + +import com.google.inject.Singleton; +import com.keepersecurity.secretsManager.core.HiddenField; +import com.keepersecurity.secretsManager.core.Host; +import com.keepersecurity.secretsManager.core.Hosts; +import com.keepersecurity.secretsManager.core.KeeperFile; +import com.keepersecurity.secretsManager.core.KeeperRecord; +import com.keepersecurity.secretsManager.core.KeeperRecordData; +import com.keepersecurity.secretsManager.core.KeeperRecordField; +import com.keepersecurity.secretsManager.core.KeyPair; +import com.keepersecurity.secretsManager.core.KeyPairs; +import com.keepersecurity.secretsManager.core.Login; +import com.keepersecurity.secretsManager.core.Password; +import com.keepersecurity.secretsManager.core.SecretsManager; +import com.keepersecurity.secretsManager.core.Text; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Service for automatically parsing out secrets and data from Keeper records. + */ +@Singleton +public class KsmRecordService { + + /** + * Regular expression which matches the labels of custom fields containing + * hostnames/addresses. + */ + private static final Pattern HOSTNAME_LABEL_PATTERN = + Pattern.compile("hostname|(ip\\s*)?address", Pattern.CASE_INSENSITIVE); + + /** + * Regular expression which matches the labels of custom fields containing + * usernames. + */ + private static final Pattern USERNAME_LABEL_PATTERN = + Pattern.compile("username", Pattern.CASE_INSENSITIVE); + + /** + * Regular expression which matches the labels of custom fields containing + * passwords. + */ + private static final Pattern PASSWORD_LABEL_PATTERN = + Pattern.compile("password", Pattern.CASE_INSENSITIVE); + + /** + * Regular expression which matches the labels of custom fields containing + * passphrases for private keys. + */ + private static final Pattern PASSPHRASE_LABEL_PATTERN = + Pattern.compile("passphrase", Pattern.CASE_INSENSITIVE); + + /** + * Regular expression which matches the labels of custom fields containing + * private keys. + */ + private static final Pattern PRIVATE_KEY_LABEL_PATTERN = + Pattern.compile("private\\s*key", Pattern.CASE_INSENSITIVE); + + /** + * Regular expression which matches the filenames of private keys attached + * to Keeper records. + */ + private static final Pattern PRIVATE_KEY_FILENAME_PATTERN = + Pattern.compile(".*\\.pem", Pattern.CASE_INSENSITIVE); + + /** + * Returns the single value stored in the given list. If the list is empty + * or contains multiple values, null is returned. + * + * @param + * The type of object stored in the list. + * + * @param values + * The list to retrieve a single value from. + * + * @return + * The single value stored in the given list, or null if the list is + * empty or contains multiple values. + */ + private T getSingleValue(List values) { + + if (values == null || values.size() != 1) + return null; + + return values.get(0); + + } + + /** + * Returns the single value stored in the given list, additionally + * performing a mapping transformation on the single value. If the list is + * empty or contains multiple values, null is returned. + * + * @param + * The type of object stored in the list. + * + * @param + * The type of object to return. + * + * @param values + * The list to retrieve a single value from. + * + * @param mapper + * The function to use to map the single object of type T to type R. + * + * @return + * The single value stored in the given list, transformed using the + * provided mapping function, or null if the list is empty or contains + * multiple values. + */ + private R getSingleValue(List values, Function mapper) { + + T value = getSingleValue(values); + if (value == null) + return null; + + return mapper.apply(value); + + } + + /** + * Returns the instance of the only field that has the given type and + * matches the given label pattern. If there are no such fields, or + * multiple such fields, null is returned. + * + * @param + * The type of field to return. + * + * @param fields + * The list of fields to retrieve the field from. + * + * @param fieldClass + * The class representing the type of field to return. + * + * @param labelPattern + * The pattern to match against the desired field's label, or null if + * no label pattern match should be performed. + * + * @return + * The field having the given type and matching the given label + * pattern, or null if there is not exactly one such field. + */ + @SuppressWarnings("unchecked") // Manually verified with isAssignableFrom() + private T getField(List fields, + Class fieldClass, Pattern labelPattern) { + + T foundField = null; + for (KeeperRecordField field : fields) { + + // Ignore fields of wrong class + if (!fieldClass.isAssignableFrom(field.getClass())) + continue; + + // Match against provided pattern, if any + if (labelPattern != null) { + + // Ignore fields without labels if a label match is requested + String label = field.getLabel(); + if (label == null) + continue; + + // Ignore fields whose labels do not match + Matcher labelMatcher = labelPattern.matcher(label); + if (!labelMatcher.matches()) + continue; + + } + + // Ignore ambiguous fields + if (foundField != null) + return null; + + // Tentative match found - we can use this as long as no other + // field matches the criteria + foundField = (T) field; + + } + + return foundField; + + } + + /** + * Returns the instance of the only field that has the given type and + * matches the given label pattern. If there are no such fields, or + * multiple such fields, null is returned. Both standard and custom fields + * are searched. As standard fields do not have labels, any given label + * pattern is ignored for standard fields. + * + * @param + * The type of field to return. + * + * @param record + * The Keeper record to retrieve the field from. + * + * @param fieldClass + * The class representing the type of field to return. + * + * @param labelPattern + * The pattern to match against the labels of custom fields, or null if + * no label pattern match should be performed. + * + * @return + * The field having the given type and matching the given label + * pattern, or null if there is not exactly one such field. + */ + private T getField(KeeperRecord record, + Class fieldClass, Pattern labelPattern) { + + KeeperRecordData data = record.getData(); + + // Attempt to find standard field first, ignoring custom fields if a + // standard field exists (NOTE: standard fields do not have labels) + T field = getField(data.getFields(), fieldClass, null); + if (field != null) + return field; + + // Fall back on custom fields + return getField(data.getCustom(), fieldClass, labelPattern); + + } + + /** + * Returns the file attached to the give Keeper record whose filename + * matches the given pattern. If there are no such files, or multiple such + * files, null is returned. + * + * @param record + * The record to retrieve the file from. + * + * @param filenamePattern + * The pattern to match filenames against. + * + * @return + * The single matching file attached to the given Keeper record, or + * null if there is not exactly one matching file. + */ + private KeeperFile getFile(KeeperRecord record, Pattern filenamePattern) { + + List files = record.getFiles(); + if (files == null) + return null; + + KeeperFile foundFile = null; + for (KeeperFile file : files) { + + // Ignore files whose filenames do not match + Matcher filenameMatcher = filenamePattern.matcher(file.getData().getName()); + if (!filenameMatcher.matches()) + continue; + + // Ignore ambiguous fields + if (foundFile != null) + return null; + + foundFile = file; + + } + + return foundFile; + + } + + /** + * Downloads the given file from the Keeper vault asynchronously. All files + * are read as UTF-8. + * + * @param file + * The file to download, which may be null. + * + * @return + * A Future which resolves with the contents of the file once + * downloaded. If no file was provided (file was null), this Future + * resolves with null. + */ + public Future download(final KeeperFile file) { + + if (file == null) + return CompletableFuture.completedFuture(null); + + return CompletableFuture.supplyAsync(() -> { + return new String(SecretsManager.downloadFile(file), StandardCharsets.UTF_8); + }); + + } + + /** + * Returns the single hostname (or address) associated with the given + * record. If the record has no associated hostname, or multiple hostnames, + * null is returned. Hostnames are retrieved from "Hosts" fields, as well + * as "Text" and "Hidden" fields that have the label "hostname", "address", + * or "ip address" (case-insensitive, space optional). + * + * @param record + * The record to retrieve the hostname from. + * + * @return + * The hostname associated with the given record, or null if the record + * has no associated hostname or multiple hostnames. + */ + public String getHostname(KeeperRecord record) { + + // Prefer standard login field + Hosts hostsField = getField(record, Hosts.class, null); + if (hostsField != null) + return getSingleValue(hostsField.getValue(), Host::getHostName); + + KeeperRecordData data = record.getData(); + List custom = data.getCustom(); + + // Use text "hostname" custom field as fallback ... + Text textField = getField(custom, Text.class, HOSTNAME_LABEL_PATTERN); + if (textField != null) + return getSingleValue(textField.getValue()); + + // ... or hidden "hostname" custom field + HiddenField hiddenField = getField(custom, HiddenField.class, HOSTNAME_LABEL_PATTERN); + if (hiddenField != null) + return getSingleValue(hiddenField.getValue()); + + return null; + + } + + /** + * Returns the single username associated with the given record. If the + * record has no associated username, or multiple usernames, null is + * returned. Usernames are retrieved from "Login" fields, as well as + * "Text" and "Hidden" fields that have the label "username" + * (case-insensitive). + * + * @param record + * The record to retrieve the username from. + * + * @return + * The username associated with the given record, or null if the record + * has no associated username or multiple usernames. + */ + public String getUsername(KeeperRecord record) { + + // Prefer standard login field + Login loginField = getField(record, Login.class, null); + if (loginField != null) + return getSingleValue(loginField.getValue()); + + KeeperRecordData data = record.getData(); + List custom = data.getCustom(); + + // Use text "username" custom field as fallback ... + Text textField = getField(custom, Text.class, USERNAME_LABEL_PATTERN); + if (textField != null) + return getSingleValue(textField.getValue()); + + // ... or hidden "username" custom field + HiddenField hiddenField = getField(custom, HiddenField.class, USERNAME_LABEL_PATTERN); + if (hiddenField != null) + return getSingleValue(hiddenField.getValue()); + + return null; + + } + + /** + * Returns the password associated with the given record. Both standard and + * custom fields are searched. Only "Password" and "Hidden" field types are + * considered. Custom fields must additionally have the label "password" + * (case-insensitive). + * + * @param record + * The record to retrieve the password from. + * + * @return + * The password associated with the given record, or null if the record + * has no associated password. + */ + public String getPassword(KeeperRecord record) { + + Password passwordField = getField(record, Password.class, PASSWORD_LABEL_PATTERN); + if (passwordField != null) + return getSingleValue(passwordField.getValue()); + + HiddenField hiddenField = getField(record, HiddenField.class, PASSWORD_LABEL_PATTERN); + if (hiddenField != null) + return getSingleValue(hiddenField.getValue()); + + return null; + + } + + /** + * Returns the private key associated with the given record. If the record + * has no associated private key, or multiple private keys, null is + * returned. Private keys are retrieved from "KeyPairs" fields. + * Alternatively, private keys are retrieved from PEM-type attachments or + * custom fields with the label "private key" (case-insensitive, space + * optional) if they are "KeyPairs", "Password", or "Hidden" fields. If + * file downloads are required, they will be performed asynchronously. + * + * @param record + * The record to retrieve the private key from. + * + * @return + * A Future which resolves with the private key associated with the + * given record. If the record has no associated private key or + * multiple private keys, the returned Future will resolve to null. + */ + public Future getPrivateKey(KeeperRecord record) { + + // Attempt to find single matching keypair field + KeyPairs keyPairsField = getField(record, KeyPairs.class, PRIVATE_KEY_LABEL_PATTERN); + if (keyPairsField != null) + return CompletableFuture.completedFuture(getSingleValue(keyPairsField.getValue(), KeyPair::getPrivateKey)); + + // Lacking a typed keypair field, prefer a PEM-type attachment + KeeperFile keyFile = getFile(record, PRIVATE_KEY_FILENAME_PATTERN); + if (keyFile != null) + return download(keyFile); + + KeeperRecordData data = record.getData(); + List custom = data.getCustom(); + + // Use password "private key" custom field as fallback ... + Password passwordField = getField(custom, Password.class, PRIVATE_KEY_LABEL_PATTERN); + if (passwordField != null) + return CompletableFuture.completedFuture(getSingleValue(passwordField.getValue())); + + // ... or hidden "private key" custom field + HiddenField hiddenField = getField(custom, HiddenField.class, PRIVATE_KEY_LABEL_PATTERN); + if (hiddenField != null) + return CompletableFuture.completedFuture(getSingleValue(hiddenField.getValue())); + + return CompletableFuture.completedFuture(null); + + } + + /** + * Returns the passphrase for the private key associated with the given + * record. Both standard and custom fields are searched. Only "Password" + * and "Hidden" field types are considered. Custom fields must additionally + * have the label "passphrase" (case-insensitive). Note that there is no + * specific association between private keys and passphrases in the + * "KeyPairs" field type. + * + * @param record + * The record to retrieve the passphrase from. + * + * @return + * The passphrase for the private key associated with the given record, + * or null if there is no such passphrase associated with the record. + */ + public String getPassphrase(KeeperRecord record) { + + KeeperRecordData data = record.getData(); + List fields = data.getFields(); + List custom = data.getCustom(); + + // For records with a standard keypair field, the passphrase is the + // standard password field + if (getField(fields, KeyPairs.class, null) != null) { + Password passwordField = getField(fields, Password.class, null); + if (passwordField != null) + return getSingleValue(passwordField.getValue()); + } + + // For records WITHOUT a standard keypair field, the passphrase can + // only reasonably be a custom field (consider a "Login" record with + // a pair of custom hidden fields for the private key and passphrase: + // the standard password field of the "Login" record refers to the + // user's own password, if any, not the passphrase of their key) + + // Use password "private key" custom field as fallback ... + Password passwordField = getField(custom, Password.class, PASSPHRASE_LABEL_PATTERN); + if (passwordField != null) + return getSingleValue(passwordField.getValue()); + + // ... or hidden "private key" custom field + HiddenField hiddenField = getField(custom, HiddenField.class, PASSPHRASE_LABEL_PATTERN); + if (hiddenField != null) + return getSingleValue(hiddenField.getValue()); + + return null; + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java new file mode 100644 index 000000000..824f9e54e --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java @@ -0,0 +1,157 @@ +/* + * 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.vault.ksm.secret; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.keepersecurity.secretsManager.core.KeeperRecord; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.protocol.GuacamoleConfiguration; +import org.apache.guacamole.token.TokenFilter; +import org.apache.guacamole.vault.secret.VaultSecretService; + +/** + * Service which retrieves secrets from Keeper Secrets Manager. + */ +@Singleton +public class KsmSecretService implements VaultSecretService { + + /** + * Client for retrieving records and secrets from Keeper Secrets Manager. + */ + @Inject + private KsmClient ksm; + + /** + * Service for retrieving data from records. + */ + @Inject + private KsmRecordService recordService; + + @Override + public String canonicalize(String nameComponent) { + try { + + // As Keeper notation is essentially a URL, encode all components + // using standard URL escaping + return URLEncoder.encode(nameComponent, "UTF-8"); + + } + catch (UnsupportedEncodingException e) { + throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); + } + } + + @Override + public Future getValue(String name) throws GuacamoleException { + return ksm.getSecret(name); + } + + /** + * Adds contextual parameter tokens for the secrets in the given record to + * the given map of existing tokens. The values of each token are + * determined from secrets within the record. Depending on the record, this + * will be a subset of the username, password, private key, and passphrase. + * + * @param tokens + * The map of parameter tokens that any new tokens should be added to. + * + * @param prefix + * The prefix that should be prepended to each added token. + * + * @param record + * The record to retrieve secrets from when generating tokens. This may + * be null. + */ + private void addRecordTokens(Map> tokens, String prefix, + KeeperRecord record) { + + if (record == null) + return; + + // Username of server-related record + String username = recordService.getUsername(record); + if (username != null) + tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(username)); + + // Password of server-related record + String password = recordService.getPassword(record); + if (password != null) + tokens.put(prefix + "PASSWORD", CompletableFuture.completedFuture(password)); + + // Key passphrase of server-related record + String passphrase = recordService.getPassphrase(record); + if (passphrase != null) + tokens.put(prefix + "PASSPHRASE", CompletableFuture.completedFuture(passphrase)); + + // Private key of server-related record + Future privateKey = recordService.getPrivateKey(record); + tokens.put(prefix + "KEY", privateKey); + + } + + @Override + public Map> getTokens(GuacamoleConfiguration config, + TokenFilter filter) throws GuacamoleException { + + Map> tokens = new HashMap<>(); + Map parameters = config.getParameters(); + + // Retrieve and define server-specific tokens, if any + String hostname = parameters.get("hostname"); + if (hostname != null && !hostname.isEmpty()) + addRecordTokens(tokens, "KEEPER_SERVER_", + ksm.getRecordByHost(filter.filter(hostname))); + + // Retrieve and define user-specific tokens, if any + String username = parameters.get("username"); + if (username != null && !username.isEmpty()) + addRecordTokens(tokens, "KEEPER_USER_", + ksm.getRecordByLogin(filter.filter(username))); + + // Tokens specific to RDP + if ("rdp".equals(config.getProtocol())) { + + // Retrieve and define gateway server-specific tokens, if any + String gatewayHostname = parameters.get("gateway-hostname"); + if (gatewayHostname != null && !gatewayHostname.isEmpty()) + addRecordTokens(tokens, "KEEPER_GATEWAY_", + ksm.getRecordByHost(filter.filter(gatewayHostname))); + + // Retrieve and define gateway user-specific tokens, if any + String gatewayUsername = parameters.get("gateway-username"); + if (gatewayUsername != null && !gatewayUsername.isEmpty()) + addRecordTokens(tokens, "KEEPER_GATEWAY_USER_", + ksm.getRecordByLogin(filter.filter(gatewayUsername))); + + } + + return tokens; + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/guac-manifest.json new file mode 100644 index 000000000..a584b88b8 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/guac-manifest.json @@ -0,0 +1,16 @@ +{ + + "guacamoleVersion" : "1.4.0", + + "name" : "Keeper Secrets Manager", + "namespace" : "keeper-secrets-manager", + + "authProviders" : [ + "org.apache.guacamole.vault.ksm.KsmAuthenticationProvider" + ], + + "translations" : [ + "translations/en.json" + ] + +} diff --git a/extensions/guacamole-vault/pom.xml b/extensions/guacamole-vault/pom.xml new file mode 100644 index 000000000..0933f08fe --- /dev/null +++ b/extensions/guacamole-vault/pom.xml @@ -0,0 +1,67 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault + pom + 1.4.0 + guacamole-vault + http://guacamole.apache.org/ + + + org.apache.guacamole + extensions + 1.4.0 + ../ + + + + + + modules/guacamole-vault-dist + + + modules/guacamole-vault-base + + + modules/guacamole-vault-ksm + + + + + + + + + org.apache.guacamole + guacamole-ext + 1.4.0 + provided + + + + + + diff --git a/extensions/pom.xml b/extensions/pom.xml index 513cf3b0f..966bf410f 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -49,6 +49,9 @@ guacamole-auth-sso guacamole-auth-totp + + guacamole-vault + diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnection.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnection.java index 8a826d88c..422e0ea34 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnection.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnection.java @@ -19,6 +19,7 @@ package org.apache.guacamole.net.auth; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.apache.guacamole.GuacamoleException; @@ -28,7 +29,9 @@ import org.apache.guacamole.protocol.GuacamoleClientInformation; /** * Connection implementation which overrides the connect() function of an * underlying Connection, adding a given set of parameter tokens to the tokens - * already supplied. + * already supplied. If not supplying a static set of tokens at construction + * time, implementations should override either {@link #addTokens(java.util.Map)} + * or {@link #getTokens()} to provide tokens dynamically. */ public class TokenInjectingConnection extends DelegatingConnection { @@ -37,6 +40,39 @@ public class TokenInjectingConnection extends DelegatingConnection { */ private final Map tokens; + /** + * Returns the tokens which should be added to an in-progress call to + * connect(). If not overridden, this function will return the tokens + * provided when this instance of TokenInjectingConnection was created. If + * the values of existing tokens need to be considered, implementations + * should override {@link #addTokens(java.util.Map)} instead. + * + * @return + * The tokens which should be added to the in-progress call to + * connect(). + * + * @throws GuacamoleException + * If the applicable tokens cannot be generated. + */ + protected Map getTokens() throws GuacamoleException { + return tokens; + } + + /** + * Adds tokens to an in-progress call to connect(). If not overridden, this + * function will add the tokens returned by {@link #getTokens()}. + * + * @param tokens + * A modifiable Map containing the tokens already supplied to + * connect(). + * + * @throws GuacamoleException + * If the applicable tokens cannot be generated. + */ + protected void addTokens(Map tokens) throws GuacamoleException { + tokens.putAll(getTokens()); + } + /** * Wraps the given Connection, automatically adding the given tokens to * each invocation of connect(). Any additional tokens which have the same @@ -54,13 +90,27 @@ public class TokenInjectingConnection extends DelegatingConnection { this.tokens = tokens; } + /** + * Wraps the given Connection such that the additional parameter tokens + * added by {@link #addTokens(java.util.Map)} or returned by + * {@link #getTokens()} are included with each invocation of connect(). + * Any additional tokens which have the same name as existing tokens will + * override the existing values. + * + * @param connection + * The Connection to wrap. + */ + public TokenInjectingConnection(Connection connection) { + this(connection, Collections.emptyMap()); + } + @Override public GuacamoleTunnel connect(GuacamoleClientInformation info, Map tokens) throws GuacamoleException { // Apply provided tokens over those given to connect() tokens = new HashMap<>(tokens); - tokens.putAll(this.tokens); + addTokens(tokens); return super.connect(info, tokens); diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnectionGroup.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnectionGroup.java index 0ec93baf4..c7dfa0a42 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnectionGroup.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingConnectionGroup.java @@ -19,6 +19,7 @@ package org.apache.guacamole.net.auth; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.apache.guacamole.GuacamoleException; @@ -28,7 +29,10 @@ import org.apache.guacamole.protocol.GuacamoleClientInformation; /** * ConnectionGroup implementation which overrides the connect() function of an * underlying ConnectionGroup, adding a given set of parameter tokens to the - * tokens already supplied. + * tokens already supplied. If not supplying a static set of tokens at + * construction time, implementations should override either + * {@link #addTokens(java.util.Map)} or {@link #getTokens()} to provide tokens + * dynamically. */ public class TokenInjectingConnectionGroup extends DelegatingConnectionGroup { @@ -37,6 +41,39 @@ public class TokenInjectingConnectionGroup extends DelegatingConnectionGroup { */ private final Map tokens; + /** + * Returns the tokens which should be added to an in-progress call to + * connect(). If not overridden, this function will return the tokens + * provided when this instance of TokenInjectingConnection was created. If + * the values of existing tokens need to be considered, implementations + * should override {@link #addTokens(java.util.Map)} instead. + * + * @return + * The tokens which should be added to the in-progress call to + * connect(). + * + * @throws GuacamoleException + * If the applicable tokens cannot be generated. + */ + protected Map getTokens() throws GuacamoleException { + return tokens; + } + + /** + * Adds tokens to an in-progress call to connect(). If not overridden, this + * function will add the tokens returned by {@link #getTokens()}. + * + * @param tokens + * A modifiable Map containing the tokens already supplied to + * connect(). + * + * @throws GuacamoleException + * If the applicable tokens cannot be generated. + */ + protected void addTokens(Map tokens) throws GuacamoleException { + tokens.putAll(getTokens()); + } + /** * Wraps the given ConnectionGroup, automatically adding the given tokens * to each invocation of connect(). Any additional tokens which have the @@ -54,13 +91,27 @@ public class TokenInjectingConnectionGroup extends DelegatingConnectionGroup { this.tokens = tokens; } + /** + * Wraps the given ConnectionGroup such that the additional parameter + * tokens added by {@link #addTokens(java.util.Map)} or returned by + * {@link #getTokens()} are included with each invocation of connect(). Any + * additional tokens which have the same name as existing tokens will + * override the existing values. + * + * @param connectionGroup + * The ConnectionGroup to wrap. + */ + public TokenInjectingConnectionGroup(ConnectionGroup connectionGroup) { + this(connectionGroup, Collections.emptyMap()); + } + @Override public GuacamoleTunnel connect(GuacamoleClientInformation info, Map tokens) throws GuacamoleException { // Apply provided tokens over those given to connect() tokens = new HashMap<>(tokens); - tokens.putAll(this.tokens); + addTokens(tokens); return super.connect(info, tokens); diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingUserContext.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingUserContext.java index a1ede96a5..79c2769b2 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingUserContext.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/TokenInjectingUserContext.java @@ -32,7 +32,7 @@ public class TokenInjectingUserContext extends DelegatingUserContext { /** * The additional tokens to include with each call to connect() if - * getTokens() is not overridden. + * getTokens() or addTokens() are not overridden. */ private final Map tokens; @@ -42,8 +42,8 @@ public class TokenInjectingUserContext extends DelegatingUserContext { * parameter tokens are included. Any additional tokens which have the same * name as existing tokens will override the existing values. If tokens * specific to a particular connection or connection group need to be - * included, getTokens() may be overridden to provide a different set of - * tokens. + * included, getTokens() or addTokens() may be overridden to provide a + * different set of tokens. * * @param userContext * The UserContext to wrap. @@ -60,9 +60,9 @@ public class TokenInjectingUserContext extends DelegatingUserContext { /** * Wraps the given UserContext, overriding the connect() function of each * retrieved Connection and ConnectionGroup such that the additional - * parameter tokens returned by getTokens() are included. Any additional - * tokens which have the same name as existing tokens will override the - * existing values. + * parameter tokens added by addTokens() or returned by getTokens() are + * included. Any additional tokens which have the same name as existing + * tokens will override the existing values. * * @param userContext * The UserContext to wrap. @@ -75,7 +75,10 @@ public class TokenInjectingUserContext extends DelegatingUserContext { * Returns the tokens which should be added to an in-progress call to * connect() for the given Connection. If not overridden, this function * will return the tokens provided when this instance of - * TokenInjectingUserContext was created. + * TokenInjectingUserContext was created. If the values of existing tokens + * need to be considered, implementations should override + * {@link #addTokens(org.apache.guacamole.net.auth.Connection, java.util.Map)} + * instead. * * @param connection * The Connection on which connect() has been called. @@ -83,16 +86,45 @@ public class TokenInjectingUserContext extends DelegatingUserContext { * @return * The tokens which should be added to the in-progress call to * connect(). + * + * @throws GuacamoleException + * If the tokens applicable to the given connection cannot be + * generated. */ - protected Map getTokens(Connection connection) { + protected Map getTokens(Connection connection) + throws GuacamoleException { return tokens; } + /** + * Adds tokens to an in-progress call to connect() for the given + * Connection. If not overridden, this function will add the tokens + * returned by {@link #getTokens(org.apache.guacamole.net.auth.Connection)}. + * + * @param connection + * The Connection on which connect() has been called. + * + * @param tokens + * A modifiable Map containing the tokens already supplied to + * connect(). + * + * @throws GuacamoleException + * If the tokens applicable to the given connection cannot be + * generated. + */ + protected void addTokens(Connection connection, Map tokens) + throws GuacamoleException { + tokens.putAll(getTokens(connection)); + } + /** * Returns the tokens which should be added to an in-progress call to * connect() for the given ConnectionGroup. If not overridden, this * function will return the tokens provided when this instance of - * TokenInjectingUserContext was created. + * TokenInjectingUserContext was created. If the values of existing tokens + * need to be considered, implementations should override + * {@link #addTokens(org.apache.guacamole.net.auth.ConnectionGroup, java.util.Map)} + * instead. * * @param connectionGroup * The ConnectionGroup on which connect() has been called. @@ -100,11 +132,37 @@ public class TokenInjectingUserContext extends DelegatingUserContext { * @return * The tokens which should be added to the in-progress call to * connect(). + * + * @throws GuacamoleException + * If the tokens applicable to the given connection group cannot be + * generated. */ - protected Map getTokens(ConnectionGroup connectionGroup) { + protected Map getTokens(ConnectionGroup connectionGroup) + throws GuacamoleException { return tokens; } + /** + * Adds tokens to an in-progress call to connect() for the given + * ConnectionGroup. If not overridden, this function will add the tokens + * returned by {@link #getTokens(org.apache.guacamole.net.auth.ConnectionGroup)}. + * + * @param connectionGroup + * The ConnectionGroup on which connect() has been called. + * + * @param tokens + * A modifiable Map containing the tokens already supplied to + * connect(). + * + * @throws GuacamoleException + * If the tokens applicable to the given connection cannot be + * generated. + */ + protected void addTokens(ConnectionGroup connectionGroup, + Map tokens) throws GuacamoleException { + tokens.putAll(getTokens(connectionGroup)); + } + @Override public Directory getConnectionGroupDirectory() throws GuacamoleException { @@ -112,7 +170,14 @@ public class TokenInjectingUserContext extends DelegatingUserContext { @Override protected ConnectionGroup decorate(ConnectionGroup object) throws GuacamoleException { - return new TokenInjectingConnectionGroup(object, getTokens(object)); + return new TokenInjectingConnectionGroup(object) { + + @Override + protected void addTokens(Map tokens) throws GuacamoleException { + TokenInjectingUserContext.this.addTokens(object, tokens); + } + + }; } @Override @@ -130,7 +195,14 @@ public class TokenInjectingUserContext extends DelegatingUserContext { @Override protected Connection decorate(Connection object) throws GuacamoleException { - return new TokenInjectingConnection(object, getTokens(object)); + return new TokenInjectingConnection(object) { + + @Override + protected void addTokens(Map tokens) throws GuacamoleException { + TokenInjectingUserContext.this.addTokens(object, tokens); + } + + }; } @Override diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/token/GuacamoleTokenUndefinedException.java b/guacamole-ext/src/main/java/org/apache/guacamole/token/GuacamoleTokenUndefinedException.java new file mode 100644 index 000000000..1a80e4788 --- /dev/null +++ b/guacamole-ext/src/main/java/org/apache/guacamole/token/GuacamoleTokenUndefinedException.java @@ -0,0 +1,96 @@ +/* + * 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.token; + +import org.apache.guacamole.GuacamoleServerException; + +/** + * An exception thrown when a token cannot be substituted because it has no + * corresponding value. Additional information describing the undefined token + * is provided. + */ +public class GuacamoleTokenUndefinedException extends GuacamoleServerException { + + /** + * The name of the token that is undefined. + */ + private final String tokenName; + + /** + * Creates a new GuacamoleTokenUndefinedException with the given message, + * cause, and associated undefined token name. + * + * @param message + * A human readable description of the exception that occurred. + * + * @param cause + * The cause of this exception. + * + * @param tokenName + * The name of the token which has no defined value. + */ + public GuacamoleTokenUndefinedException(String message, Throwable cause, + String tokenName) { + super(message, cause); + this.tokenName = tokenName; + } + + /** + * Creates a new GuacamoleTokenUndefinedException with the given + * message and associated undefined token name. + * + * @param message + * A human readable description of the exception that occurred. + * + * @param tokenName + * The name of the token which has no defined value. + */ + public GuacamoleTokenUndefinedException(String message, String tokenName) { + super(message); + this.tokenName = tokenName; + } + + /** + * Creates a new GuacamoleTokenUndefinedException with the given cause + * and associated undefined token name. + * + * @param cause + * The cause of this exception. + * + * @param tokenName + * The name of the token which has no defined value. + */ + public GuacamoleTokenUndefinedException(Throwable cause, String tokenName) { + super(cause); + this.tokenName = tokenName; + } + + /** + * Returns the name of the token which has no defined value, causing this + * exception to be thrown. + * + * @return + * The name of the token which has no defined value. + */ + public String getTokenName() { + return tokenName; + } + +} diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java index d1570e441..a9766face 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java @@ -162,19 +162,31 @@ public class TokenFilter { tokenValues.clear(); tokenValues.putAll(tokens); } - + /** * Filters the given string, replacing any tokens with their corresponding - * values. + * values. Handling of undefined tokens depends on the value given for the + * strict flag. * * @param input * The string to filter. * + * @param strict + * Whether to disallow tokens which lack values from existing in the + * string. If true, an exception will be thrown if any tokens in the + * string lack corresponding values. If false, tokens which lack values + * will be interpreted as literals. + * * @return * A copy of the input string, with any tokens replaced with their * corresponding values. + * + * @throws GuacamoleTokenUndefinedException + * If the strict flag is set to true and at least one token in the + * given string has no corresponding value. */ - public String filter(String input) { + private String filter(String input, boolean strict) + throws GuacamoleTokenUndefinedException { StringBuilder output = new StringBuilder(); Matcher tokenMatcher = tokenPattern.matcher(input); @@ -209,10 +221,20 @@ public class TokenFilter { String tokenName = tokenMatcher.group(TOKEN_NAME_GROUP); String tokenValue = getToken(tokenName); - // If token is unknown, interpret as literal + // If token is unknown, interpretation depends on whether + // strict mode is enabled if (tokenValue == null) { + + // Fail outright if strict mode is enabled + if (strict) + throw new GuacamoleTokenUndefinedException("Token " + + "has no defined value.", tokenName); + + // If strict mode is NOT enabled, simply interpret as + // a literal String notToken = tokenMatcher.group(TOKEN_GROUP); output.append(notToken); + } // Otherwise, check for modifiers and substitute value appropriately @@ -247,19 +269,71 @@ public class TokenFilter { // Update last regex match endOfLastMatch = tokenMatcher.end(); - + } // Append any remaining non-token text output.append(input.substring(endOfLastMatch)); - + return output.toString(); - + + } + + /** + * Filters the given string, replacing any tokens with their corresponding + * values. Any tokens present in the given string which lack values will + * be interpreted as literals. + * + * @param input + * The string to filter. + * + * @return + * A copy of the input string, with any tokens replaced with their + * corresponding values. + */ + public String filter(String input) { + + // Filter with strict mode disabled (should always succeed) + try { + return filter(input, false); + } + + // GuacamoleTokenUndefinedException cannot be thrown when strict mode + // is disabled + catch (GuacamoleTokenUndefinedException e) { + throw new IllegalStateException("filter() threw " + + "GuacamoleTokenUndefinedException despite strict mode " + + "being disabled."); + } + + } + + /** + * Filters the given string, replacing any tokens with their corresponding + * values. If any token in the given string has no defined value within + * this TokenFilter, a GuacamoleTokenUndefinedException will be thrown. + * + * @param input + * The string to filter. + * + * @return + * A copy of the input string, with any tokens replaced with their + * corresponding values. + * + * @throws GuacamoleTokenUndefinedException + * If at least one token in the given string has no corresponding + * value. + */ + public String filterStrict(String input) + throws GuacamoleTokenUndefinedException { + return filter(input, true); } /** * Given an arbitrary map containing String values, replace each non-null - * value with the corresponding filtered value. + * value with the corresponding filtered value. Any tokens present in the + * values of the given map which lack defined values within this + * TokenFilter will be interpreted as literals. * * @param map * The map whose values should be filtered. @@ -273,6 +347,34 @@ public class TokenFilter { String value = entry.getValue(); if (value != null) entry.setValue(filter(value)); + + } + + } + + /** + * Given an arbitrary map containing String values, replace each non-null + * value with the corresponding filtered value. If any token in any string + * has no defined value within this TokenFilter, a + * GuacamoleTokenUndefinedException will be thrown. + * + * @param map + * The map whose values should be filtered. + * + * @throws GuacamoleTokenUndefinedException + * If at least one token in at least one string has no corresponding + * value. + */ + public void filterValuesStrict(Map map) + throws GuacamoleTokenUndefinedException { + + // For each map entry + for (Map.Entry entry : map.entrySet()) { + + // If value is non-null, filter value through this TokenFilter + String value = entry.getValue(); + if (value != null) + entry.setValue(filterStrict(value)); } diff --git a/pom.xml b/pom.xml index bcfa885ad..2dd057056 100644 --- a/pom.xml +++ b/pom.xml @@ -209,8 +209,8 @@ 1.8 -Xlint:all - -Werror + true true