GUACAMOLE-136: Implement basic support for verifying user identity using Duo.

This commit is contained in:
Michael Jumper
2016-12-01 21:36:26 -08:00
parent 718e4dab00
commit 48af3ef45d
22 changed files with 3158 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
target/
*~

View File

@@ -0,0 +1,100 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.apache.guacamole</groupId>
<artifactId>guacamole-auth-duo</artifactId>
<packaging>jar</packaging>
<version>0.9.10-incubating</version>
<name>guacamole-auth-duo</name>
<url>http://guacamole.incubator.apache.org/</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<!-- Written for 1.6 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
<configuration>
<source>1.6</source>
<target>1.6</target>
<compilerArgs>
<arg>-Xlint:all</arg>
<arg>-Werror</arg>
</compilerArgs>
<fork>true</fork>
</configuration>
</plugin>
<!-- Copy dependencies prior to packaging -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>2.10</version>
<executions>
<execution>
<id>unpack-dependencies</id>
<phase>prepare-package</phase>
<goals>
<goal>unpack-dependencies</goal>
</goals>
<configuration>
<includeScope>runtime</includeScope>
<outputDirectory>${project.build.directory}/classes</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<!-- Guacamole Java API -->
<dependency>
<groupId>org.apache.guacamole</groupId>
<artifactId>guacamole-common</artifactId>
<version>0.9.10-incubating</version>
<scope>provided</scope>
</dependency>
<!-- Guacamole Extension API -->
<dependency>
<groupId>org.apache.guacamole</groupId>
<artifactId>guacamole-ext</artifactId>
<version>0.9.10-incubating</version>
<scope>provided</scope>
</dependency>
<!-- Guice -->
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>3.0</version>
</dependency>
<dependency>
<groupId>com.google.inject.extensions</groupId>
<artifactId>guice-multibindings</artifactId>
<version>3.0</version>
</dependency>
<!-- Java servlet API -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
package com.duosecurity.duoweb;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public final class DuoWeb {
private static final String DUO_PREFIX = "TX";
private static final String APP_PREFIX = "APP";
private static final String AUTH_PREFIX = "AUTH";
private static final int DUO_EXPIRE = 300;
private static final int APP_EXPIRE = 3600;
private static final int IKEY_LEN = 20;
private static final int SKEY_LEN = 40;
private static final int AKEY_LEN = 40;
public static final String ERR_USER = "ERR|The username passed to sign_request() is invalid.";
public static final String ERR_IKEY = "ERR|The Duo integration key passed to sign_request() is invalid.";
public static final String ERR_SKEY = "ERR|The Duo secret key passed to sign_request() is invalid.";
public static final String ERR_AKEY = "ERR|The application secret key passed to sign_request() must be at least " + AKEY_LEN + " characters.";
public static final String ERR_UNKNOWN = "ERR|An unknown error has occurred.";
public static String signRequest(final String ikey, final String skey, final String akey, final String username) {
return signRequest(ikey, skey, akey, username, System.currentTimeMillis() / 1000);
}
public static String signRequest(final String ikey, final String skey, final String akey, final String username, final long time) {
final String duo_sig;
final String app_sig;
if (username.equals("")) {
return ERR_USER;
}
if (username.indexOf('|') != -1) {
return ERR_USER;
}
if (ikey.equals("") || ikey.length() != IKEY_LEN) {
return ERR_IKEY;
}
if (skey.equals("") || skey.length() != SKEY_LEN) {
return ERR_SKEY;
}
if (akey.equals("") || akey.length() < AKEY_LEN) {
return ERR_AKEY;
}
try {
duo_sig = signVals(skey, username, ikey, DUO_PREFIX, DUO_EXPIRE, time);
app_sig = signVals(akey, username, ikey, APP_PREFIX, APP_EXPIRE, time);
} catch (Exception e) {
return ERR_UNKNOWN;
}
return duo_sig + ":" + app_sig;
}
public static String verifyResponse(final String ikey, final String skey, final String akey, final String sig_response)
throws DuoWebException, NoSuchAlgorithmException, InvalidKeyException, IOException {
return verifyResponse(ikey, skey, akey, sig_response, System.currentTimeMillis() / 1000);
}
public static String verifyResponse(final String ikey, final String skey, final String akey, final String sig_response, final long time)
throws DuoWebException, NoSuchAlgorithmException, InvalidKeyException, IOException {
String auth_user = null;
String app_user = null;
final String[] sigs = sig_response.split(":");
final String auth_sig = sigs[0];
final String app_sig = sigs[1];
auth_user = parseVals(skey, auth_sig, AUTH_PREFIX, ikey, time);
app_user = parseVals(akey, app_sig, APP_PREFIX, ikey, time);
if (!auth_user.equals(app_user)) {
throw new DuoWebException("Authentication failed.");
}
return auth_user;
}
private static String signVals(final String key, final String username, final String ikey, final String prefix, final int expire, final long time)
throws InvalidKeyException, NoSuchAlgorithmException {
final long expire_ts = time + expire;
final String exp = Long.toString(expire_ts);
final String val = username + "|" + ikey + "|" + exp;
final String cookie = prefix + "|" + Base64.encodeBytes(val.getBytes());
final String sig = Util.hmacSign(key, cookie);
return cookie + "|" + sig;
}
private static String parseVals(final String key, final String val, final String prefix, final String ikey, final long time)
throws InvalidKeyException, NoSuchAlgorithmException, IOException, DuoWebException {
final String[] parts = val.split("\\|");
if (parts.length != 3) {
throw new DuoWebException("Invalid response");
}
final String u_prefix = parts[0];
final String u_b64 = parts[1];
final String u_sig = parts[2];
final String sig = Util.hmacSign(key, u_prefix + "|" + u_b64);
if (!Util.hmacSign(key, sig).equals(Util.hmacSign(key, u_sig))) {
throw new DuoWebException("Invalid response");
}
if (!u_prefix.equals(prefix)) {
throw new DuoWebException("Invalid response");
}
final byte[] decoded = Base64.decode(u_b64);
final String cookie = new String(decoded);
final String[] cookie_parts = cookie.split("\\|");
if (cookie_parts.length != 3) {
throw new DuoWebException("Invalid response");
}
final String username = cookie_parts[0];
final String u_ikey = cookie_parts[1];
final String expire = cookie_parts[2];
if (!u_ikey.equals(ikey)) {
throw new DuoWebException("Invalid response");
}
final long expire_ts = Long.parseLong(expire);
if (time >= expire_ts) {
throw new DuoWebException("Transaction has expired. Please check that the system time is correct.");
}
return username;
}
}

View File

@@ -0,0 +1,8 @@
package com.duosecurity.duoweb;
public class DuoWebException extends Exception {
public DuoWebException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,26 @@
package com.duosecurity.duoweb;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Util {
public static String hmacSign(String skey, String data)
throws NoSuchAlgorithmException, InvalidKeyException {
SecretKeySpec key = new SecretKeySpec(skey.getBytes(), "HmacSHA1");
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(key);
byte[] raw = mac.doFinal(data.getBytes());
return bytesToHex(raw);
}
public static String bytesToHex(byte[] b) {
String result = "";
for (int i = 0; i < b.length; i++) {
result += Integer.toString((b[i] & 0xff) + 0x100, 16).substring(1);
}
return result;
}
}

View File

@@ -0,0 +1,109 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.auth.duo;
import com.google.inject.Inject;
import java.util.Collections;
import javax.servlet.http.HttpServletRequest;
import org.apache.guacamole.GuacamoleClientException;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
import org.apache.guacamole.auth.duo.form.DuoSignedResponseField;
import org.apache.guacamole.form.Field;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.Credentials;
import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
/**
* Service providing convenience functions for the Duo AuthenticationProvider
* implementation.
*/
public class AuthenticationProviderService {
/**
* Service for retrieving Duo configuration information.
*/
@Inject
private ConfigurationService confService;
/**
* Service for verifying users with the DuoWeb API.
*/
@Inject
private DuoWebService duoWebService;
/**
* Verifies the identity of the given user via the Duo multi-factor
* authentication service. If a signed response from Duo has not already
* been provided, a signed response from Duo is requested in the
* form of additional expected credentials. Any provided signed response
* is cryptographically verified. If no signed response is present, or the
* signed response is invalid, an exception is thrown.
*
* @param authenticatedUser
* The user whose identity should be verified against Duo.
*
* @throws GuacamoleException
* If required Duo-specific configuration options are missing or
* malformed, or if the user's identity cannot be verified.
*/
public void verifyAuthenticatedUser(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Pull the original HTTP request used to authenticate
Credentials credentials = authenticatedUser.getCredentials();
HttpServletRequest request = credentials.getRequest();
// Ignore anonymous users
if (authenticatedUser.getIdentifier().equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
return;
// Retrieve signed Duo response from request
String signedResponse = request.getParameter(DuoSignedResponseField.PARAMETER_NAME);
// If no signed response, request one
if (signedResponse == null) {
// Create field which requests a signed response from Duo that
// verifies the identity of the given user via the configured
// Duo API endpoint
Field signedResponseField = new DuoSignedResponseField(
confService.getAPIHostname(),
duoWebService.createSignedRequest(authenticatedUser));
// Create an overall description of the additional credentials
// required to verify identity
CredentialsInfo expectedCredentials = new CredentialsInfo(
Collections.singletonList(signedResponseField));
// Request additional credentials
throw new GuacamoleInsufficientCredentialsException(
"LOGIN.INFO_DUO_AUTH_REQUIRED", expectedCredentials);
}
// If signed response does not verify this user's identity, abort auth
if (!duoWebService.isValidSignedResponse(authenticatedUser, signedResponse))
throw new GuacamoleClientException("LOGIN.INFO_DUO_VALIDATION_CODE_INCORRECT");
}
}

View File

@@ -0,0 +1,100 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.auth.duo;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.AuthenticationProvider;
import org.apache.guacamole.net.auth.Credentials;
import org.apache.guacamole.net.auth.UserContext;
/**
* AuthenticationProvider implementation which uses Duo as an additional
* authentication factor for users which have already been authenticated by
* some other AuthenticationProvider.
*/
public class DuoAuthenticationProvider implements AuthenticationProvider {
/**
* Injector which will manage the object graph of this authentication
* provider.
*/
private final Injector injector;
/**
* Creates a new DuoAuthenticationProvider that verifies users
* using the Duo authentication service
*
* @throws GuacamoleException
* If a required property is missing, or an error occurs while parsing
* a property.
*/
public DuoAuthenticationProvider() throws GuacamoleException {
// Set up Guice injector.
injector = Guice.createInjector(
new DuoAuthenticationProviderModule(this)
);
}
@Override
public String getIdentifier() {
return "duo";
}
@Override
public AuthenticatedUser authenticateUser(Credentials credentials)
throws GuacamoleException {
return null;
}
@Override
public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException {
return authenticatedUser;
}
@Override
public UserContext getUserContext(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
AuthenticationProviderService authProviderService =
injector.getInstance(AuthenticationProviderService.class);
// Verify user against Duo service
authProviderService.verifyAuthenticatedUser(authenticatedUser);
// User has been verified, and authentication should be allowed to
// continue
return null;
}
@Override
public UserContext updateUserContext(UserContext context,
AuthenticatedUser authenticatedUser, Credentials credentials)
throws GuacamoleException {
return context;
}
}

View File

@@ -0,0 +1,81 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.auth.duo;
import com.google.inject.AbstractModule;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
import org.apache.guacamole.environment.Environment;
import org.apache.guacamole.environment.LocalEnvironment;
import org.apache.guacamole.net.auth.AuthenticationProvider;
/**
* Guice module which configures Duo-specific injections.
*/
public class DuoAuthenticationProviderModule extends AbstractModule {
/**
* Guacamole server environment.
*/
private final Environment environment;
/**
* A reference to the DuoAuthenticationProvider on behalf of which this
* module has configured injection.
*/
private final AuthenticationProvider authProvider;
/**
* Creates a new Duo authentication provider module which configures
* injection for the DuoAuthenticationProvider.
*
* @param authProvider
* The AuthenticationProvider for which injection is being configured.
*
* @throws GuacamoleException
* If an error occurs while retrieving the Guacamole server
* environment.
*/
public DuoAuthenticationProviderModule(AuthenticationProvider authProvider)
throws GuacamoleException {
// Get local environment
this.environment = new LocalEnvironment();
// Store associated auth provider
this.authProvider = authProvider;
}
@Override
protected void configure() {
// Bind core implementations of guacamole-ext classes
bind(AuthenticationProvider.class).toInstance(authProvider);
bind(Environment.class).toInstance(environment);
// Bind Duo-specific services
bind(AuthenticationProviderService.class);
bind(ConfigurationService.class);
bind(DuoWebService.class);
}
}

View File

@@ -0,0 +1,212 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.auth.duo;
import com.duosecurity.duoweb.DuoWeb;
import com.duosecurity.duoweb.DuoWebException;
import com.google.inject.Inject;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.guacamole.GuacamoleClientException;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.GuacamoleServerException;
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
import org.apache.guacamole.net.auth.AuthenticatedUser;
/**
* Service which wraps the DuoWeb Java API, providing predictable behavior and
* error handling.
*/
public class DuoWebService {
/**
* A regular expression which matches a valid signature part of a Duo
* signed response. A signature part may not contain pipe symbols (which
* act as delimiters between parts) nor colons (which act as delimiters
* between signatures).
*/
private final String SIGNATURE_PART = "[^:|]*";
/**
* A regular expression which matches a valid signature within a Duo
* signed response. Each signature is made up of three distinct parts,
* separated by pipe symbols.
*/
private final String SIGNATURE = SIGNATURE_PART + "\\|" + SIGNATURE_PART + "\\|" + SIGNATURE_PART;
/**
* A regular expression which matches a valid Duo signed response. Each
* response is made up of two signatures, separated by a colon.
*/
private final String RESPONSE = SIGNATURE + ":" + SIGNATURE;
/**
* A Pattern which matches valid Duo signed responses. Strings which will
* be passed to DuoWeb.verifyResponse() MUST be matched against this
* Pattern. Strings which do not match this Pattern may cause
* DuoWeb.verifyResponse() to throw unchecked exceptions.
*/
private final Pattern RESPONSE_PATTERN = Pattern.compile(RESPONSE);
/**
* Service for retrieving Duo configuration information.
*/
@Inject
private ConfigurationService confService;
/**
* Creates and signs a new request to verify the identity of the given
* user. This request may ultimately be sent to Duo, resulting in a signed
* response from Duo if that verification succeeds.
*
* @param authenticatedUser
* The user whose identity should be verified.
*
* @return
* A signed user verification request which can be sent to Duo.
*
* @throws GuacamoleException
* If required Duo-specific configuration options are missing or
* invalid, or if an error occurs within the DuoWeb API which prevents
* generation of the signed request.
*/
public String createSignedRequest(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Retrieve username from externally-authenticated user
String username = authenticatedUser.getIdentifier();
// Retrieve Duo-specific keys from configuration
String ikey = confService.getIntegrationKey();
String skey = confService.getSecretKey();
String akey = confService.getApplicationKey();
// Create signed request for the provided user
String signedRequest = DuoWeb.signRequest(ikey, skey, akey, username);
if (DuoWeb.ERR_AKEY.equals(signedRequest))
throw new GuacamoleServerException("The Duo application key "
+ "must is not valid. Duo application keys must be at "
+ "least 40 characters long.");
if (DuoWeb.ERR_IKEY.equals(signedRequest))
throw new GuacamoleServerException("The provided Duo integration "
+ "key is not valid. Integration keys must be exactly 20 "
+ "characters long.");
if (DuoWeb.ERR_SKEY.equals(signedRequest))
throw new GuacamoleServerException("The provided Duo secret key "
+ "is not valid. Secret keys must be exactly 40 "
+ "characters long.");
if (DuoWeb.ERR_USER.equals(signedRequest))
throw new GuacamoleServerException("The provided username is "
+ "not valid. Duo usernames may not be blank, nor may "
+ "they contain pipe symbols (\"|\").");
if (DuoWeb.ERR_UNKNOWN.equals(signedRequest))
throw new GuacamoleServerException("An unknown error within the "
+ "DuoWeb API prevented the signed request from being "
+ "generated.");
// Return signed request if no error is indicated
return signedRequest;
}
/**
* Returns whether the given signed response is a valid response from Duo
* which verifies the identity of the given user. If the given response is
* invalid or does not verify the identity of the given user (including if
* it is a valid response which verifies the identity of a DIFFERENT user),
* false is returned.
*
* @param authenticatedUser
* The user that the given signed response should verify.
*
* @param signedResponse
* The signed response received from Duo in response to a signed
* request.
*
* @return
* true if the signed response is a valid response from Duo AND verifies
* the identity of the given user, false otherwise.
*
* @throws GuacamoleException
* If required Duo-specific configuration options are missing or
* invalid, or if an error occurs within the DuoWeb API which prevents
* validation of the signed response.
*/
public boolean isValidSignedResponse(AuthenticatedUser authenticatedUser,
String signedResponse) throws GuacamoleException {
// Verify signature response format will not cause
// DuoWeb.verifyResponse() to fail with unchecked exceptions
Matcher responseMatcher = RESPONSE_PATTERN.matcher(signedResponse);
if (!responseMatcher.matches())
throw new GuacamoleClientException("Invalid Duo response format.");
// Retrieve username from externally-authenticated user
String username = authenticatedUser.getIdentifier();
// Retrieve Duo-specific keys from configuration
String ikey = confService.getIntegrationKey();
String skey = confService.getSecretKey();
String akey = confService.getApplicationKey();
// Verify validity of signed response
String verifiedUsername;
try {
verifiedUsername = DuoWeb.verifyResponse(ikey, skey, akey,
signedResponse);
}
// Rethrow any errors as appropriate GuacamoleExceptions
catch (IOException e) {
throw new GuacamoleClientException("Decoding of Duo response "
+ "failed: Invalid base64 content.", e);
}
catch (NumberFormatException e) {
throw new GuacamoleClientException("Decoding of Duo response "
+ "failed: Invalid expiry timestamp.", e);
}
catch (InvalidKeyException e) {
throw new GuacamoleServerException("Unable to produce HMAC "
+ "signature: " + e.getMessage(), e);
}
catch (NoSuchAlgorithmException e) {
throw new GuacamoleServerException("Environment is missing "
+ "support for producing HMAC-SHA1 signatures.", e);
}
catch (DuoWebException e) {
throw new GuacamoleClientException("Duo response verification "
+ "failed: " + e.getMessage(), e);
}
// Signed response is valid iff the associated username matches the
// user's username
return username.equals(verifiedUsername);
}
}

View File

@@ -0,0 +1,160 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.auth.duo.conf;
import com.google.inject.Inject;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.environment.Environment;
import org.apache.guacamole.properties.StringGuacamoleProperty;
/**
* Service for retrieving configuration information regarding the Duo
* authentication extension.
*/
public class ConfigurationService {
/**
* The Guacamole server environment.
*/
@Inject
private Environment environment;
/**
* The property within guacamole.properties which defines the hostname
* of the Duo API endpoint to be used to verify user identities. This will
* usually be in the form "api-XXXXXXXX.duosecurity.com", where "XXXXXXXX"
* is some arbitrary alphanumeric value assigned by Duo and specific to
* your organization.
*/
private static final StringGuacamoleProperty DUO_API_HOSTNAME =
new StringGuacamoleProperty() {
@Override
public String getName() { return "duo-api-hostname"; }
};
/**
* The property within guacamole.properties which defines the integration
* key received from Duo for verifying Guacamole users. This value MUST be
* exactly 20 characters.
*/
private static final StringGuacamoleProperty DUO_INTEGRATION_KEY =
new StringGuacamoleProperty() {
@Override
public String getName() { return "duo-integration-key"; }
};
/**
* The property within guacamole.properties which defines the secret key
* received from Duo for verifying Guacamole users. This value MUST be
* exactly 40 characters.
*/
private static final StringGuacamoleProperty DUO_SECRET_KEY =
new StringGuacamoleProperty() {
@Override
public String getName() { return "duo-secret-key"; }
};
/**
* The property within guacamole.properties which defines the arbitrary
* random key which was generated for Guacamole. Note that this value is not
* provided by Duo, but is expected to be generated by the administrator of
* the system hosting Guacamole. This value MUST be at least 40 characters.
*/
private static final StringGuacamoleProperty DUO_APPLICATION_KEY =
new StringGuacamoleProperty() {
@Override
public String getName() { return "duo-application-key"; }
};
/**
* Returns the hostname of the Duo API endpoint to be used to verify user
* identities, as defined in guacamole.properties by the "duo-api-hostname"
* property. This will usually be in the form
* "api-XXXXXXXX.duosecurity.com", where "XXXXXXXX" is some arbitrary
* alphanumeric value assigned by Duo and specific to your organization.
*
* @return
* The hostname of the Duo API endpoint to be used to verify user
* identities.
*
* @throws GuacamoleException
* If the associated property within guacamole.properties is missing.
*/
public String getAPIHostname() throws GuacamoleException {
return environment.getRequiredProperty(DUO_API_HOSTNAME);
}
/**
* Returns the integration key received from Duo for verifying Guacamole
* users, as defined in guacamole.properties by the "duo-integration-key"
* property. This value MUST be exactly 20 characters.
*
* @return
* The integration key received from Duo for verifying Guacamole
* users.
*
* @throws GuacamoleException
* If the associated property within guacamole.properties is missing.
*/
public String getIntegrationKey() throws GuacamoleException {
return environment.getRequiredProperty(DUO_INTEGRATION_KEY);
}
/**
* Returns the secret key received from Duo for verifying Guacamole users,
* as defined in guacamole.properties by the "duo-secret-key" property. This
* value MUST be exactly 20 characters.
*
* @return
* The secret key received from Duo for verifying Guacamole users.
*
* @throws GuacamoleException
* If the associated property within guacamole.properties is missing.
*/
public String getSecretKey() throws GuacamoleException {
return environment.getRequiredProperty(DUO_SECRET_KEY);
}
/**
* Returns the arbitrary random key which was generated for Guacamole, as
* defined in guacamole.properties by the "duo-application-key" property.
* Note that this value is not provided by Duo, but is expected to be
* generated by the administrator of the system hosting Guacamole. This
* value MUST be at least 40 characters.
*
* @return
* The arbitrary random key which was generated for Guacamole.
*
* @throws GuacamoleException
* If the associated property within guacamole.properties is missing.
*/
public String getApplicationKey() throws GuacamoleException {
return environment.getRequiredProperty(DUO_APPLICATION_KEY);
}
}

View File

@@ -0,0 +1,100 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.auth.duo.form;
import org.apache.guacamole.form.Field;
import org.codehaus.jackson.annotate.JsonProperty;
/**
* A custom field type which uses the DuoWeb API to produce a signed response
* for a particular user. The signed response serves as an additional
* authentication factor, as it cryptographically verifies possession of the
* physical device associated with that user's Duo account.
*/
public class DuoSignedResponseField extends Field {
/**
* The name of the HTTP parameter which an instance of this field will
* populate within a user's credentials.
*/
public static final String PARAMETER_NAME = "guac-duo-signed-response";
/**
* The unique name associated with this field type.
*/
private static final String FIELD_TYPE_NAME = "GUAC_DUO_SIGNED_RESPONSE";
/**
* The hostname of the DuoWeb API endpoint.
*/
private final String apiHost;
/**
* The signed request generated by a call to DuoWeb.signRequest().
*/
private final String signedRequest;
/**
* Creates a new field which uses the DuoWeb API to prompt the user for
* additional credentials. The provided credentials, if valid, will
* ultimately be verified by Duo's service, resulting in a signed response
* which can be cryptographically verified.
*
* @param apiHost
* The hostname of the DuoWeb API endpoint.
*
* @param signedRequest
* A signed request generated for the user in question by a call to
* DuoWeb.signRequest().
*/
public DuoSignedResponseField(String apiHost, String signedRequest) {
// Init base field type properties
super(PARAMETER_NAME, FIELD_TYPE_NAME);
// Init Duo-specific properties
this.apiHost = apiHost;
this.signedRequest = signedRequest;
}
/**
* Returns the hostname of the DuoWeb API endpoint.
*
* @return
* The hostname of the DuoWeb API endpoint.
*/
@JsonProperty("apiHost")
public String getAPIHost() {
return apiHost;
}
/**
* Returns the signed request string, which must have been generated by a
* call to DuoWeb.signRequest().
*
* @return
* The signed request generated by a call to DuoWeb.signRequest().
*/
public String getSignedRequest() {
return signedRequest;
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.
*/
/**
* Config block which registers Duo-specific field types.
*/
angular.module('guacDuo').config(['formServiceProvider',
function guacDuoConfig(formServiceProvider) {
// Define field for the signed response from the Duo service
formServiceProvider.registerFieldType('GUAC_DUO_SIGNED_RESPONSE', {
module : 'guacDuo',
controller : 'duoSignedResponseController',
templateUrl : 'app/ext/duo/templates/duoSignedResponseField.html'
});
}]);

View File

@@ -0,0 +1,78 @@
/*
* 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.
*/
/**
* Controller for the "GUAC_DUO_SIGNED_RESPONSE" field which uses the DuoWeb
* API to prompt the user for additional credentials, ultimately receiving a
* signed response from the Duo service.
*/
angular.module('guacDuo').controller('duoSignedResponseController', ['$scope',
function duoSignedResponseController($scope) {
/**
* The iframe which contains the Duo authentication interface.
*
* @type HTMLIFrameElement
*/
var iframe = $('.duo-signature-response-field iframe')[0];
/**
* Whether the Duo interface has finished loading within the iframe.
*
* @type Boolean
*/
$scope.duoInterfaceLoaded = false;
/**
* Submits the signed response from Duo once the user has authenticated.
* This is a callback invoked by the DuoWeb API after the user has been
* verified and the signed response has been received.
*
* @param {HTMLFormElement} form
* The form element provided by the DuoWeb API containing the signed
* response as the value of an input field named "sig_response".
*/
var submitSignedResponse = function submitSignedResponse(form) {
// Update model to match received response
$scope.$apply(function updateModel() {
$scope.model = form.elements['sig_response'].value;
});
// Submit updated credentials
$(iframe).parents('form').submit();
};
// Update Duo loaded state when iframe finishes loading
iframe.onload = function duoLoaded() {
$scope.$apply(function updateLoadedState() {
$scope.duoInterfaceLoaded = true;
});
};
// Initialize Duo interface within iframe
Duo.init({
iframe : iframe,
host : $scope.field.apiHost,
sig_request : $scope.field.signedRequest,
submit_callback : submitSignedResponse
});
}]);

View File

@@ -0,0 +1,28 @@
/*
* 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.
*/
/**
* Module which provides handling for Duo multi-factor authentication.
*/
angular.module('guacDuo', [
'form'
]);
// Ensure the guacDuo module is loaded along with the rest of the app
angular.module('index').requires.push('guacDuo');

View File

@@ -0,0 +1,35 @@
{
"guacamoleVersion" : "0.9.10-incubating",
"name" : "Duo TFA Authentication Backend",
"namespace" : "duo",
"authProviders" : [
"org.apache.guacamole.auth.duo.DuoAuthenticationProvider"
],
"translations" : [
"translations/en.json"
],
"js" : [
"duoModule.js",
"controllers/duoSignedResponseController.js",
"config/duoConfig.js",
"lib/DuoWeb/LICENSE.js",
"lib/DuoWeb/Duo-Web-v2.js"
],
"css" : [
"styles/duo.css"
],
"resources" : {
"templates/duoSignedResponseField.html" : "text/html"
}
}

View File

@@ -0,0 +1,366 @@
/**
* Duo Web SDK v2
* Copyright 2015, Duo Security
*/
window.Duo = (function(document, window) {
var DUO_MESSAGE_FORMAT = /^(?:AUTH|ENROLL)+\|[A-Za-z0-9\+\/=]+\|[A-Za-z0-9\+\/=]+$/;
var DUO_ERROR_FORMAT = /^ERR\|[\w\s\.\(\)]+$/;
var iframeId = 'duo_iframe',
postAction = '',
postArgument = 'sig_response',
host,
sigRequest,
duoSig,
appSig,
iframe,
submitCallback;
function throwError(message, url) {
throw new Error(
'Duo Web SDK error: ' + message +
(url ? ('\n' + 'See ' + url + ' for more information') : '')
);
}
function hyphenize(str) {
return str.replace(/([a-z])([A-Z])/, '$1-$2').toLowerCase();
}
// cross-browser data attributes
function getDataAttribute(element, name) {
if ('dataset' in element) {
return element.dataset[name];
} else {
return element.getAttribute('data-' + hyphenize(name));
}
}
// cross-browser event binding/unbinding
function on(context, event, fallbackEvent, callback) {
if ('addEventListener' in window) {
context.addEventListener(event, callback, false);
} else {
context.attachEvent(fallbackEvent, callback);
}
}
function off(context, event, fallbackEvent, callback) {
if ('removeEventListener' in window) {
context.removeEventListener(event, callback, false);
} else {
context.detachEvent(fallbackEvent, callback);
}
}
function onReady(callback) {
on(document, 'DOMContentLoaded', 'onreadystatechange', callback);
}
function offReady(callback) {
off(document, 'DOMContentLoaded', 'onreadystatechange', callback);
}
function onMessage(callback) {
on(window, 'message', 'onmessage', callback);
}
function offMessage(callback) {
off(window, 'message', 'onmessage', callback);
}
/**
* Parse the sig_request parameter, throwing errors if the token contains
* a server error or if the token is invalid.
*
* @param {String} sig Request token
*/
function parseSigRequest(sig) {
if (!sig) {
// nothing to do
return;
}
// see if the token contains an error, throwing it if it does
if (sig.indexOf('ERR|') === 0) {
throwError(sig.split('|')[1]);
}
// validate the token
if (sig.indexOf(':') === -1 || sig.split(':').length !== 2) {
throwError(
'Duo was given a bad token. This might indicate a configuration ' +
'problem with one of Duo\'s client libraries.',
'https://www.duosecurity.com/docs/duoweb#first-steps'
);
}
var sigParts = sig.split(':');
// hang on to the token, and the parsed duo and app sigs
sigRequest = sig;
duoSig = sigParts[0];
appSig = sigParts[1];
return {
sigRequest: sig,
duoSig: sigParts[0],
appSig: sigParts[1]
};
}
/**
* This function is set up to run when the DOM is ready, if the iframe was
* not available during `init`.
*/
function onDOMReady() {
iframe = document.getElementById(iframeId);
if (!iframe) {
throw new Error(
'This page does not contain an iframe for Duo to use.' +
'Add an element like <iframe id="duo_iframe"></iframe> ' +
'to this page. ' +
'See https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe ' +
'for more information.'
);
}
// we've got an iframe, away we go!
ready();
// always clean up after yourself
offReady(onDOMReady);
}
/**
* Validate that a MessageEvent came from the Duo service, and that it
* is a properly formatted payload.
*
* The Google Chrome sign-in page injects some JS into pages that also
* make use of postMessage, so we need to do additional validation above
* and beyond the origin.
*
* @param {MessageEvent} event Message received via postMessage
*/
function isDuoMessage(event) {
return Boolean(
event.origin === ('https://' + host) &&
typeof event.data === 'string' &&
(
event.data.match(DUO_MESSAGE_FORMAT) ||
event.data.match(DUO_ERROR_FORMAT)
)
);
}
/**
* Validate the request token and prepare for the iframe to become ready.
*
* All options below can be passed into an options hash to `Duo.init`, or
* specified on the iframe using `data-` attributes.
*
* Options specified using the options hash will take precedence over
* `data-` attributes.
*
* Example using options hash:
* ```javascript
* Duo.init({
* iframe: "some_other_id",
* host: "api-main.duo.test",
* sig_request: "...",
* post_action: "/auth",
* post_argument: "resp"
* });
* ```
*
* Example using `data-` attributes:
* ```
* <iframe id="duo_iframe"
* data-host="api-main.duo.test"
* data-sig-request="..."
* data-post-action="/auth"
* data-post-argument="resp"
* >
* </iframe>
* ```
*
* @param {Object} options
* @param {String} options.iframe The iframe, or id of an iframe to set up
* @param {String} options.host Hostname
* @param {String} options.sig_request Request token
* @param {String} [options.post_action=''] URL to POST back to after successful auth
* @param {String} [options.post_argument='sig_response'] Parameter name to use for response token
* @param {Function} [options.submit_callback] If provided, duo will not submit the form instead execute
* the callback function with reference to the "duo_form" form object
* submit_callback can be used to prevent the webpage from reloading.
*/
function init(options) {
if (options) {
if (options.host) {
host = options.host;
}
if (options.sig_request) {
parseSigRequest(options.sig_request);
}
if (options.post_action) {
postAction = options.post_action;
}
if (options.post_argument) {
postArgument = options.post_argument;
}
if (options.iframe) {
if ('tagName' in options.iframe) {
iframe = options.iframe;
} else if (typeof options.iframe === 'string') {
iframeId = options.iframe;
}
}
if (typeof options.submit_callback === 'function') {
submitCallback = options.submit_callback;
}
}
// if we were given an iframe, no need to wait for the rest of the DOM
if (iframe) {
ready();
} else {
// try to find the iframe in the DOM
iframe = document.getElementById(iframeId);
// iframe is in the DOM, away we go!
if (iframe) {
ready();
} else {
// wait until the DOM is ready, then try again
onReady(onDOMReady);
}
}
// always clean up after yourself!
offReady(init);
}
/**
* This function is called when a message was received from another domain
* using the `postMessage` API. Check that the event came from the Duo
* service domain, and that the message is a properly formatted payload,
* then perform the post back to the primary service.
*
* @param event Event object (contains origin and data)
*/
function onReceivedMessage(event) {
if (isDuoMessage(event)) {
// the event came from duo, do the post back
doPostBack(event.data);
// always clean up after yourself!
offMessage(onReceivedMessage);
}
}
/**
* Point the iframe at Duo, then wait for it to postMessage back to us.
*/
function ready() {
if (!host) {
host = getDataAttribute(iframe, 'host');
if (!host) {
throwError(
'No API hostname is given for Duo to use. Be sure to pass ' +
'a `host` parameter to Duo.init, or through the `data-host` ' +
'attribute on the iframe element.',
'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe'
);
}
}
if (!duoSig || !appSig) {
parseSigRequest(getDataAttribute(iframe, 'sigRequest'));
if (!duoSig || !appSig) {
throwError(
'No valid signed request is given. Be sure to give the ' +
'`sig_request` parameter to Duo.init, or use the ' +
'`data-sig-request` attribute on the iframe element.',
'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe'
);
}
}
// if postAction/Argument are defaults, see if they are specified
// as data attributes on the iframe
if (postAction === '') {
postAction = getDataAttribute(iframe, 'postAction') || postAction;
}
if (postArgument === 'sig_response') {
postArgument = getDataAttribute(iframe, 'postArgument') || postArgument;
}
// point the iframe at Duo
iframe.src = [
'https://', host, '/frame/web/v1/auth?tx=', duoSig,
'&parent=', encodeURIComponent(document.location.href),
'&v=2.3'
].join('');
// listen for the 'message' event
onMessage(onReceivedMessage);
}
/**
* We received a postMessage from Duo. POST back to the primary service
* with the response token, and any additional user-supplied parameters
* given in form#duo_form.
*/
function doPostBack(response) {
// create a hidden input to contain the response token
var input = document.createElement('input');
input.type = 'hidden';
input.name = postArgument;
input.value = response + ':' + appSig;
// user may supply their own form with additional inputs
var form = document.getElementById('duo_form');
// if the form doesn't exist, create one
if (!form) {
form = document.createElement('form');
// insert the new form after the iframe
iframe.parentElement.insertBefore(form, iframe.nextSibling);
}
// make sure we are actually posting to the right place
form.method = 'POST';
form.action = postAction;
// add the response token input to the form
form.appendChild(input);
// away we go!
if (typeof submitCallback === "function") {
submitCallback.call(null, form);
} else {
form.submit();
}
}
// when the DOM is ready, initialize
// note that this will get cleaned up if the user calls init directly!
onReady(init);
return {
init: init,
_parseSigRequest: parseSigRequest,
_isDuoMessage: isDuoMessage,
_doPostBack: doPostBack
};
}(document, window));

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2011, Duo Security, Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The name of the author may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

View File

@@ -0,0 +1,38 @@
/*
* 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.
*/
.duo-signature-response-field iframe {
width: 100%;
max-width: 620px;
height: 330px;
border: none;
}
.duo-signature-response-field iframe {
opacity: 1;
-webkit-transition: opacity 0.125s;
-moz-transition: opacity 0.125s;
-ms-transition: opacity 0.125s;
-o-transition: opacity 0.125s;
transition: opacity 0.125s;
}
.duo-signature-response-field.loading iframe {
opacity: 0;
}

View File

@@ -0,0 +1,3 @@
<div class="duo-signature-response-field" ng-class="{ loading : !duoInterfaceLoaded }">
<iframe></iframe>
</div>

View File

@@ -0,0 +1,13 @@
{
"DATA_SOURCE_DUO" : {
"NAME" : "Duo TFA Backend"
},
"LOGIN" : {
"FIELD_HEADER_GUAC_DUO_SIGNED_RESPONSE" : "",
"INFO_DUO_VALIDATION_CODE_INCORRECT" : "Duo validation code incorrect.",
"INFO_DUO_AUTH_REQUIRED" : "Please authenticate with Duo to continue."
}
}

View File

@@ -49,6 +49,7 @@
<module>guacamole-common-js</module>
<!-- Authentication extensions -->
<module>extensions/guacamole-auth-duo</module>
<module>extensions/guacamole-auth-jdbc</module>
<module>extensions/guacamole-auth-ldap</module>
<module>extensions/guacamole-auth-noauth</module>