GUACAMOLE-161: Merge changes adding Caps Lock to the do-not-auto-repeat list.
diff --git a/CONTRIBUTING b/CONTRIBUTING
index 3864b66..9f005cd 100644
--- a/CONTRIBUTING
+++ b/CONTRIBUTING
@@ -24,10 +24,10 @@
 
     The Guacamole source is maintained in git repositories hosted on GitHub:
 
-        https://github.com/apache/incubator-guacamole-client
-        https://github.com/apache/incubator-guacamole-manual
-        https://github.com/apache/incubator-guacamole-server
-        https://github.com/apache/incubator-guacamole-website
+        https://github.com/apache/guacamole-client
+        https://github.com/apache/guacamole-manual
+        https://github.com/apache/guacamole-server
+        https://github.com/apache/guacamole-website
 
     To make your changes, fork the applicable repositories and make commits
     to a topic branch in your fork. Commits should be made in logical units
diff --git a/DISCLAIMER b/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/LICENSE b/LICENSE
index 2c1ab31..3fa304c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -254,3 +254,40 @@
 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+
+TOTP Reference Implementation (https://tools.ietf.org/id/draft-mraihi-totp-timebased-07.html#Section-Reference-Impl)
+-------------------------------------------------------------------------------
+
+    Verson: 07
+    From: 'IETF Trust' (http://trustee.ietf.org/license-info)
+    License(s):
+        BSD 3-clause (extensions/guacamole-auth-duo/src/licenses/bundled/totp-reference-impl-07/license.txt)
+
+Copyright (c) 2011 IETF Trust and the persons identified as authors
+of the code. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ - Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ - 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.
+
+ - Neither the name of Internet Society, IETF or IETF Trust, nor the names
+   of specific contributors, may be used to endorse or promote products
+   derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “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 COPYRIGHT OWNER OR CONTRIBUTORS 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.
+
diff --git a/NOTICE b/NOTICE
index 47f2b4c..97e6130 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/README b/README
index 30e913b..a3a5752 100644
--- a/README
+++ b/README
@@ -9,11 +9,11 @@
 Source archives and pre-built .war files are available from the downloads
 section of the project website:
  
-    http://guacamole.incubator.apache.org/
+    http://guacamole.apache.org/
 
 A full manual is available as well:
 
-    http://guacamole.incubator.apache.org/doc/gug/
+    http://guacamole.apache.org/doc/gug/
 
 
 ------------------------------------------------------------
diff --git a/doc/guacamole-example/pom.xml b/doc/guacamole-example/pom.xml
index 74a73ed..26bddf3 100644
--- a/doc/guacamole-example/pom.xml
+++ b/doc/guacamole-example/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-example</artifactId>
     <packaging>war</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-example</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -106,7 +106,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>compile</scope>
         </dependency>
 
@@ -114,7 +114,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common-js</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <type>zip</type>
             <scope>runtime</scope>
         </dependency>
diff --git a/doc/guacamole-playback-example/pom.xml b/doc/guacamole-playback-example/pom.xml
index 1dd8aff..d925d92 100644
--- a/doc/guacamole-playback-example/pom.xml
+++ b/doc/guacamole-playback-example/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-playback-example</artifactId>
     <packaging>war</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-playback-example</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -88,7 +88,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common-js</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <type>zip</type>
             <scope>runtime</scope>
         </dependency>
diff --git a/extensions/guacamole-auth-cas/pom.xml b/extensions/guacamole-auth-cas/pom.xml
index e76c50b..f87ae3f 100644
--- a/extensions/guacamole-auth-cas/pom.xml
+++ b/extensions/guacamole-auth-cas/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-auth-cas</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-auth-cas</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -210,7 +210,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>provided</scope>
         </dependency>
 
@@ -218,7 +218,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>provided</scope>
         </dependency>
 
diff --git a/extensions/guacamole-auth-cas/src/licenses/DISCLAIMER b/extensions/guacamole-auth-cas/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-cas/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-cas/src/licenses/NOTICE b/extensions/guacamole-auth-cas/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-cas/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-cas/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/CASAuthenticationProvider.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/CASAuthenticationProvider.java
index 28122f9..5c80bca 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/CASAuthenticationProvider.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/CASAuthenticationProvider.java
@@ -108,6 +108,20 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/form/CASTicketField.java b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/form/CASTicketField.java
index 2fb96b0..eb0f68e 100644
--- a/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/form/CASTicketField.java
+++ b/extensions/guacamole-auth-cas/src/main/java/org/apache/guacamole/auth/cas/form/CASTicketField.java
@@ -41,6 +41,12 @@
     public static final String PARAMETER_NAME = "ticket";
 
     /**
+     * The standard URI name for the CAS login resource.
+     */
+    private static final String CAS_LOGIN_URI = "login";
+
+
+    /**
      * The full URI which the field should link to.
      */
     private final String authorizationURI;
@@ -57,11 +63,6 @@
      *     The full URL of the endpoint accepting CAS authentication
      *     requests.
      *
-     * @param clientID
-     *     The ID of the CAS client. This is normally determined ahead of
-     *     time by the CAS service through some manual credential request
-     *     procedure.
-     *
      * @param redirectURI
      *     The URI that the CAS service should redirect to upon successful
      *     authentication.
@@ -73,8 +74,16 @@
 
         // Build authorization URI from given values
         try {
-            this.authorizationURI = authorizationEndpoint
-                    + "?service=" + URLEncoder.encode(redirectURI, "UTF-8");
+            final StringBuilder sb = new StringBuilder();
+            sb.append(authorizationEndpoint);
+            // user might configure the endpoint with a trailing slash
+            if (sb.charAt(sb.length() - 1) != '/') {
+                sb.append('/');
+            }
+            sb.append(CAS_LOGIN_URI);
+            sb.append("?service=");
+            sb.append(URLEncoder.encode(redirectURI, "UTF-8"));
+            this.authorizationURI = sb.toString();
         }
 
         // Java is required to provide UTF-8 support
diff --git a/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json
index d241e38..6c2865f 100644
--- a/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-cas/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "CAS Authentication Extension",
     "namespace" : "guac-cas",
diff --git a/extensions/guacamole-auth-duo/pom.xml b/extensions/guacamole-auth-duo/pom.xml
index 104b594..dba5413 100644
--- a/extensions/guacamole-auth-duo/pom.xml
+++ b/extensions/guacamole-auth-duo/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-auth-duo</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-auth-duo</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -213,7 +213,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>provided</scope>
         </dependency>
 
diff --git a/extensions/guacamole-auth-duo/src/licenses/DISCLAIMER b/extensions/guacamole-auth-duo/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-duo/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-duo/src/licenses/NOTICE b/extensions/guacamole-auth-duo/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-duo/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-duo/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProvider.java b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProvider.java
index 1c84046..7d27a75 100644
--- a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProvider.java
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProvider.java
@@ -103,6 +103,20 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
index f12896c..455aa5e 100644
--- a/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "Duo TFA Authentication Backend",
     "namespace" : "duo",
diff --git a/extensions/guacamole-auth-header/pom.xml b/extensions/guacamole-auth-header/pom.xml
index c30aa9b..db944ce 100644
--- a/extensions/guacamole-auth-header/pom.xml
+++ b/extensions/guacamole-auth-header/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-auth-header</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-auth-header</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -130,7 +130,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>provided</scope>
         </dependency>
 
diff --git a/extensions/guacamole-auth-header/src/licenses/DISCLAIMER b/extensions/guacamole-auth-header/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-header/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-header/src/licenses/NOTICE b/extensions/guacamole-auth-header/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-header/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-header/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-header/src/main/java/org/apache/guacamole/auth/header/HTTPHeaderAuthenticationProvider.java b/extensions/guacamole-auth-header/src/main/java/org/apache/guacamole/auth/header/HTTPHeaderAuthenticationProvider.java
index b3385b1..ca19b39 100644
--- a/extensions/guacamole-auth-header/src/main/java/org/apache/guacamole/auth/header/HTTPHeaderAuthenticationProvider.java
+++ b/extensions/guacamole-auth-header/src/main/java/org/apache/guacamole/auth/header/HTTPHeaderAuthenticationProvider.java
@@ -108,6 +108,20 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json
index ccc9d30..434e08e 100644
--- a/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-header/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "HTTP Header Authentication Extension",
     "namespace" : "guac-header",
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml
index 4de1d69..55bfa42 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/pom.xml
@@ -27,7 +27,7 @@
     <artifactId>guacamole-auth-jdbc-base</artifactId>
     <packaging>jar</packaging>
     <name>guacamole-auth-jdbc-base</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -36,7 +36,7 @@
     <parent>
         <groupId>org.apache.guacamole</groupId>
         <artifactId>guacamole-auth-jdbc</artifactId>
-        <version>0.9.13-incubating</version>
+        <version>0.9.14</version>
         <relativePath>../../</relativePath>
     </parent>
 
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
index e73b3df..0e3e84b 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
@@ -105,6 +105,20 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
index c9274dc..0f72559 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
@@ -76,6 +76,7 @@
 import org.apache.guacamole.auth.jdbc.sharingprofile.SharingProfileService;
 import org.apache.guacamole.auth.jdbc.tunnel.RestrictedGuacamoleTunnelService;
 import org.apache.guacamole.auth.jdbc.user.PasswordRecordMapper;
+import org.apache.guacamole.auth.jdbc.user.UserRecordMapper;
 import org.mybatis.guice.MyBatisModule;
 import org.mybatis.guice.datasource.builtin.PooledDataSourceProvider;
 
@@ -126,6 +127,7 @@
         addMapperClass(SharingProfilePermissionMapper.class);
         addMapperClass(UserMapper.class);
         addMapperClass(UserPermissionMapper.class);
+        addMapperClass(UserRecordMapper.class);
         
         // Bind core implementations of guacamole-ext classes
         bind(ActiveConnectionDirectory.class);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordModel.java
new file mode 100644
index 0000000..fbf6209
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordModel.java
@@ -0,0 +1,193 @@
+/*
+ * 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.jdbc.base;
+
+import java.util.Date;
+
+/**
+ * A single activity record representing an arbitrary activity performed by a
+ * user.
+ */
+public class ActivityRecordModel {
+
+    /**
+     * The ID of this object in the database, if any.
+     */
+    private Integer recordID;
+
+    /**
+     * The database ID of the user associated with this activity record.
+     */
+    private Integer userID;
+
+    /**
+     * The username of the user that performed the activity.
+     */
+    private String username;
+
+    /**
+     * The remote host associated with the user that performed the activity.
+     */
+    private String remoteHost;
+
+    /**
+     * The time the activity was initiated by the associated user.
+     */
+    private Date startDate;
+
+    /**
+     * The time the activity ended, or null if the end time is not known or
+     * the activity is still in progress.
+     */
+    private Date endDate;
+
+    /**
+     * Returns the ID of this record in the database, if it exists.
+     *
+     * @return
+     *     The ID of this record in the database, or null if this record was
+     *     not retrieved from the database.
+     */
+    public Integer getRecordID() {
+        return recordID;
+    }
+
+    /**
+     * Sets the database ID of this record to the given value.
+     *
+     * @param recordID
+     *     The ID to assign to this object.
+     */
+    public void setRecordID(Integer recordID) {
+        this.recordID = recordID;
+    }
+
+    /**
+     * Returns the database ID of the user associated with this activity
+     * record.
+     * 
+     * @return
+     *     The database ID of the user associated with this activity record.
+     */
+    public Integer getUserID() {
+        return userID;
+    }
+
+    /**
+     * Sets the database ID of the user associated with this activity record.
+     *
+     * @param userID
+     *     The database ID of the user to associate with this activity
+     *     record.
+     */
+    public void setUserID(Integer userID) {
+        this.userID = userID;
+    }
+
+    /**
+     * Returns the username of the user that performed the activity associated
+     * with this record.
+     * 
+     * @return
+     *     The username of the user that performed the activity associated with
+     *     this record.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Sets the username of the user that performed the activity associated
+     * with this record.
+     *
+     * @param username
+     *     The username of the user that performed the activity associated with
+     *     this record.
+     */
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    /**
+     * Returns the remote host associated with the user that performed the
+     * activity.
+     *
+     * @return
+     *     The remote host associated with the user that performed the activity.
+     */
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    /**
+     * Sets the remote host associated with the user that performed the
+     * activity.
+     *
+     * @param remoteHost
+     *     The remote host associated with the user that performed the activity.
+     */
+    public void setRemoteHost(String remoteHost) {
+        this.remoteHost = remoteHost;
+    }
+
+    /**
+     * Returns the time the activity was initiated by the associated user.
+     *
+     * @return
+     *     The time the activity was initiated by the associated user.
+     */
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    /**
+     * Sets the time the activity was initiated by the associated user.
+     *
+     * @param startDate
+     *     The time the activity was initiated by the associated user.
+     */
+    public void setStartDate(Date startDate) {
+        this.startDate = startDate;
+    }
+
+    /**
+     * Returns the time the activity ended, or null if the end time is not
+     * known or the activity is still in progress.
+     *
+     * @return
+     *     The time the activity ended, or null if the end time is not known or
+     *     the activity is still in progress.
+     */
+    public Date getEndDate() {
+        return endDate;
+    }
+
+    /**
+     * Sets the time the activity ended, if known.
+     *
+     * @param endDate
+     *     The time the activity ended, or null if the end time is not known or
+     *     the activity is still in progress.
+     */
+    public void setEndDate(Date endDate) {
+        this.endDate = endDate;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSearchTerm.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordSearchTerm.java
similarity index 93%
rename from extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSearchTerm.java
rename to extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordSearchTerm.java
index 844eff7..54af1a6 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSearchTerm.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordSearchTerm.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-package org.apache.guacamole.auth.jdbc.connection;
+package org.apache.guacamole.auth.jdbc.base;
 
 import java.util.Calendar;
 import java.util.Date;
@@ -25,11 +25,11 @@
 import java.util.regex.Pattern;
 
 /**
- * A search term for querying historical connection records. This will contain
- * a the search term in string form and, if that string appears to be a date. a
- * corresponding date range.
+ * A search term for querying historical records of arbitrary activities. This
+ * will contain a the search term in string form and, if that string appears to
+ * be a date. a corresponding date range.
  */
-public class ConnectionRecordSearchTerm {
+public class ActivityRecordSearchTerm {
     
     /**
      * A pattern that can match a year, year and month, or year and month and
@@ -180,7 +180,7 @@
     }
 
     /**
-     * Creates a new ConnectionRecordSearchTerm representing the given string.
+     * Creates a new ActivityRecordSearchTerm representing the given string.
      * If the given string appears to be a date, the start and end dates of the
      * implied date range will be automatically determined and made available
      * via getStartDate() and getEndDate() respectively.
@@ -188,7 +188,7 @@
      * @param term
      *     The string that should be searched for.
      */
-    public ConnectionRecordSearchTerm(String term) {
+    public ActivityRecordSearchTerm(String term) {
 
         // Search terms absolutely must not be null
         if (term == null)
@@ -281,10 +281,10 @@
     @Override
     public boolean equals(Object obj) {
 
-        if (obj == null || !(obj instanceof ConnectionRecordSearchTerm))
+        if (obj == null || !(obj instanceof ActivityRecordSearchTerm))
             return false;
 
-        return ((ConnectionRecordSearchTerm) obj).getTerm().equals(getTerm());
+        return ((ActivityRecordSearchTerm) obj).getTerm().equals(getTerm());
 
     }
 
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSortPredicate.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordSortPredicate.java
similarity index 77%
rename from extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSortPredicate.java
rename to extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordSortPredicate.java
index 69eee78..ab0d3ce 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSortPredicate.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ActivityRecordSortPredicate.java
@@ -17,18 +17,18 @@
  * under the License.
  */
 
-package org.apache.guacamole.auth.jdbc.connection;
+package org.apache.guacamole.auth.jdbc.base;
 
 import org.apache.guacamole.net.auth.ActivityRecordSet;
 
 /**
- * A sort predicate which species the property to use when sorting connection
+ * A sort predicate which species the property to use when sorting activity
  * records, along with the sort order.
  */
-public class ConnectionRecordSortPredicate {
+public class ActivityRecordSortPredicate {
 
     /**
-     * The property to use when sorting ConnectionRecords.
+     * The property to use when sorting ActivityRecords.
      */
     private final ActivityRecordSet.SortableProperty property;
 
@@ -38,26 +38,26 @@
     private final boolean descending;
     
     /**
-     * Creates a new ConnectionRecordSortPredicate with the given sort property 
+     * Creates a new ActivityRecordSortPredicate with the given sort property
      * and sort order.
      * 
      * @param property 
-     *     The property to use when sorting ConnectionRecords.
+     *     The property to use when sorting ActivityRecords.
      * 
      * @param descending 
      *     Whether the sort order is descending (true) or ascending (false).
      */
-    public ConnectionRecordSortPredicate(ActivityRecordSet.SortableProperty property,
+    public ActivityRecordSortPredicate(ActivityRecordSet.SortableProperty property,
             boolean descending) {
         this.property   = property;
         this.descending = descending;
     }
     
     /**
-     * Returns the property that should be used when sorting ConnectionRecords.
+     * Returns the property that should be used when sorting ActivityRecords.
      *
      * @return
-     *     The property that should be used when sorting ConnectionRecords.
+     *     The property that should be used when sorting ActivityRecords.
      */
     public ActivityRecordSet.SortableProperty getProperty() {
         return property;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ArbitraryAttributeMap.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ArbitraryAttributeMap.java
new file mode 100644
index 0000000..219abe8
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ArbitraryAttributeMap.java
@@ -0,0 +1,180 @@
+/*
+ * 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.jdbc.base;
+
+import java.util.AbstractCollection;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Map of arbitrary attribute name/value pairs which can alternatively be
+ * exposed as a collection of model objects.
+ */
+public class ArbitraryAttributeMap extends HashMap<String, String> {
+
+    /**
+     * Creates a new ArbitraryAttributeMap containing the name/value pairs
+     * within the given collection of model objects.
+     *
+     * @param models
+     *     The model objects of all attributes which should be stored in the
+     *     new map as name/value pairs.
+     *
+     * @return
+     *     A new ArbitraryAttributeMap containing the name/value pairs within
+     *     the given collection of model objects.
+     */
+    public static ArbitraryAttributeMap fromModelCollection(Collection<ArbitraryAttributeModel> models) {
+
+        // Add all name/value pairs from the given collection to the map
+        ArbitraryAttributeMap map = new ArbitraryAttributeMap();
+        for (ArbitraryAttributeModel model : models)
+            map.put(model.getName(), model.getValue());
+
+        return map;
+
+    }
+
+    /**
+     * Returns a collection of model objects which mirrors the contents of this
+     * ArbitraryAttributeMap. Each name/value pair within the map is reflected
+     * by a corresponding model object within the returned collection. Removing
+     * a model object from the collection removes the corresponding name/value
+     * pair from the map. Adding a new model object to the collection adds a
+     * corresponding name/value pair to the map. Changes to a model object
+     * within the collection are NOT reflected on the map, however.
+     *
+     * @return
+     *     A collection of model objects which mirrors the contents of this
+     *     ArbitraryAttributeMap.
+     */
+    public Collection<ArbitraryAttributeModel> toModelCollection() {
+        return new AbstractCollection<ArbitraryAttributeModel>() {
+
+            @Override
+            public void clear() {
+                ArbitraryAttributeMap.this.clear();
+            }
+
+            @Override
+            public boolean remove(Object o) {
+
+                // The Collection view of an ArbitraryAttributeMap can contain
+                // only ArbitraryAttributeModel objects
+                if (!(o instanceof ArbitraryAttributeModel))
+                    return false;
+
+                // Remove only if key is actually present
+                ArbitraryAttributeModel model = (ArbitraryAttributeModel) o;
+                if (!ArbitraryAttributeMap.this.containsKey(model.getName()))
+                    return false;
+
+                // The attribute should be removed only if the value matches
+                String currentValue = ArbitraryAttributeMap.this.get(model.getName());
+                if (currentValue == null) {
+                    if (model.getValue() != null)
+                        return false;
+                }
+                else if (!currentValue.equals(model.getValue()))
+                    return false;
+
+                ArbitraryAttributeMap.this.remove(model.getName());
+                return true;
+
+            }
+
+            @Override
+            public boolean add(ArbitraryAttributeModel e) {
+
+                String newValue = e.getValue();
+                String oldValue = put(e.getName(), newValue);
+
+                // If null value is being added, collection changed only if
+                // old value was non-null
+                if (newValue == null)
+                    return oldValue != null;
+
+                // Collection changed if value changed
+                return !newValue.equals(oldValue);
+
+            }
+
+            @Override
+            public boolean contains(Object o) {
+
+                // The Collection view of an ArbitraryAttributeMap can contain
+                // only ArbitraryAttributeModel objects
+                if (!(o instanceof ArbitraryAttributeModel))
+                    return false;
+
+                // No need to check the value of the attribute if the attribute
+                // is not even present
+                ArbitraryAttributeModel model = (ArbitraryAttributeModel) o;
+                String value = get(model.getName());
+                if (value == null)
+                    return false;
+
+                // The name/value pair is present only if the value matches
+                return value.equals(model.getValue());
+
+            }
+
+            @Override
+            public Iterator<ArbitraryAttributeModel> iterator() {
+
+                // Get iterator over all string name/value entries
+                final Iterator<Map.Entry<String, String>> iterator = entrySet().iterator();
+
+                // Dynamically translate each string name/value entry into a
+                // corresponding attribute model object as iteration continues
+                return new Iterator<ArbitraryAttributeModel>() {
+
+                    @Override
+                    public boolean hasNext() {
+                        return iterator.hasNext();
+                    }
+
+                    @Override
+                    public ArbitraryAttributeModel next() {
+                        Map.Entry<String, String> entry = iterator.next();
+                        return new ArbitraryAttributeModel(entry.getKey(),
+                                entry.getValue());
+                    }
+
+                    @Override
+                    public void remove() {
+                        iterator.remove();
+                    }
+
+                };
+
+            }
+
+            @Override
+            public int size() {
+                return ArbitraryAttributeMap.this.size();
+            }
+
+        };
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ArbitraryAttributeModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ArbitraryAttributeModel.java
new file mode 100644
index 0000000..b064b54
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ArbitraryAttributeModel.java
@@ -0,0 +1,104 @@
+/*
+ * 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.jdbc.base;
+
+/**
+ * A single attribute name/value pair belonging to a object which implements
+ * the Attributes interface, such as a Connection or User. Attributes stored
+ * as raw name/value pairs are the attributes which are given to the database
+ * authentication extension for storage by other extensions. Attributes which
+ * are directly supported by the database authentication extension have defined
+ * columns and properties with proper types, constraints, etc.
+ */
+public class ArbitraryAttributeModel {
+
+    /**
+     * The name of the attribute.
+     */
+    private String name;
+
+    /**
+     * The value the attribute is set to.
+     */
+    private String value;
+
+    /**
+     * Creates a new ArbitraryAttributeModel with its name and value both set
+     * to null.
+     */
+    public ArbitraryAttributeModel() {
+    }
+
+    /**
+     * Creates a new ArbitraryAttributeModel with its name and value
+     * initialized to the given values.
+     *
+     * @param name
+     *     The name of the attribute.
+     *
+     * @param value
+     *     The value the attribute is set to.
+     */
+    public ArbitraryAttributeModel(String name, String value) {
+        this.name = name;
+        this.value = value;
+    }
+
+    /**
+     * Returns the name of this attribute.
+     *
+     * @return
+     *     The name of this attribute.
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the name of this attribute.
+     *
+     * @param name
+     *     The name of this attribute.
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Returns the value of this attribute.
+     *
+     * @return
+     *     The value of this attribute.
+     */
+    public String getValue() {
+        return value;
+    }
+
+    /**
+     * Sets the value of this attribute.
+     *
+     * @param value
+     *     The value of this attribute.
+     */
+    public void setValue(String value) {
+        this.value = value;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledActivityRecord.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledActivityRecord.java
new file mode 100644
index 0000000..95b1a25
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledActivityRecord.java
@@ -0,0 +1,73 @@
+/*
+ * 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.jdbc.base;
+
+
+import java.util.Date;
+import org.apache.guacamole.net.auth.ActivityRecord;
+
+/**
+ * An ActivityRecord which is backed by a database model.
+ */
+public class ModeledActivityRecord implements ActivityRecord {
+
+    /**
+     * The model object backing this activity record.
+     */
+    private final ActivityRecordModel model;
+
+    /**
+     * Creates a new ModeledActivityRecord backed by the given model object.
+     * Changes to this record will affect the backing model object, and changes
+     * to the backing model object will affect this record.
+     * 
+     * @param model
+     *     The model object to use to back this activity record.
+     */
+    public ModeledActivityRecord(ActivityRecordModel model) {
+        this.model = model;
+    }
+
+    @Override
+    public Date getStartDate() {
+        return model.getStartDate();
+    }
+
+    @Override
+    public Date getEndDate() {
+        return model.getEndDate();
+    }
+
+    @Override
+    public String getRemoteHost() {
+        return model.getRemoteHost();
+    }
+
+    @Override
+    public String getUsername() {
+        return model.getUsername();
+    }
+
+    @Override
+    public boolean isActive() {
+        return false;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledActivityRecordSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledActivityRecordSet.java
new file mode 100644
index 0000000..d259018
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledActivityRecordSet.java
@@ -0,0 +1,132 @@
+/*
+ * 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.jdbc.base;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.ActivityRecord;
+import org.apache.guacamole.net.auth.ActivityRecordSet;
+import org.apache.guacamole.net.auth.ActivityRecordSet.SortableProperty;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+
+/**
+ * A JDBC implementation of ActivityRecordSet. Calls to asCollection() will
+ * query history records using an implementation-specific mechanism. Which
+ * records are returned will be determined by the values passed in earlier.
+ *
+ * @param <RecordType>
+ *     The type of ActivityRecord contained within this set.
+ */
+public abstract class ModeledActivityRecordSet<RecordType extends ActivityRecord>
+        extends RestrictedObject implements ActivityRecordSet<RecordType> {
+
+    /**
+     * The set of strings that each must occur somewhere within the returned 
+     * records, whether within the associated username, an associated date, or
+     * other related data. If non-empty, any record not matching each of the
+     * strings within the collection will be excluded from the results.
+     */
+    private final Set<ActivityRecordSearchTerm> requiredContents =
+            new HashSet<ActivityRecordSearchTerm>();
+    
+    /**
+     * The maximum number of history records that should be returned by a call
+     * to asCollection().
+     */
+    private int limit = Integer.MAX_VALUE;
+    
+    /**
+     * A list of predicates to apply while sorting the resulting records,
+     * describing the properties involved and the sort order for those
+     * properties.
+     */
+    private final List<ActivityRecordSortPredicate> sortPredicates =
+            new ArrayList<ActivityRecordSortPredicate>();
+
+    /**
+     * Retrieves the history records matching the given criteria. Retrieves up
+     * to <code>limit</code> history records matching the given terms and sorted
+     * by the given predicates. Only history records associated with data that
+     * the given user can read are returned.
+     *
+     * @param user
+     *     The user retrieving the history.
+     *
+     * @param requiredContents
+     *     The search terms that must be contained somewhere within each of the
+     *     returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     A collection of all history records matching the given criteria.
+     *
+     * @throws GuacamoleException
+     *     If permission to read the history records is denied.
+     */
+    protected abstract Collection<RecordType> retrieveHistory(
+            AuthenticatedUser user,
+            Set<ActivityRecordSearchTerm> requiredContents,
+            List<ActivityRecordSortPredicate> sortPredicates,
+            int limit) throws GuacamoleException;
+
+    @Override
+    public Collection<RecordType> asCollection()
+            throws GuacamoleException {
+        return retrieveHistory(getCurrentUser(), requiredContents,
+                sortPredicates, limit);
+    }
+
+    @Override
+    public ModeledActivityRecordSet<RecordType> contains(String value)
+            throws GuacamoleException {
+        requiredContents.add(new ActivityRecordSearchTerm(value));
+        return this;
+    }
+
+    @Override
+    public ModeledActivityRecordSet<RecordType> limit(int limit) throws GuacamoleException {
+        this.limit = Math.min(this.limit, limit);
+        return this;
+    }
+
+    @Override
+    public ModeledActivityRecordSet<RecordType> sort(SortableProperty property, boolean desc)
+            throws GuacamoleException {
+        
+        sortPredicates.add(new ActivityRecordSortPredicate(
+            property,
+            desc
+        ));
+        
+        return this;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObject.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObject.java
index 0d15373..ddeda92 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObject.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObject.java
@@ -19,6 +19,11 @@
 
 package org.apache.guacamole.auth.jdbc.base;
 
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.guacamole.net.auth.Attributes;
 import org.apache.guacamole.net.auth.Identifiable;
 
 /**
@@ -31,7 +36,7 @@
  *     The type of model object that corresponds to this object.
  */
 public abstract class ModeledDirectoryObject<ModelType extends ObjectModel>
-    extends ModeledObject<ModelType> implements Identifiable {
+    extends ModeledObject<ModelType> implements Identifiable, Attributes {
 
     @Override
     public String getIdentifier() {
@@ -43,4 +48,51 @@
         getModel().setIdentifier(identifier);
     }
 
+    /**
+     * Returns the names of all attributes explicitly supported by this object.
+     * Attributes named here have associated mappings within the backing model
+     * object, and thus should not be included in the arbitrary attribute
+     * storage. Any attributes set which do not match these names, such as those
+     * set via other extensions, will be added to arbitrary attribute storage.
+     *
+     * @return
+     *     A read-only Set of the names of all attributes explicitly supported
+     *     (mapped to a property of the backing model) by this object.
+     */
+    public Set<String> getSupportedAttributeNames() {
+        return Collections.<String>emptySet();
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return new HashMap<String, String>(getModel().getArbitraryAttributeMap());
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+
+        ArbitraryAttributeMap arbitraryAttributes = getModel().getArbitraryAttributeMap();
+
+        // Get set of all supported attribute names
+        Set<String> supportedAttributes = getSupportedAttributeNames();
+
+        // Store remaining attributes only if not directly mapped to model
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+
+            String name = attribute.getKey();
+            String value = attribute.getValue();
+
+            // Handle null attributes as explicit removal of that attribute,
+            // as the underlying model cannot store null attribute values
+            if (!supportedAttributes.contains(name)) {
+                if (value == null)
+                    arbitraryAttributes.remove(name);
+                else
+                    arbitraryAttributes.put(name, value);
+            }
+
+        }
+
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java
index ebd95d9..4431e8f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectMapper.java
@@ -132,5 +132,28 @@
      *     The number of rows updated.
      */
     int update(@Param("object") ModelType object);
-    
+
+    /**
+     * Deletes any arbitrary attributes currently associated with the given
+     * object in the database.
+     *
+     * @param object
+     *     The object whose arbitrary attributes should be deleted.
+     *
+     * @return
+     *     The number of rows deleted.
+     */
+    int deleteAttributes(@Param("object") ModelType object);
+
+    /**
+     * Inserts all arbitrary attributes associated with the given object.
+     *
+     * @param object
+     *     The object whose arbitrary attributes should be inserted.
+     *
+     * @return
+     *     The number of rows inserted.
+     */
+    int insertAttributes(@Param("object") ModelType object);
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java
index 2c1402e..21508c4 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ModeledDirectoryObjectService.java
@@ -32,6 +32,7 @@
 import org.apache.guacamole.net.auth.Identifiable;
 import org.apache.guacamole.net.auth.permission.ObjectPermission;
 import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.mybatis.guice.transactional.Transactional;
 
 /**
  * Service which provides convenience methods for creating, retrieving, and
@@ -446,6 +447,7 @@
     }
 
     @Override
+    @Transactional
     public InternalType createObject(ModeledAuthenticatedUser user, ExternalType object)
         throws GuacamoleException {
 
@@ -461,6 +463,10 @@
         // Add implicit permissions
         getPermissionMapper().insert(getImplicitPermissions(user, model));
 
+        // Add any arbitrary attributes
+        if (model.hasArbitraryAttributes())
+            getObjectMapper().insertAttributes(model);
+
         return getObjectInstance(user, model);
 
     }
@@ -477,6 +483,7 @@
     }
 
     @Override
+    @Transactional
     public void updateObject(ModeledAuthenticatedUser user, InternalType object)
         throws GuacamoleException {
 
@@ -486,6 +493,11 @@
         // Update object
         getObjectMapper().update(model);
 
+        // Replace any existing arbitrary attributes
+        getObjectMapper().deleteAttributes(model);
+        if (model.hasArbitraryAttributes())
+            getObjectMapper().insertAttributes(model);
+
     }
 
     @Override
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ObjectModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ObjectModel.java
index 833c0d9..c3052b1 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ObjectModel.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/ObjectModel.java
@@ -19,6 +19,8 @@
 
 package org.apache.guacamole.auth.jdbc.base;
 
+import java.util.Collection;
+
 /**
  * Object representation of a Guacamole object, such as a user or connection,
  * as represented in the database.
@@ -34,7 +36,14 @@
      * The unique identifier which identifies this object.
      */
     private String identifier;
-    
+
+    /**
+     * Map of all arbitrary attributes associated with this object but not
+     * directly mapped to a particular column.
+     */
+    private ArbitraryAttributeMap arbitraryAttributes =
+            new ArbitraryAttributeMap();
+
     /**
      * Creates a new, empty object.
      */
@@ -82,4 +91,59 @@
         this.objectID = objectID;
     }
 
+    /**
+     * Returns a map of attribute name/value pairs for all attributes associated
+     * with this model which do not have explicit mappings to actual model
+     * properties. All other attributes (those which are explicitly supported
+     * by the model) should instead be mapped to properties with corresponding
+     * and properly-typed columns.
+     *
+     * @return
+     *     A map of attribute name/value pairs for all attributes associated
+     *     with this model which do not otherwise have explicit mappings to
+     *     properties.
+     */
+    public ArbitraryAttributeMap getArbitraryAttributeMap() {
+        return arbitraryAttributes;
+    }
+
+    /**
+     * Returns whether at least one arbitrary attribute name/value pair has
+     * been associated with this object.
+     *
+     * @return
+     *     true if this object has at least one arbitrary attribute set, false
+     *     otherwise.
+     */
+    public boolean hasArbitraryAttributes() {
+        return !arbitraryAttributes.isEmpty();
+    }
+
+    /**
+     * Returns a Collection view of the equivalent attribute model objects
+     * which make up the map of arbitrary attribute name/value pairs returned
+     * by getArbitraryAttributeMap(). Additions and removals on the returned
+     * Collection directly affect the attribute map.
+     *
+     * @return
+     *      A Collection view of the map returned by
+     *      getArbitraryAttributeMap().
+     */
+    public Collection<ArbitraryAttributeModel> getArbitraryAttributes() {
+        return arbitraryAttributes.toModelCollection();
+    }
+
+    /**
+     * Replaces all arbitrary attributes associated with this object with the
+     * attribute name/value pairs within the given collection of model objects.
+     *
+     * @param arbitraryAttributes
+     *     The Collection of model objects containing the attribute name/value
+     *     pairs which should replace all currently-stored arbitrary attributes,
+     *     if any.
+     */
+    public void setArbitraryAttributes(Collection<ArbitraryAttributeModel> arbitraryAttributes) {
+        this.arbitraryAttributes = ArbitraryAttributeMap.fromModelCollection(arbitraryAttributes);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionModel.java
index 788daa1..da45402 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionModel.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionModel.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.auth.jdbc.connection;
 
+import java.util.Date;
 import java.util.HashSet;
 import java.util.Set;
 import org.apache.guacamole.auth.jdbc.base.ChildObjectModel;
@@ -93,6 +94,12 @@
     private EncryptionMethod proxyEncryptionMethod;
 
     /**
+     * The date and time that this connection was last used, or null if this
+     * connection has never been used.
+     */
+    private Date lastActive;
+
+    /**
      * Creates a new, empty connection.
      */
     public ConnectionModel() {
@@ -341,6 +348,32 @@
         this.sharingProfileIdentifiers = sharingProfileIdentifiers;
     }
 
+    /**
+     * Returns the date and time that this connection was last used, or null if
+     * this connection has never been used.
+     *
+     * @return
+     *     The date and time that this connection was last used, or null if this
+     *     connection has never been used.
+     */
+    public Date getLastActive() {
+        return lastActive;
+    }
+
+    /**
+     * Sets the date and time that this connection was last used. This value is
+     * expected to be set automatically via queries, derived from connection
+     * history records. It does NOT correspond to an actual column, and values
+     * set manually through invoking this function will not persist.
+     *
+     * @param lastActive
+     *     The date and time that this connection was last used, or null if this
+     *     connection has never been used.
+     */
+    public void setLastActive(Date lastActive) {
+        this.lastActive = lastActive;
+    }
+
     @Override
     public String getIdentifier() {
 
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java
index aefff92..637fd0f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordMapper.java
@@ -21,6 +21,8 @@
 
 import java.util.Collection;
 import java.util.List;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSearchTerm;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSortPredicate;
 import org.apache.ibatis.annotations.Param;
 import org.apache.guacamole.auth.jdbc.user.UserModel;
 
@@ -75,8 +77,8 @@
      * @return
      *     The results of the search performed with the given parameters.
      */
-    List<ConnectionRecordModel> search(@Param("terms") Collection<ConnectionRecordSearchTerm> terms,
-            @Param("sortPredicates") List<ConnectionRecordSortPredicate> sortPredicates,
+    List<ConnectionRecordModel> search(@Param("terms") Collection<ActivityRecordSearchTerm> terms,
+            @Param("sortPredicates") List<ActivityRecordSortPredicate> sortPredicates,
             @Param("limit") int limit);
 
     /**
@@ -104,8 +106,8 @@
      *     The results of the search performed with the given parameters.
      */
     List<ConnectionRecordModel> searchReadable(@Param("user") UserModel user,
-            @Param("terms") Collection<ConnectionRecordSearchTerm> terms,
-            @Param("sortPredicates") List<ConnectionRecordSortPredicate> sortPredicates,
+            @Param("terms") Collection<ActivityRecordSearchTerm> terms,
+            @Param("sortPredicates") List<ActivityRecordSortPredicate> sortPredicates,
             @Param("limit") int limit);
 
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordModel.java
index f142f4e..29c5556 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordModel.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordModel.java
@@ -19,14 +19,14 @@
 
 package org.apache.guacamole.auth.jdbc.connection;
 
-import java.util.Date;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordModel;
 
 /**
  * A single connection record representing a past usage of a particular
  * connection. If the connection was being shared, the sharing profile used to
  * join the connection is included in the record.
  */
-public class ConnectionRecordModel {
+public class ConnectionRecordModel extends ActivityRecordModel {
 
     /**
      * The identifier of the connection associated with this connection record.
@@ -54,32 +54,6 @@
     private String sharingProfileName;
 
     /**
-     * The database ID of the user associated with this connection record.
-     */
-    private Integer userID;
-
-    /**
-     * The username of the user associated with this connection record.
-     */
-    private String username;
-
-    /**
-     * The remote host associated with this connection record.
-     */
-    private String remoteHost;
-
-    /**
-     * The time the connection was initiated by the associated user.
-     */
-    private Date startDate;
-
-    /**
-     * The time the connection ended, or null if the end time is not known or
-     * the connection is still running.
-     */
-    private Date endDate;
-
-    /**
      * Returns the identifier of the connection associated with this connection
      * record.
      *
@@ -179,109 +153,4 @@
         this.sharingProfileName = sharingProfileName;
     }
 
-    /**
-     * Returns the database ID of the user associated with this connection
-     * record.
-     * 
-     * @return
-     *     The database ID of the user associated with this connection record.
-     */
-    public Integer getUserID() {
-        return userID;
-    }
-
-    /**
-     * Sets the database ID of the user associated with this connection record.
-     *
-     * @param userID
-     *     The database ID of the user to associate with this connection
-     *     record.
-     */
-    public void setUserID(Integer userID) {
-        this.userID = userID;
-    }
-
-    /**
-     * Returns the username of the user associated with this connection record.
-     * 
-     * @return
-     *     The username of the user associated with this connection record.
-     */
-    public String getUsername() {
-        return username;
-    }
-
-    /**
-     * Sets the username of the user associated with this connection record.
-     *
-     * @param username
-     *     The username of the user to associate with this connection record.
-     */
-    public void setUsername(String username) {
-        this.username = username;
-    }
-
-    /**
-     * Returns the remote host associated with this connection record.
-     *
-     * @return
-     *     The remote host associated with this connection record.
-     */
-    public String getRemoteHost() {
-        return remoteHost;
-    }
-
-    /**
-     * Sets the remote host associated with this connection record.
-     *
-     * @param remoteHost
-     *     The remote host to associate with this connection record.
-     */
-    public void setRemoteHost(String remoteHost) {
-        this.remoteHost = remoteHost;
-    }
-
-    /**
-     * Returns the date that the associated connection was established.
-     *
-     * @return
-     *     The date the associated connection was established.
-     */
-    public Date getStartDate() {
-        return startDate;
-    }
-
-    /**
-     * Sets the date that the associated connection was established.
-     *
-     * @param startDate
-     *     The date that the associated connection was established.
-     */
-    public void setStartDate(Date startDate) {
-        this.startDate = startDate;
-    }
-
-    /**
-     * Returns the date that the associated connection ended, or null if no
-     * end date was recorded. The lack of an end date does not necessarily
-     * mean that the connection is still active.
-     *
-     * @return
-     *     The date the associated connection ended, or null if no end date was
-     *     recorded.
-     */
-    public Date getEndDate() {
-        return endDate;
-    }
-
-    /**
-     * Sets the date that the associated connection ended.
-     *
-     * @param endDate
-     *     The date that the associated connection ended.
-     */
-    public void setEndDate(Date endDate) {
-        this.endDate = endDate;
-    }
-
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSet.java
index 7b3d629..f4574f4 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSet.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionRecordSet.java
@@ -20,15 +20,14 @@
 package org.apache.guacamole.auth.jdbc.connection;
 
 import com.google.inject.Inject;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
-import org.apache.guacamole.net.auth.ActivityRecordSet;
-import org.apache.guacamole.net.auth.ActivityRecordSet.SortableProperty;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSearchTerm;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSortPredicate;
+import org.apache.guacamole.auth.jdbc.base.ModeledActivityRecordSet;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.ConnectionRecord;
 
 /**
@@ -36,8 +35,7 @@
  * asCollection() will query connection history records from the database. Which
  * records are returned will be determined by the values passed in earlier.
  */
-public class ConnectionRecordSet extends RestrictedObject
-        implements ActivityRecordSet<ConnectionRecord> {
+public class ConnectionRecordSet extends ModeledActivityRecordSet<ConnectionRecord> {
 
     /**
      * Service for managing connection objects.
@@ -45,60 +43,15 @@
     @Inject
     private ConnectionService connectionService;
     
-    /**
-     * The set of strings that each must occur somewhere within the returned 
-     * connection records, whether within the associated username, the name of 
-     * the associated connection, or any associated date. If non-empty, any 
-     * connection record not matching each of the strings within the collection 
-     * will be excluded from the results.
-     */
-    private final Set<ConnectionRecordSearchTerm> requiredContents = 
-            new HashSet<ConnectionRecordSearchTerm>();
-    
-    /**
-     * The maximum number of connection history records that should be returned
-     * by a call to asCollection().
-     */
-    private int limit = Integer.MAX_VALUE;
-    
-    /**
-     * A list of predicates to apply while sorting the resulting connection
-     * records, describing the properties involved and the sort order for those 
-     * properties.
-     */
-    private final List<ConnectionRecordSortPredicate> connectionRecordSortPredicates =
-            new ArrayList<ConnectionRecordSortPredicate>();
-    
     @Override
-    public Collection<ConnectionRecord> asCollection()
+    protected Collection<ConnectionRecord> retrieveHistory(
+            AuthenticatedUser user, Set<ActivityRecordSearchTerm> requiredContents,
+            List<ActivityRecordSortPredicate> sortPredicates, int limit)
             throws GuacamoleException {
+
+        // Retrieve history from database
         return connectionService.retrieveHistory(getCurrentUser(),
-                requiredContents, connectionRecordSortPredicates, limit);
-    }
-
-    @Override
-    public ConnectionRecordSet contains(String value)
-            throws GuacamoleException {
-        requiredContents.add(new ConnectionRecordSearchTerm(value));
-        return this;
-    }
-
-    @Override
-    public ConnectionRecordSet limit(int limit) throws GuacamoleException {
-        this.limit = Math.min(this.limit, limit);
-        return this;
-    }
-
-    @Override
-    public ConnectionRecordSet sort(SortableProperty property, boolean desc)
-            throws GuacamoleException {
-        
-        connectionRecordSortPredicates.add(new ConnectionRecordSortPredicate(
-            property,
-            desc
-        ));
-        
-        return this;
+                requiredContents, sortPredicates, limit);
 
     }
 
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionService.java
index f256324..983f395 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionService.java
@@ -34,6 +34,8 @@
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSearchTerm;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSortPredicate;
 import org.apache.guacamole.auth.jdbc.base.ModeledChildDirectoryObjectService;
 import org.apache.guacamole.auth.jdbc.permission.ConnectionPermissionMapper;
 import org.apache.guacamole.auth.jdbc.permission.ObjectPermissionMapper;
@@ -460,8 +462,8 @@
      *     If permission to read the connection history is denied.
      */
     public List<ConnectionRecord> retrieveHistory(ModeledAuthenticatedUser user,
-            Collection<ConnectionRecordSearchTerm> requiredContents,
-            List<ConnectionRecordSortPredicate> sortPredicates, int limit)
+            Collection<ActivityRecordSearchTerm> requiredContents,
+            List<ActivityRecordSortPredicate> sortPredicates, int limit)
             throws GuacamoleException {
 
         List<ConnectionRecordModel> searchResults;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnection.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnection.java
index c596b27..660212c 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnection.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnection.java
@@ -25,7 +25,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
-import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -158,6 +158,21 @@
     ));
 
     /**
+     * The names of all attributes which are explicitly supported by this
+     * extension's Connection objects.
+     */
+    public static final Set<String> ATTRIBUTE_NAMES =
+            Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(
+                GUACD_HOSTNAME_NAME,
+                GUACD_PORT_NAME,
+                GUACD_ENCRYPTION_NAME,
+                MAX_CONNECTIONS_NAME,
+                MAX_CONNECTIONS_PER_USER_NAME,
+                CONNECTION_WEIGHT,
+                FAILOVER_ONLY_NAME
+            )));
+
+    /**
      * The environment of the Guacamole server.
      */
     @Inject
@@ -235,7 +250,7 @@
 
     @Override
     public Date getLastActive() {
-        return null;
+        return getModel().getLastActive();
     }
 
     @Override
@@ -254,9 +269,15 @@
     }
 
     @Override
+    public Set<String> getSupportedAttributeNames() {
+        return ATTRIBUTE_NAMES;
+    }
+
+    @Override
     public Map<String, String> getAttributes() {
 
-        Map<String, String> attributes = new HashMap<String, String>();
+        // Include any defined arbitrary attributes
+        Map<String, String> attributes = super.getAttributes();
 
         // Set connection limit attribute
         attributes.put(MAX_CONNECTIONS_NAME, NumericField.format(getModel().getMaxConnections()));
@@ -305,6 +326,9 @@
     @Override
     public void setAttributes(Map<String, String> attributes) {
 
+        // Set arbitrary attributes
+        super.setAttributes(attributes);
+
         // Translate connection limit attribute
         try { getModel().setMaxConnections(NumericField.parse(attributes.get(MAX_CONNECTIONS_NAME))); }
         catch (NumberFormatException e) {
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java
index a5e83d4..9f34385 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ModeledConnectionRecord.java
@@ -20,13 +20,14 @@
 package org.apache.guacamole.auth.jdbc.connection;
 
 
-import java.util.Date;
+import org.apache.guacamole.auth.jdbc.base.ModeledActivityRecord;
 import org.apache.guacamole.net.auth.ConnectionRecord;
 
 /**
  * A ConnectionRecord which is backed by a database model.
  */
-public class ModeledConnectionRecord implements ConnectionRecord {
+public class ModeledConnectionRecord extends ModeledActivityRecord
+        implements ConnectionRecord {
 
     /**
      * The model object backing this connection record.
@@ -42,6 +43,7 @@
      *     The model object to use to back this connection record.
      */
     public ModeledConnectionRecord(ConnectionRecordModel model) {
+        super(model);
         this.model = model;
     }
 
@@ -65,29 +67,4 @@
         return model.getSharingProfileName();
     }
 
-    @Override
-    public Date getStartDate() {
-        return model.getStartDate();
-    }
-
-    @Override
-    public Date getEndDate() {
-        return model.getEndDate();
-    }
-
-    @Override
-    public String getRemoteHost() {
-        return model.getRemoteHost();
-    }
-
-    @Override
-    public String getUsername() {
-        return model.getUsername();
-    }
-
-    @Override
-    public boolean isActive() {
-        return false;
-    }
-
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java
index 7e65c7f..3aac52d 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ModeledConnectionGroup.java
@@ -23,7 +23,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
@@ -90,6 +90,17 @@
     ));
 
     /**
+     * The names of all attributes which are explicitly supported by this
+     * extension's ConnectionGroup objects.
+     */
+    public static final Set<String> ATTRIBUTE_NAMES =
+            Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(
+                MAX_CONNECTIONS_NAME,
+                MAX_CONNECTIONS_PER_USER_NAME,
+                ENABLE_SESSION_AFFINITY
+            )));
+
+    /**
      * The environment of the Guacamole server.
      */
     @Inject
@@ -157,9 +168,15 @@
     }
 
     @Override
+    public Set<String> getSupportedAttributeNames() {
+        return ATTRIBUTE_NAMES;
+    }
+
+    @Override
     public Map<String, String> getAttributes() {
 
-        Map<String, String> attributes = new HashMap<String, String>();
+        // Include any defined arbitrary attributes
+        Map<String, String> attributes = super.getAttributes();
 
         // Set connection limit attribute
         attributes.put(MAX_CONNECTIONS_NAME, NumericField.format(getModel().getMaxConnections()));
@@ -177,6 +194,9 @@
     @Override
     public void setAttributes(Map<String, String> attributes) {
 
+        // Set arbitrary attributes
+        super.setAttributes(attributes);
+
         // Translate connection limit attribute
         try { getModel().setMaxConnections(NumericField.parse(attributes.get(MAX_CONNECTIONS_NAME))); }
         catch (NumberFormatException e) {
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/ModeledSharingProfile.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/ModeledSharingProfile.java
index 19c70bb..1acbd79 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/ModeledSharingProfile.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/ModeledSharingProfile.java
@@ -95,14 +95,4 @@
         this.parameters = parameters;
     }
 
-    @Override
-    public Map<String, String> getAttributes() {
-        return Collections.<String, String>emptyMap();
-    }
-
-    @Override
-    public void setAttributes(Map<String, String> attributes) {
-        // Do nothing - no attributes
-    }
-
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUser.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUser.java
index fc43e36..b295655 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUser.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUser.java
@@ -28,9 +28,10 @@
 import java.util.Calendar;
 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.TimeZone;
 import org.apache.guacamole.auth.jdbc.base.ModeledDirectoryObject;
 import org.apache.guacamole.auth.jdbc.security.PasswordEncryptionService;
@@ -145,6 +146,31 @@
     ));
 
     /**
+     * The names of all attributes which are explicitly supported by this
+     * extension's User objects.
+     */
+    public static final Set<String> ATTRIBUTE_NAMES =
+            Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(
+                User.Attribute.FULL_NAME,
+                User.Attribute.EMAIL_ADDRESS,
+                User.Attribute.ORGANIZATION,
+                User.Attribute.ORGANIZATIONAL_ROLE,
+                DISABLED_ATTRIBUTE_NAME,
+                EXPIRED_ATTRIBUTE_NAME,
+                ACCESS_WINDOW_START_ATTRIBUTE_NAME,
+                ACCESS_WINDOW_END_ATTRIBUTE_NAME,
+                VALID_FROM_ATTRIBUTE_NAME,
+                VALID_UNTIL_ATTRIBUTE_NAME,
+                TIMEZONE_ATTRIBUTE_NAME
+            )));
+
+    /**
+     * Service for managing users.
+     */
+    @Inject
+    private UserService userService;
+
+    /**
      * Service for hashing passwords.
      */
     @Inject
@@ -542,9 +568,15 @@
     }
 
     @Override
+    public Set<String> getSupportedAttributeNames() {
+        return ATTRIBUTE_NAMES;
+    }
+
+    @Override
     public Map<String, String> getAttributes() {
 
-        Map<String, String> attributes = new HashMap<String, String>();
+        // Include any defined arbitrary attributes
+        Map<String, String> attributes = super.getAttributes();
 
         // Always include unrestricted attributes
         putUnrestrictedAttributes(attributes);
@@ -559,6 +591,9 @@
     @Override
     public void setAttributes(Map<String, String> attributes) {
 
+        // Set arbitrary attributes
+        super.setAttributes(attributes);
+
         // Always assign unrestricted attributes
         setUnrestrictedAttributes(attributes);
 
@@ -795,13 +830,13 @@
     }
 
     @Override
-    public Date getLastActive() {
-        return null;
+    public Timestamp getLastActive() {
+        return getModel().getLastActive();
     }
 
     @Override
     public List<ActivityRecord> getHistory() throws GuacamoleException {
-        return Collections.<ActivityRecord>emptyList();
+        return userService.retrieveHistory(getCurrentUser(), this);
     }
 
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java
index 1b238ab..5bfcda6 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java
@@ -26,9 +26,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.Collection;
+import java.util.Date;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
 import org.apache.guacamole.auth.jdbc.activeconnection.ActiveConnectionDirectory;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordModel;
 import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordSet;
 import org.apache.guacamole.auth.jdbc.connection.ModeledConnection;
 import org.apache.guacamole.auth.jdbc.connectiongroup.ModeledConnectionGroup;
@@ -44,7 +46,6 @@
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.SharingProfile;
 import org.apache.guacamole.net.auth.User;
-import org.apache.guacamole.net.auth.simple.SimpleActivityRecordSet;
 
 /**
  * UserContext implementation which is driven by an arbitrary, underlying
@@ -99,7 +100,24 @@
      */
     @Inject
     private Provider<ConnectionRecordSet> connectionRecordSetProvider;
-    
+
+    /**
+     * Provider for creating user record sets.
+     */
+    @Inject
+    private Provider<UserRecordSet> userRecordSetProvider;
+
+    /**
+     * Mapper for user login records.
+     */
+    @Inject
+    private UserRecordMapper userRecordMapper;
+
+    /**
+     * The activity record associated with this user's Guacamole session.
+     */
+    private ActivityRecordModel userRecord;
+
     @Override
     public void init(ModeledAuthenticatedUser currentUser) {
 
@@ -112,6 +130,15 @@
         sharingProfileDirectory.init(currentUser);
         activeConnectionDirectory.init(currentUser);
 
+        // Create login record for user
+        userRecord = new ActivityRecordModel();
+        userRecord.setUsername(currentUser.getIdentifier());
+        userRecord.setStartDate(new Date());
+        userRecord.setRemoteHost(currentUser.getCredentials().getRemoteHostname());
+
+        // Insert record representing login
+        userRecordMapper.insert(userRecord);
+
     }
 
     @Override
@@ -167,7 +194,9 @@
     @Override
     public ActivityRecordSet<ActivityRecord> getUserHistory()
             throws GuacamoleException {
-        return new SimpleActivityRecordSet<ActivityRecord>();
+        UserRecordSet userRecordSet = userRecordSetProvider.get();
+        userRecordSet.init(getCurrentUser());
+        return userRecordSet;
     }
 
     @Override
@@ -202,7 +231,11 @@
 
     @Override
     public void invalidate() {
-        // Nothing to invalidate
+
+        // Record logout time
+        userRecord.setEndDate(new Date());
+        userRecordMapper.update(userRecord);
+
     }
 
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserModel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserModel.java
index afaeb55..a6cf997 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserModel.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserModel.java
@@ -115,6 +115,12 @@
     private String organizationalRole;
 
     /**
+     * The date and time that this user was last active, or null if this user
+     * has never logged in.
+     */
+    private Timestamp lastActive;
+
+    /**
      * Creates a new, empty user.
      */
     public UserModel() {
@@ -465,4 +471,30 @@
         this.organizationalRole = organizationalRole;
     }
 
+    /**
+     * Returns the date and time that this user was last active, or null if
+     * this user has never logged in.
+     *
+     * @return
+     *     The date and time that this user was last active, or null if this
+     *     user has never logged in.
+     */
+    public Timestamp getLastActive() {
+        return lastActive;
+    }
+
+    /**
+     * Sets the date and time that this user was last active. This value is
+     * expected to be set automatically via queries, derived from user history
+     * records. It does NOT correspond to an actual column, and values set
+     * manually through invoking this function will not persist.
+     *
+     * @param lastActive
+     *     The date and time that this user was last active, or null if this
+     *     user has never logged in.
+     */
+    public void setLastActive(Timestamp lastActive) {
+        this.lastActive = lastActive;
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.java
new file mode 100644
index 0000000..b2177bf
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.java
@@ -0,0 +1,124 @@
+/*
+ * 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.jdbc.user;
+
+import java.util.Collection;
+import java.util.List;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordModel;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSearchTerm;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSortPredicate;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * Mapper for user login activity records.
+ */
+public interface UserRecordMapper {
+
+    /**
+     * Returns a collection of all user login records associated with the user
+     * having the given username.
+     *
+     * @param username
+     *     The username of the user whose login records are to be retrieved.
+     *
+     * @return
+     *     A collection of all user login records associated with the user
+     *     having the given username. This collection will be empty if no such
+     *     user exists.
+     */
+    List<ActivityRecordModel> select(@Param("username") String username);
+
+    /**
+     * Inserts the given user login record.
+     *
+     * @param record
+     *     The user login record to insert.
+     *
+     * @return
+     *     The number of rows inserted.
+     */
+    int insert(@Param("record") ActivityRecordModel record);
+
+    /**
+     * Updates the given user login record.
+     *
+     * @param record
+     *     The user login record to update.
+     *
+     * @return
+     *     The number of rows updated.
+     */
+    int update(@Param("record") ActivityRecordModel record);
+
+    /**
+     * Searches for up to <code>limit</code> user login records that contain
+     * the given terms, sorted by the given predicates, regardless of whether
+     * the data they are associated with is is readable by any particular user.
+     * This should only be called on behalf of a system administrator. If
+     * records are needed by a non-administrative user who must have explicit
+     * read rights, use searchReadable() instead.
+     *
+     * @param terms
+     *     The search terms that must match the returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     The results of the search performed with the given parameters.
+     */
+    List<ActivityRecordModel> search(@Param("terms") Collection<ActivityRecordSearchTerm> terms,
+            @Param("sortPredicates") List<ActivityRecordSortPredicate> sortPredicates,
+            @Param("limit") int limit);
+
+    /**
+     * Searches for up to <code>limit</code> user login records that contain
+     * the given terms, sorted by the given predicates. Only records that are
+     * associated with data explicitly readable by the given user will be
+     * returned. If records are needed by a system administrator (who, by
+     * definition, does not need explicit read rights), use search() instead.
+     *
+     * @param user
+     *    The user whose permissions should determine whether a record is
+     *    returned.
+     *
+     * @param terms
+     *     The search terms that must match the returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     The results of the search performed with the given parameters.
+     */
+    List<ActivityRecordModel> searchReadable(@Param("user") UserModel user,
+            @Param("terms") Collection<ActivityRecordSearchTerm> terms,
+            @Param("sortPredicates") List<ActivityRecordSortPredicate> sortPredicates,
+            @Param("limit") int limit);
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserRecordSet.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserRecordSet.java
new file mode 100644
index 0000000..c1b4897
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserRecordSet.java
@@ -0,0 +1,59 @@
+/*
+ * 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.jdbc.user;
+
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSearchTerm;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSortPredicate;
+import org.apache.guacamole.auth.jdbc.base.ModeledActivityRecordSet;
+import org.apache.guacamole.net.auth.ActivityRecord;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+
+/**
+ * A JDBC implementation of ActivityRecordSet for retrieving user login history.
+ * Calls to asCollection() will query user login records from the database.
+ * Which records are returned will be determined by the values passed in
+ * earlier.
+ */
+public class UserRecordSet extends ModeledActivityRecordSet<ActivityRecord> {
+
+    /**
+     * Service for managing user objects.
+     */
+    @Inject
+    private UserService userService;
+    
+    @Override
+    protected Collection<ActivityRecord> retrieveHistory(
+            AuthenticatedUser user, Set<ActivityRecordSearchTerm> requiredContents,
+            List<ActivityRecordSortPredicate> sortPredicates, int limit)
+            throws GuacamoleException {
+
+        // Retrieve history from database
+        return userService.retrieveHistory(getCurrentUser(),
+                requiredContents, sortPredicates, limit);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java
index 3dc025f..090963f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java
@@ -21,16 +21,24 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.auth.jdbc.base.ModeledDirectoryObjectMapper;
 import org.apache.guacamole.auth.jdbc.base.ModeledDirectoryObjectService;
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleSecurityException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordModel;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSearchTerm;
+import org.apache.guacamole.auth.jdbc.base.ActivityRecordSortPredicate;
+import org.apache.guacamole.auth.jdbc.base.ModeledActivityRecord;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordModel;
 import org.apache.guacamole.auth.jdbc.permission.ObjectPermissionMapper;
 import org.apache.guacamole.auth.jdbc.permission.ObjectPermissionModel;
 import org.apache.guacamole.auth.jdbc.permission.UserPermissionMapper;
@@ -38,8 +46,10 @@
 import org.apache.guacamole.auth.jdbc.security.PasswordPolicyService;
 import org.apache.guacamole.form.Field;
 import org.apache.guacamole.form.PasswordField;
+import org.apache.guacamole.net.auth.ActivityRecord;
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.ConnectionRecord;
 import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
 import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
@@ -116,7 +126,13 @@
      */
     @Inject
     private UserPermissionMapper userPermissionMapper;
-    
+
+    /**
+     * Mapper for accessing user login history.
+     */
+    @Inject
+    private UserRecordMapper userRecordMapper;
+
     /**
      * Provider for creating users.
      */
@@ -460,4 +476,119 @@
 
     }
 
+    /**
+     * Returns a ActivityRecord object which is backed by the given model.
+     *
+     * @param model
+     *     The model object to use to back the returned connection record
+     *     object.
+     *
+     * @return
+     *     A connection record object which is backed by the given model.
+     */
+    protected ActivityRecord getObjectInstance(ActivityRecordModel model) {
+        return new ModeledActivityRecord(model);
+    }
+
+    /**
+     * Returns a list of ActivityRecord objects which are backed by the
+     * models in the given list.
+     *
+     * @param models
+     *     The model objects to use to back the activity record objects
+     *     within the returned list.
+     *
+     * @return
+     *     A list of activity record objects which are backed by the models
+     *     in the given list.
+     */
+    protected List<ActivityRecord> getObjectInstances(List<ActivityRecordModel> models) {
+
+        // Create new list of records by manually converting each model
+        List<ActivityRecord> objects = new ArrayList<ActivityRecord>(models.size());
+        for (ActivityRecordModel model : models)
+            objects.add(getObjectInstance(model));
+
+        return objects;
+
+    }
+
+    /**
+     * Retrieves the login history of the given user, including any active
+     * sessions.
+     *
+     * @param authenticatedUser
+     *     The user retrieving the login history.
+     *
+     * @param user
+     *     The user whose history is being retrieved.
+     *
+     * @return
+     *     The login history of the given user, including any active sessions.
+     *
+     * @throws GuacamoleException
+     *     If permission to read the login history is denied.
+     */
+    public List<ActivityRecord> retrieveHistory(ModeledAuthenticatedUser authenticatedUser,
+            ModeledUser user) throws GuacamoleException {
+
+        String username = user.getIdentifier();
+
+        // Retrieve history only if READ permission is granted
+        if (hasObjectPermission(authenticatedUser, username, ObjectPermission.Type.READ))
+            return getObjectInstances(userRecordMapper.select(username));
+
+        // The user does not have permission to read the history
+        throw new GuacamoleSecurityException("Permission denied.");
+
+    }
+
+    /**
+     * Retrieves user login history records matching the given criteria.
+     * Retrieves up to <code>limit</code> user history records matching the
+     * given terms and sorted by the given predicates. Only history records
+     * associated with data that the given user can read are returned.
+     *
+     * @param user
+     *     The user retrieving the login history.
+     *
+     * @param requiredContents
+     *     The search terms that must be contained somewhere within each of the
+     *     returned records.
+     *
+     * @param sortPredicates
+     *     A list of predicates to sort the returned records by, in order of
+     *     priority.
+     *
+     * @param limit
+     *     The maximum number of records that should be returned.
+     *
+     * @return
+     *     The login history of the given user, including any active sessions.
+     *
+     * @throws GuacamoleException
+     *     If permission to read the user login history is denied.
+     */
+    public List<ActivityRecord> retrieveHistory(ModeledAuthenticatedUser user,
+            Collection<ActivityRecordSearchTerm> requiredContents,
+            List<ActivityRecordSortPredicate> sortPredicates, int limit)
+            throws GuacamoleException {
+
+        List<ActivityRecordModel> searchResults;
+
+        // Bypass permission checks if the user is a system admin
+        if (user.getUser().isAdministrator())
+            searchResults = userRecordMapper.search(requiredContents,
+                    sortPredicates, limit);
+
+        // Otherwise only return explicitly readable history records
+        else
+            searchResults = userRecordMapper.searchReadable(user.getUser().getModel(),
+                    requiredContents, sortPredicates, limit);
+
+        return getObjectInstances(searchResults);
+
+    }
+
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/es.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/es.json
new file mode 100644
index 0000000..74fc210
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/es.json
@@ -0,0 +1,105 @@
+{
+
+    "LOGIN" : {
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_SAME"     : "La nueva contraseña debe ser diferente a la expirada.",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+        "ERROR_NOT_VALID"         : "Esta cuenta de usuario no es válida actualmente.",
+        "ERROR_NOT_ACCESSIBLE"    : "Actualmente no se permite el acceso a esta cuenta. Por favor intente de nuevo mas tarde.",
+
+        "INFO_PASSWORD_EXPIRED" : "Su contraseña ha expirado y debe renovarla. Por favor introduzca una nueva contraseña para continuar.",
+
+        "FIELD_HEADER_NEW_PASSWORD"         : "Nueva contraseña",
+        "FIELD_HEADER_CONFIRM_NEW_PASSWORD" : "Confirmar nueva contraseña"
+
+    },
+
+    "CONNECTION_ATTRIBUTES" : {
+
+        "FIELD_HEADER_MAX_CONNECTIONS"          : "Número máximo de conexiones:",
+        "FIELD_HEADER_MAX_CONNECTIONS_PER_USER" : "Número máximo de conexiones por usuario:",
+
+        "FIELD_HEADER_FAILOVER_ONLY"            : "Usar solo para failover:",
+        "FIELD_HEADER_WEIGHT"                   : "Peso de la conexión:",
+
+        "FIELD_HEADER_GUACD_HOSTNAME"   : "Nombre de Host:",
+        "FIELD_HEADER_GUACD_ENCRYPTION" : "Encriptación:",
+        "FIELD_HEADER_GUACD_PORT"       : "Puerto:",
+
+        "FIELD_OPTION_GUACD_ENCRYPTION_EMPTY" : "",
+        "FIELD_OPTION_GUACD_ENCRYPTION_NONE"  : "Ninguna (sin encriptar)",
+        "FIELD_OPTION_GUACD_ENCRYPTION_SSL"   : "SSL / TLS",
+
+        "SECTION_HEADER_CONCURRENCY"    : "Límites de concurrencia",
+        "SECTION_HEADER_LOAD_BALANCING" : "Balanceo de carga",
+        "SECTION_HEADER_GUACD"          : "Parámetros de Proxy Guacamole (guacd)"
+
+    },
+
+    "CONNECTION_GROUP_ATTRIBUTES" : {
+
+        "FIELD_HEADER_ENABLE_SESSION_AFFINITY"  : "Habilitar afinidad de sesión:",
+        "FIELD_HEADER_MAX_CONNECTIONS"          : "Número máximo de conexiones:",
+        "FIELD_HEADER_MAX_CONNECTIONS_PER_USER" : "Número máximo de conexiones por usuario:",
+
+        "SECTION_HEADER_CONCURRENCY" : "Limites de concurrencia (Grupos de balanceo)"
+
+    },
+
+    "DATA_SOURCE_MYSQL" : {
+        "NAME" : "MySQL"
+    },
+
+    "DATA_SOURCE_MYSQL_SHARED" : {
+        "NAME" : "Conexiones Compartidas (MySQL)"
+    },
+
+    "DATA_SOURCE_POSTGRESQL" : {
+        "NAME" : "PostgreSQL"
+    },
+
+    "DATA_SOURCE_POSTGRESQL_SHARED" : {
+        "NAME" : "Conexiones Compartidas (PostgreSQL)"
+    },
+
+    "DATA_SOURCE_SQLSERVER" : {
+        "NAME" : "SQL Server"
+    },
+
+    "DATA_SOURCE_SQLSERVER_SHARED" : {
+        "NAME" : "Conexiones compartidas (SQL Server)"
+    },
+
+    "HOME" : {
+        "INFO_SHARED_BY" : "Compartidas con {USERNAME}"
+    },
+
+    "PASSWORD_POLICY" : {
+
+        "ERROR_CONTAINS_USERNAME"      : "Las contraseñas no deberían contener el nombre de usuario.",
+        "ERROR_REQUIRES_DIGIT"         : "Las contraseñas deben contener al menos un número.",
+        "ERROR_REQUIRES_MULTIPLE_CASE" : "Las contraseñas deben contener caractéres en mayúscula y minúscula.",
+        "ERROR_REQUIRES_NON_ALNUM"     : "Las contraseñas deben contener al menos un símbolo.",
+        "ERROR_REUSED"                 : "Esta contraseña ya se ha usado. Por favor no vuelva a usar ninguna de las  {HISTORY_SIZE} {HISTORY_SIZE, plural, one{password} other{passwords}} anteriores.",
+        "ERROR_TOO_SHORT"              : "Las contraseñas deben ser al menos {LENGTH} {LENGTH, plural, one{character} other{characters}} de largo.",
+        "ERROR_TOO_YOUNG"              : "La contraseña de esta cuenta ya ha sido renovada. Por favor espere al menos {WAIT} mas {WAIT, plural, one{day} other{days}} antes de cambiarla de nuevo."
+
+    },
+
+    "USER_ATTRIBUTES" : {
+
+        "FIELD_HEADER_DISABLED"            : "Sesión deshabilitada:",
+        "FIELD_HEADER_EXPIRED"             : "Contraseña expirada:",
+        "FIELD_HEADER_ACCESS_WINDOW_END"   : "No permitir acceso despues de:",
+        "FIELD_HEADER_ACCESS_WINDOW_START" : "Permitir acceso despues de:",
+        "FIELD_HEADER_TIMEZONE"            : "Zona horaria de usuario:",
+        "FIELD_HEADER_VALID_FROM"          : "Habilitar cuenta despues de:",
+        "FIELD_HEADER_VALID_UNTIL"         : "Deshabilitar cuenta despues de:",
+
+        "SECTION_HEADER_RESTRICTIONS" : "Restricciones de cuenta",
+        "SECTION_HEADER_PROFILE"      : "Perfil"
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/pom.xml
index 05f5572..a2ed0c3 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/pom.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/pom.xml
@@ -27,7 +27,7 @@
     <artifactId>guacamole-auth-jdbc-dist</artifactId>
     <packaging>jar</packaging>
     <name>guacamole-auth-jdbc-dist</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -36,7 +36,7 @@
     <parent>
         <groupId>org.apache.guacamole</groupId>
         <artifactId>guacamole-auth-jdbc</artifactId>
-        <version>0.9.13-incubating</version>
+        <version>0.9.14</version>
         <relativePath>../../</relativePath>
     </parent>
 
@@ -99,21 +99,21 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-auth-jdbc-mysql</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
         <!-- PostgreSQL Authentication Extension -->
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-auth-jdbc-postgresql</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
         <!-- SQL Server Authentication Extension -->
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-auth-jdbc-sqlserver</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
     </dependencies>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/DISCLAIMER b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/NOTICE b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-dist/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml
index 232001f..466e57b 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/pom.xml
@@ -27,7 +27,7 @@
     <artifactId>guacamole-auth-jdbc-mysql</artifactId>
     <packaging>jar</packaging>
     <name>guacamole-auth-jdbc-mysql</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -36,7 +36,7 @@
     <parent>
         <groupId>org.apache.guacamole</groupId>
         <artifactId>guacamole-auth-jdbc</artifactId>
-        <version>0.9.13-incubating</version>
+        <version>0.9.14</version>
         <relativePath>../../</relativePath>
     </parent>
 
@@ -120,7 +120,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-auth-jdbc-base</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
     </dependencies>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql
index 1873d1c..76711f1 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/001-create-schema.sql
@@ -186,6 +186,94 @@
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 --
+-- Table of arbitrary user attributes. Each attribute is simply a name/value
+-- pair associated with a user. Arbitrary attributes are defined by other
+-- extensions. Attributes defined by this extension will be mapped to
+-- properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_user_attribute (
+
+  `user_id`         int(11)       NOT NULL,
+  `attribute_name`  varchar(128)  NOT NULL,
+  `attribute_value` varchar(4096) NOT NULL,
+
+  PRIMARY KEY (user_id, attribute_name),
+  KEY `user_id` (`user_id`),
+
+  CONSTRAINT guacamole_user_attribute_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of arbitrary connection attributes. Each attribute is simply a
+-- name/value pair associated with a connection. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_attribute (
+
+  `connection_id`   int(11)       NOT NULL,
+  `attribute_name`  varchar(128)  NOT NULL,
+  `attribute_value` varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_id, attribute_name),
+  KEY `connection_id` (`connection_id`),
+
+  CONSTRAINT guacamole_connection_attribute_ibfk_1
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of arbitrary connection group attributes. Each attribute is simply a
+-- name/value pair associated with a connection group. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_group_attribute (
+
+  `connection_group_id` int(11)       NOT NULL,
+  `attribute_name`      varchar(128)  NOT NULL,
+  `attribute_value`     varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_group_id, attribute_name),
+  KEY `connection_group_id` (`connection_group_id`),
+
+  CONSTRAINT guacamole_connection_group_attribute_ibfk_1
+    FOREIGN KEY (connection_group_id)
+    REFERENCES guacamole_connection_group (connection_group_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of arbitrary sharing profile attributes. Each attribute is simply a
+-- name/value pair associated with a sharing profile. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_sharing_profile_attribute (
+
+  `sharing_profile_id` int(11)       NOT NULL,
+  `attribute_name`     varchar(128)  NOT NULL,
+  `attribute_value`    varchar(4096) NOT NULL,
+
+  PRIMARY KEY (sharing_profile_id, attribute_name),
+  KEY `sharing_profile_id` (`sharing_profile_id`),
+
+  CONSTRAINT guacamole_sharing_profile_attribute_ibfk_1
+    FOREIGN KEY (sharing_profile_id)
+    REFERENCES guacamole_sharing_profile (sharing_profile_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
 -- Table of connection permissions. Each connection permission grants a user
 -- specific access to a connection.
 --
@@ -336,6 +424,7 @@
   KEY `sharing_profile_id` (`sharing_profile_id`),
   KEY `start_date` (`start_date`),
   KEY `end_date` (`end_date`),
+  KEY `connection_start_date` (`connection_id`, `start_date`),
 
   CONSTRAINT `guacamole_connection_history_ibfk_1`
     FOREIGN KEY (`user_id`)
@@ -352,6 +441,31 @@
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 --
+-- User login/logout history
+--
+
+CREATE TABLE guacamole_user_history (
+
+  `history_id`           int(11)      NOT NULL AUTO_INCREMENT,
+  `user_id`              int(11)      DEFAULT NULL,
+  `username`             varchar(128) NOT NULL,
+  `remote_host`          varchar(256) DEFAULT NULL,
+  `start_date`           datetime     NOT NULL,
+  `end_date`             datetime     DEFAULT NULL,
+
+  PRIMARY KEY (history_id),
+  KEY `user_id` (`user_id`),
+  KEY `start_date` (`start_date`),
+  KEY `end_date` (`end_date`),
+  KEY `user_start_date` (`user_id`, `start_date`),
+
+  CONSTRAINT guacamole_user_history_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE SET NULL
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
 -- User password history
 --
 
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.14.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.14.sql
index 01be93a..ee586bf 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.14.sql
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-0.9.14.sql
@@ -37,3 +37,34 @@
 
 ALTER TABLE guacamole_connection_history
     ADD COLUMN remote_host VARCHAR(256) DEFAULT NULL;
+
+--
+-- Add covering index for connection history connection and start date
+--
+
+ALTER TABLE guacamole_connection_history ADD KEY (connection_id, start_date);
+
+--
+-- User login/logout history
+--
+
+CREATE TABLE guacamole_user_history (
+
+  `history_id`           int(11)      NOT NULL AUTO_INCREMENT,
+  `user_id`              int(11)      DEFAULT NULL,
+  `username`             varchar(128) NOT NULL,
+  `remote_host`          varchar(256) DEFAULT NULL,
+  `start_date`           datetime     NOT NULL,
+  `end_date`             datetime     DEFAULT NULL,
+
+  PRIMARY KEY (history_id),
+  KEY `user_id` (`user_id`),
+  KEY `start_date` (`start_date`),
+  KEY `end_date` (`end_date`),
+  KEY `user_start_date` (`user_id`, `start_date`),
+
+  CONSTRAINT guacamole_user_history_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE SET NULL
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-1.0.0.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-1.0.0.sql
new file mode 100644
index 0000000..2979f53
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema/upgrade/upgrade-pre-1.0.0.sql
@@ -0,0 +1,106 @@
+--
+-- 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.
+--
+
+--
+-- Table of arbitrary user attributes. Each attribute is simply a name/value
+-- pair associated with a user. Arbitrary attributes are defined by other
+-- extensions. Attributes defined by this extension will be mapped to
+-- properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_user_attribute (
+
+  `user_id`         int(11)       NOT NULL,
+  `attribute_name`  varchar(128)  NOT NULL,
+  `attribute_value` varchar(4096) NOT NULL,
+
+  PRIMARY KEY (user_id, attribute_name),
+  KEY `user_id` (`user_id`),
+
+  CONSTRAINT guacamole_user_attribute_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of arbitrary connection attributes. Each attribute is simply a
+-- name/value pair associated with a connection. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_attribute (
+
+  `connection_id`   int(11)       NOT NULL,
+  `attribute_name`  varchar(128)  NOT NULL,
+  `attribute_value` varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_id, attribute_name),
+  KEY `connection_id` (`connection_id`),
+
+  CONSTRAINT guacamole_connection_attribute_ibfk_1
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of arbitrary connection group attributes. Each attribute is simply a
+-- name/value pair associated with a connection group. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_group_attribute (
+
+  `connection_group_id` int(11)       NOT NULL,
+  `attribute_name`      varchar(128)  NOT NULL,
+  `attribute_value`     varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_group_id, attribute_name),
+  KEY `connection_group_id` (`connection_group_id`),
+
+  CONSTRAINT guacamole_connection_group_attribute_ibfk_1
+    FOREIGN KEY (connection_group_id)
+    REFERENCES guacamole_connection_group (connection_group_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+--
+-- Table of arbitrary sharing profile attributes. Each attribute is simply a
+-- name/value pair associated with a sharing profile. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_sharing_profile_attribute (
+
+  `sharing_profile_id` int(11)       NOT NULL,
+  `attribute_name`     varchar(128)  NOT NULL,
+  `attribute_value`    varchar(4096) NOT NULL,
+
+  PRIMARY KEY (sharing_profile_id, attribute_name),
+  KEY `sharing_profile_id` (`sharing_profile_id`),
+
+  CONSTRAINT guacamole_sharing_profile_attribute_ibfk_1
+    FOREIGN KEY (sharing_profile_id)
+    REFERENCES guacamole_sharing_profile (sharing_profile_id) ON DELETE CASCADE
+
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLEnvironment.java
index 19a8ef4..dc676db 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLEnvironment.java
@@ -60,41 +60,27 @@
 
     /**
      * The default value for the default maximum number of connections to be
-     * allowed per user to any one connection. Note that, as long as the
-     * legacy "disallow duplicate" and "disallow simultaneous" properties are
-     * still supported, these cannot be constants, as the legacy properties
-     * dictate the values that should be used in the absence of the correct
-     * properties.
+     * allowed per user to any one connection.
      */
-    private int DEFAULT_MAX_CONNECTIONS_PER_USER = 1;
+    private final int DEFAULT_MAX_CONNECTIONS_PER_USER = 1;
 
     /**
      * The default value for the default maximum number of connections to be
-     * allowed per user to any one connection group. Note that, as long as the
-     * legacy "disallow duplicate" and "disallow simultaneous" properties are
-     * still supported, these cannot be constants, as the legacy properties
-     * dictate the values that should be used in the absence of the correct
-     * properties.
+     * allowed per user to any one connection group.
      */
-    private int DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
+    private final int DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
 
     /**
      * The default value for the default maximum number of connections to be
-     * allowed to any one connection. Note that, as long as the legacy
-     * "disallow duplicate" and "disallow simultaneous" properties are still
-     * supported, these cannot be constants, as the legacy properties dictate
-     * the values that should be used in the absence of the correct properties.
+     * allowed to any one connection.
      */
-    private int DEFAULT_MAX_CONNECTIONS = 0;
+    private final int DEFAULT_MAX_CONNECTIONS = 0;
 
     /**
      * The default value for the default maximum number of connections to be
-     * allowed to any one connection group. Note that, as long as the legacy
-     * "disallow duplicate" and "disallow simultaneous" properties are still
-     * supported, these cannot be constants, as the legacy properties dictate
-     * the values that should be used in the absence of the correct properties.
+     * allowed to any one connection group.
      */
-    private int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+    private final int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
 
     /**
      * Constructs a new MySQLEnvironment, providing access to MySQL-specific
@@ -109,66 +95,6 @@
         // Init underlying JDBC environment
         super();
 
-        // Read legacy concurrency-related property
-        Boolean disallowSimultaneous = getProperty(MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS);
-        Boolean disallowDuplicate    = getProperty(MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS);
-
-        // Legacy "simultaneous" property dictates only the maximum number of
-        // connections per connection
-        if (disallowSimultaneous != null) {
-
-            // Translate legacy property
-            if (disallowSimultaneous) {
-                DEFAULT_MAX_CONNECTIONS       = 1;
-                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-            }
-            else {
-                DEFAULT_MAX_CONNECTIONS       = 0;
-                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-            }
-
-            // Warn of deprecation
-            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
-                    MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(),
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS.getName(),
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
-
-            // Inform of new equivalent
-            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
-                    MySQLGuacamoleProperties.MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(), disallowSimultaneous,
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS.getName(),           DEFAULT_MAX_CONNECTIONS,
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName(),     DEFAULT_MAX_GROUP_CONNECTIONS);
-
-        }
-
-        // Legacy "duplicate" property dictates whether connections and groups
-        // may be used concurrently only by different users
-        if (disallowDuplicate != null) {
-
-            // Translate legacy property
-            if (disallowDuplicate) {
-                DEFAULT_MAX_CONNECTIONS_PER_USER       = 1;
-                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
-            }
-            else {
-                DEFAULT_MAX_CONNECTIONS_PER_USER       = 0;
-                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 0;
-            }
-
-            // Warn of deprecation
-            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
-                    MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
-
-            // Inform of new equivalent
-            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
-                    MySQLGuacamoleProperties.MYSQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),         disallowDuplicate,
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),       DEFAULT_MAX_CONNECTIONS_PER_USER,
-                    MySQLGuacamoleProperties.MYSQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER.getName(), DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER);
-
-        }
-
     }
 
     @Override
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLGuacamoleProperties.java
index 9039c02..1451740 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/MySQLGuacamoleProperties.java
@@ -101,28 +101,6 @@
     };
 
     /**
-     * Whether or not multiple users accessing the same connection at the same 
-     * time should be disallowed.
-     */
-    public static final BooleanGuacamoleProperty MYSQL_DISALLOW_SIMULTANEOUS_CONNECTIONS = new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-disallow-simultaneous-connections"; }
-
-    };
-
-    /**
-     * Whether or not the same user accessing the same connection or connection 
-     * group at the same time should be disallowed.
-     */
-    public static final BooleanGuacamoleProperty MYSQL_DISALLOW_DUPLICATE_CONNECTIONS = new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "mysql-disallow-duplicate-connections"; }
-
-    };
-
-    /**
      * The maximum number of concurrent connections to allow overall. Zero
      * denotes unlimited.
      */
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
index ebea496..39bfac0 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "MySQL Authentication",
     "namespace" : "guac-mysql",
@@ -20,6 +20,7 @@
 
     "translations" : [
         "translations/en.json",
+        "translations/es.json",
         "translations/fr.json",
         "translations/ru.json"
     ]
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
index 97c2e54..e5fd2f0 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
@@ -39,6 +39,7 @@
                 javaType="org.apache.guacamole.net.auth.GuacamoleProxyConfiguration$EncryptionMethod"/>
         <result column="connection_weight"        property="connectionWeight"      jdbcType="INTEGER"/>
         <result column="failover_only"            property="failoverOnly"          jdbcType="BOOLEAN"/>
+        <result column="last_active"              property="lastActive"            jdbcType="TIMESTAMP"/>
 
         <!-- Associated sharing profiles -->
         <collection property="sharingProfileIdentifiers" resultSet="sharingProfiles" ofType="java.lang.String"
@@ -46,6 +47,14 @@
             <result column="sharing_profile_id"/>
         </collection>
 
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="connection_id" foreignColumn="connection_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all connection identifiers -->
@@ -86,11 +95,11 @@
 
     <!-- Select multiple connections by identifier -->
     <select id="select" resultMap="ConnectionResultMap"
-            resultSets="connections,sharingProfiles">
+            resultSets="connections,sharingProfiles,arbitraryAttributes">
 
         SELECT
-            connection_id,
-            connection_name,
+            guacamole_connection.connection_id,
+            guacamole_connection.connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -99,13 +108,16 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            MAX(start_date) AS last_active
         FROM guacamole_connection
-        WHERE connection_id IN
+        LEFT JOIN guacamole_connection_history ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        WHERE guacamole_connection.connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
-            </foreach>;
+            </foreach>
+        GROUP BY guacamole_connection.connection_id;
 
         SELECT primary_connection_id, sharing_profile_id
         FROM guacamole_sharing_profile
@@ -115,15 +127,26 @@
                 #{identifier,jdbcType=VARCHAR}
             </foreach>;
 
+        SELECT
+            connection_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_attribute
+        WHERE connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>;
+
     </select>
 
     <!-- Select multiple connections by identifier only if readable -->
     <select id="selectReadable" resultMap="ConnectionResultMap"
-            resultSets="connections,sharingProfiles">
+            resultSets="connections,sharingProfiles,arbitraryAttributes">
 
         SELECT
             guacamole_connection.connection_id,
-            connection_name,
+            guacamole_connection.connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -132,21 +155,38 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            MAX(start_date) AS last_active
         FROM guacamole_connection
         JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection.connection_id
+        LEFT JOIN guacamole_connection_history ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
         WHERE guacamole_connection.connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
+            AND guacamole_connection_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+        GROUP BY guacamole_connection.connection_id;
+
+        SELECT primary_connection_id, guacamole_sharing_profile.sharing_profile_id
+        FROM guacamole_sharing_profile
+        JOIN guacamole_sharing_profile_permission ON guacamole_sharing_profile_permission.sharing_profile_id = guacamole_sharing_profile.sharing_profile_id
+        WHERE primary_connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
             AND user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
-        SELECT primary_connection_id, guacamole_sharing_profile.sharing_profile_id
-        FROM guacamole_sharing_profile
-        JOIN guacamole_sharing_profile_permission ON guacamole_sharing_profile_permission.sharing_profile_id = guacamole_sharing_profile.sharing_profile_id
-        WHERE primary_connection_id IN
+        SELECT
+            guacamole_connection_attribute.connection_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_attribute
+        JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection_attribute.connection_id
+        WHERE guacamole_connection_attribute.connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
@@ -160,8 +200,8 @@
     <select id="selectOneByName" resultMap="ConnectionResultMap">
 
         SELECT
-            connection_id,
-            connection_name,
+            guacamole_connection.connection_id,
+            guacamole_connection.connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -170,12 +210,15 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            MAX(start_date) AS last_active
         FROM guacamole_connection
+        LEFT JOIN guacamole_connection_history ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
         WHERE 
             <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=VARCHAR}</if>
             <if test="parentIdentifier == null">parent_id IS NULL</if>
-            AND connection_name = #{name,jdbcType=VARCHAR}
+            AND guacamole_connection.connection_name = #{name,jdbcType=VARCHAR}
+        GROUP BY guacamole_connection.connection_id
 
     </select>
 
@@ -232,4 +275,25 @@
         WHERE connection_id = #{object.objectID,jdbcType=INTEGER}
     </update>
 
+    <!-- Delete attributes associated with connection -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_connection_attribute
+        WHERE connection_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for connection -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_connection_attribute (
+            connection_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
index f2ef3c2..e02a046 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
@@ -48,6 +48,14 @@
             <result column="connection_id"/>
         </collection>
 
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="connection_group_id" foreignColumn="connection_group_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all connection group identifiers -->
@@ -88,7 +96,7 @@
 
     <!-- Select multiple connection groups by identifier -->
     <select id="select" resultMap="ConnectionGroupResultMap"
-            resultSets="connectionGroups,childConnectionGroups,childConnections">
+            resultSets="connectionGroups,childConnectionGroups,childConnections,arbitraryAttributes">
 
         SELECT
             connection_group_id,
@@ -121,11 +129,22 @@
                 #{identifier,jdbcType=VARCHAR}
             </foreach>;
 
+        SELECT
+            connection_group_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_group_attribute
+        WHERE connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>;
+
     </select>
 
     <!-- Select multiple connection groups by identifier only if readable -->
     <select id="selectReadable" resultMap="ConnectionGroupResultMap"
-            resultSets="connectionGroups,childConnectionGroups,childConnections">
+            resultSets="connectionGroups,childConnectionGroups,childConnections,arbitraryAttributes">
 
         SELECT
             guacamole_connection_group.connection_group_id,
@@ -167,6 +186,20 @@
             AND user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
+        SELECT
+            guacamole_connection_group_attribute.connection_group_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_group_attribute
+        JOIN guacamole_connection_group_permission ON guacamole_connection_group_permission.connection_group_id = guacamole_connection_group_attribute.connection_group_id
+        WHERE guacamole_connection_group_attribute.connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
+
     </select>
 
     <!-- Select single connection group by name -->
@@ -229,4 +262,25 @@
         WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}
     </update>
 
+    <!-- Delete attributes associated with connection group -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_connection_group_attribute
+        WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for connection group -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_connection_group_attribute (
+            connection_group_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
index 49bb337..ef89913 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
@@ -25,9 +25,20 @@
 
     <!-- Result mapper for sharing profile objects -->
     <resultMap id="SharingProfileResultMap" type="org.apache.guacamole.auth.jdbc.sharingprofile.SharingProfileModel">
+
+        <!-- Sharing profile properties -->
         <id     column="sharing_profile_id"    property="objectID"         jdbcType="INTEGER"/>
         <result column="sharing_profile_name"  property="name"             jdbcType="VARCHAR"/>
         <result column="primary_connection_id" property="parentIdentifier" jdbcType="INTEGER"/>
+
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="sharing_profile_id" foreignColumn="sharing_profile_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all sharing profile identifiers -->
@@ -46,7 +57,8 @@
     </select>
 
     <!-- Select multiple sharing profiles by identifier -->
-    <select id="select" resultMap="SharingProfileResultMap">
+    <select id="select" resultMap="SharingProfileResultMap"
+            resultSets="sharingProfiles,arbitraryAttributes">
 
         SELECT
             sharing_profile_id,
@@ -57,12 +69,24 @@
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
-            </foreach>
+            </foreach>;
+
+        SELECT
+            sharing_profile_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_sharing_profile_attribute
+        WHERE sharing_profile_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>;
 
     </select>
 
     <!-- Select multiple sharing profiles by identifier only if readable -->
-    <select id="selectReadable" resultMap="SharingProfileResultMap">
+    <select id="selectReadable" resultMap="SharingProfileResultMap"
+            resultSets="sharingProfiles,arbitraryAttributes">
 
         SELECT
             guacamole_sharing_profile.sharing_profile_id,
@@ -76,7 +100,21 @@
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
             AND user_id = #{user.objectID,jdbcType=INTEGER}
-            AND permission = 'READ'
+            AND permission = 'READ';
+
+        SELECT
+            guacamole_sharing_profile_attribute.sharing_profile_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_sharing_profile_attribute
+        JOIN guacamole_sharing_profile_permission ON guacamole_sharing_profile_permission.sharing_profile_id = guacamole_sharing_profile_attribute.sharing_profile_id
+        WHERE guacamole_sharing_profile_attribute.sharing_profile_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
 
     </select>
 
@@ -123,4 +161,25 @@
         WHERE sharing_profile_id = #{object.objectID,jdbcType=INTEGER}
     </update>
 
+    <!-- Delete attributes associated with sharing profile -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_sharing_profile_attribute
+        WHERE sharing_profile_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for sharing profile -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_sharing_profile_attribute (
+            sharing_profile_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
index 4ab1182..e183fe2 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
@@ -25,6 +25,8 @@
 
     <!-- Result mapper for user objects -->
     <resultMap id="UserResultMap" type="org.apache.guacamole.auth.jdbc.user.UserModel" >
+
+        <!-- User properties -->
         <id     column="user_id"             property="objectID"           jdbcType="INTEGER"/>
         <result column="username"            property="identifier"         jdbcType="VARCHAR"/>
         <result column="password_hash"       property="passwordHash"       jdbcType="BINARY"/>
@@ -41,6 +43,16 @@
         <result column="email_address"       property="emailAddress"       jdbcType="VARCHAR"/>
         <result column="organization"        property="organization"       jdbcType="VARCHAR"/>
         <result column="organizational_role" property="organizationalRole" jdbcType="VARCHAR"/>
+        <result column="last_active"         property="lastActive"         jdbcType="TIMESTAMP"/>
+
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="user_id" foreignColumn="user_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all usernames -->
@@ -60,11 +72,12 @@
     </select>
 
     <!-- Select multiple users by username -->
-    <select id="select" resultMap="UserResultMap">
+    <select id="select" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
-            user_id,
-            username,
+            guacamole_user.user_id,
+            guacamole_user.username,
             password_hash,
             password_salt,
             password_date,
@@ -78,22 +91,38 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            MAX(start_date) AS last_active
         FROM guacamole_user
-        WHERE username IN
+        LEFT JOIN guacamole_user_history ON guacamole_user_history.user_id = guacamole_user.user_id
+        WHERE guacamole_user.username IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
+        GROUP BY guacamole_user.user_id;
+
+        SELECT
+            guacamole_user_attribute.user_id,
+            guacamole_user_attribute.attribute_name,
+            guacamole_user_attribute.attribute_value
+        FROM guacamole_user_attribute
+        JOIN guacamole_user ON guacamole_user.user_id = guacamole_user_attribute.user_id
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>;
 
     </select>
 
     <!-- Select multiple users by username only if readable -->
-    <select id="selectReadable" resultMap="UserResultMap">
+    <select id="selectReadable" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
             guacamole_user.user_id,
-            username,
+            guacamole_user.username,
             password_hash,
             password_salt,
             password_date,
@@ -107,25 +136,44 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            MAX(start_date) AS last_active
         FROM guacamole_user
         JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
+        LEFT JOIN guacamole_user_history ON guacamole_user_history.user_id = guacamole_user.user_id
+        WHERE guacamole_user.username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+        GROUP BY guacamole_user.user_id;
+
+        SELECT
+            guacamole_user_attribute.user_id,
+            guacamole_user_attribute.attribute_name,
+            guacamole_user_attribute.attribute_value
+        FROM guacamole_user_attribute
+        JOIN guacamole_user ON guacamole_user.user_id = guacamole_user_attribute.user_id
+        JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
         WHERE username IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
             AND guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
-            AND permission = 'READ'
+            AND permission = 'READ';
 
     </select>
 
     <!-- Select single user by username -->
-    <select id="selectOne" resultMap="UserResultMap">
+    <select id="selectOne" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
-            user_id,
-            username,
+            guacamole_user.user_id,
+            guacamole_user.username,
             password_hash,
             password_salt,
             password_date,
@@ -139,10 +187,21 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            MAX(start_date) AS last_active
         FROM guacamole_user
+        LEFT JOIN guacamole_user_history ON guacamole_user_history.user_id = guacamole_user.user_id
         WHERE
-            username = #{username,jdbcType=VARCHAR}
+            guacamole_user.username = #{username,jdbcType=VARCHAR}
+        GROUP BY guacamole_user.user_id;
+
+        SELECT
+            guacamole_user_attribute.user_id,
+            guacamole_user_attribute.attribute_name,
+            guacamole_user_attribute.attribute_value
+        FROM guacamole_user_attribute
+        JOIN guacamole_user ON guacamole_user.user_id = guacamole_user_attribute.user_id
+        WHERE username = #{username,jdbcType=VARCHAR};
 
     </select>
 
@@ -213,4 +272,25 @@
         WHERE user_id = #{object.objectID,jdbcType=VARCHAR}
     </update>
 
-</mapper>
\ No newline at end of file
+    <!-- Delete attributes associated with user -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_user_attribute
+        WHERE user_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for user -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_user_attribute (
+            user_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
+</mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml
new file mode 100644
index 0000000..bbae03b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml
@@ -0,0 +1,187 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+    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.
+-->
+
+<mapper namespace="org.apache.guacamole.auth.jdbc.user.UserRecordMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="UserRecordResultMap" type="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+        <id     column="history_id"  property="recordID"   jdbcType="INTEGER"/>
+        <result column="remote_host" property="remoteHost" jdbcType="VARCHAR"/>
+        <result column="user_id"     property="userID"     jdbcType="INTEGER"/>
+        <result column="username"    property="username"   jdbcType="VARCHAR"/>
+        <result column="start_date"  property="startDate"  jdbcType="TIMESTAMP"/>
+        <result column="end_date"    property="endDate"    jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <!-- Select all user records from a given user -->
+    <select id="select" resultMap="UserRecordResultMap">
+
+        SELECT
+            guacamole_user_history.remote_host,
+            guacamole_user_history.user_id,
+            guacamole_user_history.username,
+            guacamole_user_history.start_date,
+            guacamole_user_history.end_date
+        FROM guacamole_user_history
+        JOIN guacamole_user ON guacamole_user_history.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_user.username = #{username,jdbcType=VARCHAR}
+        ORDER BY
+            guacamole_user_history.start_date DESC,
+            guacamole_user_history.end_date DESC
+
+    </select>
+
+    <!-- Insert the given user record -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="record.recordID"
+            parameterType="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+
+        INSERT INTO guacamole_user_history (
+            remote_host,
+            user_id,
+            username,
+            start_date,
+            end_date
+        )
+        VALUES (
+            #{record.remoteHost,jdbcType=VARCHAR},
+            (SELECT user_id FROM guacamole_user
+             WHERE username = #{record.username,jdbcType=VARCHAR}),
+            #{record.username,jdbcType=VARCHAR},
+            #{record.startDate,jdbcType=TIMESTAMP},
+            #{record.endDate,jdbcType=TIMESTAMP}
+        )
+
+    </insert>
+
+    <!-- Update the given user record -->
+    <update id="update" parameterType="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+        UPDATE guacamole_user_history
+        SET remote_host = #{record.remoteHost,jdbcType=VARCHAR},
+            user_id     = (SELECT user_id FROM guacamole_user
+                           WHERE username = #{record.username,jdbcType=VARCHAR}),
+            username    = #{record.username,jdbcType=VARCHAR},
+            start_date  = #{record.startDate,jdbcType=TIMESTAMP},
+            end_date    = #{record.endDate,jdbcType=TIMESTAMP}
+        WHERE history_id = #{record.recordID,jdbcType=INTEGER}
+    </update>
+
+    <!-- Search for specific user records -->
+    <select id="search" resultMap="UserRecordResultMap">
+
+        SELECT
+            guacamole_user_history.remote_host,
+            guacamole_user_history.user_id,
+            guacamole_user_history.username,
+            guacamole_user_history.start_date,
+            guacamole_user_history.end_date
+        FROM guacamole_user_history
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_user_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE" value="@org.apache.guacamole.net.auth.ActivityRecordSet$SortableProperty@START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_user_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Search for specific user records -->
+    <select id="searchReadable" resultMap="UserRecordResultMap">
+
+        SELECT
+            guacamole_user_history.remote_host,
+            guacamole_user_history.user_id,
+            guacamole_user_history.username,
+            guacamole_user_history.start_date,
+            guacamole_user_history.end_date
+        FROM guacamole_user_history
+
+        <!-- Restrict to readable users -->
+        JOIN guacamole_user_permission ON
+                guacamole_user_history.user_id       = guacamole_user_permission.affected_user_id
+            AND guacamole_user_permission.user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND guacamole_user_permission.permission = 'READ'
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_user_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE" value="@org.apache.guacamole.net.auth.ActivityRecordSet$SortableProperty@START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_user_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+</mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml
index 7fd2eaa..dbad026 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/pom.xml
@@ -27,7 +27,7 @@
     <artifactId>guacamole-auth-jdbc-postgresql</artifactId>
     <packaging>jar</packaging>
     <name>guacamole-auth-jdbc-postgresql</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -36,7 +36,7 @@
     <parent>
         <groupId>org.apache.guacamole</groupId>
         <artifactId>guacamole-auth-jdbc</artifactId>
-        <version>0.9.13-incubating</version>
+        <version>0.9.14</version>
         <relativePath>../../</relativePath>
     </parent>
 
@@ -120,7 +120,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-auth-jdbc-base</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
     </dependencies>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql
index e4015d3..ddd3566 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/001-create-schema.sql
@@ -252,6 +252,102 @@
     ON guacamole_sharing_profile_parameter(sharing_profile_id);
 
 --
+-- Table of arbitrary user attributes. Each attribute is simply a name/value
+-- pair associated with a user. Arbitrary attributes are defined by other
+-- extensions. Attributes defined by this extension will be mapped to
+-- properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_user_attribute (
+
+  user_id         integer       NOT NULL,
+  attribute_name  varchar(128)  NOT NULL,
+  attribute_value varchar(4096) NOT NULL,
+
+  PRIMARY KEY (user_id, attribute_name),
+
+  CONSTRAINT guacamole_user_attribute_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_user_attribute_user_id
+    ON guacamole_user_attribute(user_id);
+
+--
+-- Table of arbitrary connection attributes. Each attribute is simply a
+-- name/value pair associated with a connection. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_attribute (
+
+  connection_id   integer       NOT NULL,
+  attribute_name  varchar(128)  NOT NULL,
+  attribute_value varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_id, attribute_name),
+
+  CONSTRAINT guacamole_connection_attribute_ibfk_1
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_connection_attribute_connection_id
+    ON guacamole_connection_attribute(connection_id);
+
+--
+-- Table of arbitrary connection group attributes. Each attribute is simply a
+-- name/value pair associated with a connection group. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_group_attribute (
+
+  connection_group_id integer       NOT NULL,
+  attribute_name      varchar(128)  NOT NULL,
+  attribute_value     varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_group_id, attribute_name),
+
+  CONSTRAINT guacamole_connection_group_attribute_ibfk_1
+    FOREIGN KEY (connection_group_id)
+    REFERENCES guacamole_connection_group (connection_group_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_connection_group_attribute_connection_group_id
+    ON guacamole_connection_group_attribute(connection_group_id);
+
+--
+-- Table of arbitrary sharing profile attributes. Each attribute is simply a
+-- name/value pair associated with a sharing profile. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_sharing_profile_attribute (
+
+  sharing_profile_id integer       NOT NULL,
+  attribute_name     varchar(128)  NOT NULL,
+  attribute_value    varchar(4096) NOT NULL,
+
+  PRIMARY KEY (sharing_profile_id, attribute_name),
+
+  CONSTRAINT guacamole_sharing_profile_attribute_ibfk_1
+    FOREIGN KEY (sharing_profile_id)
+    REFERENCES guacamole_sharing_profile (sharing_profile_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_sharing_profile_attribute_sharing_profile_id
+    ON guacamole_sharing_profile_attribute(sharing_profile_id);
+
+--
 -- Table of connection permissions. Each connection permission grants a user
 -- specific access to a connection.
 --
@@ -438,6 +534,42 @@
 CREATE INDEX guacamole_connection_history_end_date
     ON guacamole_connection_history(end_date);
 
+CREATE INDEX guacamole_connection_history_connection_id_start_date
+    ON guacamole_connection_history(connection_id, start_date);
+
+--
+-- User login/logout history
+--
+
+CREATE TABLE guacamole_user_history (
+
+  history_id           serial       NOT NULL,
+  user_id              integer      DEFAULT NULL,
+  username             varchar(128) NOT NULL,
+  remote_host          varchar(256) DEFAULT NULL,
+  start_date           timestamptz  NOT NULL,
+  end_date             timestamptz  DEFAULT NULL,
+
+  PRIMARY KEY (history_id),
+
+  CONSTRAINT guacamole_user_history_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE SET NULL
+
+);
+
+CREATE INDEX guacamole_user_history_user_id
+    ON guacamole_user_history(user_id);
+
+CREATE INDEX guacamole_user_history_start_date
+    ON guacamole_user_history(start_date);
+
+CREATE INDEX guacamole_user_history_end_date
+    ON guacamole_user_history(end_date);
+
+CREATE INDEX guacamole_user_history_user_id_start_date
+    ON guacamole_user_history(user_id, start_date);
+
 --
 -- User password history
 --
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.14.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.14.sql
index 157e896..534d4dc 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.14.sql
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-0.9.14.sql
@@ -37,3 +37,43 @@
 
 ALTER TABLE guacamole_connection_history
     ADD COLUMN remote_host VARCHAR(256) DEFAULT NULL;
+
+--
+-- Add covering index for connection history connection and start date
+--
+
+CREATE INDEX guacamole_connection_history_connection_id_start_date
+    ON guacamole_connection_history(connection_id, start_date);
+
+--
+-- User login/logout history
+--
+
+CREATE TABLE guacamole_user_history (
+
+  history_id           serial       NOT NULL,
+  user_id              integer      DEFAULT NULL,
+  username             varchar(128) NOT NULL,
+  remote_host          varchar(256) DEFAULT NULL,
+  start_date           timestamptz  NOT NULL,
+  end_date             timestamptz  DEFAULT NULL,
+
+  PRIMARY KEY (history_id),
+
+  CONSTRAINT guacamole_user_history_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE SET NULL
+
+);
+
+CREATE INDEX guacamole_user_history_user_id
+    ON guacamole_user_history(user_id);
+
+CREATE INDEX guacamole_user_history_start_date
+    ON guacamole_user_history(start_date);
+
+CREATE INDEX guacamole_user_history_end_date
+    ON guacamole_user_history(end_date);
+
+CREATE INDEX guacamole_user_history_user_id_start_date
+    ON guacamole_user_history(user_id, start_date);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-1.0.0.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-1.0.0.sql
new file mode 100644
index 0000000..db115c2
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema/upgrade/upgrade-pre-1.0.0.sql
@@ -0,0 +1,114 @@
+--
+-- 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.
+--
+
+--
+-- Table of arbitrary user attributes. Each attribute is simply a name/value
+-- pair associated with a user. Arbitrary attributes are defined by other
+-- extensions. Attributes defined by this extension will be mapped to
+-- properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_user_attribute (
+
+  user_id         integer       NOT NULL,
+  attribute_name  varchar(128)  NOT NULL,
+  attribute_value varchar(4096) NOT NULL,
+
+  PRIMARY KEY (user_id, attribute_name),
+
+  CONSTRAINT guacamole_user_attribute_ibfk_1
+    FOREIGN KEY (user_id)
+    REFERENCES guacamole_user (user_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_user_attribute_user_id
+    ON guacamole_user_attribute(user_id);
+
+--
+-- Table of arbitrary connection attributes. Each attribute is simply a
+-- name/value pair associated with a connection. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_attribute (
+
+  connection_id   integer       NOT NULL,
+  attribute_name  varchar(128)  NOT NULL,
+  attribute_value varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_id, attribute_name),
+
+  CONSTRAINT guacamole_connection_attribute_ibfk_1
+    FOREIGN KEY (connection_id)
+    REFERENCES guacamole_connection (connection_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_connection_attribute_connection_id
+    ON guacamole_connection_attribute(connection_id);
+
+--
+-- Table of arbitrary connection group attributes. Each attribute is simply a
+-- name/value pair associated with a connection group. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_connection_group_attribute (
+
+  connection_group_id integer       NOT NULL,
+  attribute_name      varchar(128)  NOT NULL,
+  attribute_value     varchar(4096) NOT NULL,
+
+  PRIMARY KEY (connection_group_id, attribute_name),
+
+  CONSTRAINT guacamole_connection_group_attribute_ibfk_1
+    FOREIGN KEY (connection_group_id)
+    REFERENCES guacamole_connection_group (connection_group_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_connection_group_attribute_connection_group_id
+    ON guacamole_connection_group_attribute(connection_group_id);
+
+--
+-- Table of arbitrary sharing profile attributes. Each attribute is simply a
+-- name/value pair associated with a sharing profile. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE guacamole_sharing_profile_attribute (
+
+  sharing_profile_id integer       NOT NULL,
+  attribute_name     varchar(128)  NOT NULL,
+  attribute_value    varchar(4096) NOT NULL,
+
+  PRIMARY KEY (sharing_profile_id, attribute_name),
+
+  CONSTRAINT guacamole_sharing_profile_attribute_ibfk_1
+    FOREIGN KEY (sharing_profile_id)
+    REFERENCES guacamole_sharing_profile (sharing_profile_id) ON DELETE CASCADE
+
+);
+
+CREATE INDEX guacamole_sharing_profile_attribute_sharing_profile_id
+    ON guacamole_sharing_profile_attribute(sharing_profile_id);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLEnvironment.java
index e0ee75f..da0caea 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLEnvironment.java
@@ -66,7 +66,7 @@
      * dictate the values that should be used in the absence of the correct
      * properties.
      */
-    private int DEFAULT_MAX_CONNECTIONS_PER_USER = 1;
+    private final int DEFAULT_MAX_CONNECTIONS_PER_USER = 1;
 
     /**
      * The default value for the default maximum number of connections to be
@@ -76,7 +76,7 @@
      * dictate the values that should be used in the absence of the correct
      * properties.
      */
-    private int DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
+    private final int DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
 
     /**
      * The default value for the default maximum number of connections to be
@@ -85,7 +85,7 @@
      * supported, these cannot be constants, as the legacy properties dictate
      * the values that should be used in the absence of the correct properties.
      */
-    private int DEFAULT_MAX_CONNECTIONS = 0;
+    private final int DEFAULT_MAX_CONNECTIONS = 0;
 
     /**
      * The default value for the default maximum number of connections to be
@@ -94,7 +94,7 @@
      * supported, these cannot be constants, as the legacy properties dictate
      * the values that should be used in the absence of the correct properties.
      */
-    private int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
+    private final int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
 
     /**
      * Constructs a new PostgreSQLEnvironment, providing access to PostgreSQL-specific
@@ -109,66 +109,6 @@
         // Init underlying JDBC environment
         super();
 
-        // Read legacy concurrency-related property
-        Boolean disallowSimultaneous = getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS);
-        Boolean disallowDuplicate    = getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS);
-
-        // Legacy "simultaneous" property dictates only the maximum number of
-        // connections per connection
-        if (disallowSimultaneous != null) {
-
-            // Translate legacy property
-            if (disallowSimultaneous) {
-                DEFAULT_MAX_CONNECTIONS       = 1;
-                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-            }
-            else {
-                DEFAULT_MAX_CONNECTIONS       = 0;
-                DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-            }
-
-            // Warn of deprecation
-            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(),
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS.getName(),
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
-
-            // Inform of new equivalent
-            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS.getName(), disallowSimultaneous,
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS.getName(),           DEFAULT_MAX_CONNECTIONS,
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName(),     DEFAULT_MAX_GROUP_CONNECTIONS);
-
-        }
-
-        // Legacy "duplicate" property dictates whether connections and groups
-        // may be used concurrently only by different users
-        if (disallowDuplicate != null) {
-
-            // Translate legacy property
-            if (disallowDuplicate) {
-                DEFAULT_MAX_CONNECTIONS_PER_USER       = 1;
-                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 1;
-            }
-            else {
-                DEFAULT_MAX_CONNECTIONS_PER_USER       = 0;
-                DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER = 0;
-            }
-
-            // Warn of deprecation
-            logger.warn("The \"{}\" property is deprecated. Use \"{}\" and \"{}\" instead.",
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS.getName());
-
-            // Inform of new equivalent
-            logger.info("To achieve the same result of setting \"{}\" to \"{}\", set \"{}\" to \"{}\" and \"{}\" to \"{}\".",
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS.getName(),         disallowDuplicate,
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_CONNECTIONS_PER_USER.getName(),       DEFAULT_MAX_CONNECTIONS_PER_USER,
-                    PostgreSQLGuacamoleProperties.POSTGRESQL_DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER.getName(), DEFAULT_MAX_GROUP_CONNECTIONS_PER_USER);
-
-        }
-
     }
 
     @Override
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java
index 3da972f..9711651 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/PostgreSQLGuacamoleProperties.java
@@ -106,32 +106,6 @@
     };
 
     /**
-     * Whether or not multiple users accessing the same connection at the same
-     * time should be disallowed.
-     */
-    public static final BooleanGuacamoleProperty
-            POSTGRESQL_DISALLOW_SIMULTANEOUS_CONNECTIONS =
-            new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "postgresql-disallow-simultaneous-connections"; }
-
-    };
-
-    /**
-     * Whether or not the same user accessing the same connection or connection
-     * group at the same time should be disallowed.
-     */
-    public static final BooleanGuacamoleProperty
-            POSTGRESQL_DISALLOW_DUPLICATE_CONNECTIONS =
-            new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "postgresql-disallow-duplicate-connections"; }
-
-    };
-
-    /**
      * The maximum number of concurrent connections to allow overall. Zero
      * denotes unlimited.
      */
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
index 4e18cb7..19d1d5e 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "PostgreSQL Authentication",
     "namespace" : "guac-postgresql",
@@ -20,6 +20,7 @@
 
     "translations" : [
         "translations/en.json",
+        "translations/es.json",
         "translations/fr.json",
         "translations/ru.json"
     ]
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
index dd9265d..0b109f6 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
@@ -39,6 +39,7 @@
                 javaType="org.apache.guacamole.net.auth.GuacamoleProxyConfiguration$EncryptionMethod"/>
         <result column="connection_weight"        property="connectionWeight"      jdbcType="INTEGER"/>
         <result column="failover_only"            property="failoverOnly"          jdbcType="BOOLEAN"/>
+        <result column="last_active"              property="lastActive"            jdbcType="TIMESTAMP"/>
 
         <!-- Associated sharing profiles -->
         <collection property="sharingProfileIdentifiers" resultSet="sharingProfiles" ofType="java.lang.String"
@@ -46,6 +47,14 @@
             <result column="sharing_profile_id"/>
         </collection>
 
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="connection_id" foreignColumn="connection_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all connection identifiers -->
@@ -86,11 +95,11 @@
 
     <!-- Select multiple connections by identifier -->
     <select id="select" resultMap="ConnectionResultMap"
-            resultSets="connections,sharingProfiles">
+            resultSets="connections,sharingProfiles,arbitraryAttributes">
 
         SELECT
-            connection_id,
-            connection_name,
+            guacamole_connection.connection_id,
+            guacamole_connection.connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -99,13 +108,16 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            MAX(start_date) AS last_active
         FROM guacamole_connection
-        WHERE connection_id IN
+        LEFT JOIN guacamole_connection_history ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
+        WHERE guacamole_connection.connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}::integer
-            </foreach>;
+            </foreach>
+        GROUP BY guacamole_connection.connection_id;
 
         SELECT primary_connection_id, sharing_profile_id
         FROM guacamole_sharing_profile
@@ -115,15 +127,26 @@
                 #{identifier,jdbcType=INTEGER}::integer
             </foreach>;
 
+        SELECT
+            connection_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_attribute
+        WHERE connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>;
+
     </select>
 
     <!-- Select multiple connections by identifier only if readable -->
     <select id="selectReadable" resultMap="ConnectionResultMap"
-            resultSets="connections,sharingProfiles">
+            resultSets="connections,sharingProfiles,arbitraryAttributes">
 
         SELECT
             guacamole_connection.connection_id,
-            connection_name,
+            guacamole_connection.connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -132,21 +155,38 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            MAX(start_date) AS last_active
         FROM guacamole_connection
         JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection.connection_id
+        LEFT JOIN guacamole_connection_history ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
         WHERE guacamole_connection.connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}::integer
             </foreach>
+            AND guacamole_connection_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+        GROUP BY guacamole_connection.connection_id;
+
+        SELECT primary_connection_id, guacamole_sharing_profile.sharing_profile_id
+        FROM guacamole_sharing_profile
+        JOIN guacamole_sharing_profile_permission ON guacamole_sharing_profile_permission.sharing_profile_id = guacamole_sharing_profile.sharing_profile_id
+        WHERE primary_connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
             AND user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
-        SELECT primary_connection_id, guacamole_sharing_profile.sharing_profile_id
-        FROM guacamole_sharing_profile
-        JOIN guacamole_sharing_profile_permission ON guacamole_sharing_profile_permission.sharing_profile_id = guacamole_sharing_profile.sharing_profile_id
-        WHERE primary_connection_id IN
+        SELECT
+            guacamole_connection_attribute.connection_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_attribute
+        JOIN guacamole_connection_permission ON guacamole_connection_permission.connection_id = guacamole_connection_attribute.connection_id
+        WHERE guacamole_connection_attribute.connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}::integer
@@ -160,8 +200,8 @@
     <select id="selectOneByName" resultMap="ConnectionResultMap">
 
         SELECT
-            connection_id,
-            connection_name,
+            guacamole_connection.connection_id,
+            guacamole_connection.connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -170,12 +210,15 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            MAX(start_date) AS last_active
         FROM guacamole_connection
+        LEFT JOIN guacamole_connection_history ON guacamole_connection_history.connection_id = guacamole_connection.connection_id
         WHERE 
             <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}::integer</if>
             <if test="parentIdentifier == null">parent_id IS NULL</if>
-            AND connection_name = #{name,jdbcType=VARCHAR}
+            AND guacamole_connection.connection_name = #{name,jdbcType=VARCHAR}
+        GROUP BY guacamole_connection.connection_id
 
     </select>
 
@@ -232,4 +275,25 @@
         WHERE connection_id = #{object.objectID,jdbcType=INTEGER}::integer
     </update>
 
+    <!-- Delete attributes associated with connection -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_connection_attribute
+        WHERE connection_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for connection -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_connection_attribute (
+            connection_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
index 0a41b3d..7cc4ac7 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
@@ -48,6 +48,14 @@
             <result column="connection_id"/>
         </collection>
 
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="connection_group_id" foreignColumn="connection_group_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all connection group identifiers -->
@@ -88,7 +96,7 @@
 
     <!-- Select multiple connection groups by identifier -->
     <select id="select" resultMap="ConnectionGroupResultMap"
-            resultSets="connectionGroups,childConnectionGroups,childConnections">
+            resultSets="connectionGroups,childConnectionGroups,childConnections,arbitraryAttributes">
 
         SELECT
             connection_group_id,
@@ -121,11 +129,22 @@
                 #{identifier,jdbcType=INTEGER}::integer
             </foreach>;
 
+        SELECT
+            connection_group_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_group_attribute
+        WHERE connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>;
+
     </select>
 
     <!-- Select multiple connection groups by identifier only if readable -->
     <select id="selectReadable" resultMap="ConnectionGroupResultMap"
-            resultSets="connectionGroups,childConnectionGroups,childConnections">
+            resultSets="connectionGroups,childConnectionGroups,childConnections,arbitraryAttributes">
 
         SELECT
             guacamole_connection_group.connection_group_id,
@@ -167,6 +186,20 @@
             AND user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
+        SELECT
+            guacamole_connection_group_attribute.connection_group_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_connection_group_attribute
+        JOIN guacamole_connection_group_permission ON guacamole_connection_group_permission.connection_group_id = guacamole_connection_group_attribute.connection_group_id
+        WHERE guacamole_connection_group_attribute.connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
+
     </select>
 
     <!-- Select single connection group by name -->
@@ -229,4 +262,25 @@
         WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}::integer
     </update>
 
+    <!-- Delete attributes associated with connection group -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_connection_group_attribute
+        WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for connection group -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_connection_group_attribute (
+            connection_group_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
index 0af4937..801d6e3 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
@@ -25,9 +25,20 @@
 
     <!-- Result mapper for sharing profile objects -->
     <resultMap id="SharingProfileResultMap" type="org.apache.guacamole.auth.jdbc.sharingprofile.SharingProfileModel">
+
+        <!-- Sharing profile properties -->
         <id     column="sharing_profile_id"    property="objectID"         jdbcType="INTEGER"/>
         <result column="sharing_profile_name"  property="name"             jdbcType="VARCHAR"/>
         <result column="primary_connection_id" property="parentIdentifier" jdbcType="INTEGER"/>
+
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="sharing_profile_id" foreignColumn="sharing_profile_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all sharing profile identifiers -->
@@ -46,7 +57,8 @@
     </select>
 
     <!-- Select multiple sharing profiles by identifier -->
-    <select id="select" resultMap="SharingProfileResultMap">
+    <select id="select" resultMap="SharingProfileResultMap"
+            resultSets="sharingProfiles,arbitraryAttributes">
 
         SELECT
             sharing_profile_id,
@@ -57,12 +69,24 @@
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}::integer
-            </foreach>
+            </foreach>;
+
+        SELECT
+            sharing_profile_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_sharing_profile_attribute
+        WHERE sharing_profile_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>;
 
     </select>
 
     <!-- Select multiple sharing profiles by identifier only if readable -->
-    <select id="selectReadable" resultMap="SharingProfileResultMap">
+    <select id="selectReadable" resultMap="SharingProfileResultMap"
+            resultSets="sharingProfiles,arbitraryAttributes">
 
         SELECT
             guacamole_sharing_profile.sharing_profile_id,
@@ -76,7 +100,21 @@
                 #{identifier,jdbcType=INTEGER}::integer
             </foreach>
             AND user_id = #{user.objectID,jdbcType=INTEGER}
-            AND permission = 'READ'
+            AND permission = 'READ';
+
+        SELECT
+            guacamole_sharing_profile_attribute.sharing_profile_id,
+            attribute_name,
+            attribute_value
+        FROM guacamole_sharing_profile_attribute
+        JOIN guacamole_sharing_profile_permission ON guacamole_sharing_profile_permission.sharing_profile_id = guacamole_sharing_profile_attribute.sharing_profile_id
+        WHERE guacamole_sharing_profile_attribute.sharing_profile_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}::integer
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
 
     </select>
 
@@ -123,4 +161,25 @@
         WHERE sharing_profile_id = #{object.objectID,jdbcType=INTEGER}::integer
     </update>
 
+    <!-- Delete attributes associated with sharing profile -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_sharing_profile_attribute
+        WHERE sharing_profile_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for sharing profile -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_sharing_profile_attribute (
+            sharing_profile_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
index 569a8ac..e183fe2 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
@@ -25,6 +25,8 @@
 
     <!-- Result mapper for user objects -->
     <resultMap id="UserResultMap" type="org.apache.guacamole.auth.jdbc.user.UserModel" >
+
+        <!-- User properties -->
         <id     column="user_id"             property="objectID"           jdbcType="INTEGER"/>
         <result column="username"            property="identifier"         jdbcType="VARCHAR"/>
         <result column="password_hash"       property="passwordHash"       jdbcType="BINARY"/>
@@ -41,6 +43,16 @@
         <result column="email_address"       property="emailAddress"       jdbcType="VARCHAR"/>
         <result column="organization"        property="organization"       jdbcType="VARCHAR"/>
         <result column="organizational_role" property="organizationalRole" jdbcType="VARCHAR"/>
+        <result column="last_active"         property="lastActive"         jdbcType="TIMESTAMP"/>
+
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="user_id" foreignColumn="user_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all usernames -->
@@ -60,11 +72,12 @@
     </select>
 
     <!-- Select multiple users by username -->
-    <select id="select" resultMap="UserResultMap">
+    <select id="select" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
-            user_id,
-            username,
+            guacamole_user.user_id,
+            guacamole_user.username,
             password_hash,
             password_salt,
             password_date,
@@ -78,22 +91,38 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            MAX(start_date) AS last_active
         FROM guacamole_user
-        WHERE username IN
+        LEFT JOIN guacamole_user_history ON guacamole_user_history.user_id = guacamole_user.user_id
+        WHERE guacamole_user.username IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
+        GROUP BY guacamole_user.user_id;
+
+        SELECT
+            guacamole_user_attribute.user_id,
+            guacamole_user_attribute.attribute_name,
+            guacamole_user_attribute.attribute_value
+        FROM guacamole_user_attribute
+        JOIN guacamole_user ON guacamole_user.user_id = guacamole_user_attribute.user_id
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>;
 
     </select>
 
     <!-- Select multiple users by username only if readable -->
-    <select id="selectReadable" resultMap="UserResultMap">
+    <select id="selectReadable" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
             guacamole_user.user_id,
-            username,
+            guacamole_user.username,
             password_hash,
             password_salt,
             password_date,
@@ -107,25 +136,44 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            MAX(start_date) AS last_active
         FROM guacamole_user
         JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
+        LEFT JOIN guacamole_user_history ON guacamole_user_history.user_id = guacamole_user.user_id
+        WHERE guacamole_user.username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=VARCHAR}
+            </foreach>
+            AND guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ'
+        GROUP BY guacamole_user.user_id;
+
+        SELECT
+            guacamole_user_attribute.user_id,
+            guacamole_user_attribute.attribute_name,
+            guacamole_user_attribute.attribute_value
+        FROM guacamole_user_attribute
+        JOIN guacamole_user ON guacamole_user.user_id = guacamole_user_attribute.user_id
+        JOIN guacamole_user_permission ON affected_user_id = guacamole_user.user_id
         WHERE username IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
             AND guacamole_user_permission.user_id = #{user.objectID,jdbcType=INTEGER}
-            AND permission = 'READ'
+            AND permission = 'READ';
 
     </select>
 
     <!-- Select single user by username -->
-    <select id="selectOne" resultMap="UserResultMap">
+    <select id="selectOne" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
-            user_id,
-            username,
+            guacamole_user.user_id,
+            guacamole_user.username,
             password_hash,
             password_salt,
             password_date,
@@ -139,10 +187,21 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            MAX(start_date) AS last_active
         FROM guacamole_user
+        LEFT JOIN guacamole_user_history ON guacamole_user_history.user_id = guacamole_user.user_id
         WHERE
-            username = #{username,jdbcType=VARCHAR}
+            guacamole_user.username = #{username,jdbcType=VARCHAR}
+        GROUP BY guacamole_user.user_id;
+
+        SELECT
+            guacamole_user_attribute.user_id,
+            guacamole_user_attribute.attribute_name,
+            guacamole_user_attribute.attribute_value
+        FROM guacamole_user_attribute
+        JOIN guacamole_user ON guacamole_user.user_id = guacamole_user_attribute.user_id
+        WHERE username = #{username,jdbcType=VARCHAR};
 
     </select>
 
@@ -213,4 +272,25 @@
         WHERE user_id = #{object.objectID,jdbcType=VARCHAR}
     </update>
 
+    <!-- Delete attributes associated with user -->
+    <delete id="deleteAttributes">
+        DELETE FROM guacamole_user_attribute
+        WHERE user_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for user -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO guacamole_user_attribute (
+            user_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml
new file mode 100644
index 0000000..014b38a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml
@@ -0,0 +1,187 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+    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.
+-->
+
+<mapper namespace="org.apache.guacamole.auth.jdbc.user.UserRecordMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="UserRecordResultMap" type="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+        <id     column="history_id"  property="recordID"   jdbcType="INTEGER"/>
+        <result column="remote_host" property="remoteHost" jdbcType="VARCHAR"/>
+        <result column="user_id"     property="userID"     jdbcType="INTEGER"/>
+        <result column="username"    property="username"   jdbcType="VARCHAR"/>
+        <result column="start_date"  property="startDate"  jdbcType="TIMESTAMP"/>
+        <result column="end_date"    property="endDate"    jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <!-- Select all user records from a given user -->
+    <select id="select" resultMap="UserRecordResultMap">
+
+        SELECT
+            guacamole_user_history.remote_host,
+            guacamole_user_history.user_id,
+            guacamole_user_history.username,
+            guacamole_user_history.start_date,
+            guacamole_user_history.end_date
+        FROM guacamole_user_history
+        JOIN guacamole_user ON guacamole_user_history.user_id = guacamole_user.user_id
+        WHERE
+            guacamole_user.username = #{username,jdbcType=VARCHAR}
+        ORDER BY
+            guacamole_user_history.start_date DESC,
+            guacamole_user_history.end_date DESC
+
+    </select>
+
+    <!-- Insert the given user record -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="record.recordID"
+            parameterType="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+
+        INSERT INTO guacamole_user_history (
+            remote_host,
+            user_id,
+            username,
+            start_date,
+            end_date
+        )
+        VALUES (
+            #{record.remoteHost,jdbcType=VARCHAR},
+            (SELECT user_id FROM guacamole_user
+             WHERE username = #{record.username,jdbcType=VARCHAR}),
+            #{record.username,jdbcType=VARCHAR},
+            #{record.startDate,jdbcType=TIMESTAMP},
+            #{record.endDate,jdbcType=TIMESTAMP}
+        )
+
+    </insert>
+
+    <!-- Update the given user record -->
+    <update id="update" parameterType="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+        UPDATE guacamole_user_history
+        SET remote_host = #{record.remoteHost,jdbcType=VARCHAR},
+            user_id     = (SELECT user_id FROM guacamole_user
+                           WHERE username = #{record.username,jdbcType=VARCHAR}),
+            username    = #{record.username,jdbcType=VARCHAR},
+            start_date  = #{record.startDate,jdbcType=TIMESTAMP},
+            end_date    = #{record.endDate,jdbcType=TIMESTAMP}
+        WHERE history_id = #{record.recordID,jdbcType=INTEGER}::integer
+    </update>
+
+    <!-- Search for specific user records -->
+    <select id="search" resultMap="UserRecordResultMap">
+
+        SELECT
+            guacamole_user_history.remote_host,
+            guacamole_user_history.user_id,
+            guacamole_user_history.username,
+            guacamole_user_history.start_date,
+            guacamole_user_history.end_date
+        FROM guacamole_user_history
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_user_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE" value="@org.apache.guacamole.net.auth.ActivityRecordSet$SortableProperty@START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_user_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Search for specific user records -->
+    <select id="searchReadable" resultMap="UserRecordResultMap">
+
+        SELECT
+            guacamole_user_history.remote_host,
+            guacamole_user_history.user_id,
+            guacamole_user_history.username,
+            guacamole_user_history.start_date,
+            guacamole_user_history.end_date
+        FROM guacamole_user_history
+
+        <!-- Restrict to readable users -->
+        JOIN guacamole_user_permission ON
+                guacamole_user_history.user_id       = guacamole_user_permission.affected_user_id
+            AND guacamole_user_permission.user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND guacamole_user_permission.permission = 'READ'
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                guacamole_user_history.user_id IN (
+                    SELECT user_id
+                    FROM guacamole_user
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE" value="@org.apache.guacamole.net.auth.ActivityRecordSet$SortableProperty@START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">guacamole_user_history.start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+</mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/pom.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/pom.xml
index 82776f7..d10db14 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/pom.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/pom.xml
@@ -27,7 +27,7 @@
     <artifactId>guacamole-auth-jdbc-sqlserver</artifactId>
     <packaging>jar</packaging>
     <name>guacamole-auth-jdbc-sqlserver</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -36,7 +36,7 @@
     <parent>
         <groupId>org.apache.guacamole</groupId>
         <artifactId>guacamole-auth-jdbc</artifactId>
-        <version>0.9.13-incubating</version>
+        <version>0.9.14</version>
         <relativePath>../../</relativePath>
     </parent>
 
@@ -120,7 +120,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-auth-jdbc-base</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
     </dependencies>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/001-create-schema.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/001-create-schema.sql
index f7a34b1..ee10dda 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/001-create-schema.sql
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/001-create-schema.sql
@@ -1,468 +1,795 @@
-/*

- * 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.

- */

-

-/**

- * Turn on ANSI_NULLS for the entire DB to make it ISO-compliant.

- */

-ALTER DATABASE CURRENT SET ANSI_NULLS ON;

-GO

-

-/**

- * Turn on QUOTED_IDENTIFIER for the entire DB.

- */

-ALTER DATABASE CURRENT SET QUOTED_IDENTIFIER ON;

-GO

-

-/**

- * List for permission data type.

- */

-CREATE RULE [guacamole_permission_list] 

-    AS

-    @list IN ('READ','UPDATE','DELETE','ADMINISTER');

-GO

-

-/**

- * List for system permission data type.

- */

-CREATE RULE [guacamole_system_permission_list] 

-    AS

-    @list IN ('CREATE_CONNECTION',

-              'CREATE_CONNECTION_GROUP',

-              'CREATE_SHARING_PROFILE',

-              'CREATE_USER',

-              'ADMINISTER');

-GO

-

-/**

- * The permission data type.

- */

-CREATE TYPE [guacamole_permission] FROM [nvarchar](10) NOT NULL;

-EXEC sp_bindrule 'guacamole_permission_list','guacamole_permission';

-

-/**

- * The system permission data type.

- */

-CREATE TYPE [guacamole_system_permission] FROM [nvarchar](32) NOT NULL;

-EXEC sp_bindrule 'guacamole_system_permission_list','guacamole_system_permission';

-GO

-

-/**

- * The connection_group table stores organizational and balancing groups.

- */

-CREATE TABLE [guacamole_connection_group](

-    [connection_group_id]      [int] IDENTITY(1,1) NOT NULL,

-    [parent_id]                [int] NULL,

-    [connection_group_name]    [nvarchar](128) NOT NULL,

-    [type]                     [nvarchar](32) NOT NULL,

-    [max_connections]          [int] NULL,

-    [max_connections_per_user] [int] NULL,

-    [enable_session_affinity]  [bit] NOT NULL,

-

-    CONSTRAINT [PK_guacmaole_connection_group] PRIMARY KEY CLUSTERED

-               ([connection_group_id] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for connection_group table.

- */

-ALTER TABLE [guacamole_connection_group]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_group_connection_group_id] FOREIGN KEY([parent_id])

-    REFERENCES                [guacamole_connection_group] ([connection_group_id]);

-ALTER TABLE [guacamole_connection_group]

-    CHECK CONSTRAINT [FK_guacamole_connection_group_connection_group_id];

-ALTER TABLE [guacamole_connection_group]

-    WITH CHECK ADD CONSTRAINT [CK_guacamole_connection_group_type] 

-    CHECK (([type]='BALANCING' OR [type]='ORGANIZATIONAL'));

-ALTER TABLE [guacamole_connection_group]

-    CHECK CONSTRAINT [CK_guacamole_connection_group_type];

-

-/**

- * Default values for connection_group table.

- */

-ALTER TABLE [guacamole_connection_group]

-    ADD CONSTRAINT [DF_guacamole_connection_group_type] DEFAULT (N'ORGANIZATIONAL') FOR [type];

-ALTER TABLE [guacamole_connection_group]

-    ADD CONSTRAINT [DF_guacamole_connection_group_enable_session_affinity] DEFAULT ((0)) FOR [enable_session_affinity];

-GO

-

-/**

- * The connection table, for storing connections and attributes.

- */

-CREATE TABLE [guacamole_connection](

-    [connection_id]            [int] IDENTITY(1,1) NOT NULL,

-    [connection_name]          [nvarchar](128) NOT NULL,

-    [parent_id]                [int] NULL,

-    [protocol]                 [nvarchar](32) NOT NULL,

-    [proxy_port]               [int] NULL,

-    [proxy_hostname]           [nvarchar](512) NULL,

-    [proxy_encryption_method]  [nvarchar](4) NULL,

-    [max_connections]          [int] NULL,

-    [max_connections_per_user] [int] NULL,

-    [connection_weight]        [int] NULL,

-    [failover_only]            [bit] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_connection] PRIMARY KEY CLUSTERED

-	([connection_id] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-ALTER TABLE [guacamole_connection]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_connection_group] FOREIGN KEY([parent_id])

-    REFERENCES [guacamole_connection_group] ([connection_group_id]);

-ALTER TABLE [guacamole_connection]

-    CHECK CONSTRAINT [FK_guacamole_connection_connection_group];

-ALTER TABLE [guacamole_connection]

-    WITH CHECK ADD CONSTRAINT [CK_proxy_encryption_method]

-    CHECK  (([proxy_encryption_method]='SSL' OR [proxy_encryption_method]='NONE'));

-ALTER TABLE [guacamole_connection]

-    CHECK CONSTRAINT [CK_proxy_encryption_method];

-ALTER TABLE [guacamole_connection]

-    ADD CONSTRAINT [DF_guacamole_connection_failover_only] DEFAULT ((0)) FOR [failover_only];

-GO

-

-/**

- * The user table stores user accounts, passwords, and properties.

- */

-CREATE TABLE [guacamole_user](

-    [user_id]               [int] IDENTITY(1,1) NOT NULL,

-    [username]              [nvarchar](128) NOT NULL,

-    [password_hash]         [binary](32) NOT NULL,

-    [password_salt]         [binary](32) NULL,

-    [password_date]         [datetime] NOT NULL,

-    [disabled]              [bit] NOT NULL,

-    [expired]               [bit] NOT NULL,

-    [access_window_start]   [time](7) NULL,

-    [access_window_end]     [time](7) NULL,

-    [valid_from]            [date] NULL,

-    [valid_until]           [date] NULL,

-    [timezone]              [nvarchar](64) NULL,

-    [full_name]             [nvarchar](256) NULL,

-    [email_address]         [nvarchar](256) NULL,

-    [organization]          [nvarchar](256) NULL,

-    [organizational_role]   [nvarchar](256) NULL,

-

-    CONSTRAINT [PK_guacamole_user] PRIMARY KEY CLUSTERED 

-        ([user_id] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Defaults for user table

- */

-ALTER TABLE [guacamole_user]

-    ADD CONSTRAINT [DF_guacamole_user_disabled] DEFAULT ((0)) FOR [disabled];

-ALTER TABLE [guacamole_user]

-    ADD CONSTRAINT [DF_guacamole_user_expired] DEFAULT ((0)) FOR [expired];

-GO

-

-/**

- * The sharing_profile table stores profiles that allow

- * connections to be shared amongst multiple users.

- */

-CREATE TABLE [guacamole_sharing_profile](

-    [sharing_profile_id]    [int] IDENTITY(1,1) NOT NULL,

-    [sharing_profile_name]  [nvarchar](128) NOT NULL,

-    [primary_connection_id] [int] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_sharing_profile] PRIMARY KEY CLUSTERED 

-        ([sharing_profile_id] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for sharing_profile table.

- */

-ALTER TABLE [guacamole_sharing_profile]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_sharing_profile_connection] FOREIGN KEY([primary_connection_id])

-    REFERENCES [guacamole_connection] ([connection_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_sharing_profile]

-    CHECK CONSTRAINT [FK_guacamole_sharing_profile_connection];

-GO

-

-/**

- * The connection_parameter table stores parameters for

- * connection objects.

- */

-CREATE TABLE [guacamole_connection_parameter](

-    [connection_id]   [int] NOT NULL,

-    [parameter_name]  [nvarchar](128) NOT NULL,

-    [parameter_value] [nvarchar](4000) NOT NULL,

-

-    CONSTRAINT [PK_guacamole_connection_parameter] PRIMARY KEY CLUSTERED 

-        ([connection_id] ASC, [parameter_name] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for the connection_parameter table.

- */

-ALTER TABLE [guacamole_connection_parameter]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_parameter_connection] FOREIGN KEY([connection_id])

-    REFERENCES [guacamole_connection] ([connection_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_connection_parameter]

-    CHECK CONSTRAINT [FK_guacamole_connection_parameter_connection];

-GO

-

-/**

- * The sharing_profile_parameter table stores parameters

- * for sharing_profile objects.

- */

-CREATE TABLE [guacamole_sharing_profile_parameter](

-    [sharing_profile_id] [int] NOT NULL,

-    [parameter_name]     [nvarchar](128) NOT NULL,

-    [parameter_value]    [nvarchar](4000) NOT NULL,

-

-    CONSTRAINT [PK_guacamole_sharing_profile_parameter] PRIMARY KEY CLUSTERED 

-        ([sharing_profile_id] ASC, [parameter_name] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for the sharing_profile_parameter

- * table.

- */

-ALTER TABLE [guacamole_sharing_profile_parameter]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_sharing_profile_parameter_sharing_profile] FOREIGN KEY([sharing_profile_id])

-    REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_sharing_profile_parameter]

-    CHECK CONSTRAINT [FK_guacamole_sharing_profile_parameter_sharing_profile];

-GO

-

-/**

- * The connection_permission table stores permission

- * mappings for connection objects.

- */

-CREATE TABLE [guacamole_connection_permission](

-    [user_id]       [int] NOT NULL,

-    [connection_id] [int] NOT NULL,

-    [permission]    [guacamole_permission] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_connection_permission] PRIMARY KEY CLUSTERED 

-        ([user_id] ASC, [connection_id] ASC, [permission] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for the connection_permission table.

- */

-ALTER TABLE [guacamole_connection_permission]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_permission_connection1] FOREIGN KEY([connection_id])

-    REFERENCES [guacamole_connection] ([connection_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_connection_permission]

-    CHECK CONSTRAINT [FK_guacamole_connection_permission_connection1];

-ALTER TABLE [guacamole_connection_permission]

-    WITH CHECK ADD  CONSTRAINT [FK_guacamole_connection_permission_user1] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_connection_permission]

-    CHECK CONSTRAINT [FK_guacamole_connection_permission_user1];

-GO

-

-/**

- * The connection_group_permission table stores permission mappings for

- * connection_group objects.

- */

-CREATE TABLE [guacamole_connection_group_permission](

-    [user_id]             [int] NOT NULL,

-    [connection_group_id] [int] NOT NULL,

-    [permission]          [guacamole_permission] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_connection_group_permission] PRIMARY KEY CLUSTERED 

-        ([user_id] ASC,	[connection_group_id] ASC, [permission] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for the connection_group_permission table.

- */

-ALTER TABLE [guacamole_connection_group_permission] 

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_group_permission_connection_group] FOREIGN KEY([connection_group_id])

-    REFERENCES [guacamole_connection_group] ([connection_group_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_connection_group_permission]

-    CHECK CONSTRAINT [FK_guacamole_connection_group_permission_connection_group];

-ALTER TABLE [guacamole_connection_group_permission]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_group_permission_user] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_connection_group_permission]

-    CHECK CONSTRAINT [FK_guacamole_connection_group_permission_user];

-GO

-

-/**

- * The sharing_profile_permission table stores permission

- * mappings for sharing_profile objects.

- */

-CREATE TABLE [guacamole_sharing_profile_permission](

-    [user_id]            [int] NOT NULL,

-    [sharing_profile_id] [int] NOT NULL,

-    [permission]         [guacamole_permission] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_sharing_profile_permission] PRIMARY KEY CLUSTERED 

-        ([user_id] ASC, [sharing_profile_id] ASC, [permission] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for the sharing_profile_permission table.

- */

-ALTER TABLE [guacamole_sharing_profile_permission]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_sharing_profile_permission_sharing_profile] FOREIGN KEY([sharing_profile_id])

-    REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_sharing_profile_permission]

-    CHECK CONSTRAINT [FK_guacamole_sharing_profile_permission_sharing_profile];

-ALTER TABLE [guacamole_sharing_profile_permission]

-    WITH CHECK ADD  CONSTRAINT [FK_guacamole_sharing_profile_permission_user] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_sharing_profile_permission]

-    CHECK CONSTRAINT [FK_guacamole_sharing_profile_permission_user];

-GO

-

-/**

- * The system_permission table stores permission mappings

- * for system-level operations.

- */

-CREATE TABLE [guacamole_system_permission](

-    [user_id]    [int] NOT NULL,

-    [permission] [guacamole_system_permission] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_system_permission] PRIMARY KEY CLUSTERED 

-        ([user_id] ASC,	[permission] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for system_permission table.

- */

-ALTER TABLE [guacamole_system_permission]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_system_permission_user] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_system_permission]

-    CHECK CONSTRAINT [FK_guacamole_system_permission_user];

-GO

-

-/**

- * The user_permission table stores permission mappings

- * for users to other users.

- */

-CREATE TABLE [guacamole_user_permission](

-    [user_id]          [int] NOT NULL,

-    [affected_user_id] [int] NOT NULL,

-    [permission]       [guacamole_permission] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_user_permission] PRIMARY KEY CLUSTERED 

-        ([user_id] ASC,	[affected_user_id] ASC,	[permission] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for user_permission table.

- */

-ALTER TABLE [guacamole_user_permission]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_user_permission_user] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_user_permission]

-    CHECK CONSTRAINT [FK_guacamole_user_permission_user];

-ALTER TABLE [guacamole_user_permission]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_user_permission_user1] FOREIGN KEY([affected_user_id])

-    REFERENCES [guacamole_user] ([user_id]);

-ALTER TABLE [guacamole_user_permission]

-    CHECK CONSTRAINT [FK_guacamole_user_permission_user1];

-GO

-

-/**

- * The connection_history table stores records for historical

- * connections.

- */

-CREATE TABLE [guacamole_connection_history](

-    [history_id]           [int] IDENTITY(1,1) NOT NULL,

-    [user_id]              [int] NULL,

-    [username]             [nvarchar](128) NOT NULL,

-    [remote_host]          [nvarchar](256) NULL,

-    [connection_id]        [int] NULL,

-    [connection_name]      [nvarchar](128) NOT NULL,

-    [sharing_profile_id]   [int] NULL,

-    [sharing_profile_name] [nvarchar](128) NULL,

-    [start_date]           [datetime] NOT NULL,

-    [end_date]             [datetime] NULL,

-

-    CONSTRAINT [PK_guacamole_connection_history] PRIMARY KEY CLUSTERED 

-        ([history_id] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for connection_history table

- */

-ALTER TABLE [guacamole_connection_history]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_history_connection] FOREIGN KEY([connection_id])

-    REFERENCES [guacamole_connection] ([connection_id])

-        ON UPDATE CASCADE

-        ON DELETE SET NULL;

-ALTER TABLE [guacamole_connection_history]

-    CHECK CONSTRAINT [FK_guacamole_connection_history_connection];

-ALTER TABLE [guacamole_connection_history]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_history_sharing_profile] FOREIGN KEY([sharing_profile_id])

-    REFERENCES [guacamole_sharing_profile] ([sharing_profile_id]);

-ALTER TABLE [guacamole_connection_history]

-    CHECK CONSTRAINT [FK_guacamole_connection_history_sharing_profile];

-ALTER TABLE [guacamole_connection_history]

-    WITH CHECK ADD CONSTRAINT [FK_guacamole_connection_history_user] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE SET NULL;

-ALTER TABLE [guacamole_connection_history]

-    CHECK CONSTRAINT [FK_guacamole_connection_history_user];

-GO

-

-/**

- * The user_password_history table stores password history

- * for users, allowing for enforcing rules associated with

- * reuse of passwords.

- */

-CREATE TABLE [guacamole_user_password_history](

-    [password_history_id] [int] IDENTITY(1,1) NOT NULL,

-    [user_id]             [int] NOT NULL,

-    [password_hash]       [binary](32) NOT NULL,

-    [password_salt]       [binary](32) NULL,

-    [password_date]       [datetime] NOT NULL,

-

-    CONSTRAINT [PK_guacamole_user_password_history] PRIMARY KEY CLUSTERED 

-        ([password_history_id] ASC) ON [PRIMARY]

-) ON [PRIMARY];

-

-/**

- * Foreign keys for user_password_history table

- */

-ALTER TABLE [guacamole_user_password_history]

-    WITH CHECK ADD  CONSTRAINT [FK_guacamole_user_password_history_user] FOREIGN KEY([user_id])

-    REFERENCES [guacamole_user] ([user_id])

-        ON UPDATE CASCADE

-        ON DELETE CASCADE;

-ALTER TABLE [guacamole_user_password_history]

-    CHECK CONSTRAINT [FK_guacamole_user_password_history_user];

-GO

+--
+-- 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.
+--
+
+--
+-- Connection group types
+--
+
+CREATE RULE [guacamole_connection_group_type_list] AS @list IN (
+    'ORGANIZATIONAL',
+    'BALANCING'
+);
+GO
+
+CREATE TYPE [guacamole_connection_group_type] FROM [nvarchar](16);
+EXEC sp_bindrule
+    'guacamole_connection_group_type_list',
+    'guacamole_connection_group_type';
+GO
+
+--
+-- Object permission types
+--
+
+CREATE RULE [guacamole_object_permission_list] AS @list IN (
+    'READ',
+    'UPDATE',
+    'DELETE',
+    'ADMINISTER'
+);
+GO
+
+CREATE TYPE [guacamole_object_permission] FROM [nvarchar](16);
+EXEC sp_bindrule
+    'guacamole_object_permission_list',
+    'guacamole_object_permission';
+GO
+
+--
+-- System permission types
+--
+
+CREATE RULE [guacamole_system_permission_list] AS @list IN (
+    'CREATE_CONNECTION',
+    'CREATE_CONNECTION_GROUP',
+    'CREATE_SHARING_PROFILE',
+    'CREATE_USER',
+    'ADMINISTER'
+);
+GO
+
+CREATE TYPE [guacamole_system_permission] FROM [nvarchar](32);
+EXEC sp_bindrule
+    'guacamole_system_permission_list',
+    'guacamole_system_permission';
+GO
+
+--
+-- Guacamole proxy (guacd) encryption methods.
+--
+
+CREATE RULE [guacamole_proxy_encryption_method_list] AS @list IN (
+    'NONE',
+    'SSL'
+);
+GO
+
+CREATE TYPE [guacamole_proxy_encryption_method] FROM [nvarchar](8);
+EXEC sp_bindrule
+    'guacamole_proxy_encryption_method_list',
+    'guacamole_proxy_encryption_method';
+GO
+
+--
+-- Table of connection groups. Each connection group has a name, type, and
+-- optional parent connection group.
+--
+
+CREATE TABLE [guacamole_connection_group] (
+
+    [connection_group_id]   [int] IDENTITY(1,1) NOT NULL,
+    [parent_id]             [int],
+    [connection_group_name] [nvarchar](128)     NOT NULL,
+    [type]                  [guacamole_connection_group_type]
+                            NOT NULL DEFAULT 'ORGANIZATIONAL',
+
+    -- Concurrency limits
+    [max_connections]          [int],
+    [max_connections_per_user] [int],
+    [enable_session_affinity]  [bit] NOT NULL DEFAULT 0,
+
+    CONSTRAINT [PK_guacamole_connection_group]
+        PRIMARY KEY CLUSTERED ([connection_group_id]),
+
+    CONSTRAINT [AK_guacamole_connection_group_name_parent]
+        UNIQUE ([connection_group_name], [parent_id]),
+
+    CONSTRAINT [FK_guacamole_connection_group_parent_id]
+        FOREIGN KEY ([parent_id])
+        REFERENCES [guacamole_connection_group] ([connection_group_id])
+        -- ON DELETE CASCADE handled by guacamole_delete_connection_group trigger
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_group_parent_id]
+    ON [guacamole_connection_group] ([parent_id]);
+GO
+
+--
+-- Table of connections. Each connection has a name, protocol, and
+-- associated set of parameters. A connection may belong to a connection group.
+--
+
+CREATE TABLE [guacamole_connection] (
+
+    [connection_id]   [int] IDENTITY(1,1) NOT NULL,
+    [connection_name] [nvarchar](128)     NOT NULL,
+    [parent_id]       [int],
+    [protocol]        [nvarchar](32)      NOT NULL,
+
+    -- Concurrency limits
+    [max_connections]          [int],
+    [max_connections_per_user] [int],
+
+    -- Connection Weight
+    [connection_weight] [int],
+    [failover_only]     [bit] NOT NULL DEFAULT 0,
+
+    -- Guacamole proxy (guacd) overrides
+    [proxy_port]              [int],
+    [proxy_hostname]          [nvarchar](512),
+    [proxy_encryption_method] [guacamole_proxy_encryption_method],
+
+    CONSTRAINT [PK_guacamole_connection]
+        PRIMARY KEY CLUSTERED ([connection_id]),
+
+    CONSTRAINT [AK_guacamole_connection_name_parent]
+        UNIQUE ([connection_name], [parent_id]),
+
+    CONSTRAINT [FK_guacamole_connection_parent_id]
+        FOREIGN KEY ([parent_id])
+        REFERENCES [guacamole_connection_group] ([connection_group_id])
+        -- ON DELETE CASCADE handled by guacamole_delete_connection_group trigger
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_parent_id]
+    ON [guacamole_connection] ([parent_id]);
+GO
+
+--
+-- Table of users. Each user has a unique username and a hashed password
+-- with corresponding salt. Although the authentication system will always set
+-- salted passwords, other systems may set unsalted passwords by simply not
+-- providing the salt.
+--
+
+CREATE TABLE [guacamole_user] (
+
+    [user_id] [int] IDENTITY(1,1) NOT NULL,
+
+    -- Username and optionally-salted password
+    [username]      [nvarchar](128) NOT NULL,
+    [password_hash] [binary](32)    NOT NULL,
+    [password_salt] [binary](32),
+    [password_date] [datetime]      NOT NULL,
+
+    -- Account disabled/expired status
+    [disabled] [bit] NOT NULL DEFAULT 0,
+    [expired]  [bit] NOT NULL DEFAULT 0,
+
+    -- Time-based access restriction
+    [access_window_start] [time](7),
+    [access_window_end]   [time](7),
+
+    -- Date-based access restriction
+    [valid_from]  [date],
+    [valid_until] [date],
+
+    -- Timezone used for all date/time comparisons and interpretation
+    [timezone] [nvarchar](64),
+
+    -- Profile information
+    [full_name]           [nvarchar](256),
+    [email_address]       [nvarchar](256),
+    [organization]        [nvarchar](256),
+    [organizational_role] [nvarchar](256),
+
+    CONSTRAINT [PK_guacamole_user]
+        PRIMARY KEY CLUSTERED ([user_id]),
+
+    CONSTRAINT [AK_guacamole_user_username]
+        UNIQUE ([username])
+
+);
+GO
+
+--
+-- Table of sharing profiles. Each sharing profile has a name, associated set
+-- of parameters, and a primary connection. The primary connection is the
+-- connection that the sharing profile shares, and the parameters dictate the
+-- restrictions/features which apply to the user joining the connection via the
+-- sharing profile.
+--
+
+CREATE TABLE [guacamole_sharing_profile] (
+
+    [sharing_profile_id]    [int] IDENTITY(1,1) NOT NULL,
+    [sharing_profile_name]  [nvarchar](128)     NOT NULL,
+    [primary_connection_id] [int]               NOT NULL,
+
+    CONSTRAINT [PK_guacamole_sharing_profile]
+        PRIMARY KEY CLUSTERED ([sharing_profile_id]),
+
+    CONSTRAINT [AK_guacamole_sharing_profile_name_primary_connection]
+        UNIQUE ([sharing_profile_name], [primary_connection_id]),
+
+    CONSTRAINT [FK_guacamole_sharing_profile_primary_connection_id]
+        FOREIGN KEY ([primary_connection_id])
+        REFERENCES [guacamole_connection] ([connection_id])
+        -- ON DELETE CASCADE handled by guacamole_delete_connection trigger
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_sharing_profile_primary_connection_id]
+    ON [guacamole_sharing_profile] ([primary_connection_id]);
+GO
+
+--
+-- Table of arbitrary user attributes. Each attribute is simply a name/value
+-- pair associated with a user. Arbitrary attributes are defined by other
+-- extensions. Attributes defined by this extension will be mapped to
+-- properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_user_attribute] (
+
+    [user_id]         [int]            NOT NULL,
+    [attribute_name]  [nvarchar](128)  NOT NULL,
+    [attribute_value] [nvarchar](4000) NOT NULL,
+
+    CONSTRAINT [PK_guacamole_user_attribute]
+        PRIMARY KEY CLUSTERED ([user_id], [attribute_name]),
+
+    CONSTRAINT [FK_guacamole_user_attribute_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_attribute_user_id]
+    ON [guacamole_user_attribute] ([user_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of arbitrary connection attributes. Each attribute is simply a
+-- name/value pair associated with a connection. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_connection_attribute] (
+
+    [connection_id]   [int]            NOT NULL,
+    [attribute_name]  [nvarchar](128)  NOT NULL,
+    [attribute_value] [nvarchar](4000) NOT NULL,
+
+    PRIMARY KEY (connection_id, attribute_name),
+
+    CONSTRAINT [FK_guacamole_connection_attribute_connection_id]
+        FOREIGN KEY ([connection_id])
+        REFERENCES [guacamole_connection] ([connection_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_attribute_connection_id]
+    ON [guacamole_connection_attribute] ([connection_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of arbitrary connection group attributes. Each attribute is simply a
+-- name/value pair associated with a connection group. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_connection_group_attribute] (
+
+    [connection_group_id] [int]            NOT NULL,
+    [attribute_name]      [nvarchar](128)  NOT NULL,
+    [attribute_value]     [nvarchar](4000) NOT NULL,
+
+    PRIMARY KEY (connection_group_id, attribute_name),
+
+    CONSTRAINT [FK_guacamole_connection_group_attribute_connection_group_id]
+        FOREIGN KEY ([connection_group_id])
+        REFERENCES [guacamole_connection_group] ([connection_group_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_group_attribute_connection_group_id]
+    ON [guacamole_connection_group_attribute] ([connection_group_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of arbitrary sharing profile attributes. Each attribute is simply a
+-- name/value pair associated with a sharing profile. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_sharing_profile_attribute] (
+
+    [sharing_profile_id] [int]            NOT NULL,
+    [attribute_name]     [nvarchar](128)  NOT NULL,
+    [attribute_value]    [nvarchar](4000) NOT NULL,
+
+    PRIMARY KEY (sharing_profile_id, attribute_name),
+
+    CONSTRAINT [FK_guacamole_sharing_profile_attribute_sharing_profile_id]
+        FOREIGN KEY ([sharing_profile_id])
+        REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_sharing_profile_attribute_sharing_profile_id]
+    ON [guacamole_sharing_profile_attribute] ([sharing_profile_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of connection parameters. Each parameter is simply a name/value pair
+-- associated with a connection.
+--
+
+CREATE TABLE [guacamole_connection_parameter] (
+
+    [connection_id]   [int]            NOT NULL,
+    [parameter_name]  [nvarchar](128)  NOT NULL,
+    [parameter_value] [nvarchar](4000) NOT NULL,
+
+    CONSTRAINT [PK_guacamole_connection_parameter]
+        PRIMARY KEY CLUSTERED ([connection_id], [parameter_name]),
+
+    CONSTRAINT [FK_guacamole_connection_parameter_connection_id]
+        FOREIGN KEY ([connection_id])
+        REFERENCES [guacamole_connection] ([connection_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_parameter_connection_id]
+    ON [guacamole_connection_parameter] ([connection_id]);
+GO
+
+--
+-- Table of sharing profile parameters. Each parameter is simply
+-- name/value pair associated with a sharing profile. These parameters dictate
+-- the restrictions/features which apply to the user joining the associated
+-- connection via the sharing profile.
+--
+
+CREATE TABLE [guacamole_sharing_profile_parameter] (
+
+    [sharing_profile_id] [int]            NOT NULL,
+    [parameter_name]     [nvarchar](128)  NOT NULL,
+    [parameter_value]    [nvarchar](4000) NOT NULL,
+
+    CONSTRAINT [PK_guacamole_sharing_profile_parameter]
+        PRIMARY KEY CLUSTERED ([sharing_profile_id], [parameter_name]),
+
+    CONSTRAINT [FK_guacamole_sharing_profile_parameter_connection_id]
+        FOREIGN KEY ([sharing_profile_id])
+        REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_sharing_profile_parameter_sharing_profile_id]
+    ON [guacamole_sharing_profile_parameter] ([sharing_profile_id]);
+GO
+
+--
+-- Table of connection permissions. Each connection permission grants a user
+-- specific access to a connection.
+--
+
+CREATE TABLE [guacamole_connection_permission] (
+
+    [user_id]       [int]                         NOT NULL,
+    [connection_id] [int]                         NOT NULL,
+    [permission]    [guacamole_object_permission] NOT NULL,
+
+    CONSTRAINT [PK_guacamole_connection_permission]
+        PRIMARY KEY CLUSTERED  ([user_id], [connection_id], [permission]),
+
+    CONSTRAINT [FK_guacamole_connection_permission_connection_id]
+        FOREIGN KEY ([connection_id])
+        REFERENCES [guacamole_connection] ([connection_id])
+        ON DELETE CASCADE,
+
+    CONSTRAINT [FK_guacamole_connection_permission_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_permission_connection_id]
+    ON [guacamole_connection_permission] ([connection_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_permission_user_id]
+    ON [guacamole_connection_permission] ([user_id]);
+GO
+
+--
+-- Table of connection group permissions. Each group permission grants a user
+-- specific access to a connection group.
+--
+
+CREATE TABLE [guacamole_connection_group_permission] (
+
+    [user_id]             [int]                         NOT NULL,
+    [connection_group_id] [int]                         NOT NULL,
+    [permission]          [guacamole_object_permission] NOT NULL,
+
+    CONSTRAINT [PK_guacamole_connection_group_permission]
+        PRIMARY KEY CLUSTERED ([user_id], [connection_group_id], [permission]),
+
+    CONSTRAINT [FK_guacamole_connection_group_permission_connection_group_id]
+        FOREIGN KEY ([connection_group_id])
+        REFERENCES [guacamole_connection_group] ([connection_group_id])
+        ON DELETE CASCADE,
+
+    CONSTRAINT [FK_guacamole_connection_group_permission_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_group_permission_connection_group_id]
+    ON [guacamole_connection_group_permission] ([connection_group_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_group_permission_user_id]
+    ON [guacamole_connection_group_permission] ([user_id]);
+GO
+
+--
+-- Table of sharing profile permissions. Each sharing profile permission grants
+-- a user specific access to a sharing profile.
+--
+
+CREATE TABLE [guacamole_sharing_profile_permission] (
+
+    [user_id]            [int]                         NOT NULL,
+    [sharing_profile_id] [int]                         NOT NULL,
+    [permission]         [guacamole_object_permission] NOT NULL,
+
+    CONSTRAINT [PK_guacamole_sharing_profile_permission]
+        PRIMARY KEY CLUSTERED ([user_id], [sharing_profile_id], [permission]),
+
+    CONSTRAINT [FK_guacamole_sharing_profile_permission_sharing_profile_id]
+        FOREIGN KEY ([sharing_profile_id])
+        REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])
+        ON DELETE CASCADE,
+
+    CONSTRAINT [FK_guacamole_sharing_profile_permission_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_sharing_profile_permission_sharing_profile_id]
+    ON [guacamole_sharing_profile_permission] ([sharing_profile_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_sharing_profile_permission_user_id]
+    ON [guacamole_sharing_profile_permission] ([user_id]);
+GO
+
+--
+-- Table of system permissions. Each system permission grants a user a
+-- system-level privilege of some kind.
+--
+
+CREATE TABLE [guacamole_system_permission] (
+
+    [user_id]    [int]                         NOT NULL,
+    [permission] [guacamole_system_permission] NOT NULL,
+
+    CONSTRAINT [PK_guacamole_system_permission]
+        PRIMARY KEY CLUSTERED ([user_id], [permission]),
+
+    CONSTRAINT [FK_guacamole_system_permission_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_system_permission_user_id]
+    ON [guacamole_system_permission] ([user_id]);
+GO
+
+--
+-- Table of user permissions. Each user permission grants a user access to
+-- another user (the "affected" user) for a specific type of operation.
+--
+
+CREATE TABLE [guacamole_user_permission] (
+
+    [user_id]          [int]                         NOT NULL,
+    [affected_user_id] [int]                         NOT NULL,
+    [permission]       [guacamole_object_permission] NOT NULL,
+
+    CONSTRAINT [PK_guacamole_user_permission]
+        PRIMARY KEY CLUSTERED ([user_id], [affected_user_id], [permission]),
+
+    CONSTRAINT [FK_guacamole_user_permission_affected_user_id]
+        FOREIGN KEY ([affected_user_id])
+        REFERENCES [guacamole_user] ([user_id]),
+        -- ON DELETE CASCADE handled by guacamole_delete_user trigger
+
+    CONSTRAINT [FK_guacamole_user_permission_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        -- ON DELETE CASCADE handled by guacamole_delete_user trigger
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_permission_user_id]
+    ON [guacamole_user_permission] ([user_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_permission_affected_user_id]
+    ON [guacamole_user_permission] ([affected_user_id]);
+GO
+
+--
+-- Table of connection history records. Each record defines a specific user's
+-- session, including the connection used, the start time, and the end time
+-- (if any).
+--
+
+CREATE TABLE [guacamole_connection_history] (
+
+    [history_id]           [int] IDENTITY(1,1) NOT NULL,
+    [user_id]              [int],
+    [username]             [nvarchar](128)     NOT NULL,
+    [remote_host]          [nvarchar](256),
+    [connection_id]        [int],
+    [connection_name]      [nvarchar](128)     NOT NULL,
+    [sharing_profile_id]   [int],
+    [sharing_profile_name] [nvarchar](128),
+    [start_date]           [datetime]          NOT NULL,
+    [end_date]             [datetime],
+
+    CONSTRAINT [PK_guacamole_connection_history]
+        PRIMARY KEY CLUSTERED ([history_id]),
+
+    CONSTRAINT [FK_guacamole_connection_history_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE SET NULL,
+
+    CONSTRAINT [FK_guacamole_connection_history_connection_id]
+        FOREIGN KEY ([connection_id])
+        REFERENCES [guacamole_connection] ([connection_id])
+        ON DELETE SET NULL,
+
+    CONSTRAINT [FK_guacamole_connection_history_sharing_profile_id]
+        FOREIGN KEY ([sharing_profile_id])
+        REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])
+        -- ON DELETE SET NULL handled by guacamole_delete_sharing profile trigger
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_history_user_id]
+    ON [guacamole_connection_history] ([user_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_history_connection_id]
+    ON [guacamole_connection_history] ([connection_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_history_sharing_profile_id]
+    ON [guacamole_connection_history] ([sharing_profile_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_history_start_date]
+    ON [guacamole_connection_history] ([start_date]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_history_end_date]
+    ON [guacamole_connection_history] ([end_date]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_history_connection_id_start_date]
+    ON [guacamole_connection_history] ([connection_id], [start_date]);
+GO
+
+--
+-- User login/logout history
+--
+
+CREATE TABLE [guacamole_user_history] (
+
+    [history_id]           [int] IDENTITY(1,1) NOT NULL,
+    [user_id]              [int]               DEFAULT NULL,
+    [username]             [nvarchar](128)     NOT NULL,
+    [remote_host]          [nvarchar](256)     DEFAULT NULL,
+    [start_date]           [datetime]          NOT NULL,
+    [end_date]             [datetime]          DEFAULT NULL,
+
+    PRIMARY KEY (history_id),
+
+    CONSTRAINT FK_guacamole_user_history_user_id
+        FOREIGN KEY (user_id)
+        REFERENCES guacamole_user (user_id) ON DELETE SET NULL
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_history_user_id]
+    ON [guacamole_user_history] ([user_id]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_history_start_date]
+    ON [guacamole_user_history] ([start_date]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_history_end_date]
+    ON [guacamole_user_history] ([end_date]);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_history_user_id_start_date]
+    ON [guacamole_user_history] ([user_id], [start_date]);
+GO
+
+--
+-- The user_password_history table stores password history
+-- for users, allowing for enforcing rules associated with
+-- reuse of passwords.
+--
+
+CREATE TABLE [guacamole_user_password_history] (
+
+    [password_history_id] [int] IDENTITY(1,1) NOT NULL,
+    [user_id]             [int]               NOT NULL,
+
+    -- Salted password
+    [password_hash] [binary](32) NOT NULL,
+    [password_salt] [binary](32),
+    [password_date] [datetime]   NOT NULL,
+
+    CONSTRAINT [PK_guacamole_user_password_history]
+        PRIMARY KEY CLUSTERED ([password_history_id]),
+
+    CONSTRAINT [FK_guacamole_user_password_history_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_password_history_user_id]
+    ON [guacamole_user_password_history] ([user_id]);
+GO
+
+--
+-- Handle cascading deletion/updates of records in response to deletion of
+-- guacamole_user records, where such deletion is not already covered by
+-- ON DELETE CASCADE or ON DELETE SET NULL.
+--
+
+CREATE TRIGGER [guacamole_delete_user]
+   ON [guacamole_user]
+   INSTEAD OF DELETE
+AS BEGIN
+
+    -- Do not take trigger into account when producing row counts for the DELETE
+    SET NOCOUNT ON;
+
+    -- Delete all associated permissions not covered by ON DELETE CASCADE
+    DELETE FROM [guacamole_user_permission]
+    WHERE
+           [user_id] IN (SELECT [user_id] FROM DELETED)
+        OR [user_id] IN (SELECT [user_id] FROM DELETED);
+
+    -- Perform original deletion
+    DELETE FROM [guacamole_user]
+    WHERE [user_id] IN (SELECT [user_id] FROM DELETED);
+
+END
+GO
+
+--
+-- Handle cascading deletion/updates of records in response to deletion of
+-- guacamole_connection records, where such deletion is not already covered by
+-- ON DELETE CASCADE or ON DELETE SET NULL.
+--
+
+CREATE TRIGGER [guacamole_delete_connection]
+   ON [guacamole_connection]
+   INSTEAD OF DELETE
+AS BEGIN
+
+    -- Do not take trigger into account when producing row counts for the DELETE
+    SET NOCOUNT ON;
+
+    -- Delete associated sharing profiles
+    DELETE FROM [guacamole_sharing_profile]
+    WHERE [primary_connection_id] IN (SELECT [connection_id] FROM DELETED);
+
+    -- Perform original deletion
+    DELETE FROM [guacamole_connection]
+    WHERE [connection_id] IN (SELECT [connection_id] FROM DELETED);
+
+END
+GO
+
+--
+-- Handle cascading deletion/updates of records in response to deletion of
+-- guacamole_connection_group records, where such deletion is not already
+-- covered by ON DELETE CASCADE or ON DELETE SET NULL.
+--
+
+CREATE TRIGGER [guacamole_delete_connection_group]
+   ON [guacamole_connection_group]
+   INSTEAD OF DELETE
+AS BEGIN
+
+    -- Do not take trigger into account when producing row counts for the DELETE
+    SET NOCOUNT ON;
+
+    -- Delete all requested connection groups, including descendants
+    WITH [connection_groups] ([connection_group_id]) AS (
+        SELECT [connection_group_id] FROM DELETED
+    UNION ALL
+        SELECT [guacamole_connection_group].[connection_group_id]
+        FROM [guacamole_connection_group]
+        JOIN [connection_groups] ON [connection_groups].[connection_group_id] = [guacamole_connection_group].[parent_id]
+    )
+    DELETE FROM [guacamole_connection_group]
+    WHERE [connection_group_id] IN (
+        SELECT [connection_group_id]
+        FROM [connection_groups]
+    );
+
+    -- Delete all child connections
+    DELETE FROM [guacamole_connection]
+    WHERE [parent_id] IN (SELECT [connection_group_id] FROM DELETED);
+
+END
+GO
+
+--
+-- Handle cascading deletion/updates of records in response to deletion of
+-- guacamole_sharing_profile records, where such deletion is not already
+-- covered by ON DELETE CASCADE or ON DELETE SET NULL.
+--
+
+CREATE TRIGGER [guacamole_delete_sharing_profile]
+   ON [guacamole_sharing_profile]
+   INSTEAD OF DELETE
+AS BEGIN
+
+    -- Do not take trigger into account when producing row counts for the DELETE
+    SET NOCOUNT ON;
+
+    -- Delete all associated permissions not covered by ON DELETE CASCADE
+    UPDATE [guacamole_connection_history]
+    SET [sharing_profile_id] = NULL
+    WHERE [sharing_profile_id] IN (SELECT [sharing_profile_id] FROM DELETED);
+
+    -- Perform original deletion
+    DELETE FROM [guacamole_sharing_profile]
+    WHERE [sharing_profile_id] IN (SELECT [sharing_profile_id] FROM DELETED);
+
+END
+GO
+
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/002-create-admin-user.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/002-create-admin-user.sql
index 4898e57..5b14651 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/002-create-admin-user.sql
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/002-create-admin-user.sql
@@ -1,49 +1,60 @@
-/*

- * 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.

- */

-

-/**

- * Create the default admin user account and set up full privileges.

- */

-INSERT INTO [guacamole_user] (username, password_hash, password_salt, password_date)

-VALUES ('guacadmin', 

-    0xCA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960,

-    0xFE24ADC5E11E2B25288D1704ABE67A79E342ECC26064CE69C5B3177795A82264,

-    getdate());

-

-INSERT INTO [guacamole_user_permission]

-SELECT [guacamole_user].[user_id], [affected].[user_id], permission

-FROM (

-    SELECT 'guacadmin' AS username, 'guacadmin' AS affected_username, 'READ' AS permission

-        UNION SELECT 'guacadmin' AS username, 'guacadmin' AS affected_username, 'UPDATE' AS permission

-        UNION SELECT 'guacadmin' AS username, 'guacadmin' AS affected_username, 'ADMINISTER' AS permission)

-    permissions

-    JOIN [guacamole_user] ON permissions.username = [guacamole_user].[username]

-    JOIN [guacamole_user] affected ON permissions.affected_username = affected.username;

-

-INSERT INTO [guacamole_system_permission]

-SELECT user_id, permission

-FROM (

-    SELECT 'guacadmin' AS username, 'CREATE_CONNECTION' AS permission

-        UNION SELECT 'guacadmin' AS username, 'CREATE_CONNECTION_GROUP' AS permission

-        UNION SELECT 'guacadmin' AS username, 'CREATE_SHARING_PROFILE' AS permission

-        UNION SELECT 'guacadmin' AS username, 'CREATE_USER' AS permission

-        UNION SELECT 'guacadmin' AS username, 'ADMINISTER' AS permission)

-    permissions

-    JOIN [guacamole_user] ON permissions.username = [guacamole_user].[username];

-GO

+--
+-- 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.
+--
+
+-- Create default user "guacadmin" with password "guacadmin"
+INSERT INTO [guacamole_user] (
+    [username],
+    [password_hash],
+    [password_salt],
+    [password_date]
+)
+VALUES (
+    'guacadmin',
+    0xCA458A7D494E3BE824F5E1E175A1556C0F8EEF2C2D7DF3633BEC4A29C4411960,
+    0xFE24ADC5E11E2B25288D1704ABE67A79E342ECC26064CE69C5B3177795A82264,
+    getdate()
+);
+
+-- Grant this user all system permissions
+INSERT INTO [guacamole_system_permission]
+SELECT
+    [user_id],
+    [permission]
+FROM (
+          SELECT 'guacadmin', 'CREATE_CONNECTION'
+    UNION SELECT 'guacadmin', 'CREATE_CONNECTION_GROUP'
+    UNION SELECT 'guacadmin', 'CREATE_SHARING_PROFILE'
+    UNION SELECT 'guacadmin', 'CREATE_USER'
+    UNION SELECT 'guacadmin', 'ADMINISTER'
+) [permissions] ([username], [permission])
+JOIN [guacamole_user] ON [permissions].[username] = [guacamole_user].[username];
+
+INSERT INTO [guacamole_user_permission]
+SELECT
+    [guacamole_user].[user_id],
+    [affected].[user_id],
+    [permission]
+FROM (
+          SELECT 'guacadmin', 'guacadmin', 'READ'
+    UNION SELECT 'guacadmin', 'guacadmin', 'UPDATE'
+    UNION SELECT 'guacadmin', 'guacadmin', 'ADMINISTER'
+) [permissions] ([username], [affected_username], [permission])
+JOIN [guacamole_user]          ON permissions.username = [guacamole_user].[username]
+JOIN [guacamole_user] [affected] ON permissions.affected_username = affected.username;
+GO
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/upgrade/upgrade-pre-1.0.0.sql b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/upgrade/upgrade-pre-1.0.0.sql
new file mode 100644
index 0000000..cb02dd5
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/schema/upgrade/upgrade-pre-1.0.0.sql
@@ -0,0 +1,127 @@
+--
+-- 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.
+--
+
+--
+-- Table of arbitrary user attributes. Each attribute is simply a name/value
+-- pair associated with a user. Arbitrary attributes are defined by other
+-- extensions. Attributes defined by this extension will be mapped to
+-- properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_user_attribute] (
+
+    [user_id]         [int]            NOT NULL,
+    [attribute_name]  [nvarchar](128)  NOT NULL,
+    [attribute_value] [nvarchar](4000) NOT NULL,
+
+    CONSTRAINT [PK_guacamole_user_attribute]
+        PRIMARY KEY CLUSTERED ([user_id], [attribute_name]),
+
+    CONSTRAINT [FK_guacamole_user_attribute_user_id]
+        FOREIGN KEY ([user_id])
+        REFERENCES [guacamole_user] ([user_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_user_attribute_user_id]
+    ON [guacamole_user_attribute] ([user_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of arbitrary connection attributes. Each attribute is simply a
+-- name/value pair associated with a connection. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_connection_attribute] (
+
+    [connection_id]   [int]            NOT NULL,
+    [attribute_name]  [nvarchar](128)  NOT NULL,
+    [attribute_value] [nvarchar](4000) NOT NULL,
+
+    PRIMARY KEY (connection_id, attribute_name),
+
+    CONSTRAINT [FK_guacamole_connection_attribute_connection_id]
+        FOREIGN KEY ([connection_id])
+        REFERENCES [guacamole_connection] ([connection_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_attribute_connection_id]
+    ON [guacamole_connection_attribute] ([connection_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of arbitrary connection group attributes. Each attribute is simply a
+-- name/value pair associated with a connection group. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_connection_group_attribute] (
+
+    [connection_group_id] [int]            NOT NULL,
+    [attribute_name]      [nvarchar](128)  NOT NULL,
+    [attribute_value]     [nvarchar](4000) NOT NULL,
+
+    PRIMARY KEY (connection_group_id, attribute_name),
+
+    CONSTRAINT [FK_guacamole_connection_group_attribute_connection_group_id]
+        FOREIGN KEY ([connection_group_id])
+        REFERENCES [guacamole_connection_group] ([connection_group_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_connection_group_attribute_connection_group_id]
+    ON [guacamole_connection_group_attribute] ([connection_group_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
+
+--
+-- Table of arbitrary sharing profile attributes. Each attribute is simply a
+-- name/value pair associated with a sharing profile. Arbitrary attributes are
+-- defined by other extensions. Attributes defined by this extension will be
+-- mapped to properly-typed columns of a specific table.
+--
+
+CREATE TABLE [guacamole_sharing_profile_attribute] (
+
+    [sharing_profile_id] [int]            NOT NULL,
+    [attribute_name]     [nvarchar](128)  NOT NULL,
+    [attribute_value]    [nvarchar](4000) NOT NULL,
+
+    PRIMARY KEY (sharing_profile_id, attribute_name),
+
+    CONSTRAINT [FK_guacamole_sharing_profile_attribute_sharing_profile_id]
+        FOREIGN KEY ([sharing_profile_id])
+        REFERENCES [guacamole_sharing_profile] ([sharing_profile_id])
+        ON DELETE CASCADE
+
+);
+
+CREATE NONCLUSTERED INDEX [IX_guacamole_sharing_profile_attribute_sharing_profile_id]
+    ON [guacamole_sharing_profile_attribute] ([sharing_profile_id])
+    INCLUDE ([attribute_name], [attribute_value]);
+GO
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json
index ee61ab5..6759c38 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "SQLServer Authentication",
     "namespace" : "guac-sqlserver",
@@ -20,6 +20,7 @@
 
     "translations" : [
         "translations/en.json",
+        "translations/es.json",
         "translations/fr.json",
         "translations/ru.json"
     ]
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
index 3e6819f..fb61757 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connection/ConnectionMapper.xml
@@ -39,6 +39,7 @@
                 javaType="org.apache.guacamole.net.auth.GuacamoleProxyConfiguration$EncryptionMethod"/>
         <result column="connection_weight"        property="connectionWeight"      jdbcType="INTEGER"/>
         <result column="failover_only"            property="failoverOnly"          jdbcType="BOOLEAN"/>
+        <result column="last_active"              property="lastActive"            jdbcType="TIMESTAMP"/>
 
         <!-- Associated sharing profiles -->
         <collection property="sharingProfileIdentifiers" resultSet="sharingProfiles" ofType="java.lang.String"
@@ -46,6 +47,14 @@
             <result column="sharing_profile_id"/>
         </collection>
 
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="connection_id" foreignColumn="connection_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all connection identifiers -->
@@ -86,11 +95,11 @@
 
     <!-- Select multiple connections by identifier -->
     <select id="select" resultMap="ConnectionResultMap"
-            resultSets="connections,sharingProfiles">
+            resultSets="connections,sharingProfiles,arbitraryAttributes">
 
         SELECT
-            connection_id,
-            connection_name,
+            [guacamole_connection].connection_id,
+            [guacamole_connection].connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -99,9 +108,14 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            (
+                SELECT MAX(start_date)
+                FROM [guacamole_connection_history]
+                WHERE [guacamole_connection_history].connection_id = [guacamole_connection].connection_id
+            ) AS last_active
         FROM [guacamole_connection]
-        WHERE connection_id IN
+        WHERE [guacamole_connection].connection_id IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}
@@ -115,15 +129,26 @@
                 #{identifier,jdbcType=INTEGER}
             </foreach>;
 
+        SELECT
+            connection_id,
+            attribute_name,
+            attribute_value
+        FROM [guacamole_connection_attribute]
+        WHERE connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>;
+
     </select>
 
     <!-- Select multiple connections by identifier only if readable -->
     <select id="selectReadable" resultMap="ConnectionResultMap"
-            resultSets="connections,sharingProfiles">
+            resultSets="connections,sharingProfiles,arbitraryAttributes">
 
         SELECT
             [guacamole_connection].connection_id,
-            connection_name,
+            [guacamole_connection].connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -132,7 +157,12 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            (
+                SELECT MAX(start_date)
+                FROM [guacamole_connection_history]
+                WHERE [guacamole_connection_history].connection_id = [guacamole_connection].connection_id
+            ) AS last_active
         FROM [guacamole_connection]
         JOIN [guacamole_connection_permission] ON [guacamole_connection_permission].connection_id = [guacamole_connection].connection_id
         WHERE [guacamole_connection].connection_id IN
@@ -140,7 +170,7 @@
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}
             </foreach>
-            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND [guacamole_connection_permission].user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
         SELECT primary_connection_id, [guacamole_sharing_profile].sharing_profile_id
@@ -154,14 +184,28 @@
             AND user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
+        SELECT
+            [guacamole_connection_attribute].connection_id,
+            attribute_name,
+            attribute_value
+        FROM [guacamole_connection_attribute]
+        JOIN [guacamole_connection_permission] ON [guacamole_connection_permission].connection_id = [guacamole_connection_attribute].connection_id
+        WHERE [guacamole_connection_attribute].connection_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
+
     </select>
 
     <!-- Select single connection by name -->
     <select id="selectOneByName" resultMap="ConnectionResultMap">
 
         SELECT
-            connection_id,
-            connection_name,
+            [guacamole_connection].connection_id,
+            [guacamole_connection].connection_name,
             parent_id,
             protocol,
             max_connections,
@@ -170,12 +214,17 @@
             proxy_port,
             proxy_encryption_method,
             connection_weight,
-            failover_only
+            failover_only,
+            (
+                SELECT MAX(start_date)
+                FROM [guacamole_connection_history]
+                WHERE [guacamole_connection_history].connection_id = [guacamole_connection].connection_id
+            ) AS last_active
         FROM [guacamole_connection]
         WHERE 
             <if test="parentIdentifier != null">parent_id = #{parentIdentifier,jdbcType=INTEGER}</if>
             <if test="parentIdentifier == null">parent_id IS NULL</if>
-            AND connection_name = #{name,jdbcType=VARCHAR}
+            AND [guacamole_connection].connection_name = #{name,jdbcType=VARCHAR}
 
     </select>
 
@@ -232,4 +281,25 @@
         WHERE connection_id = #{object.objectID,jdbcType=INTEGER}
     </update>
 
+    <!-- Delete attributes associated with connection -->
+    <delete id="deleteAttributes">
+        DELETE FROM [guacamole_connection_attribute]
+        WHERE connection_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for connection -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO [guacamole_connection_attribute] (
+            connection_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
index 452c0a8..f75943e 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupMapper.xml
@@ -48,6 +48,14 @@
             <result column="connection_id"/>
         </collection>
 
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="connection_group_id" foreignColumn="connection_group_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all connection group identifiers -->
@@ -88,7 +96,7 @@
 
     <!-- Select multiple connection groups by identifier -->
     <select id="select" resultMap="ConnectionGroupResultMap"
-            resultSets="connectionGroups,childConnectionGroups,childConnections">
+            resultSets="connectionGroups,childConnectionGroups,childConnections,arbitraryAttributes">
 
         SELECT
             connection_group_id,
@@ -121,11 +129,22 @@
                 #{identifier,jdbcType=INTEGER}
             </foreach>;
 
+        SELECT
+            connection_group_id,
+            attribute_name,
+            attribute_value
+        FROM [guacamole_connection_group_attribute]
+        WHERE connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>;
+
     </select>
 
     <!-- Select multiple connection groups by identifier only if readable -->
     <select id="selectReadable" resultMap="ConnectionGroupResultMap"
-            resultSets="connectionGroups,childConnectionGroups,childConnections">
+            resultSets="connectionGroups,childConnectionGroups,childConnections,arbitraryAttributes">
 
         SELECT
             [guacamole_connection_group].connection_group_id,
@@ -167,6 +186,20 @@
             AND user_id = #{user.objectID,jdbcType=INTEGER}
             AND permission = 'READ';
 
+        SELECT
+            [guacamole_connection_group_attribute].connection_group_id,
+            attribute_name,
+            attribute_value
+        FROM [guacamole_connection_group_attribute]
+        JOIN [guacamole_connection_group_permission] ON [guacamole_connection_group_permission].connection_group_id = [guacamole_connection_group_attribute].connection_group_id
+        WHERE [guacamole_connection_group_attribute].connection_group_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
+
     </select>
 
     <!-- Select single connection group by name -->
@@ -229,4 +262,25 @@
         WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}
     </update>
 
+    <!-- Delete attributes associated with connection group -->
+    <delete id="deleteAttributes">
+        DELETE FROM [guacamole_connection_group_attribute]
+        WHERE connection_group_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for connection group -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO [guacamole_connection_group_attribute] (
+            connection_group_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
index 3b4ba09..0b3212f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileMapper.xml
@@ -25,9 +25,20 @@
 
     <!-- Result mapper for sharing profile objects -->
     <resultMap id="SharingProfileResultMap" type="org.apache.guacamole.auth.jdbc.sharingprofile.SharingProfileModel">
+
+        <!-- Sharing profile properties -->
         <id     column="sharing_profile_id"    property="objectID"         jdbcType="INTEGER"/>
         <result column="sharing_profile_name"  property="name"             jdbcType="VARCHAR"/>
         <result column="primary_connection_id" property="parentIdentifier" jdbcType="INTEGER"/>
+
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="sharing_profile_id" foreignColumn="sharing_profile_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all sharing profile identifiers -->
@@ -46,7 +57,8 @@
     </select>
 
     <!-- Select multiple sharing profiles by identifier -->
-    <select id="select" resultMap="SharingProfileResultMap">
+    <select id="select" resultMap="SharingProfileResultMap"
+            resultSets="sharingProfiles,arbitraryAttributes">
 
         SELECT
             sharing_profile_id,
@@ -57,12 +69,24 @@
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=INTEGER}
-            </foreach>
+            </foreach>;
+
+        SELECT
+            sharing_profile_id,
+            attribute_name,
+            attribute_value
+        FROM [guacamole_sharing_profile_attribute]
+        WHERE sharing_profile_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>;
 
     </select>
 
     <!-- Select multiple sharing profiles by identifier only if readable -->
-    <select id="selectReadable" resultMap="SharingProfileResultMap">
+    <select id="selectReadable" resultMap="SharingProfileResultMap"
+            resultSets="sharingProfiles,arbitraryAttributes">
 
         SELECT
             [guacamole_sharing_profile].sharing_profile_id,
@@ -76,7 +100,21 @@
                 #{identifier,jdbcType=INTEGER}
             </foreach>
             AND user_id = #{user.objectID,jdbcType=INTEGER}
-            AND permission = 'READ'
+            AND permission = 'READ';
+
+        SELECT
+            [guacamole_sharing_profile_attribute].sharing_profile_id,
+            attribute_name,
+            attribute_value
+        FROM [guacamole_sharing_profile_attribute]
+        JOIN [guacamole_sharing_profile_permission] ON [guacamole_sharing_profile_permission].sharing_profile_id = [guacamole_sharing_profile_attribute].sharing_profile_id
+        WHERE [guacamole_sharing_profile_attribute].sharing_profile_id IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>
+            AND user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
 
     </select>
 
@@ -123,4 +161,25 @@
         WHERE sharing_profile_id = #{object.objectID,jdbcType=INTEGER}
     </update>
 
-</mapper>
+    <!-- Delete attributes associated with sharing profile -->
+    <delete id="deleteAttributes">
+        DELETE FROM [guacamole_sharing_profile_attribute]
+        WHERE sharing_profile_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for sharing profile -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO [guacamole_sharing_profile_attribute] (
+            sharing_profile_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
+</mapper>
\ No newline at end of file
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
index 6df6cf2..e9c5b02 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserMapper.xml
@@ -25,6 +25,8 @@
 
     <!-- Result mapper for user objects -->
     <resultMap id="UserResultMap" type="org.apache.guacamole.auth.jdbc.user.UserModel" >
+
+        <!-- User properties -->
         <id     column="user_id"             property="objectID"           jdbcType="INTEGER"/>
         <result column="username"            property="identifier"         jdbcType="VARCHAR"/>
         <result column="password_hash"       property="passwordHash"       jdbcType="BINARY"/>
@@ -41,6 +43,16 @@
         <result column="email_address"       property="emailAddress"       jdbcType="VARCHAR"/>
         <result column="organization"        property="organization"       jdbcType="VARCHAR"/>
         <result column="organizational_role" property="organizationalRole" jdbcType="VARCHAR"/>
+        <result column="last_active"         property="lastActive"         jdbcType="TIMESTAMP"/>
+
+        <!-- Arbitrary attributes -->
+        <collection property="arbitraryAttributes" resultSet="arbitraryAttributes"
+                    ofType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel"
+                    column="user_id" foreignColumn="user_id">
+            <result property="name"     column="attribute_name"  jdbcType="VARCHAR"/>
+            <result property="value"    column="attribute_value" jdbcType="VARCHAR"/>
+        </collection>
+
     </resultMap>
 
     <!-- Select all usernames -->
@@ -60,11 +72,12 @@
     </select>
 
     <!-- Select multiple users by username -->
-    <select id="select" resultMap="UserResultMap">
+    <select id="select" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
-            user_id,
-            username,
+            [guacamole_user].user_id,
+            [guacamole_user].username,
             password_hash,
             password_salt,
             password_date,
@@ -78,22 +91,40 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            (
+                SELECT MAX(start_date)
+                FROM [guacamole_user_history]
+                WHERE [guacamole_user_history].user_id = [guacamole_user].user_id
+            ) AS last_active
         FROM [guacamole_user]
-        WHERE username IN
+        WHERE [guacamole_user].username IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
-            </foreach>
+            </foreach>;
+
+        SELECT
+            [guacamole_user_attribute].user_id,
+            [guacamole_user_attribute].attribute_name,
+            [guacamole_user_attribute].attribute_value
+        FROM [guacamole_user_attribute]
+        JOIN [guacamole_user] ON [guacamole_user].user_id = [guacamole_user_attribute].user_id
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>;
 
     </select>
 
     <!-- Select multiple users by username only if readable -->
-    <select id="selectReadable" resultMap="UserResultMap">
+    <select id="selectReadable" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
             [guacamole_user].user_id,
-            username,
+            [guacamole_user].username,
             password_hash,
             password_salt,
             password_date,
@@ -107,25 +138,46 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            (
+                SELECT MAX(start_date)
+                FROM [guacamole_user_history]
+                WHERE [guacamole_user_history].user_id = [guacamole_user].user_id
+            ) AS last_active
         FROM [guacamole_user]
         JOIN [guacamole_user_permission] ON affected_user_id = [guacamole_user].user_id
-        WHERE username IN
+        WHERE [guacamole_user].username IN
             <foreach collection="identifiers" item="identifier"
                      open="(" separator="," close=")">
                 #{identifier,jdbcType=VARCHAR}
             </foreach>
             AND [guacamole_user_permission].user_id = #{user.objectID,jdbcType=INTEGER}
-            AND permission = 'READ'
+            AND permission = 'READ';
+
+        SELECT
+            [guacamole_user_attribute].user_id,
+            [guacamole_user_attribute].attribute_name,
+            [guacamole_user_attribute].attribute_value
+        FROM [guacamole_user_attribute]
+        JOIN [guacamole_user] ON [guacamole_user].user_id = [guacamole_user_attribute].user_id
+        JOIN [guacamole_user_permission] ON affected_user_id = [guacamole_user].user_id
+        WHERE username IN
+            <foreach collection="identifiers" item="identifier"
+                     open="(" separator="," close=")">
+                #{identifier,jdbcType=INTEGER}
+            </foreach>
+            AND [guacamole_user_permission].user_id = #{user.objectID,jdbcType=INTEGER}
+            AND permission = 'READ';
 
     </select>
 
     <!-- Select single user by username -->
-    <select id="selectOne" resultMap="UserResultMap">
+    <select id="selectOne" resultMap="UserResultMap"
+            resultSets="users,arbitraryAttributes">
 
         SELECT
-            user_id,
-            username,
+            [guacamole_user].user_id,
+            [guacamole_user].username,
             password_hash,
             password_salt,
             password_date,
@@ -139,10 +191,24 @@
             full_name,
             email_address,
             organization,
-            organizational_role
+            organizational_role,
+            (
+                SELECT MAX(start_date)
+                FROM [guacamole_user_history]
+                WHERE [guacamole_user_history].user_id = [guacamole_user].user_id
+            ) AS last_active
         FROM [guacamole_user]
+        LEFT JOIN [guacamole_user_history] ON [guacamole_user_history].user_id = [guacamole_user].user_id
         WHERE
-            username = #{username,jdbcType=VARCHAR}
+            [guacamole_user].username = #{username,jdbcType=VARCHAR};
+
+        SELECT
+            [guacamole_user_attribute].user_id,
+            [guacamole_user_attribute].attribute_name,
+            [guacamole_user_attribute].attribute_value
+        FROM [guacamole_user_attribute]
+        JOIN [guacamole_user] ON [guacamole_user].user_id = [guacamole_user_attribute].user_id
+        WHERE username = #{username,jdbcType=VARCHAR};
 
     </select>
 
@@ -213,4 +279,25 @@
         WHERE user_id = #{object.objectID,jdbcType=VARCHAR}
     </update>
 
+    <!-- Delete attributes associated with user -->
+    <delete id="deleteAttributes">
+        DELETE FROM [guacamole_user_attribute]
+        WHERE user_id = #{object.objectID,jdbcType=INTEGER}
+    </delete>
+
+    <!-- Insert attributes for user -->
+    <insert id="insertAttributes" parameterType="org.apache.guacamole.auth.jdbc.base.ArbitraryAttributeModel">
+        INSERT INTO [guacamole_user_attribute] (
+            user_id,
+            attribute_name,
+            attribute_value
+        )
+        VALUES
+            <foreach collection="object.arbitraryAttributes" item="attribute" separator=",">
+                (#{object.objectID,jdbcType=INTEGER},
+                 #{attribute.name,jdbcType=VARCHAR},
+                 #{attribute.value,jdbcType=VARCHAR})
+            </foreach>
+    </insert>
+
 </mapper>
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml
new file mode 100644
index 0000000..0143dda
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/org/apache/guacamole/auth/jdbc/user/UserRecordMapper.xml
@@ -0,0 +1,187 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
+
+<!--
+    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.
+-->
+
+<mapper namespace="org.apache.guacamole.auth.jdbc.user.UserRecordMapper" >
+
+    <!-- Result mapper for system permissions -->
+    <resultMap id="UserRecordResultMap" type="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+        <id     column="history_id"  property="recordID"   jdbcType="INTEGER"/>
+        <result column="remote_host" property="remoteHost" jdbcType="VARCHAR"/>
+        <result column="user_id"     property="userID"     jdbcType="INTEGER"/>
+        <result column="username"    property="username"   jdbcType="VARCHAR"/>
+        <result column="start_date"  property="startDate"  jdbcType="TIMESTAMP"/>
+        <result column="end_date"    property="endDate"    jdbcType="TIMESTAMP"/>
+    </resultMap>
+
+    <!-- Select all user records from a given user -->
+    <select id="select" resultMap="UserRecordResultMap">
+
+        SELECT
+            [guacamole_user_history].remote_host,
+            [guacamole_user_history].user_id,
+            [guacamole_user_history].username,
+            [guacamole_user_history].start_date,
+            [guacamole_user_history].end_date
+        FROM [guacamole_user_history]
+        JOIN [guacamole_user] ON [guacamole_user_history].user_id = [guacamole_user].user_id
+        WHERE
+            [guacamole_user].username = #{username,jdbcType=VARCHAR}
+        ORDER BY
+            [guacamole_user_history].start_date DESC,
+            [guacamole_user_history].end_date DESC
+
+    </select>
+
+    <!-- Insert the given user record -->
+    <insert id="insert" useGeneratedKeys="true" keyProperty="record.recordID"
+            parameterType="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+
+        INSERT INTO [guacamole_user_history] (
+            remote_host,
+            user_id,
+            username,
+            start_date,
+            end_date
+        )
+        VALUES (
+            #{record.remoteHost,jdbcType=VARCHAR},
+            (SELECT user_id FROM [guacamole_user]
+             WHERE username = #{record.username,jdbcType=VARCHAR}),
+            #{record.username,jdbcType=VARCHAR},
+            #{record.startDate,jdbcType=TIMESTAMP},
+            #{record.endDate,jdbcType=TIMESTAMP}
+        )
+
+    </insert>
+
+    <!-- Update the given user record -->
+    <update id="update" parameterType="org.apache.guacamole.auth.jdbc.base.ActivityRecordModel">
+        UPDATE [guacamole_user_history]
+        SET remote_host = #{record.remoteHost,jdbcType=VARCHAR},
+            user_id     = (SELECT user_id FROM [guacamole_user]
+                           WHERE username = #{record.username,jdbcType=VARCHAR}),
+            username    = #{record.username,jdbcType=VARCHAR},
+            start_date  = #{record.startDate,jdbcType=TIMESTAMP},
+            end_date    = #{record.endDate,jdbcType=TIMESTAMP}
+        WHERE history_id = #{record.recordID,jdbcType=INTEGER}
+    </update>
+
+    <!-- Search for specific user records -->
+    <select id="search" resultMap="UserRecordResultMap">
+
+        SELECT
+            [guacamole_user_history].remote_host,
+            [guacamole_user_history].user_id,
+            [guacamole_user_history].username,
+            [guacamole_user_history].start_date,
+            [guacamole_user_history].end_date
+        FROM [guacamole_user_history]
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                [guacamole_user_history].user_id IN (
+                    SELECT user_id
+                    FROM [guacamole_user]
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE" value="@org.apache.guacamole.net.auth.ActivityRecordSet$SortableProperty@START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">[guacamole_user_history].start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+    <!-- Search for specific user records -->
+    <select id="searchReadable" resultMap="UserRecordResultMap">
+
+        SELECT
+            [guacamole_user_history].remote_host,
+            [guacamole_user_history].user_id,
+            [guacamole_user_history].username,
+            [guacamole_user_history].start_date,
+            [guacamole_user_history].end_date
+        FROM [guacamole_user_history]
+
+        <!-- Restrict to readable users -->
+        JOIN [guacamole_user_permission] ON
+                [guacamole_user_history].user_id       = [guacamole_user_permission].affected_user_id
+            AND [guacamole_user_permission].user_id    = #{user.objectID,jdbcType=INTEGER}
+            AND [guacamole_user_permission].permission = 'READ'
+
+        <!-- Search terms -->
+        <foreach collection="terms" item="term"
+                 open="WHERE " separator=" AND ">
+            (
+
+                [guacamole_user_history].user_id IN (
+                    SELECT user_id
+                    FROM [guacamole_user]
+                    WHERE POSITION(#{term.term,jdbcType=VARCHAR} IN username) > 0
+                )
+
+                <if test="term.startDate != null and term.endDate != null">
+                    OR start_date BETWEEN #{term.startDate,jdbcType=TIMESTAMP} AND #{term.endDate,jdbcType=TIMESTAMP}
+                </if>
+
+            )
+        </foreach>
+
+        <!-- Bind sort property enum values for sake of readability -->
+        <bind name="START_DATE" value="@org.apache.guacamole.net.auth.ActivityRecordSet$SortableProperty@START_DATE"/>
+
+        <!-- Sort predicates -->
+        <foreach collection="sortPredicates" item="sortPredicate"
+                 open="ORDER BY " separator=", ">
+            <choose>
+                <when test="sortPredicate.property == START_DATE">[guacamole_user_history].start_date</when>
+                <otherwise>1</otherwise>
+            </choose>
+            <if test="sortPredicate.descending">DESC</if>
+        </foreach>
+
+        LIMIT #{limit,jdbcType=INTEGER}
+
+    </select>
+
+</mapper>
diff --git a/extensions/guacamole-auth-jdbc/pom.xml b/extensions/guacamole-auth-jdbc/pom.xml
index 7869c86..e436ea2 100644
--- a/extensions/guacamole-auth-jdbc/pom.xml
+++ b/extensions/guacamole-auth-jdbc/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-auth-jdbc</artifactId>
     <packaging>pom</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-auth-jdbc</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -81,7 +81,7 @@
             <dependency>
                 <groupId>org.apache.guacamole</groupId>
                 <artifactId>guacamole-ext</artifactId>
-                <version>0.9.13-incubating</version>
+                <version>0.9.14</version>
                 <scope>provided</scope>
             </dependency>
 
diff --git a/extensions/guacamole-auth-ldap/pom.xml b/extensions/guacamole-auth-ldap/pom.xml
index 7eef08b..8f3655f 100644
--- a/extensions/guacamole-auth-ldap/pom.xml
+++ b/extensions/guacamole-auth-ldap/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-auth-ldap</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-auth-ldap</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -130,7 +130,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>provided</scope>
         </dependency>
 
diff --git a/extensions/guacamole-auth-ldap/src/licenses/DISCLAIMER b/extensions/guacamole-auth-ldap/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-ldap/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-ldap/src/licenses/NOTICE b/extensions/guacamole-auth-ldap/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-ldap/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-ldap/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConfigurationService.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConfigurationService.java
index c7e4819..2ab7aad 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConfigurationService.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConfigurationService.java
@@ -252,6 +252,24 @@
     }
 
     /**
+     * Returns the boolean value for whether the connection should
+     * follow referrals or not.  By default, it will not.
+     *
+     * @return
+     *     The boolean value of whether to follow referrals
+     *     as configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public boolean getFollowReferrals() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_FOLLOW_REFERRALS,
+            false
+        );
+    }
+
+    /**
      * Returns a set of LDAPSearchConstraints to apply globally
      * to all LDAP searches.
      *
@@ -273,6 +291,23 @@
     }
 
     /**
+     * Returns the maximum number of referral hops to follow.
+     *
+     * @return
+     *     The maximum number of referral hops to follow
+     *     as configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getMaxReferralHops() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_MAX_REFERRAL_HOPS,
+            5
+        );
+    }
+
+    /**
      * Returns the search filter that should be used when querying the
      * LDAP server for Guacamole users.  If no filter is specified,
      * a default of "(objectClass=*)" is returned.
@@ -292,4 +327,21 @@
         );
     }
 
+    /**
+     * Returns the maximum number of seconds to wait for LDAP operations.
+     *
+     * @return
+     *     The maximum number of seconds to wait for LDAP operations
+     *     as configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getOperationTimeout() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_OPERATION_TIMEOUT,
+            30
+        );
+    }
+
 }
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPAuthenticationProvider.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPAuthenticationProvider.java
index f9c4a7d..2d8dfd7 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPAuthenticationProvider.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPAuthenticationProvider.java
@@ -104,6 +104,20 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java
index bf0534c..f849126 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java
@@ -21,12 +21,14 @@
 
 import com.google.inject.Inject;
 import com.novell.ldap.LDAPConnection;
+import com.novell.ldap.LDAPConstraints;
 import com.novell.ldap.LDAPException;
 import com.novell.ldap.LDAPJSSESecureSocketFactory;
 import com.novell.ldap.LDAPJSSEStartTLSFactory;
 import java.io.UnsupportedEncodingException;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
+import org.apache.guacamole.auth.ldap.ReferralAuthHandler;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -111,6 +113,27 @@
         // Obtain appropriately-configured LDAPConnection instance
         LDAPConnection ldapConnection = createLDAPConnection();
 
+        // Configure LDAP connection constraints
+        LDAPConstraints ldapConstraints = ldapConnection.getConstraints();
+        if (ldapConstraints == null)
+          ldapConstraints = new LDAPConstraints();
+
+        // Set whether or not we follow referrals
+        ldapConstraints.setReferralFollowing(confService.getFollowReferrals());
+
+        // Set referral authentication to use the provided credentials.
+        if (userDN != null && !userDN.isEmpty())
+            ldapConstraints.setReferralHandler(new ReferralAuthHandler(userDN, password));
+
+        // Set the maximum number of referrals we follow
+        ldapConstraints.setHopLimit(confService.getMaxReferralHops());
+
+        // Set timelimit to wait for LDAP operations, converting to ms
+        ldapConstraints.setTimeLimit(confService.getOperationTimeout() * 1000);
+
+        // Apply the constraints to the connection
+        ldapConnection.setConstraints(ldapConstraints);
+
         try {
 
             // Connect to LDAP server
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPGuacamoleProperties.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPGuacamoleProperties.java
index e13264d..0d3823f 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPGuacamoleProperties.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPGuacamoleProperties.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.auth.ldap;
 
+import org.apache.guacamole.properties.BooleanGuacamoleProperty;
 import org.apache.guacamole.properties.IntegerGuacamoleProperty;
 import org.apache.guacamole.properties.StringGuacamoleProperty;
 
@@ -174,4 +175,34 @@
 
     };
 
+    /**
+     * Whether or not we should follow referrals.
+     */
+    public static final BooleanGuacamoleProperty LDAP_FOLLOW_REFERRALS = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-follow-referrals"; }
+
+    };
+
+    /**
+     * Maximum number of referral hops to follow.
+     */
+    public static final IntegerGuacamoleProperty LDAP_MAX_REFERRAL_HOPS = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-max-referral-hops"; }
+
+    };
+
+    /**
+     * Number of seconds to wait for LDAP operations to complete.
+     */
+    public static final IntegerGuacamoleProperty LDAP_OPERATION_TIMEOUT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ldap-operation-timeout"; }
+
+    };
+
 }
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ReferralAuthHandler.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ReferralAuthHandler.java
new file mode 100644
index 0000000..e605b3c
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ReferralAuthHandler.java
@@ -0,0 +1,76 @@
+/*
+ * 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.ldap;
+
+import com.google.inject.Inject;
+import com.novell.ldap.LDAPAuthHandler;
+import com.novell.ldap.LDAPAuthProvider;
+import com.novell.ldap.LDAPConnection;
+import java.io.UnsupportedEncodingException;
+import org.apache.guacamole.GuacamoleException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class that implements the necessary authentication handling
+ * for following referrals in LDAP connections.
+ */
+public class ReferralAuthHandler implements LDAPAuthHandler {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(ReferralAuthHandler.class);
+
+    /**
+     * The LDAPAuthProvider object that will be set and returned to the referral handler.
+     */
+    private final LDAPAuthProvider ldapAuth;
+
+    /**
+     * Creates a ReferralAuthHandler object to handle authentication when
+     * following referrals in a LDAP connection, using the provided dn and
+     * password.
+     */
+    public ReferralAuthHandler(String dn, String password) {
+        byte[] passwordBytes;
+        try {
+
+            // Convert password into corresponding byte array
+            if (password != null)
+                passwordBytes = password.getBytes("UTF-8");
+            else
+                passwordBytes = null;
+
+        }   
+        catch (UnsupportedEncodingException e) {
+            logger.error("Unexpected lack of support for UTF-8: {}", e.getMessage());
+            logger.debug("Support for UTF-8 (as required by Java spec) not found.", e); 
+            throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
+        }
+        ldapAuth = new LDAPAuthProvider(dn, passwordBytes);
+    }
+
+    @Override
+    public LDAPAuthProvider getAuthProvider(String host, int port) {
+        return ldapAuth;
+    }
+
+}
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/connection/ConnectionService.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/connection/ConnectionService.java
index eea1a95..3ce00e3 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/connection/ConnectionService.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/connection/ConnectionService.java
@@ -24,6 +24,7 @@
 import com.novell.ldap.LDAPConnection;
 import com.novell.ldap.LDAPEntry;
 import com.novell.ldap.LDAPException;
+import com.novell.ldap.LDAPReferralException;
 import com.novell.ldap.LDAPSearchResults;
 import java.util.Collections;
 import java.util.Enumeration;
@@ -129,62 +130,79 @@
             Map<String, Connection> connections = new HashMap<String, Connection>();
             while (results.hasMore()) {
 
-                LDAPEntry entry = results.next();
+                try {
 
-                // Get common name (CN)
-                LDAPAttribute cn = entry.getAttribute("cn");
-                if (cn == null) {
-                    logger.warn("guacConfigGroup is missing a cn.");
-                    continue;
-                }
+                    LDAPEntry entry = results.next();
 
-                // Get associated protocol
-                LDAPAttribute protocol = entry.getAttribute("guacConfigProtocol");
-                if (protocol == null) {
-                    logger.warn("guacConfigGroup \"{}\" is missing the "
-                              + "required \"guacConfigProtocol\" attribute.",
-                            cn.getStringValue());
-                    continue;
-                }
+                    // Get common name (CN)
+                    LDAPAttribute cn = entry.getAttribute("cn");
+                    if (cn == null) {
+                        logger.warn("guacConfigGroup is missing a cn.");
+                        continue;
+                    }
 
-                // Set protocol
-                GuacamoleConfiguration config = new GuacamoleConfiguration();
-                config.setProtocol(protocol.getStringValue());
+                    // Get associated protocol
+                    LDAPAttribute protocol = entry.getAttribute("guacConfigProtocol");
+                    if (protocol == null) {
+                        logger.warn("guacConfigGroup \"{}\" is missing the "
+                                  + "required \"guacConfigProtocol\" attribute.",
+                                cn.getStringValue());
+                        continue;
+                    }
 
-                // Get parameters, if any
-                LDAPAttribute parameterAttribute = entry.getAttribute("guacConfigParameter");
-                if (parameterAttribute != null) {
+                    // Set protocol
+                    GuacamoleConfiguration config = new GuacamoleConfiguration();
+                    config.setProtocol(protocol.getStringValue());
 
-                    // For each parameter
-                    Enumeration<?> parameters = parameterAttribute.getStringValues();
-                    while (parameters.hasMoreElements()) {
+                    // Get parameters, if any
+                    LDAPAttribute parameterAttribute = entry.getAttribute("guacConfigParameter");
+                    if (parameterAttribute != null) {
 
-                        String parameter = (String) parameters.nextElement();
+                        // For each parameter
+                        Enumeration<?> parameters = parameterAttribute.getStringValues();
+                        while (parameters.hasMoreElements()) {
 
-                        // Parse parameter
-                        int equals = parameter.indexOf('=');
-                        if (equals != -1) {
+                            String parameter = (String) parameters.nextElement();
 
-                            // Parse name
-                            String name = parameter.substring(0, equals);
-                            String value = parameter.substring(equals+1);
+                            // Parse parameter
+                            int equals = parameter.indexOf('=');
+                            if (equals != -1) {
 
-                            config.setParameter(name, value);
+                                // Parse name
+                                String name = parameter.substring(0, equals);
+                                String value = parameter.substring(equals+1);
+
+                                config.setParameter(name, value);
+
+                            }
 
                         }
 
                     }
 
+                    // Filter the configuration, substituting all defined tokens
+                    tokenFilter.filterValues(config.getParameters());
+
+                    // Store connection using cn for both identifier and name
+                    String name = cn.getStringValue();
+                    Connection connection = new SimpleConnection(name, name, config);
+                    connection.setParentIdentifier(LDAPAuthenticationProvider.ROOT_CONNECTION_GROUP);
+                    connections.put(name, connection);
+
                 }
 
-                // Filter the configuration, substituting all defined tokens
-                tokenFilter.filterValues(config.getParameters());
-
-                // Store connection using cn for both identifier and name
-                String name = cn.getStringValue();
-                Connection connection = new SimpleConnection(name, name, config);
-                connection.setParentIdentifier(LDAPAuthenticationProvider.ROOT_CONNECTION_GROUP);
-                connections.put(name, connection);
+                // Deal with issues following LDAP referrals
+                catch (LDAPReferralException e) {
+                    if (confService.getFollowReferrals()) {
+                        logger.error("Could not follow referral: {}", e.getFailedReferral());
+                        logger.debug("Error encountered trying to follow referral.", e);
+                        throw new GuacamoleServerException("Could not follow LDAP referral.", e);
+                    }
+                    else {
+                        logger.warn("Given a referral, but referrals are disabled. Error was: {}", e.getMessage());
+                        logger.debug("Got a referral, but configured to not follow them.", e);
+                    }
+                }
 
             }
 
@@ -251,8 +269,22 @@
             // The guacConfig group uses the seeAlso attribute to refer
             // to these other groups
             while (userRoleGroupResults.hasMore()) {
-                LDAPEntry entry = userRoleGroupResults.next();
-                connectionSearchFilter.append("(seeAlso=").append(escapingService.escapeLDAPSearchFilter(entry.getDN())).append(")");
+                try {
+                    LDAPEntry entry = userRoleGroupResults.next();
+                    connectionSearchFilter.append("(seeAlso=").append(escapingService.escapeLDAPSearchFilter(entry.getDN())).append(")");
+                }
+
+                catch (LDAPReferralException e) {
+                    if (confService.getFollowReferrals()) {
+                        logger.error("Could not follow referral: {}", e.getFailedReferral());
+                        logger.debug("Error encountered trying to follow referral.", e);
+                        throw new GuacamoleServerException("Could not follow LDAP referral.", e);
+                    }
+                    else {
+                        logger.warn("Given a referral, but referrals are disabled. Error was: {}", e.getMessage());
+                        logger.debug("Got a referral, but configured to not follow them.", e);
+                    }
+                }
             }
         }
 
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/user/UserService.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/user/UserService.java
index 91f1636..9d27f1e 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/user/UserService.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/user/UserService.java
@@ -24,6 +24,7 @@
 import com.novell.ldap.LDAPConnection;
 import com.novell.ldap.LDAPEntry;
 import com.novell.ldap.LDAPException;
+import com.novell.ldap.LDAPReferralException;
 import com.novell.ldap.LDAPSearchResults;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -107,19 +108,36 @@
             // Read all visible users
             while (results.hasMore()) {
 
-                LDAPEntry entry = results.next();
+                try {
 
-                // Get username from record
-                LDAPAttribute username = entry.getAttribute(usernameAttribute);
-                if (username == null) {
-                    logger.warn("Queried user is missing the username attribute \"{}\".", usernameAttribute);
-                    continue;
+                    LDAPEntry entry = results.next();
+
+                    // Get username from record
+                    LDAPAttribute username = entry.getAttribute(usernameAttribute);
+                    if (username == null) {
+                        logger.warn("Queried user is missing the username attribute \"{}\".", usernameAttribute);
+                        continue;
+                    }
+
+                    // Store user using their username as the identifier
+                    String identifier = username.getStringValue();
+                    if (users.put(identifier, new SimpleUser(identifier)) != null)
+                        logger.warn("Possibly ambiguous user account: \"{}\".", identifier);
+
                 }
 
-                // Store user using their username as the identifier
-                String identifier = username.getStringValue();
-                if (users.put(identifier, new SimpleUser(identifier)) != null)
-                    logger.warn("Possibly ambiguous user account: \"{}\".", identifier);
+                // Deal with errors trying to follow referrals
+                catch (LDAPReferralException e) {
+                    if (confService.getFollowReferrals()) {
+                        logger.error("Could not follow referral: {}", e.getFailedReferral());
+                        logger.debug("Error encountered trying to follow referral.", e);
+                        throw new GuacamoleServerException("Could not follow LDAP referral.", e);
+                    }
+                    else {
+                        logger.warn("Given a referral, but referrals are disabled. Error was: {}", e.getMessage());
+                        logger.debug("Got a referral, but configured to not follow them.", e);
+                    }
+                }
 
             }
 
@@ -267,8 +285,23 @@
 
             // Add all DNs for found users
             while (results.hasMore()) {
-                LDAPEntry entry = results.next();
-                userDNs.add(entry.getDN());
+                try {
+                    LDAPEntry entry = results.next();
+                    userDNs.add(entry.getDN());
+                }
+          
+                // Deal with errors following referrals
+                catch (LDAPReferralException e) {
+                    if (confService.getFollowReferrals()) {
+                        logger.error("Error trying to follow a referral: {}", e.getFailedReferral());
+                        logger.debug("Encountered an error trying to follow a referral.", e);
+                        throw new GuacamoleServerException("Failed while trying to follow referrals.", e);
+                    }
+                    else {
+                        logger.warn("Given a referral, not following it. Error was: {}", e.getMessage());
+                        logger.debug("Given a referral, but configured to not follow them.", e);
+                    }
+                }
             }
 
             // Return all discovered DNs (if any)
diff --git a/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json
index 6d3a9e3..639c2e1 100644
--- a/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-ldap/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "LDAP Authentication",
     "namespace" : "guac-ldap",
diff --git a/extensions/guacamole-auth-noauth/.gitignore b/extensions/guacamole-auth-noauth/.gitignore
deleted file mode 100644
index 42f4a1a..0000000
--- a/extensions/guacamole-auth-noauth/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-target/
-*~
diff --git a/extensions/guacamole-auth-noauth/doc/example/noauth-config.xml b/extensions/guacamole-auth-noauth/doc/example/noauth-config.xml
deleted file mode 100644
index 0063f70..0000000
--- a/extensions/guacamole-auth-noauth/doc/example/noauth-config.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-    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.
--->
-<configs>
-    <config name="myconfig" protocol="rdp">
-        <param name="hostname" value="rdp-server" />
-        <param name="port" value="3389" />
-    </config>
-</configs>
diff --git a/extensions/guacamole-auth-noauth/pom.xml b/extensions/guacamole-auth-noauth/pom.xml
deleted file mode 100644
index 65a43be..0000000
--- a/extensions/guacamole-auth-noauth/pom.xml
+++ /dev/null
@@ -1,139 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-    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.
--->
-<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-noauth</artifactId>
-    <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
-    <name>guacamole-auth-noauth</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>
-
-            <!-- Assembly plugin - for easy distribution -->
-            <plugin>
-                <artifactId>maven-assembly-plugin</artifactId>
-                <version>2.5.3</version>
-                <configuration>
-                    <finalName>${project.artifactId}-${project.version}</finalName>
-                    <appendAssemblyId>false</appendAssemblyId>
-                    <descriptors>
-                        <descriptor>src/main/assembly/dist.xml</descriptor>
-                    </descriptors>
-                </configuration>
-                <executions>
-                    <execution>
-                        <id>make-dist-archive</id>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>single</goal>
-                        </goals>
-                    </execution>
-                </executions>
-            </plugin>
-
-            <!-- Verify format using Apache RAT -->
-            <plugin>
-                <groupId>org.apache.rat</groupId>
-                <artifactId>apache-rat-plugin</artifactId>
-                <version>0.12</version>
-
-                <configuration>
-                    <excludes>
-                        <exclude>**/*.json</exclude>
-                        <exclude>src/licenses/**/*</exclude>
-                    </excludes>
-                </configuration>
-
-                <!-- Bind RAT to validate phase -->
-                <executions>
-                    <execution>
-                        <id>validate</id>
-                        <phase>validate</phase>
-                        <goals>
-                            <goal>check</goal>
-                        </goals>
-                    </execution>
-                </executions>
-
-            </plugin>
-
-        </plugins>
-    </build>
-
-    <dependencies>
-
-        <!-- Guacamole Extension API -->
-        <dependency>
-            <groupId>org.apache.guacamole</groupId>
-            <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
-            <scope>provided</scope>
-        </dependency>
-
-    </dependencies>
-
-</project>
diff --git a/extensions/guacamole-auth-noauth/src/licenses/DISCLAIMER b/extensions/guacamole-auth-noauth/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-noauth/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-noauth/src/main/java/org/apache/guacamole/auth/noauth/NoAuthConfigContentHandler.java b/extensions/guacamole-auth-noauth/src/main/java/org/apache/guacamole/auth/noauth/NoAuthConfigContentHandler.java
deleted file mode 100644
index abf823f..0000000
--- a/extensions/guacamole-auth-noauth/src/main/java/org/apache/guacamole/auth/noauth/NoAuthConfigContentHandler.java
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.guacamole.auth.noauth;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.guacamole.protocol.GuacamoleConfiguration;
-import org.xml.sax.Attributes;
-import org.xml.sax.SAXException;
-import org.xml.sax.helpers.DefaultHandler;
-
-/**
- * XML parser for the configuration file used by the NoAuth auth provider.
- */
-public class NoAuthConfigContentHandler extends DefaultHandler {
-
-    /**
-     * Map of all configurations, indexed by name.
-     */
-    private Map<String, GuacamoleConfiguration> configs = new HashMap<String, GuacamoleConfiguration>();
-
-    /**
-     * The name of the current configuration, if any.
-     */
-    private String current = null;
-
-    /**
-     * The current configuration being parsed, if any.
-     */
-    private GuacamoleConfiguration currentConfig = null;
-
-    /**
-     * Returns the a map of all available configurations as parsed from the
-     * XML file. This map is unmodifiable.
-     *
-     * @return A map of all available configurations.
-     */
-    public Map<String, GuacamoleConfiguration> getConfigs() {
-        return Collections.unmodifiableMap(configs);
-    }
-
-    @Override
-    public void endElement(String uri, String localName, String qName) throws SAXException {
-
-        // If end of config element, add to map
-        if (localName.equals("config")) {
-
-            // Add to map
-            configs.put(current, currentConfig);
-
-            // Reset state for next configuration
-            currentConfig = null;
-            current = null;
-
-        }
-
-    }
-
-    @Override
-    public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
-
-        // Begin configuration parsing if config element
-        if (localName.equals("config")) {
-
-            // Ensure this config is on the top level
-            if (current != null)
-                throw new SAXException("Configurations cannot be nested.");
-
-            // Read name
-            String name = attributes.getValue("name");
-            if (name == null)
-                throw new SAXException("Each configuration must have a name.");
-
-            // Read protocol
-            String protocol = attributes.getValue("protocol");
-            if (protocol == null)
-                throw new SAXException("Each configuration must have a protocol.");
-
-            // Create config stub
-            current = name;
-            currentConfig = new GuacamoleConfiguration();
-            currentConfig.setProtocol(protocol);
-
-        }
-
-        // Add parameters to existing configuration
-        else if (localName.equals("param")) {
-
-            // Ensure a corresponding config exists
-            if (currentConfig == null)
-                throw new SAXException("Parameter without corresponding configuration.");
-
-            currentConfig.setParameter(attributes.getValue("name"), attributes.getValue("value"));
-
-        }
-
-    }
-
-}
diff --git a/extensions/guacamole-auth-noauth/src/main/java/org/apache/guacamole/auth/noauth/NoAuthenticationProvider.java b/extensions/guacamole-auth-noauth/src/main/java/org/apache/guacamole/auth/noauth/NoAuthenticationProvider.java
deleted file mode 100644
index bf665b5..0000000
--- a/extensions/guacamole-auth-noauth/src/main/java/org/apache/guacamole/auth/noauth/NoAuthenticationProvider.java
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.guacamole.auth.noauth;
-
-import java.util.Map;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.io.IOException;
-import java.io.Reader;
-import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.GuacamoleServerException;
-import org.apache.guacamole.environment.Environment;
-import org.apache.guacamole.environment.LocalEnvironment;
-import org.apache.guacamole.net.auth.simple.SimpleAuthenticationProvider;
-import org.apache.guacamole.net.auth.Credentials;
-import org.apache.guacamole.properties.FileGuacamoleProperty;
-import org.apache.guacamole.protocol.GuacamoleConfiguration;
-import org.slf4j.LoggerFactory;
-import org.slf4j.Logger;
-import org.xml.sax.InputSource;
-import org.xml.sax.SAXException;
-import org.xml.sax.XMLReader;
-import org.xml.sax.helpers.XMLReaderFactory;
-
-/**
- * Disable authentication in Guacamole. All users accessing Guacamole are
- * automatically authenticated as "Anonymous" user and are able to use all
- * available GuacamoleConfigurations.
- *
- * GuacamoleConfiguration are read from the XML file defined by `noauth-config`
- * in the Guacamole configuration file (`guacamole.properties`).
- *
- *
- * Example `guacamole.properties`:
- *
- *  noauth-config: /etc/guacamole/noauth-config.xml
- *
- *
- * Example `noauth-config.xml`:
- *
- *  <configs>
- *    <config name="my-rdp-server" protocol="rdp">
- *      <param name="hostname" value="my-rdp-server-hostname" />
- *      <param name="port" value="3389" />
- *    </config>
- *  </configs>
- */
-@Deprecated
-public class NoAuthenticationProvider extends SimpleAuthenticationProvider {
-
-    /**
-     * Logger for this class.
-     */
-    private Logger logger = LoggerFactory.getLogger(NoAuthenticationProvider.class);
-
-    /**
-     * Map of all known configurations, indexed by identifier.
-     */
-    private Map<String, GuacamoleConfiguration> configs;
-
-    /**
-     * The last time the configuration XML was modified, as milliseconds since
-     * UNIX epoch.
-     */
-    private long configTime;
-
-    /**
-     * Guacamole server environment.
-     */
-    private final Environment environment;
-    
-    /**
-     * The XML file to read the configuration from.
-     */
-    public static final FileGuacamoleProperty NOAUTH_CONFIG = new FileGuacamoleProperty() {
-
-        @Override
-        public String getName() {
-            return "noauth-config";
-        }
-
-    };
-
-    /**
-     * The default filename to use for the configuration, if not defined within
-     * guacamole.properties.
-     */
-    public static final String DEFAULT_NOAUTH_CONFIG = "noauth-config.xml";
-
-    /**
-     * Creates a new NoAuthenticationProvider that does not perform any
-     * authentication at all. All attempts to access the Guacamole system are
-     * presumed to be authorized.
-     *
-     * @throws GuacamoleException
-     *     If a required property is missing, or an error occurs while parsing
-     *     a property.
-     */
-    public NoAuthenticationProvider() throws GuacamoleException {
-        environment = new LocalEnvironment();
-        logger.warn("The \"NoAuth\" extension is **DEPRECATED**! This "
-                + "extension will be removed from the Guacamole codebase "
-                + "entirely in a future release. Please consider writing an "
-                + "extension using Guacamole's extension API instead.");
-    }
-
-    @Override
-    public String getIdentifier() {
-        return "noauth";
-    }
-
-    /**
-     * Retrieves the configuration file, as defined within guacamole.properties.
-     *
-     * @return The configuration file, as defined within guacamole.properties.
-     * @throws GuacamoleException If an error occurs while reading the
-     *                            property.
-     */
-    private File getConfigurationFile() throws GuacamoleException {
-
-        // Get config file, defaulting to GUACAMOLE_HOME/noauth-config.xml
-        File configFile = environment.getProperty(NOAUTH_CONFIG);
-        if (configFile == null)
-            configFile = new File(environment.getGuacamoleHome(), DEFAULT_NOAUTH_CONFIG);
-
-        return configFile;
-
-    }
-
-    public synchronized void init() throws GuacamoleException {
-
-        // Get configuration file
-        File configFile = getConfigurationFile();
-        logger.debug("Reading configuration file: \"{}\"", configFile);
-
-        // Parse document
-        try {
-
-            // Set up parser
-            NoAuthConfigContentHandler contentHandler = new NoAuthConfigContentHandler();
-
-            XMLReader parser = XMLReaderFactory.createXMLReader();
-            parser.setContentHandler(contentHandler);
-
-            // Read and parse file
-            Reader reader = new BufferedReader(new FileReader(configFile));
-            parser.parse(new InputSource(reader));
-            reader.close();
-
-            // Init configs
-            configTime = configFile.lastModified();
-            configs = contentHandler.getConfigs();
-
-        }
-        catch (IOException e) {
-            throw new GuacamoleServerException("Error reading configuration file.", e);
-        }
-        catch (SAXException e) {
-            throw new GuacamoleServerException("Error parsing XML file.", e);
-        }
-
-    }
-
-    @Override
-    public Map<String, GuacamoleConfiguration> getAuthorizedConfigurations(Credentials credentials) throws GuacamoleException {
-
-        // Check mapping file mod time
-        File configFile = getConfigurationFile();
-        if (configFile.exists() && configTime < configFile.lastModified()) {
-
-            // If modified recently, gain exclusive access and recheck
-            synchronized (this) {
-                if (configFile.exists() && configTime < configFile.lastModified()) {
-                    logger.debug("Configuration file \"{}\" has been modified.", configFile);
-                    init(); // If still not up to date, re-init
-                }
-            }
-
-        }
-
-        // If no mapping available, report as such
-        if (configs == null)
-            throw new GuacamoleServerException("Configuration could not be read.");
-
-        return configs;
-
-    }
-}
diff --git a/extensions/guacamole-auth-noauth/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-noauth/src/main/resources/guac-manifest.json
deleted file mode 100644
index 8df24f6..0000000
--- a/extensions/guacamole-auth-noauth/src/main/resources/guac-manifest.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-
-    "guacamoleVersion" : "0.9.13-incubating",
-
-    "name"      : "Disabled Authentication (DEPRECATED)",
-    "namespace" : "guac-noauth",
-
-    "authProviders" : [
-        "org.apache.guacamole.auth.noauth.NoAuthenticationProvider"
-    ],
-
-    "translations" : [
-        "translations/en.json"
-    ]
-
-}
diff --git a/extensions/guacamole-auth-noauth/src/main/resources/translations/en.json b/extensions/guacamole-auth-noauth/src/main/resources/translations/en.json
deleted file mode 100644
index f755bd7..0000000
--- a/extensions/guacamole-auth-noauth/src/main/resources/translations/en.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-
-    "DATA_SOURCE_NOAUTH" : {
-        "NAME" : "NoAuth"
-    }
-
-}
diff --git a/extensions/guacamole-auth-openid/pom.xml b/extensions/guacamole-auth-openid/pom.xml
index 4076998..ccac0a3 100644
--- a/extensions/guacamole-auth-openid/pom.xml
+++ b/extensions/guacamole-auth-openid/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-auth-openid</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-auth-openid</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -210,7 +210,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>provided</scope>
         </dependency>
 
diff --git a/extensions/guacamole-auth-openid/src/licenses/DISCLAIMER b/extensions/guacamole-auth-openid/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/extensions/guacamole-auth-openid/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/extensions/guacamole-auth-openid/src/licenses/NOTICE b/extensions/guacamole-auth-openid/src/licenses/NOTICE
index 2ef7e54..97e6130 100644
--- a/extensions/guacamole-auth-openid/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-openid/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2016 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java b/extensions/guacamole-auth-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java
index 57b4831..cf0b96e 100644
--- a/extensions/guacamole-auth-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java
+++ b/extensions/guacamole-auth-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProvider.java
@@ -108,6 +108,20 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json
index e71c140..aacca86 100644
--- a/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-openid/src/main/resources/guac-manifest.json
@@ -1,6 +1,6 @@
 {
 
-    "guacamoleVersion" : "0.9.13-incubating",
+    "guacamoleVersion" : "0.9.14",
 
     "name"      : "OpenID Authentication Extension",
     "namespace" : "guac-openid",
diff --git a/extensions/guacamole-auth-radius/.gitignore b/extensions/guacamole-auth-radius/.gitignore
new file mode 100644
index 0000000..1de9633
--- /dev/null
+++ b/extensions/guacamole-auth-radius/.gitignore
@@ -0,0 +1,3 @@
+src/main/resources/generated/
+target/
+*~
diff --git a/extensions/guacamole-auth-radius/pom.xml b/extensions/guacamole-auth-radius/pom.xml
new file mode 100644
index 0000000..d46e0b1
--- /dev/null
+++ b/extensions/guacamole-auth-radius/pom.xml
@@ -0,0 +1,236 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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.
+-->
+<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-radius</artifactId>
+    <packaging>jar</packaging>
+    <version>0.9.14</version>
+    <name>guacamole-auth-radius</name>
+    <url>http://guacamole.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>
+
+            <!-- Pre-cache Angular templates with maven-angular-plugin -->
+            <plugin>
+                <groupId>com.keithbranton.mojo</groupId>
+                <artifactId>angular-maven-plugin</artifactId>
+                <version>0.3.2</version>
+                <executions>
+                    <execution>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>html2js</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <sourceDir>${basedir}/src/main/resources</sourceDir>
+                    <include>**/*.html</include>
+                    <target>${basedir}/src/main/resources/generated/templates-main/templates.js</target>
+                    <prefix>app/ext/radius</prefix>
+                </configuration>
+            </plugin>
+
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.samaxes.maven</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <version>1.7.5</version>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <configuration>
+                            <charset>UTF-8</charset>
+
+                            <webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
+                            <webappTargetDir>${project.build.directory}/classes</webappTargetDir>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>radius.js</jsFinalFile>
+
+                            <jsSourceFiles>
+                                <jsSourceFile>license.txt</jsSourceFile>
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                            <!-- Do not minify and include tests -->
+                            <jsSourceExcludes>
+                                <jsSourceExclude>**/*.test.js</jsSourceExclude>
+                            </jsSourceExcludes>
+                            <jsEngine>CLOSURE</jsEngine>
+
+                            <!-- Disable warnings for JSDoc annotations -->
+                            <closureWarningLevels>
+                                <misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
+                                <nonStandardJsDocs>OFF</nonStandardJsDocs>
+                            </closureWarningLevels>
+
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+
+            <!-- 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>
+                            <excludes>META-INF/*.SF,META-INF/*.DSA</excludes>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
+            <!-- Verify format using Apache RAT -->
+            <plugin>
+                <groupId>org.apache.rat</groupId>
+                <artifactId>apache-rat-plugin</artifactId>
+                <version>0.12</version>
+
+                <configuration>
+                    <excludes>
+                        <exclude>**/*.json</exclude>
+                        <exclude>src/licenses/**/*</exclude>
+                        <exclude>src/main/resources/templates/*.html</exclude>
+                    </excludes>
+                </configuration>
+
+                <!-- Bind RAT to validate phase -->
+                <executions>
+                    <execution>
+                        <id>validate</id>
+                        <phase>validate</phase>
+                        <goals>
+                            <goal>check</goal>
+                        </goals>
+                    </execution>
+                </executions>
+
+            </plugin>
+
+        </plugins>
+    </build>
+
+    <dependencies>
+
+        <!-- Guacamole Java API -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-common</artifactId>
+            <version>0.9.14</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+            <version>0.9.14</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>
+
+        <!-- JRadius Core Library -->
+        <dependency>
+            <groupId>net.jradius</groupId>
+            <artifactId>jradius-core</artifactId>
+            <version>1.1.5</version>
+        </dependency>
+
+        <!-- JRadius Dictionary, for accessing packet attributes -->
+        <dependency>
+            <groupId>net.jradius</groupId>
+            <artifactId>jradius-dictionary</artifactId>
+            <version>1.1.5</version>
+        </dependency>
+
+        <!-- JRadius Extended, which inlcudes the SSL/TLS protocols -->
+        <dependency>
+            <groupId>net.jradius</groupId>
+            <artifactId>jradius-extended</artifactId>
+            <version>1.1.5</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-radius/src/licenses/LICENSE
similarity index 87%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-radius/src/licenses/LICENSE
index 6b0b127..7ad231d 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-radius/src/licenses/LICENSE
@@ -201,3 +201,57 @@
    See the License for the specific language governing permissions and
    limitations under the License.
 
+
+==============================================================================
+
+APACHE GUACAMOLE SUBCOMPONENTS
+
+Apache Guacamole includes a number of subcomponents with separate copyright
+notices and license terms. Your use of these subcomponents is subject to the
+terms and conditions of the following licenses.
+
+
+AOP Alliance (http://aopalliance.sourceforge.net/)
+--------------------------------------------------
+
+    Version: 1.0
+    From: 'AOP Alliance' (http://aopalliance.sourceforge.net/members.html)
+    License(s):
+        Public Domain (bundled/aopalliance-1.0/LICENSE)
+
+
+BouncyCastle Crypto (https://www.bouncycastle.org/java.html)
+------------------------------------------------------------
+
+    Version: 1.44
+    From: 'The Legion of Bouncy Caslte' (https://www.bouncycastle.org)
+    License(s):
+        The Bouncy Castle License (bundled/bouncycastle-1.44/LICENSE
+
+
+Google Guice (https://github.com/google/guice)
+----------------------------------------------
+
+    Version: 3.0
+    From: 'Google Inc.' (http://www.google.com/)
+    License(s):
+        Apache v2.0 (bundled/guice-3.0/COPYING)
+
+
+JRadius (https://github.com/coova/jradius)
+------------------------------------------
+
+    Version: 1.1.5
+    From: 'Coova Technologies, LLC' (https://coova.github.io)
+    License(s):
+        LGPL-2.1 (bundled/jradius-1.1.5/LICENSE)
+
+
+JSR-330 / Dependency Injection for Java (http://code.google.com/p/atinject/)
+----------------------------------------------------------------------------
+
+    Version: 1
+    From: 'JSR-330 Expert Group' (https://jcp.org/en/jsr/detail?id=330)
+    License(s):
+        Apache v2.0 (bundled/javax.inject-1/LICENSE-2.0.txt)
+
diff --git a/extensions/guacamole-auth-noauth/src/licenses/NOTICE b/extensions/guacamole-auth-radius/src/licenses/NOTICE
similarity index 72%
rename from extensions/guacamole-auth-noauth/src/licenses/NOTICE
rename to extensions/guacamole-auth-radius/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-radius/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-radius/src/licenses/bundled/README b/extensions/guacamole-auth-radius/src/licenses/bundled/README
new file mode 100644
index 0000000..47ba19d
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/licenses/bundled/README
@@ -0,0 +1,4 @@
+Apache Guacamole includes a number of subcomponents with separate copyright
+notices and license terms. Your use of these subcomponents is subject to the
+terms and conditions of their respective licenses, included within this
+directory for reference.
diff --git a/extensions/guacamole-auth-radius/src/licenses/bundled/aopalliance-1.0/LICENSE b/extensions/guacamole-auth-radius/src/licenses/bundled/aopalliance-1.0/LICENSE
new file mode 100644
index 0000000..8e0e378
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/licenses/bundled/aopalliance-1.0/LICENSE
@@ -0,0 +1,4 @@
+From http://aopalliance.sourceforge.net/:
+
+    LICENCE: all the source code provided by AOP Alliance is Public Domain.
+
diff --git a/extensions/guacamole-auth-radius/src/licenses/bundled/bouncycastle-1.44/LICENSE b/extensions/guacamole-auth-radius/src/licenses/bundled/bouncycastle-1.44/LICENSE
new file mode 100644
index 0000000..15c29d9
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/licenses/bundled/bouncycastle-1.44/LICENSE
@@ -0,0 +1,7 @@
+Copyright (c) 2000 - 2016 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/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-radius/src/licenses/bundled/guice-3.0/COPYING
similarity index 99%
rename from extensions/guacamole-auth-noauth/src/licenses/LICENSE
rename to extensions/guacamole-auth-radius/src/licenses/bundled/guice-3.0/COPYING
index 6b0b127..d645695 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-radius/src/licenses/bundled/guice-3.0/COPYING
@@ -200,4 +200,3 @@
    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.
-
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-radius/src/licenses/bundled/javax.inject-1/LICENSE-2.0.txt
similarity index 99%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-radius/src/licenses/bundled/javax.inject-1/LICENSE-2.0.txt
index 6b0b127..d645695 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-radius/src/licenses/bundled/javax.inject-1/LICENSE-2.0.txt
@@ -200,4 +200,3 @@
    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.
-
diff --git a/extensions/guacamole-auth-radius/src/licenses/bundled/jradius-1.1.5/LICENSE b/extensions/guacamole-auth-radius/src/licenses/bundled/jradius-1.1.5/LICENSE
new file mode 100644
index 0000000..ff1be90
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/licenses/bundled/jradius-1.1.5/LICENSE
@@ -0,0 +1,545 @@
+==========================================================================
+
+Coova JRadius License:
+
+The Coova JRadius core libraries are licensed under the The GNU
+Library or "Lesser" General Public License (LGPL), while stand-alone
+applications within in the JRadius project, listed below, are released 
+under the standard The GNU General Public License (GPL). For details, 
+visit http://www.coova.org/JRadius. See source files for details 
+regarding stand-alone applications contained herein which are 
+released under the GPL. 
+
+Copyright 2006-2010 Coova Technologies, LLC. All rights reserved.
+
+See doc/GPL-LICENSE, doc/LGPL-LICENSE, and source files for details.
+
+==========================================================================
+
+Original JRadius License:
+
+The JRadius core library is licensed under the The GNU Library or "Lesser"
+General Public License (LGPL), while stand-alone applications within in the
+JRadius project, listed below, are released under the standard The GNU
+General Public License (GPL). For details, visit http://jradius.net/
+
+This software is OSI Certified Open Source Software. OSI Certified is a
+certification mark of the Open Source Initiative.
+
+Stand-alone JRadius Applications:
+
+    * JRadiusSimulator
+    * JRadiusWiFiClient
+    * RadClient
+    * RadBench
+    * RadiusDictionary 
+
+Copyright 2004-2006 PicoPoint, B.V.
+Copyright 2006-2008 David Bird <david@coova.com>
+
+See doc/GPL-LICENSE, doc/LGPL-LICENSE, and source files for details.
+
+==========================================================================
+
+GNU LESSER GENERAL PUBLIC LICENSE
+                       Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+(This is the first released version of the Lesser GPL.  It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.)
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+  This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it.  You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+  When we speak of free software, we are referring to freedom of use,
+not price.  Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+  To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights.  These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+  For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you.  You must make sure that they, too, receive or can get the source
+code.  If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it.  And you must show them these terms so they know their rights.
+
+  We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+  To protect each distributor, we want to make it very clear that
+there is no warranty for the free library.  Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+  Finally, software patents pose a constant threat to the existence of
+any free program.  We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder.  Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+  Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License.  This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License.  We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+  When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library.  The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom.  The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+  We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License.  It also provides other free software developers Less
+of an advantage over competing non-free programs.  These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries.  However, the Lesser license provides advantages in certain
+special circumstances.
+
+  For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard.  To achieve this, non-free programs must be
+allowed to use the library.  A more frequent case is that a free
+library does the same job as widely used non-free libraries.  In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+  In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software.  For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+  Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.  Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library".  The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+                  GNU LESSER GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+  A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+  The "Library", below, refers to any such software library or work
+which has been distributed under these terms.  A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language.  (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+  "Source code" for a work means the preferred form of the work for
+making modifications to it.  For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+  Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it).  Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+  1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+  You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+  2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) The modified work must itself be a software library.
+
+    b) You must cause the files modified to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    c) You must cause the whole of the work to be licensed at no
+    charge to all third parties under the terms of this License.
+
+    d) If a facility in the modified Library refers to a function or a
+    table of data to be supplied by an application program that uses
+    the facility, other than as an argument passed when the facility
+    is invoked, then you must make a good faith effort to ensure that,
+    in the event an application does not supply such function or
+    table, the facility still operates, and performs whatever part of
+    its purpose remains meaningful.
+
+    (For example, a function in a library to compute square roots has
+    a purpose that is entirely well-defined independent of the
+    application.  Therefore, Subsection 2d requires that any
+    application-supplied function or table used by this function must
+    be optional: if the application does not supply it, the square
+    root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library.  To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License.  (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.)  Do not make any other change in
+these notices.
+
+  Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+  This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+  4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+  If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library".  Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+  However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library".  The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+  When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library.  The
+threshold for this to be true is not precisely defined by law.
+
+  If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work.  (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+  Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+  6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+  You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License.  You must supply a copy of this License.  If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License.  Also, you must do one
+of these things:
+
+    a) Accompany the work with the complete corresponding
+    machine-readable source code for the Library including whatever
+    changes were used in the work (which must be distributed under
+    Sections 1 and 2 above); and, if the work is an executable linked
+    with the Library, with the complete machine-readable "work that
+    uses the Library", as object code and/or source code, so that the
+    user can modify the Library and then relink to produce a modified
+    executable containing the modified Library.  (It is understood
+    that the user who changes the contents of definitions files in the
+    Library will not necessarily be able to recompile the application
+    to use the modified definitions.)
+
+    b) Use a suitable shared library mechanism for linking with the
+    Library.  A suitable mechanism is one that (1) uses at run time a
+    copy of the library already present on the user's computer system,
+    rather than copying library functions into the executable, and (2)
+    will operate properly with a modified version of the library, if
+    the user installs one, as long as the modified version is
+    interface-compatible with the version that the work was made with.
+
+    c) Accompany the work with a written offer, valid for at
+    least three years, to give the same user the materials
+    specified in Subsection 6a, above, for a charge no more
+    than the cost of performing this distribution.
+
+    d) If distribution of the work is made by offering access to copy
+    from a designated place, offer equivalent access to copy the above
+    specified materials from the same place.
+
+    e) Verify that the user has already received a copy of these
+    materials or that you have already sent this user a copy.
+
+  For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it.  However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+  It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system.  Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+  7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+    a) Accompany the combined library with a copy of the same work
+    based on the Library, uncombined with any other library
+    facilities.  This must be distributed under the terms of the
+    Sections above.
+
+    b) Give prominent notice with the combined library of the fact
+    that part of it is a work based on the Library, and explaining
+    where to find the accompanying uncombined form of the same work.
+
+  8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License.  Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License.  However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+  9. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Library or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+  10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+  11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded.  In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+  13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation.  If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+  14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission.  For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this.  Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+                            NO WARRANTY
+
+  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+           How to Apply These Terms to Your New Libraries
+
+  If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change.  You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+  To apply these terms, attach the following notices to the library.  It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+    {description}
+    Copyright (C) {year} {fullname}
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the
+  library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+  {signature of Ty Coon}, 1 April 1990
+  Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/AuthenticationProviderService.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/AuthenticationProviderService.java
new file mode 100644
index 0000000..852eb72
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/AuthenticationProviderService.java
@@ -0,0 +1,221 @@
+/*
+ * 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.radius;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.lang.IllegalArgumentException;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import javax.servlet.http.HttpServletRequest;
+import javax.xml.bind.DatatypeConverter;
+import org.apache.guacamole.auth.radius.user.AuthenticatedUser;
+import org.apache.guacamole.auth.radius.form.RadiusChallengeResponseField;
+import org.apache.guacamole.auth.radius.form.RadiusStateField;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import net.jradius.dictionary.Attr_State;
+import net.jradius.dictionary.Attr_ReplyMessage;
+import net.jradius.packet.RadiusPacket;
+import net.jradius.packet.AccessAccept;
+import net.jradius.packet.AccessChallenge;
+import net.jradius.packet.AccessReject;
+import net.jradius.packet.attribute.RadiusAttribute;
+
+/**
+ * Service providing convenience functions for the RADIUS AuthenticationProvider
+ * implementation.
+ */
+public class AuthenticationProviderService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(AuthenticationProviderService.class);
+
+    /**
+     * Service for creating and managing connections to RADIUS servers.
+     */
+    @Inject
+    private RadiusConnectionService radiusService;
+
+    /**
+     * Provider for AuthenticatedUser objects.
+     */
+    @Inject
+    private Provider<AuthenticatedUser> authenticatedUserProvider;
+
+    /**
+     * Returns the expected credentials from a RADIUS challenge.
+     *
+     * @param challengePacket
+     *     The AccessChallenge RadiusPacket received from the RADIUS 
+     *     server.
+     *
+     * @return
+     *     A CredentialsInfo object that represents fields that need to
+     *     be presented to the user in order to complete authentication.
+     *     One of these must be the RADIUS state.
+     */
+    private CredentialsInfo getRadiusChallenge(RadiusPacket challengePacket) {
+
+        // Try to get the state attribute - if it's not there, we have a problem
+        RadiusAttribute stateAttr = challengePacket.findAttribute(Attr_State.TYPE);
+        if (stateAttr == null) {
+            logger.error("Something went wrong, state attribute not present.");
+            logger.debug("State Attribute turned up null, which shouldn't happen in AccessChallenge.");
+            return null;
+        }
+
+        // We need to get the reply message so we know what to ask the user
+        RadiusAttribute replyAttr = challengePacket.findAttribute(Attr_ReplyMessage.TYPE);
+        if (replyAttr == null) {
+            logger.error("No reply message received from the server.");
+            logger.debug("Expecting a Attr_ReplyMessage attribute on this packet, and did not get one.");
+            return null;
+        }
+
+        // We have the required attributes - convert to strings and then generate the additional login box/field
+        String replyMsg = replyAttr.toString();
+        String radiusState = DatatypeConverter.printHexBinary(stateAttr.getValue().getBytes());
+        Field radiusResponseField = new RadiusChallengeResponseField(replyMsg);
+        Field radiusStateField = new RadiusStateField(radiusState);
+
+        // Return the CredentialsInfo object that has the state and the expected response.
+        return new CredentialsInfo(Arrays.asList(radiusResponseField,radiusStateField));
+    }
+
+    /**
+     * Returns an AuthenticatedUser representing the user authenticated by the
+     * given credentials.
+     *
+     * @param credentials
+     *     The credentials to use for authentication.
+     *
+     * @return
+     *     An AuthenticatedUser representing the user authenticated by the
+     *     given credentials.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while authenticating the user, or if access is
+     *     denied.
+     */
+    public AuthenticatedUser authenticateUser(Credentials credentials)
+            throws GuacamoleException {
+
+        // Ignore anonymous users
+        if (credentials.getUsername() == null || credentials.getUsername().isEmpty())
+            return null;
+
+        // Password is required
+        if (credentials.getPassword() == null || credentials.getPassword().isEmpty())
+            return null;
+
+        // Grab HTTP request object and a response to a challenge.
+        HttpServletRequest request = credentials.getRequest();
+        String challengeResponse = request.getParameter(RadiusChallengeResponseField.PARAMETER_NAME);
+
+        // RadiusPacket object to store response from server.
+        RadiusPacket radPack;
+
+        // No challenge response, proceed with username/password authentication.
+        if (challengeResponse == null) {
+
+            try {
+                radPack = radiusService.authenticate(credentials.getUsername(),
+                                                credentials.getPassword(), null);
+            }
+            catch (GuacamoleException e) {
+                logger.error("Cannot configure RADIUS server: {}", e.getMessage());
+                logger.debug("Error configuring RADIUS server.", e);
+                throw new GuacamoleInvalidCredentialsException("Authentication error.", CredentialsInfo.USERNAME_PASSWORD);
+            }
+        }
+
+        // This is a response to a previous challenge, authenticate with that.
+        else {
+            try {
+                String stateString = request.getParameter(RadiusStateField.PARAMETER_NAME);
+                if (stateString == null) {
+                    logger.warn("Expected state parameter was not present in challenge/response.");
+                    throw new GuacamoleInvalidCredentialsException("Authentication error.", CredentialsInfo.USERNAME_PASSWORD);
+                }
+
+                byte[] stateBytes = DatatypeConverter.parseHexBinary(stateString);
+                radPack = radiusService.sendChallengeResponse(credentials.getUsername(),
+                                                              challengeResponse,
+                                                              stateBytes);
+            }
+            catch (IllegalArgumentException e) {
+                logger.warn("Illegal hexadecimal value while parsing RADIUS state string: {}", e.getMessage());
+                logger.debug("Encountered exception while attempting to parse the hexidecimal state value.", e);
+                throw new GuacamoleInvalidCredentialsException("Authentication error.", CredentialsInfo.USERNAME_PASSWORD);
+            }
+            catch (GuacamoleException e) {
+                logger.error("Cannot configure RADIUS server: {}", e.getMessage());
+                logger.debug("Error configuring RADIUS server.", e);
+                throw new GuacamoleInvalidCredentialsException("Authentication error.", CredentialsInfo.USERNAME_PASSWORD);
+            }
+        }
+
+        // No RadiusPacket is returned, we've encountered an error.
+        if (radPack == null) {
+            logger.debug("Nothing in the RADIUS packet.");
+            throw new GuacamoleInvalidCredentialsException("Authentication error.", CredentialsInfo.USERNAME_PASSWORD);
+        }
+
+        // Received AccessReject packet, login is denied.
+        else if (radPack instanceof AccessReject) {
+            logger.debug("Login has been rejected by RADIUS server.");
+            throw new GuacamoleInvalidCredentialsException("Authentication failed.", CredentialsInfo.USERNAME_PASSWORD);
+        }
+
+        // Received AccessAccept, authentication has succeeded
+        else if (radPack instanceof AccessAccept) {
+            AuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
+            authenticatedUser.init(credentials);
+            return authenticatedUser;
+        }
+
+        // Received AccessChallenge packet, more credentials required to complete authentication
+        else if (radPack instanceof AccessChallenge) {
+            CredentialsInfo expectedCredentials = getRadiusChallenge(radPack);
+
+            if (expectedCredentials == null)
+                throw new GuacamoleInvalidCredentialsException("Authentication error.", CredentialsInfo.USERNAME_PASSWORD);
+
+            throw new GuacamoleInsufficientCredentialsException("LOGIN.INFO_RADIUS_ADDL_REQUIRED", expectedCredentials);
+        }
+
+        // Something unanticipated happened, so panic and go back to login.
+        else {
+            logger.error("Unexpected failure authenticating with RADIUS server.");
+            throw new GuacamoleInvalidCredentialsException("Unknown error trying to authenticate.", CredentialsInfo.USERNAME_PASSWORD);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/ConfigurationService.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/ConfigurationService.java
new file mode 100644
index 0000000..381ea13
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/ConfigurationService.java
@@ -0,0 +1,320 @@
+/*
+ * 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.radius;
+
+import com.google.inject.Inject;
+import java.io.File;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.environment.Environment;
+
+/**
+ * Service for retrieving configuration information regarding the RADIUS server.
+ */
+public class ConfigurationService {
+
+    /**
+     * The Guacamole server environment.
+     */
+    @Inject
+    private Environment environment;
+
+    /**
+     * Returns the hostname of the RADIUS server as configured with
+     * guacamole.properties. By default, this will be "localhost".
+     *
+     * @return
+     *     The hostname of the RADIUS server, as configured with
+     *     guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusServer() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_HOSTNAME,
+            "localhost"
+        );
+    }
+
+    /**
+     * Returns the UDP port that will be used to communicate authentication
+     * and authorization information to the RADIUS server, as configured in
+     * guacamole.properties.  By default this will be 1812.
+     *
+     * @return
+     *     The authentication port of the RADIUS server, as configured with
+     *     guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getRadiusAuthPort() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_AUTH_PORT,
+            1812
+        );
+    }
+
+    /**
+     * Returns the UDP port that will be used to communicate accounting
+     * information to the RADIUS server, as configured in
+     * guacamole.properties.  The default is 1813.
+     *
+     * @return
+     *     The accouting port of the RADIUS server, as configured with
+     *     guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getRadiusAcctPort() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_ACCT_PORT,
+            1813
+        );
+    }
+
+    /**
+     * Returns the shared secret used to communicate with the RADIUS server,
+     * as configured in guacamole.properties.  This must match the value
+     * in the RADIUS server configuration.
+     *
+     * @return
+     *     The shared secret of the RADIUS server, as configured with
+     *     guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusSharedSecret() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_SHARED_SECRET
+        );
+    }
+
+    /**
+     * Returns the authentication protocol Guacamole should use when
+     * communicating with the RADIUS server, as configured in
+     * guacamole.properties.  This must match the configuration
+     * of the RADIUS server, so that the RADIUS server and Guacamole
+     * client are "speaking the same language."
+     *
+     * @return
+     *     The authentication protocol of the RADIUS server, 
+     *     from guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusAuthProtocol() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_AUTH_PROTOCOL
+        );
+    }
+
+    /**
+     * Returns the maximum number of retries for connecting to the RADIUS server
+     * from guacamole.properties.  The default number of retries is 5.
+     *
+     * @return
+     *     The number of retries for connection to the RADIUS server,
+     *     from guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getRadiusMaxRetries() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_MAX_RETRIES,
+            5
+        );
+    }
+
+    /**
+     * Returns the timeout, in seconds, for connecting to the RADIUS server
+     * from guacamole.properties.  The default timeout is 60 seconds.
+     *
+     * @return
+     *     The timeout, in seconds, for connection to the RADIUS server,
+     *     from guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getRadiusTimeout() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_TIMEOUT,
+            60
+        );
+    }
+
+    /**
+     * Returns the CA file for validating certificates for encrypted
+     * connections to the RADIUS server, as configured in
+     * guacamole.properties.
+     *
+     * @return
+     *     The file name for the CA file for validating
+     *     RADIUS server certificates
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public File getRadiusCAFile() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_CA_FILE,
+            new File(environment.getGuacamoleHome(), "ca.crt")
+        );
+    }
+
+    /**
+     * Returns the key file for the client for creating encrypted
+     * connections to RADIUS servers as specified in
+     * guacamole.properties.  By default a file called radius.pem
+     * is used.
+     *
+     * @return
+     *     The file name for the client certificate/key pair
+     *     for making encrypted RADIUS connections.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public File getRadiusKeyFile() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_KEY_FILE,
+            new File(environment.getGuacamoleHome(), "radius.key")
+        );
+    }
+
+    /**
+     * Returns the password for the CA file, if it is
+     * password-protected, as configured in guacamole.properties.
+     *
+     * @return
+     *     The password for the CA file
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusCAPassword() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_CA_PASSWORD
+        );
+    }
+
+    /**
+     * Returns the type of store that the CA file represents
+     * so that it can be correctly processed by the RADIUS
+     * library, as configured in guacamole.properties.  By
+     * default the pem type is used.
+     *
+     * @return
+     *     The type of store that the CA file is encoded
+     *     as, as configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusCAType() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_CA_TYPE,
+            "pem"
+        );
+    }
+
+    /**
+     * Returns the password for the key file, if it is
+     * password-protected, as configured in guacamole.properties.
+     *
+     * @return
+     *     The password for the key file
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusKeyPassword() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_KEY_PASSWORD
+        );
+    }
+
+    /**
+     * Returns the type of store that the key file represents
+     * so that it can be correctly processed by the RADIUS
+     * library, as configured in guacamole.properties.  By
+     * default the pem type is used.
+     *
+     * @return
+     *     The type of store that the key file is encoded
+     *     as, as configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusKeyType() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_KEY_TYPE,
+            "pem"
+        );
+    }
+
+    /**
+     * Returns the boolean value of whether or not the
+     * RADIUS library should trust all server certificates
+     * or should validate them against known CA certificates,
+     * as configured in guacamole.properties.  By default
+     * this is false, indicating that server certificates
+     * must be validated against a known good CA.
+     *
+     * @return
+     *     True if the RADIUS client should trust all
+     *     server certificates; false if it should
+     *     validate against known good CA certificates.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public Boolean getRadiusTrustAll() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_TRUST_ALL,
+            false
+        );
+    }
+
+    /**
+     * Returns the tunneled protocol that RADIUS should use
+     * when the authentication protocol is set to EAP-TTLS, as
+     * configured in the guacamole.properties file.
+     *
+     * @return
+     *     The tunneled protocol that should be used inside
+     *     an EAP-TTLS RADIUS connection. 
+     *     
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public String getRadiusEAPTTLSInnerProtocol() throws GuacamoleException {
+        return environment.getProperty(
+            RadiusGuacamoleProperties.RADIUS_EAP_TTLS_INNER_PROTOCOL
+        );
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusAuthenticationProvider.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusAuthenticationProvider.java
new file mode 100644
index 0000000..02c23de
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusAuthenticationProvider.java
@@ -0,0 +1,120 @@
+/*
+ * 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.radius;
+
+
+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;
+
+/**
+ * Allows users to be authenticated against an RADIUS server. Each user may have
+ * any number of authorized configurations. Authorized configurations may be
+ * shared.
+ */
+public class RadiusAuthenticationProvider implements AuthenticationProvider {
+
+    /**
+     * Injector which will manage the object graph of this authentication
+     * provider.
+     */
+    private final Injector injector;
+
+    /**
+     * Creates a new RadiusAuthenticationProvider that authenticates users
+     * against an RADIUS service.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public RadiusAuthenticationProvider() throws GuacamoleException {
+
+        // Set up Guice injector.
+        injector = Guice.createInjector(
+            new RadiusAuthenticationProviderModule(this)
+        );
+
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "radius";
+    }
+
+    @Override
+    public Object getResource() throws GuacamoleException {
+        return null;
+    }
+
+    @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public void shutdown() {
+        // Do nothing
+    }
+
+    @Override
+    public AuthenticatedUser authenticateUser(Credentials credentials) throws GuacamoleException {
+
+        AuthenticationProviderService authProviderService = injector.getInstance(AuthenticationProviderService.class);
+        return authProviderService.authenticateUser(credentials);
+
+    }
+
+    @Override
+    public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+        return authenticatedUser;
+    }
+
+    @Override
+    public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+
+        return null;
+
+    }
+
+    @Override
+    public UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+        return context;
+    }
+
+}
+
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusAuthenticationProviderModule.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusAuthenticationProviderModule.java
new file mode 100644
index 0000000..37ecb79
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusAuthenticationProviderModule.java
@@ -0,0 +1,79 @@
+/*
+ * 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.radius;
+
+import com.google.inject.AbstractModule;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.environment.LocalEnvironment;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+
+/**
+ * Guice module which configures RADIUS-specific injections.
+ */
+public class RadiusAuthenticationProviderModule extends AbstractModule {
+
+    /**
+     * Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * A reference to the RadiusAuthenticationProvider on behalf of which this
+     * module has configured injection.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
+     * Creates a new RADIUS authentication provider module which configures
+     * injection for the RadiusAuthenticationProvider.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider for which injection is being configured.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the Guacamole server
+     *     environment.
+     */
+    public RadiusAuthenticationProviderModule(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 RADIUS-specific services
+        bind(ConfigurationService.class);
+        bind(RadiusConnectionService.class);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusConnectionService.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusConnectionService.java
new file mode 100644
index 0000000..ec82a63
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusConnectionService.java
@@ -0,0 +1,307 @@
+/*
+ * 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.radius;
+
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.security.NoSuchAlgorithmException;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import net.jradius.client.RadiusClient;
+import net.jradius.dictionary.Attr_CleartextPassword;
+import net.jradius.dictionary.Attr_ReplyMessage;
+import net.jradius.dictionary.Attr_State;
+import net.jradius.dictionary.Attr_UserName;
+import net.jradius.dictionary.Attr_UserPassword;
+import net.jradius.exception.RadiusException;
+import net.jradius.packet.RadiusPacket;
+import net.jradius.packet.AccessRequest;
+import net.jradius.packet.attribute.AttributeList;
+import net.jradius.client.auth.EAPTLSAuthenticator;
+import net.jradius.client.auth.EAPTTLSAuthenticator;
+import net.jradius.client.auth.RadiusAuthenticator;
+import net.jradius.client.auth.PEAPAuthenticator;
+import net.jradius.packet.attribute.AttributeFactory;
+import net.jradius.packet.AccessChallenge;
+import net.jradius.packet.RadiusResponse;
+
+/**
+ * Service for creating and managing connections to RADIUS servers.
+ */
+public class RadiusConnectionService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(RadiusConnectionService.class);
+
+    /**
+     * Service for retrieving RADIUS server configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+
+    /**
+     * Creates a new instance of RadiusClient, configured with parameters
+     * from guacamole.properties.
+     *
+     * @return
+     *     A RadiusClient instance, configured with server, shared secret,
+     *     ports, and timeout, as configured in guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while parsing guacamole.properties, or if the
+     *     configuration of RadiusClient fails.
+     */
+    private RadiusClient createRadiusConnection() throws GuacamoleException {
+
+        // Create the RADIUS client with the configuration parameters
+        try {
+            return new RadiusClient(InetAddress.getByName(confService.getRadiusServer()),
+                                            confService.getRadiusSharedSecret(),
+                                            confService.getRadiusAuthPort(),
+                                            confService.getRadiusAcctPort(),
+                                            confService.getRadiusTimeout());
+        }
+        catch (UnknownHostException e) {
+            logger.debug("Failed to resolve host.", e);
+            throw new GuacamoleServerException("Unable to resolve RADIUS server host.", e);
+        }
+        catch (IOException e) {
+            logger.debug("Failed to communicate with host.", e);
+            throw new GuacamoleServerException("Failed to communicate with RADIUS server.", e);
+        }
+
+    }
+
+    /**
+     * Creates a new instance of RadiusAuthentictor, configured with
+     * parameters specified within guacamole.properties.
+     *
+     * @param radiusClient
+     *     A RadiusClient instance that has been initialized to
+     *     communicate with a RADIUS server.
+     *
+     * @return
+     *     A new RadiusAuthenticator instance which has been configured
+     *     with parameters from guacamole.properties, or null if
+     *     configuration fails.
+     *
+     * @throws GuacamoleException
+     *     If the configuration cannot be read or the inner protocol is
+     *     not configured when the client is set up for a tunneled
+     *     RADIUS connection.
+     */
+    private RadiusAuthenticator setupRadiusAuthenticator(RadiusClient radiusClient)
+            throws GuacamoleException {
+
+        // If we don't have a radiusClient object, yet, don't go any further.
+        if (radiusClient == null) {
+            logger.error("RADIUS client hasn't been set up, yet.");
+            logger.debug("We can't run this method until the RADIUS client has been set up.");
+            return null;
+        }
+
+        RadiusAuthenticator radAuth = radiusClient.getAuthProtocol(confService.getRadiusAuthProtocol());
+        if (radAuth == null)
+            throw new GuacamoleException("Could not get a valid RadiusAuthenticator for specified protocol: " + confService.getRadiusAuthProtocol());
+
+        // If we're using any of the TLS protocols, we need to configure them
+        if (radAuth instanceof PEAPAuthenticator || 
+            radAuth instanceof EAPTLSAuthenticator || 
+            radAuth instanceof EAPTTLSAuthenticator) {
+
+            // Pull TLS configuration parameters from guacamole.properties
+            File caFile = confService.getRadiusCAFile();
+            String caPassword = confService.getRadiusCAPassword();
+            File keyFile = confService.getRadiusKeyFile();
+            String keyPassword = confService.getRadiusKeyPassword();
+
+            if (caFile != null) {
+                ((EAPTLSAuthenticator)radAuth).setCaFile(caFile.toString());
+                ((EAPTLSAuthenticator)radAuth).setCaFileType(confService.getRadiusCAType());
+                if (caPassword != null)
+                    ((EAPTLSAuthenticator)radAuth).setCaPassword(caPassword);
+            }
+
+            if (keyPassword != null)
+                ((EAPTLSAuthenticator)radAuth).setKeyPassword(keyPassword);
+
+            ((EAPTLSAuthenticator)radAuth).setKeyFile(keyFile.toString());
+            ((EAPTLSAuthenticator)radAuth).setKeyFileType(confService.getRadiusKeyType());
+            ((EAPTLSAuthenticator)radAuth).setTrustAll(confService.getRadiusTrustAll());
+        }
+
+        // If we're using EAP-TTLS, we need to define tunneled protocol
+        if (radAuth instanceof EAPTTLSAuthenticator) {
+            String innerProtocol = confService.getRadiusEAPTTLSInnerProtocol();
+            if (innerProtocol == null)
+                throw new GuacamoleException("Trying to use EAP-TTLS, but no inner protocol specified.");
+
+            ((EAPTTLSAuthenticator)radAuth).setInnerProtocol(innerProtocol);
+        }
+
+        return radAuth;
+
+    }
+
+    /**
+     * Authenticate to the RADIUS server using existing state and a response
+     *
+     * @param username
+     *     The username for the authentication
+     *
+     * @param secret
+     *     The secret, usually a password or challenge response, to send
+     *     to authenticate to the RADIUS server.
+     *
+     * @param state
+     *     The previous state of the RADIUS connection
+     *
+     * @return
+     *     A RadiusPacket with the response of the server.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while talking to the server.
+     */
+    public RadiusPacket authenticate(String username, String secret, byte[] state)
+            throws GuacamoleException {
+
+        // If a username wasn't passed, we quit
+        if (username == null || username.isEmpty()) {
+            logger.warn("Anonymous access not allowed with RADIUS client.");
+            return null;
+        }
+
+        // If secret wasn't passed, we quit
+        if (secret == null || secret.isEmpty()) {
+            logger.warn("Password/secret required for RADIUS authentication.");
+            return null;
+        }
+
+        // Create the RADIUS connection and set up the dictionary
+        RadiusClient radiusClient = createRadiusConnection();
+        AttributeFactory.loadAttributeDictionary("net.jradius.dictionary.AttributeDictionaryImpl");
+
+        // Client failed to set up, so we return null
+        if (radiusClient == null)
+            return null;
+
+        // Set up the RadiusAuthenticator
+        RadiusAuthenticator radAuth = setupRadiusAuthenticator(radiusClient);
+        if (radAuth == null)
+            throw new GuacamoleException("Unknown RADIUS authentication protocol.");
+
+        // Add attributes to the connection and send the packet
+        try {
+            AttributeList radAttrs = new AttributeList();
+            radAttrs.add(new Attr_UserName(username));
+            if (state != null && state.length > 0)
+                radAttrs.add(new Attr_State(state));
+            radAttrs.add(new Attr_UserPassword(secret));
+            radAttrs.add(new Attr_CleartextPassword(secret));
+
+            AccessRequest radAcc = new AccessRequest(radiusClient);
+
+            // EAP-TTLS tunnels protected attributes inside the TLS layer
+            if (radAuth instanceof EAPTTLSAuthenticator) {
+                radAuth.setUsername(new Attr_UserName(username));
+                ((EAPTTLSAuthenticator)radAuth).setTunneledAttributes(radAttrs);
+            }
+            else
+                radAcc.addAttributes(radAttrs);
+
+            radAuth.setupRequest(radiusClient, radAcc);
+            radAuth.processRequest(radAcc);
+            RadiusResponse reply = radiusClient.sendReceive(radAcc, confService.getRadiusMaxRetries());
+
+            // We receive a Challenge not asking for user input, so silently process the challenge
+            while((reply instanceof AccessChallenge) && (reply.findAttribute(Attr_ReplyMessage.TYPE) == null)) {
+                radAuth.processChallenge(radAcc, reply);
+                reply = radiusClient.sendReceive(radAcc, confService.getRadiusMaxRetries());
+            }
+            return reply;
+        }
+        catch (RadiusException e) {
+            logger.error("Unable to complete authentication.", e.getMessage());
+            logger.debug("Authentication with RADIUS failed.", e);
+            return null;
+        }
+        catch (NoSuchAlgorithmException e) {
+            logger.error("No such RADIUS algorithm: {}", e.getMessage());
+            logger.debug("Unknown RADIUS algorithm.", e);
+            return null;
+        }
+        finally {
+            radiusClient.close();
+        }
+    }
+
+    /**
+     * Send a challenge response to the RADIUS server by validating the input and
+     * then sending it along to the authenticate method.
+     *
+     * @param username
+     *     The username to send to the RADIUS server for authentication.
+     *
+     * @param response
+     *     The response phrase to send to the RADIUS server in response to the
+     *     challenge previously provided.
+     *
+     * @param state
+     *     The state data provided by the RADIUS server in order to continue
+     *     the RADIUS conversation.
+     *
+     * @return
+     *     A RadiusPacket containing the server's response to the authentication
+     *     attempt.
+     *
+     * @throws GuacamoleException
+     *     If an error is encountered trying to talk to the RADIUS server.
+     */
+    public RadiusPacket sendChallengeResponse(String username, String response, byte[] state)
+            throws GuacamoleException {
+
+        if (username == null || username.isEmpty()) {
+            logger.error("Challenge/response to RADIUS requires a username.");
+            return null;
+        }
+
+        if (state == null || state.length == 0) {
+            logger.error("Challenge/response to RADIUS requires a prior state.");
+            return null;
+        }
+
+        if (response == null || response.isEmpty()) {
+            logger.error("Challenge/response to RADIUS requires a response.");
+            return null;
+        }
+
+        return authenticate(username,response,state);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusGuacamoleProperties.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusGuacamoleProperties.java
new file mode 100644
index 0000000..aaa445e
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/RadiusGuacamoleProperties.java
@@ -0,0 +1,192 @@
+/*
+ * 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.radius;
+
+import org.apache.guacamole.properties.BooleanGuacamoleProperty;
+import org.apache.guacamole.properties.FileGuacamoleProperty;
+import org.apache.guacamole.properties.IntegerGuacamoleProperty;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
+
+
+/**
+ * Provides properties required for use of the RADIUS authentication provider.
+ * These properties will be read from guacamole.properties when the RADIUS
+ * authentication provider is used.
+ */
+public class RadiusGuacamoleProperties {
+
+    /**
+     * This class should not be instantiated.
+     */
+    private RadiusGuacamoleProperties() {}
+
+    /**
+     * The port on the RADIUS server to connect to when authenticating users.
+     */
+    public static final IntegerGuacamoleProperty RADIUS_AUTH_PORT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-auth-port"; }
+
+    };
+
+    /**
+     * The port on the server to connect to when performing RADIUS accounting.
+     */
+    public static final IntegerGuacamoleProperty RADIUS_ACCT_PORT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-acct-port"; }
+
+    };
+
+
+    /**
+     * The hostname or IP address of the RADIUS server to connect to when authenticating users.
+     */
+    public static final StringGuacamoleProperty RADIUS_HOSTNAME = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-hostname"; }
+
+    };
+
+    /**
+     * The shared secret to use when connecting to the RADIUS server.
+     */
+    public static final StringGuacamoleProperty RADIUS_SHARED_SECRET = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-shared-secret"; }
+
+    };
+
+    /**
+     * The authentication protocol of the RADIUS server to connect to when authenticating users.
+     */
+    public static final StringGuacamoleProperty RADIUS_AUTH_PROTOCOL = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-auth-protocol"; }
+
+    };
+
+    /**
+     * The maximum number of retries when attempting a RADIUS packet transaction.
+     */
+    public static final IntegerGuacamoleProperty RADIUS_MAX_RETRIES = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-max-retries"; }
+
+    };
+
+    /**
+     * The network timeout, in seconds, when attempting a RADIUS packet transaction.
+     */
+    public static final IntegerGuacamoleProperty RADIUS_TIMEOUT = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-timeout"; }
+
+    };
+
+    /**
+     * The CA file to use to validate RADIUS server certificates.
+     */
+    public static final FileGuacamoleProperty RADIUS_CA_FILE = new FileGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-ca-file"; }
+
+    };
+
+    /**
+     * The type of file the RADIUS CA file is (PEM, PKCS12, DER).
+     */
+    public static final StringGuacamoleProperty RADIUS_CA_TYPE = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-ca-type"; }
+
+    };
+
+    /**
+     * The password for the CA file.
+     */
+    public static final StringGuacamoleProperty RADIUS_CA_PASSWORD = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-ca-password"; }
+
+    };
+
+    /**
+     * The file that stores the key/certificate pair to use for the RADIUS client connection.
+     */
+    public static final FileGuacamoleProperty RADIUS_KEY_FILE = new FileGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-key-file"; }
+
+    };
+
+    /**
+     * The type of file the RADIUS key file is (PEM, PKCS12, DER).
+     */
+    public static final StringGuacamoleProperty RADIUS_KEY_TYPE = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-key-type"; }
+
+    };
+
+    /**
+     * The password for the key file.
+     */
+    public static final StringGuacamoleProperty RADIUS_KEY_PASSWORD = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-key-password"; }
+
+    };
+
+    /**
+     * Whether or not to trust all RADIUS server certificates.
+     */
+    public static final BooleanGuacamoleProperty RADIUS_TRUST_ALL = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-trust-all"; }
+
+    };
+
+    /**
+     * The tunneled protocol to use inside a RADIUS EAP-TTLS connection.
+     */
+    public static final StringGuacamoleProperty RADIUS_EAP_TTLS_INNER_PROTOCOL = new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "radius-eap-ttls-inner-protocol"; }
+
+    };
+
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/form/RadiusChallengeResponseField.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/form/RadiusChallengeResponseField.java
new file mode 100644
index 0000000..32ceb90
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/form/RadiusChallengeResponseField.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.radius.form;
+
+import org.apache.guacamole.form.Field;
+
+/**
+ * A form used to prompt the user for additional information when
+ * the RADIUS server sends a challenge back to the user with a reply
+ * message.
+ */
+public class RadiusChallengeResponseField extends Field {
+
+    /**
+     * The field returned by the RADIUS challenge/response.
+     */
+    public static final String PARAMETER_NAME = "guac-radius-challenge-response";
+
+    /**
+     * The type of field to initialize for the challenge/response.
+     */
+    private static final String RADIUS_FIELD_TYPE = "GUAC_RADIUS_CHALLENGE_RESPONSE";
+
+    /**
+     * The message the RADIUS server sent back in the challenge.
+     */
+    private final String challenge;
+
+    /**
+     * Initialize the field with the challenge sent back by the RADIUS server.
+     *
+     * @param challenge
+     *     The challenge message sent back by the RADIUS server.
+     */
+    public RadiusChallengeResponseField(String challenge) {
+        super(PARAMETER_NAME, RADIUS_FIELD_TYPE);
+        this.challenge = challenge;
+
+    }
+
+    /**
+     * Get the challenge sent by the RADIUS server.
+     *
+     * @return
+     *     A String that indicates the challenge returned
+     *     by the RADIUS server.
+     */
+    public String getChallenge() {
+        return challenge;
+    }
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/form/RadiusStateField.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/form/RadiusStateField.java
new file mode 100644
index 0000000..201df2c
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/form/RadiusStateField.java
@@ -0,0 +1,69 @@
+/*
+ * 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.radius.form;
+
+import org.apache.guacamole.form.Field;
+
+/**
+ * The invisible field that stores the state of the RADIUS
+ * connection.  The state is simply a placeholder that helps
+ * the client and server pick back up the conversation
+ * at the correct spot during challenge/response.
+ */
+public class RadiusStateField extends Field {
+    
+    /**
+     * The parameter returned by the RADIUS state.
+     */
+    public static final String PARAMETER_NAME = "guac-radius-state";
+
+    /**
+     * The type of field to initialize for the state.
+     */
+    private static final String RADIUS_FIELD_TYPE = "GUAC_RADIUS_STATE";
+
+    /**
+     * The state of the connection passed by the previous RADIUS attempt.
+     */
+    private final String radiusState;
+
+    /**
+     * Initialize the field with the state returned by the RADIUS server.
+     *
+     * @param radiusState
+     *     The state returned by the RADIUS server.
+     */
+    public RadiusStateField(String radiusState) {
+        super(PARAMETER_NAME, RADIUS_FIELD_TYPE);
+        this.radiusState = radiusState;
+
+    }
+
+    /**
+     * Get the state provided by the RADIUS server.
+     *
+     * @return
+     *     The state provided by the RADIUS server.
+     */
+    public String getRadiusState() {
+        return radiusState;
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/user/AuthenticatedUser.java b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/user/AuthenticatedUser.java
new file mode 100644
index 0000000..a429253
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/java/org/apache/guacamole/auth/radius/user/AuthenticatedUser.java
@@ -0,0 +1,66 @@
+/*
+ * 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.radius.user;
+
+import com.google.inject.Inject;
+import org.apache.guacamole.net.auth.AbstractAuthenticatedUser;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.Credentials;
+
+/**
+ * An RADIUS-specific implementation of AuthenticatedUser, associating a
+ * particular set of credentials with the RADIUS authentication provider.
+ */
+public class AuthenticatedUser extends AbstractAuthenticatedUser {
+
+    /**
+     * Reference to the authentication provider associated with this
+     * authenticated user.
+     */
+    @Inject
+    private AuthenticationProvider authProvider;
+
+    /**
+     * The credentials provided when this user was authenticated.
+     */
+    private Credentials credentials;
+
+    /**
+     * Initializes this AuthenticatedUser using the given credentials.
+     *
+     * @param credentials
+     *     The credentials provided when this user was authenticated.
+     */
+    public void init(Credentials credentials) {
+        this.credentials = credentials;
+        setIdentifier(credentials.getUsername().toLowerCase());
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    @Override
+    public Credentials getCredentials() {
+        return credentials;
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/resources/config/radiusConfig.js b/extensions/guacamole-auth-radius/src/main/resources/config/radiusConfig.js
new file mode 100644
index 0000000..dab0ffc
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/config/radiusConfig.js
@@ -0,0 +1,40 @@
+/*
+ * 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 Radius-specific field types.
+ */
+angular.module('guacRadius').config(['formServiceProvider',
+        function guacRadiusConfig(formServiceProvider) {
+
+    // Define field for the challenge from the RADIUS service
+    formServiceProvider.registerFieldType('GUAC_RADIUS_CHALLENGE_RESPONSE', {
+        module      : 'guacRadius',
+        controller  : 'radiusResponseController',
+        templateUrl : 'app/ext/radius/templates/radiusResponseField.html'
+    });
+
+    // Define the hidden field for the RADIUS state
+    formServiceProvider.registerFieldType('GUAC_RADIUS_STATE', {
+        module      : 'guacRadius',
+        controller  : 'radiusStateController',
+        templateUrl : 'app/ext/radius/templates/radiusStateField.html'
+    });
+
+}]);
diff --git a/extensions/guacamole-auth-radius/src/main/resources/controllers/radiusResponseController.js b/extensions/guacamole-auth-radius/src/main/resources/controllers/radiusResponseController.js
new file mode 100644
index 0000000..4782b20
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/controllers/radiusResponseController.js
@@ -0,0 +1,30 @@
+/*
+ * 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_RADIUS_CHALLENGE_RESPONSE" field which
+ * passes the RADIUS server challenge to the user and takes the response.
+ */
+angular.module('guacRadius').controller('radiusResponseController', ['$scope', '$injector',
+        function radiusResponseController($scope, $injector) {
+
+    // Populate the reply message field
+    $scope.radiusPlaceholder = $scope.field.challenge;
+
+}]);
diff --git a/extensions/guacamole-auth-radius/src/main/resources/controllers/radiusStateController.js b/extensions/guacamole-auth-radius/src/main/resources/controllers/radiusStateController.js
new file mode 100644
index 0000000..fb956ab
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/controllers/radiusStateController.js
@@ -0,0 +1,31 @@
+/*
+ * 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_RADIUS_STATE" field which is used to pass
+ * the RADIUS server state to maintain the session with the RADIUS
+ * server.
+ */
+angular.module('guacRadius').controller('radiusStateController', ['$scope', '$injector',
+        function radiusStateController($scope, $injector) {
+
+    // Populate the hidden field for the connection state
+    $scope.model = $scope.field.radiusState;
+
+}]);
diff --git a/extensions/guacamole-auth-radius/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-radius/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..f85ab1f
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/guac-manifest.json
@@ -0,0 +1,25 @@
+{
+
+    "guacamoleVersion" : "0.9.14",
+
+    "name"      : "RADIUS Authentication Backend",
+    "namespace" : "radius",
+
+    "authProviders" : [
+        "org.apache.guacamole.auth.radius.RadiusAuthenticationProvider"
+    ],
+
+    "translations" : [
+        "translations/en.json"
+    ],
+
+    "js" : [
+        "radius.min.js"
+    ],
+
+    "resources" : {
+        "templates/radiusResponseField.html" : "text/html",
+        "templates/radiusStateField.html"    : "text/html"
+    }
+
+}
diff --git a/extensions/guacamole-auth-radius/src/main/resources/license.txt b/extensions/guacamole-auth-radius/src/main/resources/license.txt
new file mode 100644
index 0000000..042f3ce
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/license.txt
@@ -0,0 +1,18 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git a/extensions/guacamole-auth-radius/src/main/resources/radiusModule.js b/extensions/guacamole-auth-radius/src/main/resources/radiusModule.js
new file mode 100644
index 0000000..7d6c0f3
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/radiusModule.js
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Module which provides handling for RADIUS challenge-response 
+ * authentication.
+ */
+angular.module('guacRadius', [
+    'form'
+]);
+
+// Ensure the guacRadius module is loaded along with the rest of the app
+angular.module('index').requires.push('guacRadius');
diff --git a/extensions/guacamole-auth-radius/src/main/resources/templates/radiusResponseField.html b/extensions/guacamole-auth-radius/src/main/resources/templates/radiusResponseField.html
new file mode 100644
index 0000000..eec760f
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/templates/radiusResponseField.html
@@ -0,0 +1 @@
+<input type="password" ng-model="model" ng-trim="false" autocorrect="off" autocapitalize="off" placeholder="{{radiusPlaceholder}}" />
diff --git a/extensions/guacamole-auth-radius/src/main/resources/templates/radiusStateField.html b/extensions/guacamole-auth-radius/src/main/resources/templates/radiusStateField.html
new file mode 100644
index 0000000..bfd9c4f
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/templates/radiusStateField.html
@@ -0,0 +1 @@
+<input type="hidden" ng-model="model" />
diff --git a/extensions/guacamole-auth-radius/src/main/resources/translations/en.json b/extensions/guacamole-auth-radius/src/main/resources/translations/en.json
new file mode 100644
index 0000000..c068a70
--- /dev/null
+++ b/extensions/guacamole-auth-radius/src/main/resources/translations/en.json
@@ -0,0 +1,13 @@
+{
+
+    "DATA_SOURCE_RADIUS" : {
+        "NAME" : "RADIUS Backend"
+    },
+
+    "LOGIN" : {
+        "FIELD_HEADER_GUAC_RADIUS_CHALLENGE_RESPONSE" : "",
+        "FIELD_HEADER_GUAC_RADIUS_STATE"              : "",
+        "INFO_RADIUS_ADDL_REQUIRED"                   : "Please supply additional credentials"
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/.gitignore b/extensions/guacamole-auth-totp/.gitignore
new file mode 100644
index 0000000..1de9633
--- /dev/null
+++ b/extensions/guacamole-auth-totp/.gitignore
@@ -0,0 +1,3 @@
+src/main/resources/generated/
+target/
+*~
diff --git a/extensions/guacamole-auth-totp/pom.xml b/extensions/guacamole-auth-totp/pom.xml
new file mode 100644
index 0000000..af44455
--- /dev/null
+++ b/extensions/guacamole-auth-totp/pom.xml
@@ -0,0 +1,274 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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.
+-->
+<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-totp</artifactId>
+    <packaging>jar</packaging>
+    <version>0.9.14</version>
+    <name>guacamole-auth-totp</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>
+
+            <!-- Pre-cache Angular templates with maven-angular-plugin -->
+            <plugin>
+                <groupId>com.keithbranton.mojo</groupId>
+                <artifactId>angular-maven-plugin</artifactId>
+                <version>0.3.2</version>
+                <executions>
+                    <execution>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>html2js</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <sourceDir>${basedir}/src/main/resources</sourceDir>
+                    <include>**/*.html</include>
+                    <target>${basedir}/src/main/resources/generated/templates-main/templates.js</target>
+                    <prefix>app/ext/totp</prefix>
+                </configuration>
+            </plugin>
+
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.samaxes.maven</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <version>1.7.5</version>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <configuration>
+                            <charset>UTF-8</charset>
+
+                            <webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
+                            <webappTargetDir>${project.build.directory}/classes</webappTargetDir>
+
+                            <cssSourceDir>/</cssSourceDir>
+                            <cssTargetDir>/</cssTargetDir>
+                            <cssFinalFile>totp.css</cssFinalFile>
+
+                            <cssSourceFiles>
+                                <cssSourceFile>license.txt</cssSourceFile>
+                            </cssSourceFiles>
+
+                            <cssSourceIncludes>
+                                <cssSourceInclude>**/*.css</cssSourceInclude>
+                            </cssSourceIncludes>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>totp.js</jsFinalFile>
+
+                            <jsSourceFiles>
+                                <jsSourceFile>license.txt</jsSourceFile>
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                            <!-- Do not minify and include tests -->
+                            <jsSourceExcludes>
+                                <jsSourceExclude>**/*.test.js</jsSourceExclude>
+                            </jsSourceExcludes>
+                            <jsEngine>CLOSURE</jsEngine>
+
+                            <!-- Disable warnings for JSDoc annotations -->
+                            <closureWarningLevels>
+                                <misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
+                                <nonStandardJsDocs>OFF</nonStandardJsDocs>
+                            </closureWarningLevels>
+
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+            <!-- Assembly plugin - for easy distribution -->
+            <plugin>
+                <artifactId>maven-assembly-plugin</artifactId>
+                <version>2.5.3</version>
+                <configuration>
+                    <finalName>${project.artifactId}-${project.version}</finalName>
+                    <appendAssemblyId>false</appendAssemblyId>
+                    <descriptors>
+                        <descriptor>src/main/assembly/dist.xml</descriptor>
+                    </descriptors>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>make-dist-archive</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>single</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </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>
+
+            <!-- Verify format using Apache RAT -->
+            <plugin>
+                <groupId>org.apache.rat</groupId>
+                <artifactId>apache-rat-plugin</artifactId>
+                <version>0.12</version>
+
+                <configuration>
+                    <excludes>
+                        <exclude>**/*.json</exclude>
+                        <exclude>src/licenses/**/*</exclude>
+                        <exclude>src/main/resources/templates/*.html</exclude>
+                    </excludes>
+                </configuration>
+
+                <!-- Bind RAT to validate phase -->
+                <executions>
+                    <execution>
+                        <id>validate</id>
+                        <phase>validate</phase>
+                        <goals>
+                            <goal>check</goal>
+                        </goals>
+                    </execution>
+                </executions>
+
+            </plugin>
+
+        </plugins>
+    </build>
+
+    <dependencies>
+
+       <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+            <version>0.9.14</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>
+
+        <!-- Guava - Utility Library -->
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+            <version>18.0</version>
+        </dependency>
+
+        <!-- JUnit -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.12</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- ZXing - Barcode library -->
+        <dependency>
+            <groupId>com.google.zxing</groupId>
+            <artifactId>javase</artifactId>
+            <version>3.3.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.google.zxing</groupId>
+            <artifactId>core</artifactId>
+            <version>3.3.1</version>
+        </dependency>
+
+        <!-- Guacamole depends on an implementation of JAX-WS -->
+        <dependency>
+            <groupId>javax.ws.rs</groupId>
+            <artifactId>javax.ws.rs-api</artifactId>
+            <version>2.0</version>
+            <scope>provided</scope>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-totp/src/licenses/LICENSE
similarity index 75%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-totp/src/licenses/LICENSE
index 6b0b127..8a66d23 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-totp/src/licenses/LICENSE
@@ -201,3 +201,94 @@
    See the License for the specific language governing permissions and
    limitations under the License.
 
+
+==============================================================================
+
+APACHE GUACAMOLE SUBCOMPONENTS
+
+Apache Guacamole includes a number of subcomponents with separate copyright
+notices and license terms. Your use of these subcomponents is subject to the
+terms and conditions of the following licenses.
+
+
+AOP Alliance (http://aopalliance.sourceforge.net/)
+--------------------------------------------------
+
+    Version: 1.0
+    From: 'AOP Alliance' (http://aopalliance.sourceforge.net/members.html)
+    License(s):
+        Public Domain (bundled/aopalliance-1.0/LICENSE)
+
+
+Google Guice (https://github.com/google/guice)
+----------------------------------------------
+
+    Version: 3.0
+    From: 'Google Inc.' (http://www.google.com/)
+    License(s):
+        Apache v2.0 (bundled/guice-3.0/COPYING)
+
+
+Guava: Google Core Libraries for Java (https://github.com/google/guava)
+-----------------------------------------------------------------------
+
+    Version: 18.0
+    From: 'Google Inc.' (http://www.google.com/)
+    License(s):
+        Apache v2.0 (bundled/guava-18.0/COPYING)
+
+
+JSR-330 / Dependency Injection for Java (http://code.google.com/p/atinject/)
+----------------------------------------------------------------------------
+
+    Version: 1
+    From: 'JSR-330 Expert Group' (https://jcp.org/en/jsr/detail?id=330)
+    License(s):
+        Apache v2.0 (bundled/javax.inject-1/LICENSE-2.0.txt)
+
+
+TOTP Reference Implementation (https://tools.ietf.org/id/draft-mraihi-totp-timebased-07.html#Section-Reference-Impl)
+-------------------------------------------------------------------------------
+
+    Verson: 07
+    From: 'IETF Trust' (http://trustee.ietf.org/license-info)
+    License(s):
+        BSD 3-clause (bundled/totp-reference-impl-07/license.txt)
+
+Copyright (c) 2011 IETF Trust and the persons identified as authors
+of the code. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ - Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ - 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.
+
+ - Neither the name of Internet Society, IETF or IETF Trust, nor the names
+   of specific contributors, may be used to endorse or promote products
+   derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “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 COPYRIGHT OWNER OR CONTRIBUTORS 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.
+
+
+ZXing Barcode Scanning Library (https://github.com/zxing/zxing/)
+----------------------------------------------------------------
+
+    Version: 3.3.1
+    From: 'ZXing authors' (https://github.com/zxing/zxing/blob/zxing-3.3.1/AUTHORS)
+    License(s):
+        Apache v2.0 (bundled/zxing-3.3.1/LICENSE)
+
diff --git a/extensions/guacamole-auth-noauth/src/licenses/NOTICE b/extensions/guacamole-auth-totp/src/licenses/NOTICE
similarity index 72%
copy from extensions/guacamole-auth-noauth/src/licenses/NOTICE
copy to extensions/guacamole-auth-totp/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/NOTICE
+++ b/extensions/guacamole-auth-totp/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/extensions/guacamole-auth-totp/src/licenses/bundled/README b/extensions/guacamole-auth-totp/src/licenses/bundled/README
new file mode 100644
index 0000000..47ba19d
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/README
@@ -0,0 +1,4 @@
+Apache Guacamole includes a number of subcomponents with separate copyright
+notices and license terms. Your use of these subcomponents is subject to the
+terms and conditions of their respective licenses, included within this
+directory for reference.
diff --git a/extensions/guacamole-auth-totp/src/licenses/bundled/aopalliance-1.0/LICENSE b/extensions/guacamole-auth-totp/src/licenses/bundled/aopalliance-1.0/LICENSE
new file mode 100644
index 0000000..8e0e378
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/aopalliance-1.0/LICENSE
@@ -0,0 +1,4 @@
+From http://aopalliance.sourceforge.net/:
+
+    LICENCE: all the source code provided by AOP Alliance is Public Domain.
+
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-totp/src/licenses/bundled/guava-18.0/COPYING
similarity index 99%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-totp/src/licenses/bundled/guava-18.0/COPYING
index 6b0b127..d645695 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/guava-18.0/COPYING
@@ -200,4 +200,3 @@
    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.
-
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-totp/src/licenses/bundled/guice-3.0/COPYING
similarity index 99%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-totp/src/licenses/bundled/guice-3.0/COPYING
index 6b0b127..d645695 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/guice-3.0/COPYING
@@ -200,4 +200,3 @@
    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.
-
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-totp/src/licenses/bundled/javax.inject-1/LICENSE-2.0.txt
similarity index 99%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-totp/src/licenses/bundled/javax.inject-1/LICENSE-2.0.txt
index 6b0b127..d645695 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/javax.inject-1/LICENSE-2.0.txt
@@ -200,4 +200,3 @@
    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.
-
diff --git a/extensions/guacamole-auth-totp/src/licenses/bundled/totp-reference-impl-07/license.txt b/extensions/guacamole-auth-totp/src/licenses/bundled/totp-reference-impl-07/license.txt
new file mode 100644
index 0000000..bb1afcd
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/totp-reference-impl-07/license.txt
@@ -0,0 +1,28 @@
+Copyright (c) 2011 IETF Trust and the persons identified as authors
+of the code. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ - Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ - 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.
+
+ - Neither the name of Internet Society, IETF or IETF Trust, nor the names
+   of specific contributors, may be used to endorse or promote products
+   derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “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 COPYRIGHT OWNER OR CONTRIBUTORS 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.
+
diff --git a/extensions/guacamole-auth-noauth/src/licenses/LICENSE b/extensions/guacamole-auth-totp/src/licenses/bundled/zxing-3.3.1/LICENSE
similarity index 85%
copy from extensions/guacamole-auth-noauth/src/licenses/LICENSE
copy to extensions/guacamole-auth-totp/src/licenses/bundled/zxing-3.3.1/LICENSE
index 6b0b127..510991e 100644
--- a/extensions/guacamole-auth-noauth/src/licenses/LICENSE
+++ b/extensions/guacamole-auth-totp/src/licenses/bundled/zxing-3.3.1/LICENSE
@@ -1,4 +1,3 @@
-
                                  Apache License
                            Version 2.0, January 2004
                         http://www.apache.org/licenses/
@@ -201,3 +200,46 @@
    See the License for the specific language governing permissions and
    limitations under the License.
 
+========================================================================
+jai-imageio
+========================================================================
+
+Copyright (c) 2005 Sun Microsystems, Inc.
+Copyright © 2010-2014 University of Manchester
+Copyright © 2010-2015 Stian Soiland-Reyes
+Copyright © 2015 Peter Hull
+All Rights Reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+- Redistribution of source code must retain the above copyright
+  notice, this list of conditions and the following disclaimer.
+
+- Redistribution 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.
+
+Neither the name of Sun Microsystems, Inc. or the names of
+contributors may be used to endorse or promote products derived
+from this software without specific prior written permission.
+
+This software is provided "AS IS," without a warranty of any
+kind. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
+WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, ARE HEREBY
+EXCLUDED. SUN MIDROSYSTEMS, INC. ("SUN") AND ITS LICENSORS SHALL
+NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF
+USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR ITS
+DERIVATIVES. IN NO EVENT WILL SUN OR ITS LICENSORS BE LIABLE FOR
+ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, INDIRECT, SPECIAL,
+CONSEQUENTIAL, INCIDENTAL OR PUNITIVE DAMAGES, HOWEVER CAUSED AND
+REGARDLESS OF THE THEORY OF LIABILITY, ARISING OUT OF THE USE OF OR
+INABILITY TO USE THIS SOFTWARE, EVEN IF SUN HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+You acknowledge that this software is not designed or intended for
+use in the design, construction, operation or maintenance of any
+nuclear facility.
\ No newline at end of file
diff --git a/extensions/guacamole-auth-noauth/src/main/assembly/dist.xml b/extensions/guacamole-auth-totp/src/main/assembly/dist.xml
similarity index 92%
rename from extensions/guacamole-auth-noauth/src/main/assembly/dist.xml
rename to extensions/guacamole-auth-totp/src/main/assembly/dist.xml
index 9f2293a..b89fd53 100644
--- a/extensions/guacamole-auth-noauth/src/main/assembly/dist.xml
+++ b/extensions/guacamole-auth-totp/src/main/assembly/dist.xml
@@ -30,14 +30,9 @@
         <format>tar.gz</format>
     </formats>
 
-    <!-- Include docs and extension .jar -->
+    <!-- Include licenses and extension .jar -->
     <fileSets>
 
-        <!-- Include docs -->
-        <fileSet>
-            <directory>doc</directory>
-        </fileSet>
-
         <!-- Include licenses -->
         <fileSet>
             <outputDirectory></outputDirectory>
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/TOTPAuthenticationProvider.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/TOTPAuthenticationProvider.java
new file mode 100644
index 0000000..4f18304
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/TOTPAuthenticationProvider.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.auth.totp;
+
+import org.apache.guacamole.auth.totp.user.UserVerificationService;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.totp.user.CodeUsageTrackingService;
+import org.apache.guacamole.auth.totp.user.TOTPUserContext;
+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 TOTP as an additional
+ * authentication factor for users which have already been authenticated by
+ * some other AuthenticationProvider.
+ */
+public class TOTPAuthenticationProvider implements AuthenticationProvider {
+
+    /**
+     * Injector which will manage the object graph of this authentication
+     * provider.
+     */
+    private final Injector injector;
+
+    /**
+     * Creates a new TOTPAuthenticationProvider that verifies users using TOTP.
+     *
+     * @throws GuacamoleException
+     *     If a required property is missing, or an error occurs while parsing
+     *     a property.
+     */
+    public TOTPAuthenticationProvider() throws GuacamoleException {
+
+        // Set up Guice injector.
+        injector = Guice.createInjector(
+            new TOTPAuthenticationProviderModule(this)
+        );
+
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "totp";
+    }
+
+    @Override
+    public Object getResource() {
+        return null;
+    }
+
+    @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 {
+        return null;
+    }
+
+    @Override
+    public UserContext updateUserContext(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+                throws GuacamoleException {
+        return context;
+    }
+
+    @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+
+        UserVerificationService verificationService =
+                injector.getInstance(UserVerificationService.class);
+
+        // Verify identity of user
+        verificationService.verifyIdentity(context, authenticatedUser);
+
+        // User has been verified, and authentication should be allowed to
+        // continue
+        return new TOTPUserContext(context);
+
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return new TOTPUserContext(context);
+    }
+
+    @Override
+    public void shutdown() {
+        injector.getInstance(CodeUsageTrackingService.class).shutdown();
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/TOTPAuthenticationProviderModule.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/TOTPAuthenticationProviderModule.java
new file mode 100644
index 0000000..d1f7f96
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/TOTPAuthenticationProviderModule.java
@@ -0,0 +1,83 @@
+/*
+ * 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.totp;
+
+import org.apache.guacamole.auth.totp.user.UserVerificationService;
+import com.google.inject.AbstractModule;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.totp.conf.ConfigurationService;
+import org.apache.guacamole.auth.totp.user.CodeUsageTrackingService;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.environment.LocalEnvironment;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+
+/**
+ * Guice module which configures TOTP-specific injections.
+ */
+public class TOTPAuthenticationProviderModule extends AbstractModule {
+
+    /**
+     * Guacamole server environment.
+     */
+    private final Environment environment;
+
+    /**
+     * A reference to the TOTPAuthenticationProvider on behalf of which this
+     * module has configured injection.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
+     * Creates a new TOTP authentication provider module which configures
+     * injection for the TOTPAuthenticationProvider.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider for which injection is being configured.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the Guacamole server
+     *     environment.
+     */
+    public TOTPAuthenticationProviderModule(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 TOTP-specific services
+        bind(CodeUsageTrackingService.class);
+        bind(ConfigurationService.class);
+        bind(UserVerificationService.class);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/conf/ConfigurationService.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/conf/ConfigurationService.java
new file mode 100644
index 0000000..8658849
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/conf/ConfigurationService.java
@@ -0,0 +1,161 @@
+/*
+ * 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.totp.conf;
+
+import com.google.inject.Inject;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.properties.IntegerGuacamoleProperty;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
+import org.apache.guacamole.totp.TOTPGenerator;
+
+/**
+ * Service for retrieving configuration information regarding the TOTP
+ * authentication extension.
+ */
+public class ConfigurationService {
+
+    /**
+     * The Guacamole server environment.
+     */
+    @Inject
+    private Environment environment;
+
+    /**
+     * The human-readable name of the entity issuing user accounts. By default,
+     * this will be "Apache Guacamole".
+     */
+    private static final StringGuacamoleProperty TOTP_ISSUER =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "totp-issuer"; }
+
+    };
+
+    /**
+     * The number of digits which should be included in each generated TOTP
+     * code. By default, this will be 6.
+     */
+    private static final IntegerGuacamoleProperty TOTP_DIGITS=
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "totp-digits"; }
+
+    };
+
+    /**
+     * The duration that each generated code should remain valid, in seconds.
+     * By default, this will be 30.
+     */
+    private static final IntegerGuacamoleProperty TOTP_PERIOD =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "totp-period"; }
+
+    };
+
+    /**
+     * The hash algorithm that should be used to generate TOTP codes. By
+     * default, this will be "sha1". Legal values are "sha1", "sha256", and
+     * "sha512".
+     */
+    private static final TOTPModeProperty TOTP_MODE =
+            new TOTPModeProperty() {
+
+        @Override
+        public String getName() { return "totp-mode"; }
+
+    };
+
+    /**
+     * Returns the human-readable name of the entity issuing user accounts. If
+     * not specified, "Apache Guacamole" will be used by default.
+     *
+     * @return
+     *     The human-readable name of the entity issuing user accounts.
+     *
+     * @throws GuacamoleException
+     *     If the "totp-issuer" property cannot be read from
+     *     guacamole.properties.
+     */
+    public String getIssuer() throws GuacamoleException {
+        return environment.getProperty(TOTP_ISSUER, "Apache Guacamole");
+    }
+
+    /**
+     * Returns the number of digits which should be included in each generated
+     * TOTP code. If not specified, 6 will be used by default.
+     *
+     * @return
+     *     The number of digits which should be included in each generated
+     *     TOTP code.
+     *
+     * @throws GuacamoleException
+     *     If the "totp-digits" property cannot be read from
+     *     guacamole.properties.
+     */
+    public int getDigits() throws GuacamoleException {
+
+        // Validate legal number of digits
+        int digits = environment.getProperty(TOTP_DIGITS, 6);
+        if (digits < 6 || digits > 8)
+            throw new GuacamoleServerException("TOTP codes may have no fewer "
+                    + "than 6 digits and no more than 8 digits.");
+
+        return digits;
+
+    }
+
+    /**
+     * Returns the duration that each generated code should remain valid, in
+     * seconds. If not specified, 30 will be used by default.
+     *
+     * @return
+     *     The duration that each generated code should remain valid, in
+     *     seconds.
+     *
+     * @throws GuacamoleException
+     *     If the "totp-period" property cannot be read from
+     *     guacamole.properties.
+     */
+    public int getPeriod() throws GuacamoleException {
+        return environment.getProperty(TOTP_PERIOD, 30);
+    }
+
+    /**
+     * Returns the hash algorithm that should be used to generate TOTP codes. If
+     * not specified, SHA1 will be used by default.
+     *
+     * @return
+     *     The hash algorithm that should be used to generate TOTP codes.
+     *
+     * @throws GuacamoleException
+     *     If the "totp-mode" property cannot be read from
+     *     guacamole.properties.
+     */
+    public TOTPGenerator.Mode getMode() throws GuacamoleException {
+        return environment.getProperty(TOTP_MODE, TOTPGenerator.Mode.SHA1);
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/conf/TOTPModeProperty.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/conf/TOTPModeProperty.java
new file mode 100644
index 0000000..bfe3ef3
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/conf/TOTPModeProperty.java
@@ -0,0 +1,62 @@
+/*
+ * 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.totp.conf;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.properties.GuacamoleProperty;
+import org.apache.guacamole.totp.TOTPGenerator;
+
+/**
+ * A GuacamoleProperty whose value is a TOTP generation method. The string
+ * values "sha1", "sha256", and "sha512" are each parsed to their corresponding
+ * values within the TOTPGenerator.Mode enum. All other string values result in
+ * parse errors.
+ */
+public abstract class TOTPModeProperty
+        implements GuacamoleProperty<TOTPGenerator.Mode> {
+
+    @Override
+    public TOTPGenerator.Mode parseValue(String value)
+            throws GuacamoleException {
+
+        // If no value provided, return null.
+        if (value == null)
+            return null;
+
+        // SHA1
+        if (value.equals("sha1"))
+            return TOTPGenerator.Mode.SHA1;
+
+        // SHA256
+        if (value.equals("sha256"))
+            return TOTPGenerator.Mode.SHA256;
+
+        // SHA512
+        if (value.equals("sha512"))
+            return TOTPGenerator.Mode.SHA512;
+
+        // The provided value is not legal
+        throw new GuacamoleServerException("TOTP mode must be one of "
+                + "\"sha1\", \"sha256\", or \"sha512\".");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/form/AuthenticationCodeField.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/form/AuthenticationCodeField.java
new file mode 100644
index 0000000..1a61e89
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/form/AuthenticationCodeField.java
@@ -0,0 +1,316 @@
+/*
+ * 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.totp.form;
+
+import com.google.common.io.BaseEncoding;
+import com.google.inject.Inject;
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.WriterException;
+import com.google.zxing.client.j2se.MatrixToImageWriter;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import javax.ws.rs.core.UriBuilder;
+import javax.xml.bind.DatatypeConverter;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.totp.user.UserTOTPKey;
+import org.apache.guacamole.auth.totp.conf.ConfigurationService;
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.totp.TOTPGenerator;
+import org.codehaus.jackson.annotate.JsonProperty;
+
+/**
+ * Field which prompts the user for an authentication code generated via TOTP.
+ */
+public class AuthenticationCodeField extends Field {
+
+    /**
+     * The name of the HTTP parameter which will contain the TOTP code provided
+     * by the user to verify their identity.
+     */
+    public static final String PARAMETER_NAME = "guac-totp";
+
+    /**
+     * The unique name associated with this field type.
+     */
+    private static final String FIELD_TYPE_NAME = "GUAC_TOTP_CODE";
+
+    /**
+     * The width of QR codes to generate, in pixels.
+     */
+    private static final int QR_CODE_WIDTH = 256;
+
+    /**
+     * The height of QR codes to generate, in pixels.
+     */
+    private static final int QR_CODE_HEIGHT = 256;
+
+    /**
+     * BaseEncoding which encodes/decodes base32.
+     */
+    private static final BaseEncoding BASE32 = BaseEncoding.base32();
+
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * The TOTP key to expose to the user for the sake of enrollment, if any.
+     * If no such key should be exposed to the user, this will be null.
+     */
+    private UserTOTPKey key;
+
+    /**
+     * Creates a new field which prompts the user for an authentication code
+     * generated via TOTP. The user's TOTP key is not exposed for enrollment.
+     */
+    public AuthenticationCodeField() {
+        super(PARAMETER_NAME, FIELD_TYPE_NAME);
+    }
+
+    /**
+     * Exposes the given key to facilitate enrollment.
+     *
+     * @param key
+     *     The TOTP key to expose to the user for the sake of enrollment.
+     */
+    public void exposeKey(UserTOTPKey key) {
+        this.key = key;
+    }
+
+    /**
+     * Returns the username of the user associated with the key being used to
+     * generate TOTP codes. If the user's key is not being exposed to facilitate
+     * enrollment, this value will not be exposed either.
+     *
+     * @return
+     *     The username of the user associated with the key being used to
+     *     generate TOTP codes, or null if the user's key is not being exposed
+     *     to facilitate enrollment.
+     */
+    public String getUsername() {
+
+        // Do not reveal TOTP mode unless enrollment is in progress
+        if (key == null)
+            return null;
+
+        return key.getUsername();
+
+    }
+
+    /**
+     * Returns the base32-encoded secret key that is being used to generate TOTP
+     * codes for the authenticating user. If the user's key is not being exposed
+     * to facilitate enrollment, this value will not be exposed either.
+     *
+     * @return
+     *     The base32-encoded secret key that is being used to generate TOTP
+     *     codes for the authenticating user, or null if the user's key is not
+     *     being exposed to facilitate enrollment.
+     */
+    public String getSecret() {
+
+        // Do not reveal TOTP mode unless enrollment is in progress
+        if (key == null)
+            return null;
+
+        return BASE32.encode(key.getSecret());
+
+    }
+
+    /**
+     * Returns the number of digits used for each TOTP code. If the user's key
+     * is not being exposed to facilitate enrollment, this value will not be
+     * exposed either.
+     *
+     * @return
+     *     The number of digits used for each TOTP code, or null if the user's
+     *     key is not being exposed to facilitate enrollment.
+     *
+     * @throws GuacamoleException
+     *     If the number of digits cannot be read from guacamole.properties.
+     */
+    public Integer getDigits() throws GuacamoleException {
+
+        // Do not reveal code size unless enrollment is in progress
+        if (key == null)
+            return null;
+
+        return confService.getDigits();
+        
+    }
+
+    /**
+     * Returns the human-readable name of the entity issuing user accounts. If
+     * the user's key is not being exposed to facilitate enrollment, this value
+     * will not be exposed either.
+     *
+     * @return
+     *     The human-readable name of the entity issuing user accounts, or null
+     *     if the user's key is not being exposed to facilitate enrollment.
+     *
+     * @throws GuacamoleException
+     *     If the issuer cannot be read from guacamole.properties.
+     */
+    public String getIssuer() throws GuacamoleException {
+
+        // Do not reveal code issuer unless enrollment is in progress
+        if (key == null)
+            return null;
+
+        return confService.getIssuer();
+
+    }
+
+    /**
+     * Returns the mode that TOTP code generation is operating in. This value
+     * will be one of "SHA1", "SHA256", or "SHA512". If the user's key is not
+     * being exposed to facilitate enrollment, this value will not be exposed
+     * either.
+     *
+     * @return
+     *     The mode that TOTP code generation is operating in, such as "SHA1",
+     *     "SHA256", or "SHA512", or null if the user's key is not being
+     *     exposed to facilitate enrollment.
+     *
+     * @throws GuacamoleException
+     *     If the TOTP mode cannot be read from guacamole.properties.
+     */
+    public TOTPGenerator.Mode getMode() throws GuacamoleException {
+
+        // Do not reveal TOTP mode unless enrollment is in progress
+        if (key == null)
+            return null;
+
+        return confService.getMode();
+
+    }
+
+    /**
+     * Returns the number of seconds that each TOTP code remains valid. If the
+     * user's key is not being exposed to facilitate enrollment, this value will
+     * not be exposed either.
+     *
+     * @return
+     *     The number of seconds that each TOTP code remains valid, or null if
+     *     the user's key is not being exposed to facilitate enrollment.
+     *
+     * @throws GuacamoleException
+     *     If the period cannot be read from guacamole.properties.
+     */
+    public Integer getPeriod() throws GuacamoleException {
+
+        // Do not reveal code period unless enrollment is in progress
+        if (key == null)
+            return null;
+
+        return confService.getPeriod();
+
+    }
+
+    /**
+     * Returns the "otpauth" URI for the secret key used to generate TOTP codes
+     * for the current user. If the secret key is not being exposed to
+     * facilitate enrollment, null is returned.
+     *
+     * @return
+     *     The "otpauth" URI for the secret key used to generate TOTP codes
+     *     for the current user, or null is the secret ket is not being exposed
+     *     to facilitate enrollment.
+     *
+     * @throws GuacamoleException
+     *     If the configuration information required for generating the key URI
+     *     cannot be read from guacamole.properties.
+     */
+    @JsonProperty("keyUri")
+    public URI getKeyURI() throws GuacamoleException {
+
+        // Do not generate a key URI if no key is being exposed
+        if (key == null)
+            return null;
+
+        // Format "otpauth" URL (see https://github.com/google/google-authenticator/wiki/Key-Uri-Format)
+        String issuer = confService.getIssuer();
+        return UriBuilder.fromUri("otpauth://totp/")
+                .path(issuer + ":" + key.getUsername())
+                .queryParam("secret", BASE32.encode(key.getSecret()))
+                .queryParam("issuer", issuer)
+                .queryParam("algorithm", confService.getMode())
+                .queryParam("digits", confService.getDigits())
+                .queryParam("period", confService.getPeriod())
+                .build();
+
+    }
+
+    /**
+     * Returns the URL of a QR code describing the user's TOTP key and
+     * configuration. If the key is not being exposed for enrollment, null is
+     * returned.
+     *
+     * @return 
+     *     The URL of a QR code describing the user's TOTP key and
+     *     configuration, or null if the key is not being exposed for
+     *     enrollment.
+     *
+     * @throws GuacamoleException
+     *     If the configuration information required for generating the QR code
+     *     cannot be read from guacamole.properties.
+     */
+    @JsonProperty("qrCode")
+    public String getQRCode() throws GuacamoleException {
+
+        // Do not generate a QR code if no key is being exposed
+        URI keyURI = getKeyURI();
+        if (keyURI == null)
+            return null;
+
+        ByteArrayOutputStream stream = new ByteArrayOutputStream();
+
+        try {
+
+            // Create QR code writer
+            QRCodeWriter writer = new QRCodeWriter();
+            BitMatrix matrix = writer.encode(keyURI.toString(),
+                    BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT);
+
+            // Produce PNG image of TOTP key text
+            MatrixToImageWriter.writeToStream(matrix, "PNG", stream);
+
+        }
+        catch (WriterException e) {
+            throw new IllegalArgumentException("QR code could not be "
+                    + "generated for TOTP key.", e);
+        }
+        catch (IOException e) {
+            throw new IllegalStateException("Image stream of QR code could "
+                    + "not be written.", e);
+        }
+
+        // Return data URI for generated image
+        return "data:image/png;base64,"
+                + DatatypeConverter.printBase64Binary(stream.toByteArray());
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/CodeUsageTrackingService.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/CodeUsageTrackingService.java
new file mode 100644
index 0000000..c9a94b4
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/CodeUsageTrackingService.java
@@ -0,0 +1,264 @@
+/*
+ * 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.totp.user;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.totp.conf.ConfigurationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service for tracking past valid uses of TOTP codes. An internal thread
+ * periodically walks through records of past codes, removing records which
+ * should be invalid by their own nature (no longer matching codes generated by
+ * the secret key).
+ */
+@Singleton
+public class CodeUsageTrackingService {
+
+    /**
+     * The number of periods during which a previously-used code should remain
+     * unusable. Once this period has elapsed, the code can be reused again if
+     * it is otherwise valid.
+     */
+    private static final int INVALID_INTERVAL = 2;
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(CodeUsageTrackingService.class);
+
+    /**
+     * Executor service which runs the cleanup task.
+     */
+    private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
+
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Map of previously-used codes to the timestamp after which the code can
+     * be used again, providing the TOTP key legitimately generates that code.
+     */
+    private final ConcurrentMap<UsedCode, Long> invalidCodes =
+            new ConcurrentHashMap<UsedCode, Long>();
+
+    /**
+     * Creates a new CodeUsageTrackingService which tracks past valid uses of
+     * TOTP codes on a per-user basis.
+     */
+    public CodeUsageTrackingService() {
+        executor.scheduleAtFixedRate(new CodeEvictionTask(), 1, 1, TimeUnit.MINUTES);
+    }
+
+    /**
+     * Task which iterates through all explicitly-invalidated codes, evicting
+     * those codes which are old enough that they would fail validation against
+     * the secret key anyway.
+     */
+    private class CodeEvictionTask implements Runnable {
+
+        @Override
+        public void run() {
+
+            // Get start time of cleanup check
+            long checkStart = System.currentTimeMillis();
+
+            // For each code still being tracked, remove those which are old
+            // enough that they would fail validation against the secret key
+            Iterator<Map.Entry<UsedCode, Long>> entries = invalidCodes.entrySet().iterator();
+            while (entries.hasNext()) {
+
+                Map.Entry<UsedCode, Long> entry = entries.next();
+                long invalidUntil = entry.getValue();
+
+                // If code is sufficiently old, evict it and check the next one
+                if (checkStart >= invalidUntil)
+                    entries.remove();
+
+            }
+
+            // Log completion and duration
+            logger.debug("TOTP tracking cleanup check completed in {} ms.",
+                    System.currentTimeMillis() - checkStart);
+
+        }
+
+    }
+
+    /**
+     * A valid TOTP code which was previously used by a particular user.
+     */
+    private class UsedCode {
+
+        /**
+         * The username of the user which previously used this code.
+         */
+        private final String username;
+
+        /**
+         * The valid code given by the user.
+         */
+        private final String code;
+
+        /**
+         * Creates a new UsedCode which records the given code as having been
+         * used by the given user.
+         *
+         * @param username
+         *     The username of the user which previously used the given code.
+         *
+         * @param code
+         *     The valid code given by the user.
+         */
+        public UsedCode(String username, String code) {
+            this.username = username;
+            this.code = code;
+        }
+
+        /**
+         * Returns the username of the user which previously used the code
+         * associated with this UsedCode.
+         *
+         * @return
+         *     The username of the user which previously used this code.
+         */
+        public String getUsername() {
+            return username;
+        }
+
+        /**
+         * Returns the valid code given by the user when this UsedCode was
+         * created.
+         *
+         * @return
+         *     The valid code given by the user.
+         */
+        public String getCode() {
+            return code;
+        }
+
+        @Override
+        public int hashCode() {
+            int hash = 7;
+            hash = 79 * hash + this.username.hashCode();
+            hash = 79 * hash + this.code.hashCode();
+            return hash;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+
+            if (this == obj)
+                return true;
+
+            if (obj == null)
+                return false;
+
+            if (getClass() != obj.getClass())
+                return false;
+
+            final UsedCode other = (UsedCode) obj;
+            return username.equals(other.username) && code.equals(other.code);
+
+        }
+
+    }
+
+    /**
+     * Attempts to mark the given code as used. The code MUST have already been
+     * validated against the user's secret key, as this function only verifies
+     * whether the code has been previously used, not whether it is actually
+     * valid. If the code has not previously been used, the code is stored as
+     * having been used by the given user at the current time.
+     *
+     * @param username
+     *     The username of the user who has attempted to use the given valid
+     *     code.
+     *
+     * @param code
+     *     The otherwise-valid code given by the user.
+     *
+     * @return
+     *     true if the code has not previously been used by the given user and
+     *     has now been marked as previously used, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If configuration information necessary to determine the length of
+     *     time a code should be marked as invalid cannot be read from
+     *     guacamole.properties.
+     */
+    public boolean useCode(String username, String code)
+            throws GuacamoleException {
+
+        // Repeatedly attempt to use the given code until an explicit success
+        // or failure has occurred
+        UsedCode usedCode = new UsedCode(username, code);
+        for (;;) {
+
+            // Explicitly invalidate each used code for two periods after its
+            // first successful use
+            long current = System.currentTimeMillis();
+            long invalidUntil = current + confService.getPeriod() * 1000 * INVALID_INTERVAL;
+
+            // Try to use the given code, marking it as used within the map of
+            // now-invalidated codes
+            Long expires = invalidCodes.putIfAbsent(usedCode, invalidUntil);
+            if (expires == null)
+                return true;
+
+            // If the code was already used, fail to use the code if
+            // insufficient time has elapsed since it was last used
+            // successfully
+            if (expires > current)
+                return false;
+
+
+            // Otherwise, the code is actually valid - remove the invalidated
+            // code only if it still has the expected expiration time, and
+            // retry using the code
+            invalidCodes.remove(usedCode, expires);
+
+        }
+
+    }
+
+    /**
+     * Cleans up resources which may be in use by this service in the
+     * background, such as other threads. This function MUST be invoked during
+     * webapp shutdown to avoid leaking these resources.
+     */
+    public void shutdown() {
+        executor.shutdownNow();
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
new file mode 100644
index 0000000..4199d43
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
@@ -0,0 +1,102 @@
+/*
+ * 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.totp.user;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.guacamole.net.auth.DelegatingUser;
+import org.apache.guacamole.net.auth.User;
+
+/**
+ * TOTP-specific User implementation which wraps a User from another extension,
+ * hiding and blocking access to the core attributes used by TOTP.
+ */
+public class TOTPUser extends DelegatingUser {
+
+    /**
+     * The name of the user attribute which stores the TOTP key.
+     */
+    public static final String TOTP_KEY_SECRET_ATTRIBUTE_NAME = "guac-totp-key-secret";
+
+    /**
+     * The name of the user attribute defines whether the TOTP key has been
+     * confirmed by the user, and the user is thus fully enrolled.
+     */
+    public static final String TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME = "guac-totp-key-confirmed";
+
+    /**
+     * The User object wrapped by this TOTPUser.
+     */
+    private final User undecorated;
+
+    /**
+     * Wraps the given User object, hiding and blocking access to the core
+     * attributes used by TOTP.
+     *
+     * @param user
+     *     The User object to wrap.
+     */
+    public TOTPUser(User user) {
+        super(user);
+        this.undecorated = user;
+    }
+
+    /**
+     * Returns the User object wrapped by this TOTPUser.
+     *
+     * @return
+     *     The wrapped User object.
+     */
+    public User getUndecorated() {
+        return undecorated;
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        // Create independent, mutable copy of attributes
+        Map<String, String> attributes =
+                new HashMap<String, String>(super.getAttributes());
+
+        // Do not expose any TOTP-related attributes outside this extension
+        attributes.remove(TOTP_KEY_SECRET_ATTRIBUTE_NAME);
+        attributes.remove(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME);
+
+        // Expose only non-TOTP attributes
+        return attributes;
+
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+
+        // Create independent, mutable copy of attributes
+        attributes = new HashMap<String, String>(attributes);
+
+        // Do not expose any TOTP-related attributes outside this extension
+        attributes.remove(TOTP_KEY_SECRET_ATTRIBUTE_NAME);
+        attributes.remove(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME);
+
+        // Set only non-TOTP attributes
+        super.setAttributes(attributes);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
new file mode 100644
index 0000000..980bbf7
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
@@ -0,0 +1,64 @@
+/*
+ * 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.totp.user;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.DecoratingDirectory;
+import org.apache.guacamole.net.auth.DelegatingUserContext;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.UserContext;
+
+/**
+ * TOTP-specific UserContext implementation which wraps the UserContext of
+ * some other extension, providing (or hiding) additional data.
+ */
+public class TOTPUserContext extends DelegatingUserContext {
+
+    /**
+     * Creates a new TOTPUserContext which wraps the given UserContext,
+     * providing (or hiding) additional TOTP-specific data.
+     *
+     * @param userContext
+     *     The UserContext to wrap.
+     */
+    public TOTPUserContext(UserContext userContext) {
+        super(userContext);
+    }
+
+    @Override
+    public Directory<User> getUserDirectory() throws GuacamoleException {
+        return new DecoratingDirectory<User>(super.getUserDirectory()) {
+
+            @Override
+            protected User decorate(User object) {
+                return new TOTPUser(object);
+            }
+
+            @Override
+            protected User undecorate(User object) {
+                assert(object instanceof TOTPUser);
+                return ((TOTPUser) object).getUndecorated();
+            }
+
+        };
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserTOTPKey.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserTOTPKey.java
new file mode 100644
index 0000000..d7bc903
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserTOTPKey.java
@@ -0,0 +1,148 @@
+/*
+ * 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.totp.user;
+
+import java.security.SecureRandom;
+import java.util.Random;
+
+/**
+ * The key used to generate TOTP codes for a particular user.
+ */
+public class UserTOTPKey {
+
+    /**
+     * Secure source of random bytes.
+     */
+    private static final Random RANDOM = new SecureRandom();
+
+    /**
+     * The username of the user associated with this key.
+     */
+    private final String username;
+
+    /**
+     * Whether the associated secret key has been confirmed by the user. A key
+     * is confirmed once the user has successfully entered a valid TOTP
+     * derived from that key.
+     */
+    private boolean confirmed;
+
+    /**
+     * The base32-encoded TOTP key associated with the user.
+     */
+    private byte[] secret;
+
+    /**
+     * Generates the given number of random bytes.
+     *
+     * @param length
+     *     The number of random bytes to generate.
+     *
+     * @return
+     *     A new array of exactly the given number of random bytes.
+     */
+    private static byte[] generateBytes(int length) {
+        byte[] bytes = new byte[length];
+        RANDOM.nextBytes(bytes);
+        return bytes;
+    }
+
+    /**
+     * Creates a new, unconfirmed, randomly-generated TOTP key having the given
+     * length.
+     *
+     * @param username
+     *     The username of the user associated with this key.
+     *
+     * @param length
+     *     The length of the key to generate, in bytes.
+     */
+    public UserTOTPKey(String username, int length) {
+        this(username, generateBytes(length), false);
+    }
+
+    /**
+     * Creates a new UserTOTPKey containing the given key and having the given
+     * confirmed state.
+     *
+     * @param username
+     *     The username of the user associated with this key.
+     *
+     * @param secret
+     *     The raw binary secret key to be used to generate TOTP codes.
+     *
+     * @param confirmed
+     *     true if the user associated with the key has confirmed that they can
+     *     successfully generate the corresponding TOTP codes (the user has
+     *     been "enrolled"), false otherwise.
+     */
+    public UserTOTPKey(String username, byte[] secret, boolean confirmed) {
+        this.username = username;
+        this.confirmed = confirmed;
+        this.secret = secret;
+    }
+
+    /**
+     * Returns the username of the user associated with this key.
+     *
+     * @return
+     *     The username of the user associated with this key.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Returns the raw binary secret key to be used to generate TOTP codes.
+     *
+     * @return
+     *     The raw binary secret key to be used to generate TOTP codes.
+     */
+    public byte[] getSecret() {
+        return secret;
+    }
+
+    /**
+     * Returns whether the user associated with the key has confirmed that they
+     * can successfully generate the corresponding TOTP codes (the user has
+     * been "enrolled").
+     *
+     * @return
+     *     true if the user has confirmed that they can successfully generate
+     *     the TOTP codes generated by this key, false otherwise.
+     */
+    public boolean isConfirmed() {
+        return confirmed;
+    }
+
+    /**
+     * Sets whether the user associated with the key has confirmed that they
+     * can successfully generate the corresponding TOTP codes (the user has
+     * been "enrolled").
+     *
+     * @param confirmed
+     *     true if the user has confirmed that they can successfully generate
+     *     the TOTP codes generated by this key, false otherwise.
+     */
+    public void setConfirmed(boolean confirmed) {
+        this.confirmed = confirmed;
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java
new file mode 100644
index 0000000..30108e1
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java
@@ -0,0 +1,288 @@
+/*
+ * 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.totp.user;
+
+import com.google.common.io.BaseEncoding;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.security.InvalidKeyException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.guacamole.GuacamoleClientException;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleUnsupportedException;
+import org.apache.guacamole.auth.totp.conf.ConfigurationService;
+import org.apache.guacamole.auth.totp.form.AuthenticationCodeField;
+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.User;
+import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.apache.guacamole.totp.TOTPGenerator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Service for verifying the identity of a user using TOTP.
+ */
+public class UserVerificationService {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(UserVerificationService.class);
+
+    /**
+     * BaseEncoding instance which decoded/encodes base32.
+     */
+    private static final BaseEncoding BASE32 = BaseEncoding.base32();
+
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Service for tracking whether TOTP codes have been used.
+     */
+    @Inject
+    private CodeUsageTrackingService codeService;
+
+    /**
+     * Provider for AuthenticationCodeField instances.
+     */
+    @Inject
+    private Provider<AuthenticationCodeField> codeFieldProvider;
+
+    /**
+     * Retrieves and decodes the base32-encoded TOTP key associated with user
+     * having the given UserContext. If no TOTP key is associated with the user,
+     * a random key is generated and associated with the user. If the extension
+     * storing the user does not support storage of the TOTP key, null is
+     * returned.
+     *
+     * @param context
+     *     The UserContext of the user whose TOTP key should be retrieved.
+     *
+     * @param username
+     *     The username of the user associated with the given UserContext.
+     *
+     * @return
+     *     The TOTP key associated with the user having the given UserContext,
+     *     or null if the extension storing the user does not support storage
+     *     of the TOTP key.
+     *
+     * @throws GuacamoleException
+     *     If a new key is generated, but the extension storing the associated
+     *     user fails while updating the user account.
+     */
+    private UserTOTPKey getKey(UserContext context,
+            String username) throws GuacamoleException {
+
+        // Retrieve attributes from current user
+        User self = context.self();
+        Map<String, String> attributes = context.self().getAttributes();
+
+        // If no key is defined, attempt to generate a new key
+        String secret = attributes.get(TOTPUser.TOTP_KEY_SECRET_ATTRIBUTE_NAME);
+        if (secret == null) {
+
+            // Generate random key for user
+            TOTPGenerator.Mode mode = confService.getMode();
+            UserTOTPKey generated = new UserTOTPKey(username,mode.getRecommendedKeyLength());
+            if (setKey(context, generated))
+                return generated;
+
+            // Fail if key cannot be set
+            return null;
+
+        }
+
+        // Parse retrieved base32 key value
+        byte[] key;
+        try {
+            key = BASE32.decode(secret);
+        }
+
+        // If key is not valid base32, warn but otherwise pretend the key does
+        // not exist
+        catch (IllegalArgumentException e) {
+            logger.warn("TOTP key of user \"{}\" is not valid base32.", self.getIdentifier());
+            logger.debug("TOTP key is not valid base32.", e);
+            return null;
+        }
+
+        // Otherwise, parse value from attributes
+        boolean confirmed = "true".equals(attributes.get(TOTPUser.TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME));
+        return new UserTOTPKey(username, key, confirmed);
+
+    }
+
+    /**
+     * Attempts to store the given TOTP key within the user account of the user
+     * having the given UserContext. As not all extensions will support storage
+     * of arbitrary attributes, this operation may fail.
+     *
+     * @param context
+     *     The UserContext associated with the user whose TOTP key is to be
+     *     stored.
+     *
+     * @param key
+     *     The TOTP key to store.
+     *
+     * @return
+     *     true if the TOTP key was successfully stored, false if the extension
+     *     handling storage does not support storage of the key.
+     *
+     * @throws GuacamoleException
+     *     If the extension handling storage fails internally while attempting
+     *     to update the user.
+     */
+    private boolean setKey(UserContext context, UserTOTPKey key)
+            throws GuacamoleException {
+
+        // Get mutable set of attributes
+        User self = context.self();
+        Map<String, String> attributes = new HashMap<String, String>();
+
+        // Set/overwrite current TOTP key state
+        attributes.put(TOTPUser.TOTP_KEY_SECRET_ATTRIBUTE_NAME, BASE32.encode(key.getSecret()));
+        attributes.put(TOTPUser.TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, key.isConfirmed() ? "true" : "false");
+        self.setAttributes(attributes);
+
+        // Confirm that attributes have actually been set
+        Map<String, String> setAttributes = self.getAttributes();
+        if (!setAttributes.containsKey(TOTPUser.TOTP_KEY_SECRET_ATTRIBUTE_NAME)
+                || !setAttributes.containsKey(TOTPUser.TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME))
+            return false;
+
+        // Update user object
+        try {
+            context.getUserDirectory().update(self);
+        }
+        catch (GuacamoleUnsupportedException e) {
+            logger.debug("Extension storage for user is explicitly read-only. "
+                    + "Cannot update attributes to store TOTP key.", e);
+            return false;
+        }
+
+        // TOTP key successfully stored/updated
+        return true;
+
+    }
+
+    /**
+     * Verifies the identity of the given user using TOTP. If a authentication
+     * code from the user's TOTP device has not already been provided, a code is
+     * requested in the form of additional expected credentials. Any provided
+     * code is cryptographically verified. If no code is present, or the
+     * received code is invalid, an exception is thrown.
+     *
+     * @param context
+     *     The UserContext provided for the user by another authentication
+     *     extension.
+     *
+     * @param authenticatedUser
+     *     The user whose identity should be verified using TOTP.
+     *
+     * @throws GuacamoleException
+     *     If required TOTP-specific configuration options are missing or
+     *     malformed, or if the user's identity cannot be verified.
+     */
+    public void verifyIdentity(UserContext context,
+            AuthenticatedUser authenticatedUser) throws GuacamoleException {
+
+        // Ignore anonymous users
+        String username = authenticatedUser.getIdentifier();
+        if (username.equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
+            return;
+
+        // Ignore users which do not have an associated key
+        UserTOTPKey key = getKey(context, username);
+        if (key == null)
+            return;
+
+        // Pull the original HTTP request used to authenticate
+        Credentials credentials = authenticatedUser.getCredentials();
+        HttpServletRequest request = credentials.getRequest();
+
+        // Retrieve TOTP from request
+        String code = request.getParameter(AuthenticationCodeField.PARAMETER_NAME);
+
+        // If no TOTP provided, request one
+        if (code == null) {
+
+            AuthenticationCodeField field = codeFieldProvider.get();
+
+            // If the user hasn't completed enrollment, request that they do
+            if (!key.isConfirmed()) {
+                field.exposeKey(key);
+                throw new GuacamoleInsufficientCredentialsException(
+                        "TOTP.INFO_ENROLL_REQUIRED", new CredentialsInfo(
+                            Collections.<Field>singletonList(field)
+                        ));
+            }
+
+            // Otherwise simply request the user's authentication code
+            throw new GuacamoleInsufficientCredentialsException(
+                    "TOTP.INFO_CODE_REQUIRED", new CredentialsInfo(
+                        Collections.<Field>singletonList(field)
+                    ));
+
+        }
+
+        try {
+
+            // Get generator based on user's key and provided configuration
+            TOTPGenerator totp = new TOTPGenerator(key.getSecret(),
+                    confService.getMode(), confService.getDigits());
+
+            // Verify provided TOTP against value produced by generator
+            if ((code.equals(totp.generate()) || code.equals(totp.previous()))
+                    && codeService.useCode(username, code)) {
+
+                // Record key as confirmed, if it hasn't already been so recorded
+                if (!key.isConfirmed()) {
+                    key.setConfirmed(true);
+                    setKey(context, key);
+                }
+
+                // User has been verified
+                return;
+
+            }
+
+        }
+        catch (InvalidKeyException e) {
+            logger.warn("User \"{}\" is associated with an invalid TOTP key.", username);
+            logger.debug("TOTP key is not valid.", e);
+        }
+
+        // Provided code is not valid
+        throw new GuacamoleClientException("TOTP.INFO_VERIFICATION_FAILED");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/totp/TOTPGenerator.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/totp/TOTPGenerator.java
new file mode 100644
index 0000000..d075c8a
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/totp/TOTPGenerator.java
@@ -0,0 +1,456 @@
+/*
+ * 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.totp;
+
+import com.google.common.primitives.Longs;
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/*
+ * NOTE: This TOTP implementation is based on the TOTP reference implementation
+ * provided by the IETF Trust at:
+ *
+ * https://tools.ietf.org/id/draft-mraihi-totp-timebased-07.html#Section-Reference-Impl
+ */
+
+/*
+ * Copyright (c) 2011 IETF Trust and the persons identified as authors
+ * of the code. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ *  - Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ *
+ *  - 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.
+ *
+ *  - Neither the name of Internet Society, IETF or IETF Trust, nor the names
+ *    of specific contributors, may be used to endorse or promote products
+ *    derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “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 COPYRIGHT OWNER OR CONTRIBUTORS 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.
+ */
+
+/**
+ * Generator which uses the TOTP algorithm to generate authentication codes.
+ */
+public class TOTPGenerator {
+
+    /**
+     * The default time to use as the basis for comparison when transforming
+     * provided TOTP timestamps into counter values required for HOTP, in
+     * seconds since midnight, 1970-01-01, UTC (UNIX epoch).
+     */
+    public static final long DEFAULT_START_TIME = 0;
+
+    /**
+     * The default frequency at which new TOTP codes should be generated (and
+     * old codes invalidated), in seconds.
+     */
+    public static final long DEFAULT_TIME_STEP = 30;
+
+    /**
+     * The TOTP generation mode. The mode dictates the hash function which
+     * should be used to generate authentication codes, as well as the required
+     * key size.
+     */
+    private final Mode mode;
+
+    /**
+     * The shared key to use to generate authentication codes. The size
+     * required for this key depends on the generation mode.
+     */
+    private final Key key;
+
+    /**
+     * The length of codes to generate, in digits.
+     */
+    private final int length;
+
+    /**
+     * The base time against which the timestamp specified for each TOTP
+     * should be compared to produce the corresponding HOTP counter value, in
+     * seconds since midnight, 1970-01-01, UTC (UNIX epoch). This is the value
+     * value referred to as "T0" in the TOTP specification.
+     */
+    private final long startTime;
+
+    /**
+     * The frequency that new TOTP codes should be generated and invalidated,
+     * in seconds. This is the value referred to as "X" in the TOTP
+     * specification.
+     */
+    private final long timeStep;
+
+    /**
+     * The operating mode for TOTP, defining the hash algorithm to be used.
+     */
+    public enum Mode {
+
+        /**
+         * TOTP mode which generates hashes using SHA1. TOTP in SHA1 mode
+         * requires 160-bit keys.
+         */
+        SHA1("HmacSHA1", 20),
+
+        /**
+         * TOTP mode which generates hashes using SHA256. TOTP in SHA256 mode
+         * requires 256-bit keys.
+         */
+        SHA256("HmacSHA256", 32),
+
+        /**
+         * TOTP mode which generates hashes using SHA512. TOTP in SHA512 mode
+         * requires 512-bit keys.
+         */
+        SHA512("HmacSHA512", 64);
+
+        /**
+         * The name of the HMAC algorithm which the TOTP implementation should
+         * use when operating in this mode, in the format required by
+         * Mac.getInstance().
+         */
+        private final String algorithmName;
+
+        /**
+         * The recommended length of keys generated for TOTP in this mode, in
+         * bytes. Keys are recommended to be the same length as the hash
+         * involved.
+         */
+        private final int recommendedKeyLength;
+
+        /**
+         * Creates a new TOTP operating mode which is associated with the
+         * given HMAC algorithm.
+         *
+         * @param algorithmName
+         *     The name of the HMAC algorithm which the TOTP implementation
+         *     should use when operating in this mode, in the format required
+         *     by Mac.getInstance().
+         *
+         * @param recommendedKeyLength
+         *     The recommended length of keys generated for TOTP in this mode,
+         *     in bytes.
+         */
+        private Mode(String algorithmName, int recommendedKeyLength) {
+            this.algorithmName = algorithmName;
+            this.recommendedKeyLength = recommendedKeyLength;
+        }
+
+        /**
+         * Returns the name of the HMAC algorithm which the TOTP implementation
+         * should use when operating in this mode. The name returned will be
+         * in the format required by Mac.getInstance().
+         *
+         * @return
+         *     The name of the HMAC algorithm which the TOTP implementation
+         *     should use.
+         */
+        public String getAlgorithmName() {
+            return algorithmName;
+        }
+
+        /**
+         * Returns the recommended length of keys generated for TOTP in this
+         * mode, in bytes. Keys are recommended to be the same length as the
+         * hash involved.
+         *
+         * @return
+         *     The recommended length of keys generated for TOTP in this mode,
+         *     in bytes.
+         */
+        public int getRecommendedKeyLength() {
+            return recommendedKeyLength;
+        }
+
+    }
+
+    /**
+     * Creates a new TOTP generator which uses the given shared key to generate
+     * authentication codes. The provided generation mode dictates the size of
+     * the key required, while the given start time and time step dictate how
+     * timestamps provided for code generation are converted to the counter
+     * value used by HOTP (the algorithm which forms the basis of TOTP).
+     *
+     * @param key
+     *     The shared key to use to generate authentication codes.
+     *
+     * @param mode
+     *     The mode in which the TOTP algorithm should operate.
+     *
+     * @param length
+     *     The length of the codes to generate, in digits. As required
+     *     by the specification, this value MUST be at least 6 but no greater
+     *     than 8.
+     *
+     * @param startTime
+     *     The base time against which the timestamp specified for each TOTP
+     *     should be compared to produce the corresponding HOTP counter value,
+     *     in seconds since midnight, 1970-01-01, UTC (UNIX epoch). This is the
+     *     value referred to as "T0" in the TOTP specification.
+     *
+     * @param timeStep
+     *     The frequency that new TOTP codes should be generated and
+     *     invalidated, in seconds. This is the value referred to as "X" in the
+     *     TOTP specification.
+     *
+     * @throws InvalidKeyException
+     *     If the provided key is invalid for the requested TOTP mode.
+     */
+    public TOTPGenerator(byte[] key, Mode mode, int length, long startTime,
+            long timeStep) throws InvalidKeyException {
+
+        // Validate length is within spec
+        if (length < 6 || length > 8)
+            throw new IllegalArgumentException("TOTP codes must be at least 6 "
+                    + "digits and no more than 8 digits.");
+
+        this.key = new SecretKeySpec(key, "RAW");
+        this.mode = mode;
+        this.length = length;
+        this.startTime = startTime;
+        this.timeStep = timeStep;
+
+        // Verify key validity
+        getMacInstance(this.mode, this.key);
+
+    }
+
+    /**
+     * Creates a new TOTP generator which uses the given shared key to generate
+     * authentication codes. The provided generation mode dictates the size of
+     * the key required. The start time and time step used to produce the
+     * counter value used by HOTP (the algorithm which forms the basis of TOTP)
+     * are set to the default values recommended by the TOTP specification (0
+     * and 30 respectively).
+     *
+     * @param key
+     *     The shared key to use to generate authentication codes.
+     *
+     * @param mode
+     *     The mode in which the TOTP algorithm should operate.
+     *
+     * @param length
+     *     The length of the codes to generate, in digits. As required
+     *     by the specification, this value MUST be at least 6 but no greater
+     *     than 8.
+     *
+     * @throws InvalidKeyException
+     *     If the provided key is invalid for the requested TOTP mode.
+     */
+    public TOTPGenerator(byte[] key, Mode mode, int length)
+            throws InvalidKeyException {
+        this(key, mode, length, DEFAULT_START_TIME, DEFAULT_TIME_STEP);
+    }
+
+    /**
+     * Returns a new Mac instance which produces message authentication codes
+     * using the given secret key and the algorithm required by the given TOTP
+     * mode.
+     *
+     * @param mode
+     *     The TOTP mode which dictates the HMAC algorithm to be used.
+     *
+     * @param key
+     *     The secret key to use to produce message authentication codes.
+     *
+     * @return
+     *     A new Mac instance which produces message authentication codes
+     *     using the given secret key and the algorithm required by the given
+     *     TOTP mode.
+     *
+     * @throws InvalidKeyException
+     *     If the provided key is invalid for the requested TOTP mode.
+     */
+    private static Mac getMacInstance(Mode mode, Key key)
+            throws InvalidKeyException {
+
+        try {
+            Mac hmac = Mac.getInstance(mode.getAlgorithmName());
+            hmac.init(key);
+            return hmac;
+        }
+        catch (NoSuchAlgorithmException e) {
+            throw new UnsupportedOperationException("Support for the HMAC "
+                    + "algorithm required for TOTP in " + mode + " mode is "
+                    + "missing.", e);
+        }
+
+    }
+
+    /**
+     * Calculates the HMAC for the given message using the key and algorithm
+     * provided when this TOTPGenerator was created.
+     *
+     * @param message
+     *     The message to calculate the HMAC of.
+     *
+     * @return
+     *     The HMAC of the given message.
+     */
+    private byte[] getHMAC(byte[] message) {
+
+        try {
+            return getMacInstance(mode, key).doFinal(message);
+        }
+        catch (InvalidKeyException e) {
+
+            // As the key is verified during construction of the TOTPGenerator,
+            // this should never happen
+            throw new IllegalStateException("Provided key became invalid after "
+                    + "passing validation.", e);
+
+        }
+
+    }
+
+    /**
+     * Given an arbitrary integer value, returns a code containing the decimal
+     * representation of that value and exactly the given number of digits. If
+     * the given value has more than the desired number of digits, leading
+     * digits will be truncated to reduce the length. If the given value has
+     * fewer than the desired number of digits, leading zeroes will be added to
+     * increase the length.
+     *
+     * @param value
+     *     The value to convert into a decimal code of the given length.
+     *
+     * @param length
+     *     The number of digits to include in the code.
+     *
+     * @return
+     *     A code containing the decimal value of the given integer, truncated
+     *     or padded such that exactly the given number of digits are present.
+     */
+    private String toCode(int value, int length) {
+
+        // Convert value to simple integer string
+        String valueString = Integer.toString(value);
+
+        // If the resulting string is too long, truncate to the last N digits
+        if (valueString.length() > length)
+            return valueString.substring(valueString.length() - length);
+
+        // Otherwise, add zeroes until the desired length is reached
+        StringBuilder builder = new StringBuilder(length);
+        for (int i = valueString.length(); i < length; i++)
+            builder.append('0');
+
+        // Return the padded integer string
+        builder.append(valueString);
+        return builder.toString();
+
+    }
+
+    /**
+     * Generates a TOTP code of the given length using the given absolute
+     * timestamp rather than the current system time.
+     *
+     * @param time
+     *     The absolute timestamp to use to generate the TOTP code, in seconds
+     *     since midnight, 1970-01-01, UTC (UNIX epoch).
+     *
+     * @return
+     *     The TOTP code which corresponds to the given timestamp, having
+     *     exactly the given length.
+     *
+     * @throws IllegalArgumentException
+     *     If the given length is invalid as defined by the TOTP specification.
+     */
+    public String generate(long time) {
+
+        // Calculate HOTP counter value based on provided time
+        long counter = (time - startTime) / timeStep;
+        byte[] hash = getHMAC(Longs.toByteArray(counter));
+
+        // Calculate HOTP value as defined by section 5.2 of RFC 4226:
+        // https://tools.ietf.org/html/rfc4226#section-5.2
+        int offset = hash[hash.length - 1] & 0xF;
+        int binary
+                = ((hash[offset]     & 0x7F) << 24)
+                | ((hash[offset + 1] & 0xFF) << 16)
+                | ((hash[offset + 2] & 0xFF) << 8)
+                |  (hash[offset + 3] & 0xFF);
+
+        // Truncate or pad the value accordingly
+        return toCode(binary, length);
+
+    }
+
+    /**
+     * Generates a TOTP code of the given length using the current system time.
+     *
+     * @return
+     *     The TOTP code which corresponds to the current system time, having
+     *     exactly the given length.
+     *
+     * @throws IllegalArgumentException
+     *     If the given length is invalid as defined by the TOTP specification.
+     */
+    public String generate() {
+        return generate(System.currentTimeMillis() / 1000);
+    }
+
+    /**
+     * Returns the TOTP code which would have been generated immediately prior
+     * to the code returned by invoking generate() with the given timestamp.
+     *
+     * @param time
+     *     The absolute timestamp to use to generate the TOTP code, in seconds
+     *     since midnight, 1970-01-01, UTC (UNIX epoch).
+     *
+     * @return
+     *     The TOTP code which would have been generated immediately prior to
+     *     the the code returned by invoking generate() with the given
+     *     timestamp.
+     */
+    public String previous(long time) {
+        return generate(Math.max(startTime, time - timeStep));
+    }
+
+    /**
+     * Returns the TOTP code which would have been generated immediately prior
+     * to the code currently being returned by generate().
+     *
+     * @return
+     *     The TOTP code which would have been generated immediately prior to
+     *     the code currently being returned by generate().
+     */
+    public String previous() {
+        return previous(System.currentTimeMillis() / 1000);
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/resources/config/totpConfig.js b/extensions/guacamole-auth-totp/src/main/resources/config/totpConfig.js
new file mode 100644
index 0000000..54bb56c
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/config/totpConfig.js
@@ -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 TOTP-specific field types.
+ */
+angular.module('guacTOTP').config(['formServiceProvider',
+    function guacTOTPConfig(formServiceProvider) {
+
+    // Define field for the TOTP code provided by the user
+    formServiceProvider.registerFieldType('GUAC_TOTP_CODE', {
+        module      : 'guacTOTP',
+        controller  : 'authenticationCodeFieldController',
+        templateUrl : 'app/ext/totp/templates/authenticationCodeField.html'
+    });
+
+}]);
diff --git a/extensions/guacamole-auth-totp/src/main/resources/controllers/authenticationCodeFieldController.js b/extensions/guacamole-auth-totp/src/main/resources/controllers/authenticationCodeFieldController.js
new file mode 100644
index 0000000..8f19c9f
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/controllers/authenticationCodeFieldController.js
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Controller for the "GUAC_TOTP_CODE" field which prompts the user to enter
+ * the code generated by their authentication device.
+ */
+angular.module('guacTOTP').controller('authenticationCodeFieldController', ['$scope', '$window',
+    function authenticationCodeFieldController($scope, $window) {
+
+    /**
+     * The secret key split into groups of at most four characters each, or
+     * null if the secret key is not exposed.
+     *
+     * @type String[]
+     */
+    $scope.groupedSecret = $scope.field.secret && $scope.field.secret.match(/.{1,4}/g);
+
+    /**
+     * Whether the raw details of the secret key and TOTP configuration should
+     * be shown. By default, such details are hidden. If the secret key is not
+     * exposed, this property has no effect.
+     */
+    $scope.detailsShown = false;
+
+    /**
+     * Shows the raw details of the secret key and TOTP configuration. If the
+     * secret key is not exposed, or the details are already shown, this
+     * function has no effect.
+     */
+    $scope.showDetails = function showDetails() {
+        $scope.detailsShown = true;
+    };
+
+    /**
+     * Hides the raw details of the secret key and TOTP configuration. If the
+     * details are already hidden, this function has no effect.
+     */
+    $scope.hideDetails = function hideDetails() {
+        $scope.detailsShown = false;
+    };
+
+    /**
+     * Attempts to open the "otpauth" URI containing the user's TOTP key,
+     * invoking whichever application may be installed locally for handling
+     * multi-factor authentication.
+     */
+    $scope.openKeyURI = function openKeyURI() {
+        $window.open($scope.field.keyUri);
+    };
+
+}]);
diff --git a/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..dee8291
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json
@@ -0,0 +1,28 @@
+{
+
+    "guacamoleVersion" : "0.9.14",
+
+    "name"      : "TOTP TFA Authentication Backend",
+    "namespace" : "totp",
+
+    "authProviders" : [
+        "org.apache.guacamole.auth.totp.TOTPAuthenticationProvider"
+    ],
+
+    "translations" : [
+        "translations/en.json"
+    ],
+
+    "js" : [
+        "totp.min.js"
+    ],
+
+    "css" : [
+        "totp.min.css"
+    ],
+
+    "resources" : {
+        "templates/authenticationCodeField.html" : "text/html"
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/resources/license.txt b/extensions/guacamole-auth-totp/src/main/resources/license.txt
new file mode 100644
index 0000000..042f3ce
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/license.txt
@@ -0,0 +1,18 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git a/extensions/guacamole-auth-totp/src/main/resources/styles/totp.css b/extensions/guacamole-auth-totp/src/main/resources/styles/totp.css
new file mode 100644
index 0000000..c8a2505
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/styles/totp.css
@@ -0,0 +1,88 @@
+/*
+ * 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.
+ */
+
+.totp-enroll p, .totp-details {
+    font-size: 0.8em;
+}
+
+.totp-qr-code {
+    text-align: center;
+}
+
+.totp-qr-code img {
+    margin: 1em;
+    border: 1px solid rgba(0,0,0,0.25);
+    box-shadow: 1px 1px 2px rgba(0,0,0,0.25);
+    cursor: pointer;
+}
+
+h3.totp-details-header {
+    font-size: 0.8em;
+}
+
+h3.totp-details-header::before {
+    content: '▸ ';
+}
+
+.totp-details-visible h3.totp-details-header::before {
+    content: '▾ ';
+}
+
+.totp-details,
+.totp-hide-details {
+    display: none;
+}
+
+.totp-details-visible .totp-details {
+    display: table;
+}
+
+.totp-details-visible .totp-hide-details {
+    display: inline;
+}
+
+.totp-details-visible .totp-show-details {
+    display: none;
+}
+
+.totp-hide-details, .totp-show-details {
+    color: blue;
+    text-decoration: underline;
+    cursor: pointer;
+    margin: 0 0.25em;
+    font-weight: normal;
+}
+
+.totp-details {
+    margin: 0 auto;
+}
+
+.totp-details th {
+    padding-right: 0.25em;
+    text-align: left;
+}
+
+.totp-details td {
+    font-family: monospace;
+}
+
+.totp-detail {
+    display: inline-block;
+    margin: 0 0.25em;
+}
diff --git a/extensions/guacamole-auth-totp/src/main/resources/templates/authenticationCodeField.html b/extensions/guacamole-auth-totp/src/main/resources/templates/authenticationCodeField.html
new file mode 100644
index 0000000..c493a20
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/templates/authenticationCodeField.html
@@ -0,0 +1,47 @@
+<div class="totp-code-field" ng-class="{ 'totp-details-visible' : detailsShown }">
+
+    <!-- Enroll user if necessary -->
+    <div class="totp-enroll" ng-show="field.qrCode">
+
+        <p translate="TOTP.HELP_ENROLL_BARCODE"></p>
+
+        <!-- Barcode and key details -->
+        <div class="totp-qr-code"><img ng-src="{{field.qrCode}}" ng-click="openKeyURI()"></div>
+        <h3 class="totp-details-header">
+            {{'TOTP.SECTION_HEADER_DETAILS' | translate}}
+            <a class="totp-show-details" ng-click="showDetails()">{{'TOTP.ACTION_SHOW_DETAILS' | translate}}</a>
+            <a class="totp-hide-details" ng-click="hideDetails()">{{'TOTP.ACTION_HIDE_DETAILS' | translate}}</a>
+        </h3>
+        <table class="totp-details">
+            <tr>
+                <th>{{'TOTP.FIELD_HEADER_SECRET_KEY' | translate}}</th>
+                <td><span ng-repeat="group in groupedSecret"
+                          class="totp-detail">{{ group }}</span></td>
+            </tr>
+            <tr>
+                <th>{{'TOTP.FIELD_HEADER_DIGITS' | translate}}</th>
+                <td><span class="totp-detail">{{ field.digits }}</span></td>
+            </tr>
+            <tr>
+                <th>{{'TOTP.FIELD_HEADER_ALGORITHM' | translate}}</th>
+                <td><span class="totp-detail">{{ field.mode }}</span></td>
+            </tr>
+            <tr>
+                <th>{{'TOTP.FIELD_HEADER_INTERVAL' | translate}}</th>
+                <td><span class="totp-detail">{{ field.period }}</span></td>
+            </tr>
+        </table>
+
+        <p translate="TOTP.HELP_ENROLL_VERIFY"
+           translate-values="{ DIGITS : field.digits }"></p>
+
+    </div>
+
+    <!-- Field for entry of the current TOTP code -->
+    <div class="totp-code">
+        <input type="text"
+               placeholder="{{'TOTP.FIELD_PLACEHOLDER_CODE' |translate}}"
+               ng-model="model" autocorrect="off" autocapitalize="off"/>
+    </div>
+
+</div>
diff --git a/extensions/guacamole-auth-totp/src/main/resources/totpModule.js b/extensions/guacamole-auth-totp/src/main/resources/totpModule.js
new file mode 100644
index 0000000..c6a0c7e
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/totpModule.js
@@ -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 TOTP multi-factor authentication.
+ */
+angular.module('guacTOTP', [
+    'form'
+]);
+
+// Ensure the guacTOTP module is loaded along with the rest of the app
+angular.module('index').requires.push('guacTOTP');
diff --git a/extensions/guacamole-auth-totp/src/main/resources/translations/en.json b/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
new file mode 100644
index 0000000..6f73aa0
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
@@ -0,0 +1,34 @@
+{
+
+    "DATA_SOURCE_TOTP" : {
+        "NAME" : "TOTP TFA Backend"
+    },
+
+    "LOGIN" : {
+        "FIELD_HEADER_GUAC_TOTP" : ""
+    },
+
+    "TOTP" : {
+
+        "ACTION_HIDE_DETAILS" : "Hide",
+        "ACTION_SHOW_DETAILS" : "Show",
+
+        "FIELD_HEADER_ALGORITHM"  : "Algorithm:",
+        "FIELD_HEADER_DIGITS"     : "Digits:",
+        "FIELD_HEADER_INTERVAL"   : "Interval:",
+        "FIELD_HEADER_SECRET_KEY" : "Secret Key:",
+
+        "FIELD_PLACEHOLDER_CODE" : "Authentication Code",
+
+        "INFO_CODE_REQUIRED"       : "Please enter your authentication code to verify your identity.",
+        "INFO_ENROLL_REQUIRED"     : "Multi-factor authentication has been enabled on your account.",
+        "INFO_VERIFICATION_FAILED" : "Verification failed. Please try again.",
+
+        "HELP_ENROLL_BARCODE" : "To complete the enrollment process, scan the barcode below with the two-factor authentication app on your phone or device.",
+        "HELP_ENROLL_VERIFY"  : "After scanning the barcode, enter the {DIGITS}-digit authentication code displayed to verify that enrollment was successful.",
+
+        "SECTION_HEADER_DETAILS" : "Details:"
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/test/java/org/apache/guacamole/totp/TOTPGeneratorTest.java b/extensions/guacamole-auth-totp/src/test/java/org/apache/guacamole/totp/TOTPGeneratorTest.java
new file mode 100644
index 0000000..9266ad7
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/test/java/org/apache/guacamole/totp/TOTPGeneratorTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.totp;
+
+import java.security.InvalidKeyException;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+/*
+ * NOTE: The tests for this TOTP implementation is based on the TOTP reference
+ * implementation provided by the IETF Trust at:
+ *
+ * https://tools.ietf.org/id/draft-mraihi-totp-timebased-07.html#Section-Reference-Impl
+ */
+
+/*
+ * Copyright (c) 2011 IETF Trust and the persons identified as authors
+ * of the code. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ *  - Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ *
+ *  - 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.
+ *
+ *  - Neither the name of Internet Society, IETF or IETF Trust, nor the names
+ *    of specific contributors, may be used to endorse or promote products
+ *    derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “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 COPYRIGHT OWNER OR CONTRIBUTORS 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.
+ */
+
+/**
+ * Test which verifies the correctness of the TOTPGenerator class against the
+ * test inputs and results provided in the IETF reference implementation and
+ * spec for TOTP:
+ *
+ * https://tools.ietf.org/id/draft-mraihi-totp-timebased-07.html#Section-Test-Vectors
+ */
+public class TOTPGeneratorTest {
+
+    /**
+     * Verifies the results of generating authentication codes using the TOTP
+     * algorithm in SHA1 mode.
+     */
+    @Test
+    public void testGenerateSHA1() {
+
+        // 160-bit key consisting of the bytes "12345678901234567890" repeated
+        // as necessary
+        final byte[] key = {
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0'
+        };
+
+        try {
+            final TOTPGenerator totp = new TOTPGenerator(key, TOTPGenerator.Mode.SHA1, 8);
+            assertEquals("94287082", totp.generate(59));
+            assertEquals("07081804", totp.generate(1111111109));
+            assertEquals("14050471", totp.generate(1111111111));
+            assertEquals("89005924", totp.generate(1234567890));
+            assertEquals("69279037", totp.generate(2000000000));
+            assertEquals("65353130", totp.generate(20000000000L));
+        }
+        catch (InvalidKeyException e) {
+            fail("SHA1 test key is invalid.");
+        }
+
+
+    }
+
+    /**
+     * Verifies the results of generating authentication codes using the TOTP
+     * algorithm in SHA256 mode.
+     */
+    @Test
+    public void testGenerateSHA256() {
+
+        // 256-bit key consisting of the bytes "12345678901234567890" repeated
+        // as necessary
+        final byte[] key = {
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2'
+        };
+
+        try {
+            final TOTPGenerator totp = new TOTPGenerator(key, TOTPGenerator.Mode.SHA256, 8);
+            assertEquals("46119246", totp.generate(59));
+            assertEquals("68084774", totp.generate(1111111109));
+            assertEquals("67062674", totp.generate(1111111111));
+            assertEquals("91819424", totp.generate(1234567890));
+            assertEquals("90698825", totp.generate(2000000000));
+            assertEquals("77737706", totp.generate(20000000000L));
+        }
+        catch (InvalidKeyException e) {
+            fail("SHA256 test key is invalid.");
+        }
+
+    }
+
+    /**
+     * Verifies the results of generating authentication codes using the TOTP
+     * algorithm in SHA512 mode.
+     */
+    @Test
+    public void testGenerateSHA512() {
+
+        // 512-bit key consisting of the bytes "12345678901234567890" repeated
+        // as necessary
+        final byte[] key = {
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
+            '1', '2', '3', '4'
+        };
+
+        try {
+            final TOTPGenerator totp = new TOTPGenerator(key, TOTPGenerator.Mode.SHA512, 8);
+            assertEquals("90693936", totp.generate(59));
+            assertEquals("25091201", totp.generate(1111111109));
+            assertEquals("99943326", totp.generate(1111111111));
+            assertEquals("93441116", totp.generate(1234567890));
+            assertEquals("38618901", totp.generate(2000000000));
+            assertEquals("47863826", totp.generate(20000000000L));
+        }
+        catch (InvalidKeyException e) {
+            fail("SHA512 test key is invalid.");
+        }
+
+    }
+
+}
diff --git a/guacamole-common-js/pom.xml b/guacamole-common-js/pom.xml
index 10c8c44..0eeb588 100644
--- a/guacamole-common-js/pom.xml
+++ b/guacamole-common-js/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-common-js</artifactId>
     <packaging>pom</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-common-js</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <description>
         The base JavaScript API of the Guacamole project, providing JavaScript
@@ -47,25 +47,11 @@
 
     <!-- Git repository -->
     <scm>
-        <url>https://github.com/apache/incubator-guacamole-client</url>
-        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/incubator-guacamole-client.git</connection>
+        <url>https://github.com/apache/guacamole-client</url>
+        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/guacamole-client.git</connection>
 
     </scm>
 
-    <!-- Developers -->
-    <developers>
-        <developer>
-            <id>mike.jumper</id>
-            <name>Michael Jumper</name>
-            <email>mike.jumper@guac-dev.org</email>
-        </developer>
-        <developer>
-            <id>james.muehlner</id>
-            <name>James Muehlner</name>
-            <email>james.muehlner@guac-dev.org</email>
-        </developer>
-    </developers>
-
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
diff --git a/guacamole-common-js/src/main/webapp/modules/AudioRecorder.js b/guacamole-common-js/src/main/webapp/modules/AudioRecorder.js
index 954b79f..d91bf50 100644
--- a/guacamole-common-js/src/main/webapp/modules/AudioRecorder.js
+++ b/guacamole-common-js/src/main/webapp/modules/AudioRecorder.js
@@ -181,16 +181,20 @@
      */
     var context = Guacamole.AudioContextFactory.getAudioContext();
 
-    /**
-     * A function which directly invokes the browser's implementation of
-     * navigator.getUserMedia() with all provided parameters.
-     *
-     * @type Function
-     */
-    var getUserMedia = (navigator.getUserMedia
-            || navigator.webkitGetUserMedia
-            || navigator.mozGetUserMedia
-            || navigator.msGetUserMedia).bind(navigator);
+    // Some browsers do not implement navigator.mediaDevices - this
+    // shims in this functionality to ensure code compatibility.
+    if (!navigator.mediaDevices)
+        navigator.mediaDevices = {};
+
+    // Browsers that either do not implement navigator.mediaDevices
+    // at all or do not implement it completely need the getUserMedia
+    // method defined.  This shims in this function by detecting
+    // one of the supported legacy methods.
+    if (!navigator.mediaDevices.getUserMedia)
+        navigator.mediaDevices.getUserMedia = (navigator.getUserMedia
+                || navigator.webkitGetUserMedia
+                || navigator.mozGetUserMedia
+                || navigator.msGetUserMedia).bind(navigator);
 
     /**
      * Guacamole.ArrayBufferWriter wrapped around the audio output stream
@@ -419,7 +423,7 @@
     var beginAudioCapture = function beginAudioCapture() {
 
         // Attempt to retrieve an audio input stream from the browser
-        getUserMedia({ 'audio' : true }, function streamReceived(stream) {
+        navigator.mediaDevices.getUserMedia({ 'audio' : true }, function streamReceived(stream) {
 
             // Create processing node which receives appropriately-sized audio buffers
             processor = context.createScriptProcessor(BUFFER_SIZE, format.channels, format.channels);
diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js
index 006a262..85e9346 100644
--- a/guacamole-common-js/src/main/webapp/modules/Client.js
+++ b/guacamole-common-js/src/main/webapp/modules/Client.js
@@ -351,31 +351,6 @@
     };
 
     /**
-     * Sets the clipboard of the remote client to the given text data.
-     *
-     * @deprecated Use createClipboardStream() instead. 
-     * @param {String} data The data to send as the clipboard contents.
-     */
-    this.setClipboard = function(data) {
-
-        // Do not send requests if not connected
-        if (!isConnected())
-            return;
-
-        // Open stream
-        var stream = guac_client.createClipboardStream("text/plain");
-        var writer = new Guacamole.StringWriter(stream);
-
-        // Send text chunks
-        for (var i=0; i<data.length; i += 4096)
-            writer.sendText(data.substring(i, i+4096));
-
-        // Close stream
-        writer.sendEnd();
-
-    };
-
-    /**
      * Allocates an available stream index and creates a new
      * Guacamole.OutputStream using that index, associating the resulting
      * stream with this Guacamole.Client. Note that this stream will not yet
diff --git a/guacamole-common-js/src/main/webapp/modules/InputSink.js b/guacamole-common-js/src/main/webapp/modules/InputSink.js
new file mode 100644
index 0000000..5b1bfdf
--- /dev/null
+++ b/guacamole-common-js/src/main/webapp/modules/InputSink.js
@@ -0,0 +1,128 @@
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * A hidden input field which attempts to keep itself focused at all times,
+ * except when another input field has been intentionally focused, whether
+ * programatically or by the user. The actual underlying input field, returned
+ * by getElement(), may be used as a reliable source of keyboard-related events,
+ * particularly composition and input events which may require a focused input
+ * field to be dispatched at all.
+ *
+ * @constructor
+ */
+Guacamole.InputSink = function InputSink() {
+
+    /**
+     * Reference to this instance of Guacamole.InputSink.
+     *
+     * @private
+     * @type {Guacamole.InputSink}
+     */
+    var sink = this;
+
+    /**
+     * The underlying input field, styled to be invisible.
+     *
+     * @private
+     * @type {Element}
+     */
+    var field = document.createElement('textarea');
+    field.style.position   = 'fixed';
+    field.style.outline    = 'none';
+    field.style.border     = 'none';
+    field.style.margin     = '0';
+    field.style.padding    = '0';
+    field.style.height     = '0';
+    field.style.width      = '0';
+    field.style.left       = '0';
+    field.style.bottom     = '0';
+    field.style.resize     = 'none';
+    field.style.background = 'transparent';
+    field.style.color      = 'transparent';
+
+    // Keep field clear when modified via normal keypresses
+    field.addEventListener("keypress", function clearKeypress(e) {
+        field.value = '';
+    }, false);
+
+    // Keep field clear when modofied via composition events
+    field.addEventListener("compositionend", function clearCompletedComposition(e) {
+        if (e.data)
+            field.value = '';
+    }, false);
+
+    // Keep field clear when modofied via input events
+    field.addEventListener("input", function clearCompletedInput(e) {
+        if (e.data && !e.isComposing)
+            field.value = '';
+    }, false);
+
+    // Whenever focus is gained, automatically click to ensure cursor is
+    // actually placed within the field (the field may simply be highlighted or
+    // outlined otherwise)
+    field.addEventListener("focus", function focusReceived() {
+        window.setTimeout(function deferRefocus() {
+            field.click();
+            field.select();
+        }, 0);
+    }, true);
+
+    /**
+     * Attempts to focus the underlying input field. The focus attempt occurs
+     * asynchronously, and may silently fail depending on browser restrictions.
+     */
+    this.focus = function focus() {
+        window.setTimeout(function deferRefocus() {
+            field.focus(); // Focus must be deferred to work reliably across browsers
+        }, 0);
+    };
+
+    /**
+     * Returns the underlying input field. This input field MUST be manually
+     * added to the DOM for the Guacamole.InputSink to have any effect.
+     *
+     * @returns {Element}
+     */
+    this.getElement = function getElement() {
+        return field;
+    };
+
+    // Automatically refocus input sink if part of DOM
+    document.addEventListener("keydown", function refocusSink(e) {
+
+        // Do not refocus if focus is on an input field
+        var focused = document.activeElement;
+        if (focused && focused !== document.body) {
+
+            // Only consider focused input fields which are actually visible
+            var rect = focused.getBoundingClientRect();
+            if (rect.left + rect.width > 0 && rect.top + rect.height > 0)
+                return;
+
+        }
+
+        // Refocus input sink instead of handling click
+        sink.focus();
+
+    }, true);
+
+};
diff --git a/guacamole-common-js/src/main/webapp/modules/Keyboard.js b/guacamole-common-js/src/main/webapp/modules/Keyboard.js
index 954367b..532e9b9 100644
--- a/guacamole-common-js/src/main/webapp/modules/Keyboard.js
+++ b/guacamole-common-js/src/main/webapp/modules/Keyboard.js
@@ -25,9 +25,12 @@
  * which represent keys as their corresponding X11 keysym.
  * 
  * @constructor
- * @param {Element} element The Element to use to provide keyboard events.
+ * @param {Element|Document} [element]
+ *    The Element to use to provide keyboard events. If omitted, at least one
+ *    Element must be manually provided through the listenTo() function for
+ *    the Guacamole.Keyboard instance to have any effect.
  */
-Guacamole.Keyboard = function(element) {
+Guacamole.Keyboard = function Keyboard(element) {
 
     /**
      * Reference to this Guacamole.Keyboard.
@@ -36,6 +39,25 @@
     var guac_keyboard = this;
 
     /**
+     * An integer value which uniquely identifies this Guacamole.Keyboard
+     * instance with respect to other Guacamole.Keyboard instances.
+     *
+     * @private
+     * @type {Number}
+     */
+    var guacKeyboardID = Guacamole.Keyboard._nextID++;
+
+    /**
+     * The name of the property which is added to event objects via markEvent()
+     * to note that they have already been handled by this Guacamole.Keyboard.
+     *
+     * @private
+     * @constant
+     * @type {String}
+     */
+    var EVENT_MARKER = '_GUAC_KEYBOARD_HANDLED_BY_' + guacKeyboardID;
+
+    /**
      * Fired whenever the user presses a key with the element associated
      * with this Guacamole.Keyboard in focus.
      * 
@@ -56,6 +78,58 @@
     this.onkeyup = null;
 
     /**
+     * Set of known platform-specific or browser-specific quirks which must be
+     * accounted for to properly interpret key events, even if the only way to
+     * reliably detect that quirk is to platform/browser-sniff.
+     *
+     * @private
+     * @type {Object.<String, Boolean>}
+     */
+    var quirks = {
+
+        /**
+         * Whether keyup events are universally unreliable.
+         *
+         * @type {Boolean}
+         */
+        keyupUnreliable: false,
+
+        /**
+         * Whether the Alt key is actually a modifier for typable keys and is
+         * thus never used for keyboard shortcuts.
+         *
+         * @type {Boolean}
+         */
+        altIsTypableOnly: false,
+
+        /**
+         * Whether we can rely on receiving a keyup event for the Caps Lock
+         * key.
+         *
+         * @type {Boolean}
+         */
+        capsLockKeyupUnreliable: false
+
+    };
+
+    // Set quirk flags depending on platform/browser, if such information is
+    // available
+    if (navigator && navigator.platform) {
+
+        // All keyup events are unreliable on iOS (sadly)
+        if (navigator.platform.match(/ipad|iphone|ipod/i))
+            quirks.keyupUnreliable = true;
+
+        // The Alt key on Mac is never used for keyboard shortcuts, and the
+        // Caps Lock key never dispatches keyup events
+        else if (navigator.platform.match(/^mac/i)) {
+            quirks.altIsTypableOnly = true;
+            quirks.capsLockKeyupUnreliable = true;
+        }
+
+    }
+
+    /**
      * A key event having a corresponding timestamp. This event is non-specific.
      * Its subclasses should be used instead when recording specific key
      * events.
@@ -175,6 +249,14 @@
         this.keysym =  keysym_from_key_identifier(key, location)
                     || keysym_from_keycode(keyCode, location);
 
+        /**
+         * Whether the keyup following this keydown event is known to be
+         * reliable. If false, we cannot rely on the keyup event to occur.
+         *
+         * @type {Boolean}
+         */
+        this.keyupReliable = !quirks.keyupUnreliable;
+
         // DOM3 and keyCode are reliable sources if the corresponding key is
         // not a printable key
         if (this.keysym && !isPrintable(this.keysym))
@@ -184,9 +266,17 @@
         if (!this.keysym && key_identifier_sane(keyCode, keyIdentifier))
             this.keysym = keysym_from_key_identifier(keyIdentifier, location, guac_keyboard.modifiers.shift);
 
+        // If a key is pressed while meta is held down, the keyup will
+        // never be sent in Chrome (bug #108404)
+        if (guac_keyboard.modifiers.meta && this.keysym !== 0xFFE7 && this.keysym !== 0xFFE8)
+            this.keyupReliable = false;
+
+        // We cannot rely on receiving keyup for Caps Lock on certain platforms
+        else if (this.keysym === 0xFFE5 && quirks.capsLockKeyupUnreliable)
+            this.keyupReliable = false;
+
         // Determine whether default action for Alt+combinations must be prevented
-        var prevent_alt =  !guac_keyboard.modifiers.ctrl
-                        && !(navigator && navigator.platform && navigator.platform.match(/^mac/i));
+        var prevent_alt = !guac_keyboard.modifiers.ctrl && !quirks.altIsTypableOnly;
 
         // Determine whether default action for Ctrl+combinations must be prevented
         var prevent_ctrl = !guac_keyboard.modifiers.alt;
@@ -406,22 +496,6 @@
         "Compose": [0xFF20],
         "Control": [0xFFE3, 0xFFE3, 0xFFE4],
         "ContextMenu": [0xFF67],
-        "DeadGrave": [0xFE50],
-        "DeadAcute": [0xFE51],
-        "DeadCircumflex": [0xFE52],
-        "DeadTilde": [0xFE53],
-        "DeadMacron": [0xFE54],
-        "DeadBreve": [0xFE55],
-        "DeadAboveDot": [0xFE56],
-        "DeadUmlaut": [0xFE57],
-        "DeadAboveRing": [0xFE58],
-        "DeadDoubleacute": [0xFE59],
-        "DeadCaron": [0xFE5A],
-        "DeadCedilla": [0xFE5B],
-        "DeadOgonek": [0xFE5C],
-        "DeadIota": [0xFE5D],
-        "DeadVoicedSound": [0xFE5E],
-        "DeadSemivoicedSound": [0xFE5F],
         "Delete": [0xFFFF],
         "Down": [0xFF54],
         "End": [0xFF57],
@@ -498,6 +572,11 @@
         "SingleCandidate": [0xFF3C],
         "Super": [0xFFEB, 0xFFEB, 0xFFEC],
         "Tab": [0xFF09],
+        "UIKeyInputDownArrow": [0xFF54],
+        "UIKeyInputEscape": [0xFF1B],
+        "UIKeyInputLeftArrow": [0xFF51],
+        "UIKeyInputRightArrow": [0xFF53],
+        "UIKeyInputUpArrow": [0xFF52],
         "Up": [0xFF52],
         "Undo": [0xFF65],
         "Win": [0xFFEB],
@@ -786,6 +865,30 @@
     };
 
     /**
+     * Presses and releases the keys necessary to type the given string of
+     * text.
+     *
+     * @param {String} str
+     *     The string to type.
+     */
+    this.type = function type(str) {
+
+        // Press/release the key corresponding to each character in the string
+        for (var i = 0; i < str.length; i++) {
+
+            // Determine keysym of current character
+            var codepoint = str.codePointAt ? str.codePointAt(i) : str.charCodeAt(i);
+            var keysym = keysym_from_charcode(codepoint);
+
+            // Press and release key for current character
+            guac_keyboard.press(keysym);
+            guac_keyboard.release(keysym);
+
+        }
+
+    };
+
+    /**
      * Resets the state of this keyboard, releasing all keys, and firing keyup
      * events for each released key.
      */
@@ -801,6 +904,37 @@
     };
 
     /**
+     * Given the remote and local state of a particular key, resynchronizes the
+     * remote state of that key with the local state through pressing or
+     * releasing keysyms.
+     *
+     * @private
+     * @param {Boolean} remoteState
+     *     Whether the key is currently pressed remotely.
+     *
+     * @param {Boolean} localState
+     *     Whether the key is currently pressed remotely locally. If the state
+     *     of the key is not known, this may be undefined.
+     *
+     * @param {Number[]} keysyms
+     *     The keysyms which represent the key being updated.
+     */
+    var updateModifierState = function updateModifierState(remoteState, localState, keysyms) {
+
+        // Release all related keys if modifier is implicitly released
+        if (remoteState && localState === false) {
+            for (var i = 0; i < keysyms.length; i++) {
+                guac_keyboard.release(keysyms[i]);
+            }
+        }
+
+        // Press if modifier is implicitly pressed
+        else if (!remoteState && localState)
+            guac_keyboard.press(keysyms[0]);
+
+    };
+
+    /**
      * Given a keyboard event, updates the local modifier state and remote
      * key state based on the modifier flags within the event. This function
      * pays no attention to keycodes.
@@ -809,41 +943,41 @@
      * @param {KeyboardEvent} e
      *     The keyboard event containing the flags to update.
      */
-    var update_modifier_state = function update_modifier_state(e) {
+    var syncModifierStates = function syncModifierStates(e) {
 
         // Get state
         var state = Guacamole.Keyboard.ModifierState.fromKeyboardEvent(e);
 
-        // Release alt if implicitly released
-        if (guac_keyboard.modifiers.alt && state.alt === false) {
-            guac_keyboard.release(0xFFE9); // Left alt
-            guac_keyboard.release(0xFFEA); // Right alt
-            guac_keyboard.release(0xFE03); // AltGr
-        }
+        // Resync state of alt
+        updateModifierState(guac_keyboard.modifiers.alt, state.alt, [
+            0xFFE9, // Left alt
+            0xFFEA, // Right alt
+            0xFE03  // AltGr
+        ]);
 
-        // Release shift if implicitly released
-        if (guac_keyboard.modifiers.shift && state.shift === false) {
-            guac_keyboard.release(0xFFE1); // Left shift
-            guac_keyboard.release(0xFFE2); // Right shift
-        }
+        // Resync state of shift
+        updateModifierState(guac_keyboard.modifiers.shift, state.shift, [
+            0xFFE1, // Left shift
+            0xFFE2  // Right shift
+        ]);
 
-        // Release ctrl if implicitly released
-        if (guac_keyboard.modifiers.ctrl && state.ctrl === false) {
-            guac_keyboard.release(0xFFE3); // Left ctrl 
-            guac_keyboard.release(0xFFE4); // Right ctrl 
-        }
+        // Resync state of ctrl
+        updateModifierState(guac_keyboard.modifiers.ctrl, state.ctrl, [
+            0xFFE3, // Left ctrl
+            0xFFE4  // Right ctrl
+        ]);
 
-        // Release meta if implicitly released
-        if (guac_keyboard.modifiers.meta && state.meta === false) {
-            guac_keyboard.release(0xFFE7); // Left meta 
-            guac_keyboard.release(0xFFE8); // Right meta 
-        }
+        // Resync state of meta
+        updateModifierState(guac_keyboard.modifiers.meta, state.meta, [
+            0xFFE7, // Left meta
+            0xFFE8  // Right meta
+        ]);
 
-        // Release hyper if implicitly released
-        if (guac_keyboard.modifiers.hyper && state.hyper === false) {
-            guac_keyboard.release(0xFFEB); // Left hyper
-            guac_keyboard.release(0xFFEC); // Right hyper
-        }
+        // Resync state of hyper
+        updateModifierState(guac_keyboard.modifiers.hyper, state.hyper, [
+            0xFFEB, // Left hyper
+            0xFFEC  // Right hyper
+        ]);
 
         // Update state
         guac_keyboard.modifiers = state;
@@ -962,9 +1096,9 @@
                     var defaultPrevented = !guac_keyboard.press(keysym);
                     recentKeysym[first.keyCode] = keysym;
 
-                    // If a key is pressed while meta is held down, the keyup will
-                    // never be sent in Chrome, so send it now. (bug #108404)
-                    if (guac_keyboard.modifiers.meta && keysym !== 0xFFE7 && keysym !== 0xFFE8)
+                    // Release the key now if we cannot rely on the associated
+                    // keyup event
+                    if (!first.keyupReliable)
                         guac_keyboard.release(keysym);
 
                     // Record whether default was prevented
@@ -980,7 +1114,7 @@
         } // end if keydown
 
         // Keyup event
-        else if (first instanceof KeyupEvent) {
+        else if (first instanceof KeyupEvent && !quirks.keyupUnreliable) {
 
             // Release specific key if known
             var keysym = first.keysym;
@@ -999,7 +1133,8 @@
 
         } // end if keyup
 
-        // Ignore any other type of event (keypress by itself is invalid)
+        // Ignore any other type of event (keypress by itself is invalid, and
+        // unreliable keyup events should simply be dumped)
         else
             return eventLog.shift();
 
@@ -1037,82 +1172,197 @@
 
     };
 
-    // When key pressed
-    element.addEventListener("keydown", function(e) {
+    /**
+     * Attempts to mark the given Event as having been handled by this
+     * Guacamole.Keyboard. If the Event has already been marked as handled,
+     * false is returned.
+     *
+     * @param {Event} e
+     *     The Event to mark.
+     *
+     * @returns {Boolean}
+     *     true if the given Event was successfully marked, false if the given
+     *     Event was already marked.
+     */
+    var markEvent = function markEvent(e) {
 
-        // Only intercept if handler set
-        if (!guac_keyboard.onkeydown) return;
+        // Fail if event is already marked
+        if (e[EVENT_MARKER])
+            return false;
 
-        var keyCode;
-        if (window.event) keyCode = window.event.keyCode;
-        else if (e.which) keyCode = e.which;
+        // Mark event otherwise
+        e[EVENT_MARKER] = true;
+        return true;
 
-        // Fix modifier states
-        update_modifier_state(e);
+    };
 
-        // Ignore (but do not prevent) the "composition" keycode sent by some
-        // browsers when an IME is in use (see: http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html)
-        if (keyCode === 229)
-            return;
+    /**
+     * Attaches event listeners to the given Element, automatically translating
+     * received key, input, and composition events into simple keydown/keyup
+     * events signalled through this Guacamole.Keyboard's onkeydown and
+     * onkeyup handlers.
+     *
+     * @param {Element|Document} element
+     *     The Element to attach event listeners to for the sake of handling
+     *     key or input events.
+     */
+    this.listenTo = function listenTo(element) {
 
-        // Log event
-        var keydownEvent = new KeydownEvent(keyCode, e.keyIdentifier, e.key, getEventLocation(e));
-        eventLog.push(keydownEvent);
+        // When key pressed
+        element.addEventListener("keydown", function(e) {
 
-        // Interpret as many events as possible, prevent default if indicated
-        if (interpret_events())
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeydown) return;
+
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
+
+            var keyCode;
+            if (window.event) keyCode = window.event.keyCode;
+            else if (e.which) keyCode = e.which;
+
+            // Fix modifier states
+            syncModifierStates(e);
+
+            // Ignore (but do not prevent) the "composition" keycode sent by some
+            // browsers when an IME is in use (see: http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html)
+            if (keyCode === 229)
+                return;
+
+            // Log event
+            var keydownEvent = new KeydownEvent(keyCode, e.keyIdentifier, e.key, getEventLocation(e));
+            eventLog.push(keydownEvent);
+
+            // Interpret as many events as possible, prevent default if indicated
+            if (interpret_events())
+                e.preventDefault();
+
+        }, true);
+
+        // When key pressed
+        element.addEventListener("keypress", function(e) {
+
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
+
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
+
+            var charCode;
+            if (window.event) charCode = window.event.keyCode;
+            else if (e.which) charCode = e.which;
+
+            // Fix modifier states
+            syncModifierStates(e);
+
+            // Log event
+            var keypressEvent = new KeypressEvent(charCode);
+            eventLog.push(keypressEvent);
+
+            // Interpret as many events as possible, prevent default if indicated
+            if (interpret_events())
+                e.preventDefault();
+
+        }, true);
+
+        // When key released
+        element.addEventListener("keyup", function(e) {
+
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeyup) return;
+
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
+
             e.preventDefault();
 
-    }, true);
+            var keyCode;
+            if (window.event) keyCode = window.event.keyCode;
+            else if (e.which) keyCode = e.which;
 
-    // When key pressed
-    element.addEventListener("keypress", function(e) {
+            // Fix modifier states
+            syncModifierStates(e);
 
-        // Only intercept if handler set
-        if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
+            // Log event, call for interpretation
+            var keyupEvent = new KeyupEvent(keyCode, e.keyIdentifier, e.key, getEventLocation(e));
+            eventLog.push(keyupEvent);
+            interpret_events();
 
-        var charCode;
-        if (window.event) charCode = window.event.keyCode;
-        else if (e.which) charCode = e.which;
+        }, true);
 
-        // Fix modifier states
-        update_modifier_state(e);
+        /**
+         * Handles the given "input" event, typing the data within the input text.
+         * If the event is complete (text is provided), handling of "compositionend"
+         * events is suspended, as such events may conflict with input events.
+         *
+         * @private
+         * @param {InputEvent} e
+         *     The "input" event to handle.
+         */
+        var handleInput = function handleInput(e) {
 
-        // Log event
-        var keypressEvent = new KeypressEvent(charCode);
-        eventLog.push(keypressEvent);
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
 
-        // Interpret as many events as possible, prevent default if indicated
-        if (interpret_events())
-            e.preventDefault();
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
 
-    }, true);
+            // Type all content written
+            if (e.data && !e.isComposing) {
+                element.removeEventListener("compositionend", handleComposition, false);
+                guac_keyboard.type(e.data);
+            }
 
-    // When key released
-    element.addEventListener("keyup", function(e) {
+        };
 
-        // Only intercept if handler set
-        if (!guac_keyboard.onkeyup) return;
+        /**
+         * Handles the given "compositionend" event, typing the data within the
+         * composed text. If the event is complete (composed text is provided),
+         * handling of "input" events is suspended, as such events may conflict
+         * with composition events.
+         *
+         * @private
+         * @param {CompositionEvent} e
+         *     The "compositionend" event to handle.
+         */
+        var handleComposition = function handleComposition(e) {
 
-        e.preventDefault();
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
 
-        var keyCode;
-        if (window.event) keyCode = window.event.keyCode;
-        else if (e.which) keyCode = e.which;
-        
-        // Fix modifier states
-        update_modifier_state(e);
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
 
-        // Log event, call for interpretation
-        var keyupEvent = new KeyupEvent(keyCode, e.keyIdentifier, e.key, getEventLocation(e));
-        eventLog.push(keyupEvent);
-        interpret_events();
+            // Type all content written
+            if (e.data) {
+                element.removeEventListener("input", handleInput, false);
+                guac_keyboard.type(e.data);
+            }
 
-    }, true);
+        };
+
+        // Automatically type text entered into the wrapped field
+        element.addEventListener("input", handleInput, false);
+        element.addEventListener("compositionend", handleComposition, false);
+
+    };
+
+    // Listen to given element, if any
+    if (element)
+        guac_keyboard.listenTo(element);
 
 };
 
 /**
+ * The unique numerical identifier to assign to the next Guacamole.Keyboard
+ * instance.
+ *
+ * @private
+ * @type {Number}
+ */
+Guacamole.Keyboard._nextID = 0;
+
+/**
  * The state of all supported keyboard modifiers.
  * @constructor
  */
diff --git a/guacamole-common-js/src/main/webapp/modules/Tunnel.js b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
index 2b1826d..c8f8502 100644
--- a/guacamole-common-js/src/main/webapp/modules/Tunnel.js
+++ b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
@@ -182,8 +182,13 @@
  *     Whether tunnel requests will be cross-domain, and thus must use CORS
  *     mechanisms and headers. By default, it is assumed that tunnel requests
  *     will be made to the same domain.
+ *
+ * @param {Object} [extraTunnelHeaders={}]
+ *     Key value pairs containing the header names and values of any additional
+ *     headers to be sent in tunnel requests. By default, no extra headers will
+ *     be added.
  */
-Guacamole.HTTPTunnel = function(tunnelURL, crossDomain) {
+Guacamole.HTTPTunnel = function(tunnelURL, crossDomain, extraTunnelHeaders) {
 
     /**
      * Reference to this HTTP tunnel.
@@ -215,6 +220,32 @@
     var receive_timeout = null;
 
     /**
+     * Additional headers to be sent in tunnel requests. This dictionary can be
+     * populated with key/value header pairs to pass information such as authentication
+     * tokens, etc.
+     *
+     * @private
+     */
+    var extraHeaders = extraTunnelHeaders || {};
+
+    /**
+     * Adds the configured additional headers to the given request.
+     *
+     * @param {XMLHttpRequest} request
+     *     The request where the configured extra headers will be added.
+     *
+     * @param {Object} headers
+     *     The headers to be added to the request.
+     *
+     * @private
+     */
+    function addExtraHeaders(request, headers) {
+        for (var name in headers) {
+            request.setRequestHeader(name, headers[name]);
+        }
+    }
+
+    /**
      * Initiates a timeout which, if data is not received, causes the tunnel
      * to close with an error.
      * 
@@ -322,6 +353,7 @@
             var message_xmlhttprequest = new XMLHttpRequest();
             message_xmlhttprequest.open("POST", TUNNEL_WRITE + tunnel.uuid);
             message_xmlhttprequest.withCredentials = withCredentials;
+            addExtraHeaders(message_xmlhttprequest, extraHeaders);
             message_xmlhttprequest.setRequestHeader("Content-type", "application/octet-stream");
 
             // Once response received, send next queued event.
@@ -553,6 +585,7 @@
         var xmlhttprequest = new XMLHttpRequest();
         xmlhttprequest.open("GET", TUNNEL_READ + tunnel.uuid + ":" + (request_id++));
         xmlhttprequest.withCredentials = withCredentials;
+        addExtraHeaders(xmlhttprequest, extraHeaders);
         xmlhttprequest.send(null);
 
         return xmlhttprequest;
@@ -595,6 +628,7 @@
 
         connect_xmlhttprequest.open("POST", TUNNEL_CONNECT, true);
         connect_xmlhttprequest.withCredentials = withCredentials;
+        addExtraHeaders(connect_xmlhttprequest, extraHeaders);
         connect_xmlhttprequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded; charset=UTF-8");
         connect_xmlhttprequest.send(data);
 
@@ -1057,8 +1091,13 @@
  *     Whether tunnel requests will be cross-domain, and thus must use CORS
  *     mechanisms and headers. By default, it is assumed that tunnel requests
  *     will be made to the same domain.
+ *
+ * @param {Object} [extraTunnelHeaders={}]
+ *     Key value pairs containing the header names and values of any additional
+ *     headers to be sent in tunnel requests. By default, no extra headers will
+ *     be added.
  */
-Guacamole.StaticHTTPTunnel = function StaticHTTPTunnel(url, crossDomain) {
+Guacamole.StaticHTTPTunnel = function StaticHTTPTunnel(url, crossDomain, extraTunnelHeaders) {
 
     /**
      * Reference to this Guacamole.StaticHTTPTunnel.
@@ -1077,6 +1116,32 @@
     var xhr = null;
 
     /**
+     * Additional headers to be sent in tunnel requests. This dictionary can be
+     * populated with key/value header pairs to pass information such as authentication
+     * tokens, etc.
+     *
+     * @private
+     */
+    var extraHeaders = extraTunnelHeaders || {};
+
+    /**
+     * Adds the configured additional headers to the given request.
+     *
+     * @param {XMLHttpRequest} request
+     *     The request where the configured extra headers will be added.
+     *
+     * @param {Object} headers
+     *     The headers to be added to the request.
+     *
+     * @private
+     */
+    function addExtraHeaders(request, headers) {
+        for (var name in headers) {
+            request.setRequestHeader(name, headers[name]);
+        }
+    }
+
+    /**
      * Returns the Guacamole protocol status code which most closely
      * represents the given HTTP status code.
      *
@@ -1137,6 +1202,7 @@
         xhr = new XMLHttpRequest();
         xhr.open('GET', url);
         xhr.withCredentials = !!crossDomain;
+        addExtraHeaders(xhr, extraHeaders);
         xhr.responseType = 'text';
         xhr.send(null);
 
diff --git a/guacamole-common-js/src/main/webapp/modules/Version.js b/guacamole-common-js/src/main/webapp/modules/Version.js
index c9f28b2..2ec8c20 100644
--- a/guacamole-common-js/src/main/webapp/modules/Version.js
+++ b/guacamole-common-js/src/main/webapp/modules/Version.js
@@ -27,4 +27,4 @@
  *
  * @type {String}
  */
-Guacamole.API_VERSION = "0.9.13-incubating";
+Guacamole.API_VERSION = "0.9.14";
diff --git a/guacamole-common/pom.xml b/guacamole-common/pom.xml
index b410858..beb5c9e 100644
--- a/guacamole-common/pom.xml
+++ b/guacamole-common/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-common</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-common</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <description>
         The base Java API of the Guacamole project, providing Java support for
@@ -46,24 +46,10 @@
 
     <!-- Git repository -->
     <scm>
-        <url>https://github.com/apache/incubator-guacamole-client</url>
-        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/incubator-guacamole-client.git</connection>
+        <url>https://github.com/apache/guacamole-client</url>
+        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/guacamole-client.git</connection>
     </scm>
 
-    <!-- Developers -->
-    <developers>
-        <developer>
-            <id>mike.jumper</id>
-            <name>Michael Jumper</name>
-            <email>mike.jumper@guac-dev.org</email>
-        </developer>
-        <developer>
-            <id>james.muehlner</id>
-            <name>James Muehlner</name>
-            <email>james.muehlner@guac-dev.org</email>
-        </developer>
-    </developers>
-
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
diff --git a/guacamole-common/src/main/java/org/apache/guacamole/GuacamoleException.java b/guacamole-common/src/main/java/org/apache/guacamole/GuacamoleException.java
index 1b2226d..d984226 100644
--- a/guacamole-common/src/main/java/org/apache/guacamole/GuacamoleException.java
+++ b/guacamole-common/src/main/java/org/apache/guacamole/GuacamoleException.java
@@ -68,5 +68,29 @@
     public GuacamoleStatus getStatus() {
         return GuacamoleStatus.SERVER_ERROR;
     }
+
+    /**
+     * Returns the most applicable HTTP status code that can be associated
+     * with this exception.
+     *
+     * @return
+     *     An integer representing the most applicable HTTP status code
+     *     associated with this exception.
+     */
+    public int getHttpStatusCode() {
+        return getStatus().getHttpStatusCode();
+    }
+
+    /**
+     * Returns the most applicable WebSocket status code that can be
+     * associated with this exception.
+     *
+     * @return
+     *     An integer representing the most applicable WebSocket status
+     *     code associated with this exception.
+     */
+    public int getWebSocketCode() {
+        return getStatus().getWebSocketCode();
+    }
     
 }
diff --git a/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleHTTPTunnelServlet.java b/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleHTTPTunnelServlet.java
index fdbfb8e..be2da13 100644
--- a/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleHTTPTunnelServlet.java
+++ b/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleHTTPTunnelServlet.java
@@ -149,26 +149,29 @@
      * @param response
      *     The HTTP response to use to send the error.
      *
-     * @param guacStatus
-     *     The status to send
+     * @param guacamoleStatusCode
+     *     The GuacamoleStatus code to send.
+     *
+     * @param guacamoleHttpCode
+     *     The numeric HTTP code to send.
      *
      * @param message
-     *     A human-readable message that can be presented to the user.
+     *     The human-readable error message to send.
      *
      * @throws ServletException
      *     If an error prevents sending of the error code.
      */
-    protected void sendError(HttpServletResponse response,
-            GuacamoleStatus guacStatus, String message)
+    protected void sendError(HttpServletResponse response, int guacamoleStatusCode,
+            int guacamoleHttpCode, String message)
             throws ServletException {
 
         try {
 
             // If response not committed, send error code and message
             if (!response.isCommitted()) {
-                response.addHeader("Guacamole-Status-Code", Integer.toString(guacStatus.getGuacamoleStatusCode()));
+                response.addHeader("Guacamole-Status-Code", Integer.toString(guacamoleStatusCode));
                 response.addHeader("Guacamole-Error-Message", message);
-                response.sendError(guacStatus.getHttpStatusCode());
+                response.sendError(guacamoleHttpCode);
             }
 
         }
@@ -237,14 +240,14 @@
 
             // If read operation, call doRead() with tunnel UUID, ignoring any
             // characters following the tunnel UUID.
-            else if(query.startsWith(READ_PREFIX))
+            else if (query.startsWith(READ_PREFIX))
                 doRead(request, response, query.substring(
                         READ_PREFIX_LENGTH,
                         READ_PREFIX_LENGTH + UUID_LENGTH));
 
             // If write operation, call doWrite() with tunnel UUID, ignoring any
             // characters following the tunnel UUID.
-            else if(query.startsWith(WRITE_PREFIX))
+            else if (query.startsWith(WRITE_PREFIX))
                 doWrite(request, response, query.substring(
                         WRITE_PREFIX_LENGTH,
                         WRITE_PREFIX_LENGTH + UUID_LENGTH));
@@ -258,12 +261,14 @@
         // HTTP response, logging each error appropriately.
         catch (GuacamoleClientException e) {
             logger.warn("HTTP tunnel request rejected: {}", e.getMessage());
-            sendError(response, e.getStatus(), e.getMessage());
+            sendError(response, e.getStatus().getGuacamoleStatusCode(),
+                    e.getStatus().getHttpStatusCode(), e.getMessage());
         }
         catch (GuacamoleException e) {
             logger.error("HTTP tunnel request failed: {}", e.getMessage());
             logger.debug("Internal error in HTTP tunnel.", e);
-            sendError(response, e.getStatus(), "Internal server error.");
+            sendError(response, e.getStatus().getGuacamoleStatusCode(),
+                    e.getStatus().getHttpStatusCode(), "Internal server error.");
         }
 
     }
diff --git a/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleSession.java b/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleSession.java
deleted file mode 100644
index 3afb872..0000000
--- a/guacamole-common/src/main/java/org/apache/guacamole/servlet/GuacamoleSession.java
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.guacamole.servlet;
-
-import javax.servlet.http.HttpSession;
-import org.apache.guacamole.net.GuacamoleTunnel;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Provides abstract access to the tunnels associated with a Guacamole session.
- */
-@Deprecated
-public class GuacamoleSession {
-
-    /**
-     * Logger for this class.
-     */
-    private final Logger logger = LoggerFactory.getLogger(GuacamoleSession.class);
-
-    /**
-     * Creates a new GuacamoleSession. In prior versions of Guacamole, the
-     * GuacamoleSession object stored the tunnels associated with a particular
-     * user's use of the HTTP tunnel. The HTTP tunnel now stores all of these
-     * tunnels itself, and thus this class is no longer necessary. Its use will
-     * result in a warning being logged, and its functions will have no effect.
-     *
-     * @param session
-     *     The HttpSession that older versions of Guacamole would use as tunnel
-     *     storage. This parameter is now ignored, and the GuacamoleSession
-     *     class overall is deprecated.
-     */
-    public GuacamoleSession(HttpSession session) {
-        logger.warn("GuacamoleSession is deprecated. It is no longer "
-                  + "necessary and its use will have no effect.");
-    }
-
-    /**
-     * Attaches the given tunnel to this GuacamoleSession. The GuacamoleSession
-     * class is now deprecated, and this function has no effect.
-     *
-     * @param tunnel
-     *     The tunnel to attach to this GucacamoleSession.
-     */
-    public void attachTunnel(GuacamoleTunnel tunnel) {
-        // Deprecated - no effect
-    }
-
-    /**
-     * Detaches the given tunnel to this GuacamoleSession. The GuacamoleSession
-     * class is now deprecated, and this function has no effect.
-     *
-     * @param tunnel
-     *     The tunnel to detach to this GucacamoleSession.
-     */
-    public void detachTunnel(GuacamoleTunnel tunnel) {
-        // Deprecated - no effect
-    }
-
-    /**
-     * Returns the tunnel with the given UUID attached to this GuacamoleSession,
-     * if any. The GuacamoleSession class is now deprecated, and this function
-     * has no effect. It will ALWAYS return null.
-     *
-     * @param tunnelUUID
-     *     The UUID of an attached tunnel.
-     *
-     * @return
-     *     The tunnel corresponding to the given UUID, if attached, or null if
-     *     if no such tunnel is attached.
-     */
-    public GuacamoleTunnel getTunnel(String tunnelUUID) {
-
-        // Deprecated - no effect
-        return null;
-
-    }
-
-}
diff --git a/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java b/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
index 0cae732..0e02622 100644
--- a/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
+++ b/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
@@ -66,17 +66,24 @@
     private GuacamoleTunnel tunnel;
     
     /**
-     * Sends the given status on the given WebSocket connection and closes the
-     * connection.
+     * Sends the numeric Guacaomle Status Code and Web Socket
+     * code and closes the connection.
      *
-     * @param session The outbound WebSocket connection to close.
-     * @param guac_status The status to send.
+     * @param session
+     *     The outbound WebSocket connection to close.
+     *
+     * @param guacamoleStatusCode
+     *     The numeric Guacamole status to send.
+     *
+     * @param webSocketCode
+     *     The numeric WebSocket status to send.
      */
-    private void closeConnection(Session session, GuacamoleStatus guac_status) {
+    private void closeConnection(Session session, int guacamoleStatusCode,
+            int webSocketCode) {
 
         try {
-            CloseCode code = CloseReason.CloseCodes.getCloseCode(guac_status.getWebSocketCode());
-            String message = Integer.toString(guac_status.getGuacamoleStatusCode());
+            CloseCode code = CloseReason.CloseCodes.getCloseCode(webSocketCode);
+            String message = Integer.toString(guacamoleStatusCode);
             session.close(new CloseReason(code, message));
         }
         catch (IOException e) {
@@ -86,6 +93,21 @@
     }
 
     /**
+     * Sends the given Guacaomle Status and closes the given
+     * connection.
+     *
+     * @param session
+     *     The outbound WebSocket connection to close.
+     *
+     * @param guacStatus
+     *     The status to use for the connection.
+     */
+    private void closeConnection(Session session, GuacamoleStatus guacStatus) {
+        closeConnection(session, guacStatus.getGuacamoleStatusCode(),
+                guacStatus.getWebSocketCode());
+    }
+
+    /**
      * Returns a new tunnel for the given session. How this tunnel is created
      * or retrieved is implementation-dependent.
      *
@@ -117,7 +139,8 @@
         catch (GuacamoleException e) {
             logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
             logger.debug("Error connecting WebSocket tunnel.", e);
-            closeConnection(session, e.getStatus());
+            closeConnection(session, e.getStatus().getGuacamoleStatusCode(),
+                    e.getWebSocketCode());
             return;
         }
 
@@ -181,7 +204,8 @@
                     catch (GuacamoleClientException e) {
                         logger.info("WebSocket connection terminated: {}", e.getMessage());
                         logger.debug("WebSocket connection terminated due to client error.", e);
-                        closeConnection(session, e.getStatus());
+                        closeConnection(session, e.getStatus().getGuacamoleStatusCode(),
+                                e.getWebSocketCode());
                     }
                     catch (GuacamoleConnectionClosedException e) {
                         logger.debug("Connection to guacd closed.", e);
@@ -190,7 +214,8 @@
                     catch (GuacamoleException e) {
                         logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
                         logger.debug("Internal error during connection to guacd.", e);
-                        closeConnection(session, e.getStatus());
+                        closeConnection(session, e.getStatus().getGuacamoleStatusCode(),
+                                e.getWebSocketCode());
                     }
 
                 }
diff --git a/guacamole-docker/README.md b/guacamole-docker/README.md
index 04066a0..89ea1fa 100644
--- a/guacamole-docker/README.md
+++ b/guacamole-docker/README.md
@@ -1,9 +1,9 @@
 What is Apache Guacamole?
 =========================
 
-[Apache Guacamole](http://guacamole.incubator.apache.org/) is a clientless
-remote desktop gateway. It supports standard protocols like VNC and RDP. We
-call it clientless because no plugins or client software are required.
+[Apache Guacamole](http://guacamole.apache.org/) is a clientless remote desktop
+gateway. It supports standard protocols like VNC and RDP. We call it clientless
+because no plugins or client software are required.
 
 Thanks to HTML5, once Guacamole is installed on a server, all you need to
 access your desktops is a web browser.
@@ -54,12 +54,13 @@
 
 To generate a SQL script which can be used to initialize a fresh PostgreSQL
 database
-[as documented in the Guacamole manual](http://guacamole.incubator.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-postgresql):
+[as documented in the Guacamole manual](http://guacamole.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-postgresql):
 
     docker run --rm guacamole/guacamole /opt/guacamole/bin/initdb.sh --postgres > initdb.sql
 
-Alternatively, you can use the SQL scripts included with
-[guacamole-auth-jdbc](https://github.com/apache/incubator-guacamole-client/tree/0.9.10-incubating/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/schema).
+Alternatively, you can use the SQL scripts included with the
+guacamole-auth-jdbc extension from
+[the corresponding release](http://guacamole.apache.org/releases/).
 
 Once this script is generated, you must:
 
@@ -70,7 +71,7 @@
 
 The process for doing this via the `psql` and `createdb` utilities included
 with PostgreSQL is documented in
-[the Guacamole manual](http://guacamole.incubator.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-postgresql).
+[the Guacamole manual](http://guacamole.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-postgresql).
 
 Deploying Guacamole with MySQL authentication
 --------------------------------------------------
@@ -97,12 +98,12 @@
 necessary SQL to do this is included in the Guacamole image.
 
 To generate a SQL script which can be used to initialize a fresh MySQL database
-[as documented in the Guacamole manual](http://guacamole.incubator.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-mysql):
+[as documented in the Guacamole manual](http://guacamole.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-mysql):
 
     docker run --rm guacamole/guacamole /opt/guacamole/bin/initdb.sh --mysql > initdb.sql
 
 Alternatively, you can use the SQL scripts included with
-[guacamole-auth-jdbc](https://github.com/apache/incubator-guacamole-client/tree/0.9.10-incubating/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema).
+[guacamole-auth-jdbc](https://github.com/apache/guacamole-client/tree/0.9.10-incubating/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/schema).
 
 Once this script is generated, you must:
 
@@ -113,7 +114,7 @@
 
 The process for doing this via the `mysql` utility included with MySQL is
 documented in
-[the Guacamole manual](http://guacamole.incubator.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-mysql).
+[the Guacamole manual](http://guacamole.apache.org/doc/gug/jdbc-auth.html#jdbc-auth-mysql).
 
 Reporting issues
 ================
diff --git a/guacamole-docker/bin/build-guacamole.sh b/guacamole-docker/bin/build-guacamole.sh
index 41e01b5..8a35119 100755
--- a/guacamole-docker/bin/build-guacamole.sh
+++ b/guacamole-docker/bin/build-guacamole.sh
@@ -22,11 +22,11 @@
 ## @fn build-guacamole.sh
 ##
 ## Builds Guacamole, saving "guacamole.war" and all applicable extension .jars
-## using the incubator-guacamole-client source contained within the given
-## directory. Extension files will be grouped by their associated type, with
-## all MySQL files being placed within the "mysql/" subdirectory of the
-## destination, all PostgreSQL files being placed within the "postgresql/"
-## subdirectory of the destination, etc.
+## using the guacamole-client source contained within the given directory.
+## Extension files will be grouped by their associated type, with all MySQL
+## files being placed within the "mysql/" subdirectory of the destination, all
+## PostgreSQL files being placed within the "postgresql/" subdirectory of the
+## destination, etc.
 ##
 ## @param BUILD_DIR
 ##     The directory which currently contains the guacamole-client source and
diff --git a/guacamole-ext/pom.xml b/guacamole-ext/pom.xml
index e1c0fa9..339abf8 100644
--- a/guacamole-ext/pom.xml
+++ b/guacamole-ext/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-ext</artifactId>
     <packaging>jar</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-ext</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <description>
         The Java API for extending the main Guacamole web application. This
@@ -46,24 +46,10 @@
 
     <!-- Git repository -->
     <scm>
-        <url>https://github.com/apache/incubator-guacamole-client</url>
-        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/incubator-guacamole-client.git</connection>
+        <url>https://github.com/apache/guacamole-client</url>
+        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/guacamole-client.git</connection>
     </scm>
 
-    <!-- Developers -->
-    <developers>
-        <developer>
-            <id>mike.jumper</id>
-            <name>Michael Jumper</name>
-            <email>mike.jumper@guac-dev.org</email>
-        </developer>
-        <developer>
-            <id>james.muehlner</id>
-            <name>James Muehlner</name>
-            <email>james.muehlner@guac-dev.org</email>
-        </developer>
-    </developers>
-
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
@@ -162,7 +148,7 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <scope>compile</scope>
         </dependency>
 
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java b/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java
index f45bcaa..1b45c95 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java
@@ -31,6 +31,7 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.net.auth.GuacamoleProxyConfiguration;
+import org.apache.guacamole.properties.BooleanGuacamoleProperty;
 import org.apache.guacamole.properties.GuacamoleProperty;
 import org.apache.guacamole.protocols.ProtocolInfo;
 import org.slf4j.Logger;
@@ -73,6 +74,18 @@
     private static final boolean DEFAULT_GUACD_SSL = false;
 
     /**
+     * A property that determines whether environment variables are evaluated
+     * to override properties specified in guacamole.properties.
+     */
+    private static final BooleanGuacamoleProperty ENABLE_ENVIRONMENT_PROPERTIES =
+        new BooleanGuacamoleProperty() {
+            @Override
+            public String getName() {
+                return "enable-environment-properties";
+            }
+        };
+
+    /**
      * All properties read from guacamole.properties.
      */
     private final Properties properties;
@@ -88,6 +101,11 @@
     private final Map<String, ProtocolInfo> availableProtocols;
 
     /**
+     * Flag indicating whether environment variables can override properties.
+     */
+    private final boolean environmentPropertiesEnabled;
+
+    /**
      * The Jackson parser for parsing JSON files.
      */
     private static final ObjectMapper mapper = new ObjectMapper();
@@ -141,6 +159,8 @@
         // Read all protocols
         availableProtocols = readProtocols();
 
+        // Should environment variables override configuration properties?
+        environmentPropertiesEnabled = environmentPropertiesEnabled(properties);
     }
 
     /**
@@ -299,14 +319,74 @@
 
     }
 
+    /**
+     * Checks for the presence of the {@link #ENABLE_ENVIRONMENT_PROPERTIES}
+     * property in the given properties collection.
+     *
+     * @param properties
+     *     The properties collection to check.
+     *
+     * @return
+     *     true if the property is present in the given properties collection
+     *     and its parsed value is true
+     *
+     * @throws GuacamoleException If the value specified for the property
+     *                            cannot be successfully parsed as a Boolean
+     *
+     */
+    private static boolean environmentPropertiesEnabled(Properties properties)
+            throws GuacamoleException {
+
+        final Boolean enabled = ENABLE_ENVIRONMENT_PROPERTIES.parseValue(
+                properties.getProperty(ENABLE_ENVIRONMENT_PROPERTIES.getName()));
+
+        return enabled != null && enabled;
+    }
+
     @Override
     public File getGuacamoleHome() {
         return guacHome;
     }
 
+    /**
+     * Gets the string value for a property name.
+     *
+     * The value may come from either the OS environment (if property override
+     * is enabled) or the Properties collection that was loaded from
+     * guacamole.properties. When checking the environment for the named
+     * property, the name is first transformed by converting all hyphens to
+     * underscores and converting the string to upper case letter, in accordance
+     * with common convention for environment strings.
+     *
+     * @param name
+     *     The name of the property value to retrieve.
+     *
+     * @return
+     *     The corresponding value for the property. If property override
+     *     is enabled and the value is found in the OS environment, the value
+     *     from the environment is returned. Otherwise, the value from
+     *     guacamole.properties, if any, is returned.
+     */
+    private String getPropertyValue(String name) {
+
+        // Check for corresponding environment variable if overrides enabled
+        if (environmentPropertiesEnabled) {
+
+            // Transform the name according to common convention
+            final String envName = name.replace('-', '_').toUpperCase();
+            final String envValue = System.getenv(envName);
+
+            if (envValue != null) {
+                return envValue;
+            }
+        }
+
+        return properties.getProperty(name);
+    }
+
     @Override
     public <Type> Type getProperty(GuacamoleProperty<Type> property) throws GuacamoleException {
-        return property.parseValue(properties.getProperty(property.getName()));
+        return property.parseValue(getPropertyValue(property.getName()));
     }
 
     @Override
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/form/NumericField.java b/guacamole-ext/src/main/java/org/apache/guacamole/form/NumericField.java
index cc2e2a5..ce2dba4 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/form/NumericField.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/form/NumericField.java
@@ -76,7 +76,7 @@
             return null;
 
         // Parse as integer
-        return new Integer(str);
+        return Integer.valueOf(str);
 
     }
 
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Attributes.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Attributes.java
new file mode 100644
index 0000000..050017d
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Attributes.java
@@ -0,0 +1,59 @@
+/*
+ * 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.net.auth;
+
+import java.util.Map;
+
+/**
+ * An object which is associated with a set of arbitrary attributes, defined
+ * as name/value pairs.
+ */
+public interface Attributes {
+
+    /**
+     * Returns all attributes associated with this object. The returned map
+     * may not be modifiable.
+     *
+     * @return
+     *     A map of all attribute identifiers to their corresponding values,
+     *     for all attributes associated with this object, which may not be
+     *     modifiable.
+     */
+    Map<String, String> getAttributes();
+
+    /**
+     * Sets the given attributes. If an attribute within the map is not
+     * supported, it will simply be dropped. Any attributes not within the given
+     * map will be left untouched. Attributes which are not declared within the
+     * associated UserContext MUST NOT be submitted, but other extensions may
+     * manipulate the declared attributes through decorate() and redecorate().
+     *
+     * Implementations may optionally allow storage of unsupported attributes.
+     * Extensions which rely on other extensions to store their attribute
+     * values should verify that such storage is supported by first testing
+     * that the attribute value is retrievable via getAttributes() after being
+     * set.
+     *
+     * @param attributes
+     *     A map of all attribute identifiers to their corresponding values.
+     */
+    void setAttributes(Map<String, String> attributes);
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticationProvider.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticationProvider.java
index 448f495..fd7d844 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticationProvider.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AuthenticationProvider.java
@@ -169,6 +169,79 @@
             Credentials credentials) throws GuacamoleException;
 
     /**
+     * Given a UserContext returned from getUserContext() of a different
+     * AuthenticationProvider, returns a UserContext instance which decorates
+     * (wraps) that UserContext, delegating and overriding implemented
+     * functions as necessary. Each UserContext created via getUserContext()
+     * will be passed to the decorate() functions of all other
+     * AuthenticationProviders, allowing those AuthenticationProviders to
+     * augment (or perhaps even limit) the functionality or data provided.
+     *
+     * @param context
+     *     An existing UserContext generated by getUserContext() of a different
+     *     AuthenticationProvider.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser object representing the user associated with
+     *     the given UserContext.
+     *
+     * @param credentials
+     *     The credentials which were most recently submitted for the given
+     *     AuthenticatedUser. These are not guaranteed to be the same as the
+     *     credentials associated with the AuthenticatedUser object, which are
+     *     the credentials provided when the user originally authenticated.
+     *
+     * @return
+     *     A decorated (wrapped) UserContext object, or the original,
+     *     undecorated UserContext.
+     *
+     * @throws GuacamoleException
+     *     If the UserContext cannot be decorated due to an error.
+     */
+    UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException;
+
+    /**
+     * Given a UserContext returned by updateUserContext() of a different
+     * AuthenticationProvider, returns a UserContext instance which decorates
+     * (wraps) that UserContext, delegating and overriding implemented
+     * functions as necessary. Each UserContext created via updateUserContext()
+     * will be passed to the decorate() functions of all other
+     * AuthenticationProviders, allowing those AuthenticationProviders to
+     * augment (or perhaps even limit) the functionality or data provided.
+     *
+     * @param decorated
+     *     The UserContext returned when decorate() was invoked on this
+     *     AuthenticationProvider for the UserContext which was just updated
+     *     via a call to updateUserContext().
+     *
+     * @param context
+     *     An existing UserContext generated by updateUserContext() of a
+     *     different AuthenticationProvider.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser object representing the user associated with
+     *     the given UserContext.
+     *
+     * @param credentials
+     *     The credentials which were most recently submitted for the given
+     *     AuthenticatedUser. These are not guaranteed to be the same as the
+     *     credentials associated with the AuthenticatedUser object, which are
+     *     the credentials provided when the user originally authenticated.
+     *
+     * @return
+     *     A decorated (wrapped) UserContext object, or the original,
+     *     undecorated UserContext.
+     *
+     * @throws GuacamoleException
+     *     If the UserContext cannot be decorated due to an error.
+     */
+    UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException;
+
+    /**
      * Frees all resources associated with this AuthenticationProvider. This
      * function will be automatically invoked when the Guacamole server is
      * shutting down.
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java
index 85fd168..5b1d13d 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java
@@ -21,7 +21,6 @@
 
 import java.util.Date;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
@@ -32,7 +31,7 @@
  * backing GuacamoleConfiguration may be intentionally obfuscated or tokenized
  * to protect sensitive configuration information.
  */
-public interface Connection extends Identifiable, Connectable {
+public interface Connection extends Identifiable, Connectable, Attributes {
 
     /**
      * Returns the name assigned to this Connection.
@@ -83,27 +82,6 @@
     public void setConfiguration(GuacamoleConfiguration config);
 
     /**
-     * Returns all attributes associated with this connection. The returned map
-     * may not be modifiable.
-     *
-     * @return
-     *     A map of all attribute identifiers to their corresponding values,
-     *     for all attributes associated with this connection, which may not be
-     *     modifiable.
-     */
-    Map<String, String> getAttributes();
-
-    /**
-     * Sets the given attributes. If an attribute within the map is not
-     * supported, it will simply be dropped. Any attributes not within the
-     * given map will be left untouched.
-     *
-     * @param attributes
-     *     A map of all attribute identifiers to their corresponding values.
-     */
-    void setAttributes(Map<String, String> attributes);
-
-    /**
      * Returns the date and time that this connection was last used. If the
      * connection was never used, the time that the connection was last used is
      * unknown, or this information is not visible to the current user, this
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java
index 8e34e41..74412de 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java
@@ -19,7 +19,6 @@
 
 package org.apache.guacamole.net.auth;
 
-import java.util.Map;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
 
@@ -27,7 +26,7 @@
  * Represents a connection group, which can contain both other connection groups
  * as well as connections.
  */
-public interface ConnectionGroup extends Identifiable, Connectable {
+public interface ConnectionGroup extends Identifiable, Connectable, Attributes {
   
     /**
      * All legal types of connection group.
@@ -124,25 +123,4 @@
     public Set<String> getConnectionGroupIdentifiers()
             throws GuacamoleException;
 
-    /**
-     * Returns all attributes associated with this connection group. The
-     * returned map may not be modifiable.
-     *
-     * @return
-     *     A map of all attribute identifiers to their corresponding values,
-     *     for all attributes associated with this connection group, which may
-     *     not be modifiable.
-     */
-    Map<String, String> getAttributes();
-
-    /**
-     * Sets the given attributes. If an attribute within the map is not
-     * supported, it will simply be dropped. Any attributes not within the
-     * given map will be left untouched.
-     *
-     * @param attributes
-     *     A map of all attribute identifiers to their corresponding values.
-     */
-    void setAttributes(Map<String, String> attributes);
-
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DecoratingDirectory.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DecoratingDirectory.java
new file mode 100644
index 0000000..dc35e3b
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DecoratingDirectory.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.auth;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.apache.guacamole.GuacamoleException;
+
+/**
+ * Directory implementation which simplifies decorating the objects within an
+ * underlying Directory. The decorate() and undecorate() functions must be
+ * implemented to define how each object is decorated, and how that decoration
+ * may be removed.
+ *
+ * @param <ObjectType>
+ *     The type of objects stored within this Directory.
+ */
+public abstract class DecoratingDirectory<ObjectType extends Identifiable>
+        extends DelegatingDirectory<ObjectType> {
+
+    /**
+     * Creates a new DecoratingDirectory which decorates the objects within
+     * the given directory.
+     *
+     * @param directory
+     *     The Directory whose objects are being decorated.
+     */
+    public DecoratingDirectory(Directory<ObjectType> directory) {
+        super(directory);
+    }
+
+    /**
+     * Given an object retrieved from a Directory which originates from a
+     * different AuthenticationProvider, returns an identical type of object
+     * optionally wrapped with additional information, functionality, etc. If
+     * this directory chooses to decorate the object provided, it is up to the
+     * implementation of that decorated object to properly pass through
+     * operations as appropriate, as well as provide for an eventual
+     * undecorate() operation. All objects retrieved from this
+     * DecoratingDirectory will first be passed through this function.
+     *
+     * @param object
+     *     An object from a Directory which originates from a different
+     *     AuthenticationProvider.
+     *
+     * @return
+     *     An object which may have been decorated by this
+     *     DecoratingDirectory. If the object was not decorated, the original,
+     *     unmodified object may be returned instead.
+     *
+     * @throws GuacamoleException
+     *     If the provided object cannot be decorated due to an error.
+     */
+    protected abstract ObjectType decorate(ObjectType object)
+            throws GuacamoleException;
+
+    /**
+     * Given an object originally returned from a call to this
+     * DecoratingDirectory's decorate() function, reverses the decoration
+     * operation, returning the original object. This function is effectively
+     * the exact inverse of the decorate() function. The return value of
+     * undecorate(decorate(X)) must be identically X. All objects given to this
+     * DecoratingDirectory via add() or update() will first be passed through
+     * this function.
+     *
+     * @param object
+     *     An object which was originally returned by a call to this
+     *     DecoratingDirectory's decorate() function.
+     *
+     * @return
+     *     The original object which was provided to this DecoratingDirectory's
+     *     decorate() function.
+     *
+     * @throws GuacamoleException
+     *     If the provided object cannot be undecorated due to an error.
+     */
+    protected abstract ObjectType undecorate(ObjectType object)
+            throws GuacamoleException;
+
+    @Override
+    public ObjectType get(String identifier) throws GuacamoleException {
+
+        // Decorate only if object exists
+        ObjectType object = super.get(identifier);
+        if (object != null)
+            return decorate(object);
+
+        return null;
+
+    }
+
+    @Override
+    public Collection<ObjectType> getAll(Collection<String> identifiers)
+            throws GuacamoleException {
+
+        Collection<ObjectType> objects = super.getAll(identifiers);
+
+        // Decorate all retrieved objects, if any
+        Collection<ObjectType> decorated = new ArrayList<ObjectType>(objects.size());
+        for (ObjectType object : objects)
+            decorated.add(decorate(object));
+
+        return decorated;
+
+    }
+
+    @Override
+    public void add(ObjectType object) throws GuacamoleException {
+        super.add(decorate(object));
+    }
+
+    @Override
+    public void update(ObjectType object) throws GuacamoleException {
+        super.update(undecorate(object));
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingConnection.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingConnection.java
new file mode 100644
index 0000000..c5e9a1f
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingConnection.java
@@ -0,0 +1,131 @@
+/*
+ * 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.net.auth;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.GuacamoleTunnel;
+import org.apache.guacamole.protocol.GuacamoleClientInformation;
+import org.apache.guacamole.protocol.GuacamoleConfiguration;
+
+/**
+ * Connection implementation which simply delegates all function calls to an
+ * underlying Connection.
+ */
+public class DelegatingConnection implements Connection {
+
+    /**
+     * The wrapped Connection.
+     */
+    private final Connection connection;
+
+    /**
+     * Wraps the given Connection such that all function calls against this
+     * DelegatingConnection will be delegated to it.
+     *
+     * @param connection
+     *     The Connection to wrap.
+     */
+    public DelegatingConnection(Connection connection) {
+        this.connection = connection;
+    }
+
+    @Override
+    public String getIdentifier() {
+        return connection.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        connection.setIdentifier(identifier);
+    }
+
+    @Override
+    public String getName() {
+        return connection.getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        connection.setName(name);
+    }
+
+    @Override
+    public String getParentIdentifier() {
+        return connection.getParentIdentifier();
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        connection.setParentIdentifier(parentIdentifier);
+    }
+
+    @Override
+    public GuacamoleConfiguration getConfiguration() {
+        return connection.getConfiguration();
+    }
+
+    @Override
+    public void setConfiguration(GuacamoleConfiguration config) {
+        connection.setConfiguration(config);
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return connection.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        connection.setAttributes(attributes);
+    }
+
+    @Override
+    public Date getLastActive() {
+        return connection.getLastActive();
+    }
+
+    @Override
+    public List<? extends ConnectionRecord> getHistory()
+            throws GuacamoleException {
+        return connection.getHistory();
+    }
+
+    @Override
+    public Set<String> getSharingProfileIdentifiers()
+            throws GuacamoleException {
+        return connection.getSharingProfileIdentifiers();
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info)
+            throws GuacamoleException {
+        return connection.connect(info);
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return connection.getActiveConnections();
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingConnectionGroup.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingConnectionGroup.java
new file mode 100644
index 0000000..9f71ebb
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingConnectionGroup.java
@@ -0,0 +1,120 @@
+/*
+ * 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.net.auth;
+
+import java.util.Map;
+import java.util.Set;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.GuacamoleTunnel;
+import org.apache.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * ConnectionGroup implementation which simply delegates all function calls to
+ * an underlying ConnectionGroup.
+ */
+public class DelegatingConnectionGroup implements ConnectionGroup {
+
+    /**
+     * The wrapped ConnectionGroup.
+     */
+    private final ConnectionGroup connectionGroup;
+
+    /**
+     * Wraps the given ConnectionGroup such that all function calls against this
+     * DelegatingConnectionGroup will be delegated to it.
+     *
+     * @param connectionGroup
+     *     The ConnectionGroup to wrap.
+     */
+    public DelegatingConnectionGroup(ConnectionGroup connectionGroup) {
+        this.connectionGroup = connectionGroup;
+    }
+
+    @Override
+    public String getIdentifier() {
+        return connectionGroup.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        connectionGroup.setIdentifier(identifier);
+    }
+
+    @Override
+    public String getName() {
+        return connectionGroup.getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        connectionGroup.setName(name);
+    }
+
+    @Override
+    public String getParentIdentifier() {
+        return connectionGroup.getParentIdentifier();
+    }
+
+    @Override
+    public void setParentIdentifier(String parentIdentifier) {
+        connectionGroup.setParentIdentifier(parentIdentifier);
+    }
+
+    @Override
+    public void setType(Type type) {
+        connectionGroup.setType(type);
+    }
+
+    @Override
+    public Type getType() {
+        return connectionGroup.getType();
+    }
+
+    @Override
+    public Set<String> getConnectionIdentifiers() throws GuacamoleException {
+        return connectionGroup.getConnectionIdentifiers();
+    }
+
+    @Override
+    public Set<String> getConnectionGroupIdentifiers() throws GuacamoleException {
+        return connectionGroup.getConnectionGroupIdentifiers();
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return connectionGroup.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        connectionGroup.setAttributes(attributes);
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info) throws GuacamoleException {
+        return connectionGroup.connect(info);
+    }
+
+    @Override
+    public int getActiveConnections() {
+        return connectionGroup.getActiveConnections();
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java
new file mode 100644
index 0000000..f1bf274
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java
@@ -0,0 +1,83 @@
+/*
+ * 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.net.auth;
+
+import java.util.Collection;
+import java.util.Set;
+import org.apache.guacamole.GuacamoleException;
+
+/**
+ * Directory implementation which simply delegates all function calls to an
+ * underlying Directory.
+ *
+ * @param <ObjectType>
+ *     The type of objects stored within this Directory.
+ */
+public class DelegatingDirectory<ObjectType extends Identifiable>
+        implements Directory<ObjectType> {
+
+    /**
+     * The wrapped Directory.
+     */
+    private final Directory<ObjectType> directory;
+
+    /**
+     * Wraps the given Directory such that all function calls against this
+     * DelegatingDirectory will be delegated to it.
+     *
+     * @param directory
+     *     The directory to wrap.
+     */
+    public DelegatingDirectory(Directory<ObjectType> directory) {
+        this.directory = directory;
+    }
+
+    @Override
+    public ObjectType get(String identifier) throws GuacamoleException {
+        return directory.get(identifier);
+    }
+
+    @Override
+    public Collection<ObjectType> getAll(Collection<String> identifiers)
+            throws GuacamoleException {
+        return directory.getAll(identifiers);
+    }
+
+    @Override
+    public Set<String> getIdentifiers() throws GuacamoleException {
+        return directory.getIdentifiers();
+    }
+
+    @Override
+    public void add(ObjectType object) throws GuacamoleException {
+        directory.add(object);
+    }
+
+    @Override
+    public void update(ObjectType object) throws GuacamoleException {
+        directory.update(object);
+    }
+
+    @Override
+    public void remove(String identifier) throws GuacamoleException {
+        directory.remove(identifier);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingSharingProfile.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingSharingProfile.java
new file mode 100644
index 0000000..576e882
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingSharingProfile.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.net.auth;
+
+import java.util.Map;
+
+/**
+ * SharingProfile implementation which simply delegates all function calls to an
+ * underlying SharingProfile.
+ */
+public class DelegatingSharingProfile implements SharingProfile {
+
+    /**
+     * The wrapped SharingProfile.
+     */
+    private final SharingProfile sharingProfile;
+
+    /**
+     * Wraps the given SharingProfile such that all function calls against this
+     * DelegatingSharingProfile will be delegated to it.
+     *
+     * @param sharingProfile
+     *     The SharingProfile to wrap.
+     */
+    public DelegatingSharingProfile(SharingProfile sharingProfile) {
+        this.sharingProfile = sharingProfile;
+    }
+
+    @Override
+    public String getIdentifier() {
+        return sharingProfile.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        sharingProfile.setIdentifier(identifier);
+    }
+
+    @Override
+    public String getName() {
+        return sharingProfile.getName();
+    }
+
+    @Override
+    public void setName(String name) {
+        sharingProfile.setName(name);
+    }
+
+    @Override
+    public String getPrimaryConnectionIdentifier() {
+        return sharingProfile.getPrimaryConnectionIdentifier();
+    }
+
+    @Override
+    public void setPrimaryConnectionIdentifier(String identifier) {
+        sharingProfile.setPrimaryConnectionIdentifier(identifier);
+    }
+
+    @Override
+    public Map<String, String> getParameters() {
+        return sharingProfile.getParameters();
+    }
+
+    @Override
+    public void setParameters(Map<String, String> parameters) {
+        sharingProfile.setParameters(parameters);
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return sharingProfile.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        sharingProfile.setAttributes(attributes);
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUser.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUser.java
new file mode 100644
index 0000000..65f0577
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUser.java
@@ -0,0 +1,127 @@
+/*
+ * 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.net.auth;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
+import org.apache.guacamole.net.auth.permission.SystemPermissionSet;
+
+/**
+ * User implementation which simply delegates all function calls to an
+ * underlying User.
+ */
+public class DelegatingUser implements User {
+
+    /**
+     * The wrapped User.
+     */
+    private final User user;
+
+    /**
+     * Wraps the given User such that all function calls against this
+     * DelegatingUser will be delegated to it.
+     *
+     * @param user
+     *     The User to wrap.
+     */
+    public DelegatingUser(User user) {
+        this.user = user;
+    }
+
+    @Override
+    public String getIdentifier() {
+        return user.getIdentifier();
+    }
+
+    @Override
+    public void setIdentifier(String identifier) {
+        user.setIdentifier(identifier);
+    }
+
+    @Override
+    public String getPassword() {
+        return user.getPassword();
+    }
+
+    @Override
+    public void setPassword(String password) {
+        user.setPassword(password);
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+        return user.getAttributes();
+    }
+
+    @Override
+    public void setAttributes(Map<String, String> attributes) {
+        user.setAttributes(attributes);
+    }
+
+    @Override
+    public Date getLastActive() {
+        return user.getLastActive();
+    }
+
+    @Override
+    public List<? extends ActivityRecord> getHistory()
+            throws GuacamoleException {
+        return user.getHistory();
+    }
+
+    @Override
+    public SystemPermissionSet getSystemPermissions()
+            throws GuacamoleException {
+        return user.getSystemPermissions();
+    }
+
+    @Override
+    public ObjectPermissionSet getConnectionPermissions()
+            throws GuacamoleException {
+        return user.getConnectionPermissions();
+    }
+
+    @Override
+    public ObjectPermissionSet getConnectionGroupPermissions()
+            throws GuacamoleException {
+        return user.getConnectionGroupPermissions();
+    }
+
+    @Override
+    public ObjectPermissionSet getSharingProfilePermissions()
+            throws GuacamoleException {
+        return user.getSharingProfilePermissions();
+    }
+
+    @Override
+    public ObjectPermissionSet getActiveConnectionPermissions()
+            throws GuacamoleException {
+        return user.getActiveConnectionPermissions();
+    }
+
+    @Override
+    public ObjectPermissionSet getUserPermissions() throws GuacamoleException {
+        return user.getUserPermissions();
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java
new file mode 100644
index 0000000..a37faf9
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.auth;
+
+import java.util.Collection;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.form.Form;
+
+/**
+ * UserContext implementation which simply delegates all function calls to
+ * an underlying UserContext.
+ */
+public class DelegatingUserContext implements UserContext {
+
+    /**
+     * The wrapped UserContext.
+     */
+    private final UserContext userContext;
+
+    /**
+     * Wraps the given UserContext such that all function calls against this
+     * DelegatingUserContext will be delegated to it.
+     *
+     * @param userContext
+     *     The UserContext to wrap.
+     */
+    public DelegatingUserContext(UserContext userContext) {
+        this.userContext = userContext;
+    }
+
+    @Override
+    public User self() {
+        return userContext.self();
+    }
+
+    @Override
+    public Object getResource() throws GuacamoleException {
+        return userContext.getResource();
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return userContext.getAuthenticationProvider();
+    }
+
+    @Override
+    public Directory<User> getUserDirectory() throws GuacamoleException {
+        return userContext.getUserDirectory();
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory()
+            throws GuacamoleException {
+        return userContext.getConnectionDirectory();
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory()
+            throws GuacamoleException {
+        return userContext.getConnectionGroupDirectory();
+    }
+
+    @Override
+    public Directory<ActiveConnection> getActiveConnectionDirectory()
+            throws GuacamoleException {
+        return userContext.getActiveConnectionDirectory();
+    }
+
+    @Override
+    public Directory<SharingProfile> getSharingProfileDirectory()
+            throws GuacamoleException {
+        return userContext.getSharingProfileDirectory();
+    }
+
+    @Override
+    public ActivityRecordSet<ConnectionRecord> getConnectionHistory()
+            throws GuacamoleException {
+        return userContext.getConnectionHistory();
+    }
+
+    @Override
+    public ActivityRecordSet<ActivityRecord> getUserHistory()
+            throws GuacamoleException {
+        return userContext.getUserHistory();
+    }
+
+    @Override
+    public ConnectionGroup getRootConnectionGroup() throws GuacamoleException {
+        return userContext.getRootConnectionGroup();
+    }
+
+    @Override
+    public Collection<Form> getUserAttributes() {
+        return userContext.getUserAttributes();
+    }
+
+    @Override
+    public Collection<Form> getConnectionAttributes() {
+        return userContext.getConnectionAttributes();
+    }
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+        return userContext.getConnectionGroupAttributes();
+    }
+
+    @Override
+    public Collection<Form> getSharingProfileAttributes() {
+        return userContext.getSharingProfileAttributes();
+    }
+
+    @Override
+    public void invalidate() {
+        userContext.invalidate();
+    }
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java
index f9ec34c..2d4c432 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java
@@ -25,7 +25,7 @@
  * Represents the semantics which apply to an existing connection when shared,
  * along with a human-readable name and unique identifier.
  */
-public interface SharingProfile extends Identifiable {
+public interface SharingProfile extends Identifiable, Attributes {
 
     /**
      * Returns the human-readable name assigned to this SharingProfile.
@@ -93,25 +93,4 @@
      */
     public void setParameters(Map<String, String> parameters);
 
-    /**
-     * Returns all attributes associated with this sharing profile. The returned
-     * map may not be modifiable.
-     *
-     * @return
-     *     A map of all attribute identifiers to their corresponding values,
-     *     for all attributes associated with this sharing profile, which may
-     *     not be modifiable.
-     */
-    Map<String, String> getAttributes();
-
-    /**
-     * Sets the given attributes. If an attribute within the map is not
-     * supported, it will simply be dropped. Any attributes not within the
-     * given map will be left untouched.
-     *
-     * @param attributes
-     *     A map of all attribute identifiers to their corresponding values.
-     */
-    void setAttributes(Map<String, String> attributes);
-
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/User.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/User.java
index f7bd61c..a39a772 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/User.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/User.java
@@ -21,7 +21,6 @@
 
 import java.util.Date;
 import java.util.List;
-import java.util.Map;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.net.auth.permission.SystemPermissionSet;
@@ -30,7 +29,7 @@
 /**
  * A user of the Guacamole web application.
  */
-public interface User extends Identifiable {
+public interface User extends Identifiable, Attributes {
 
     /**
      * All standard attribute names with semantics defined by the Guacamole web
@@ -82,27 +81,6 @@
     public void setPassword(String password);
 
     /**
-     * Returns all attributes associated with this user. The returned map may
-     * not be modifiable.
-     *
-     * @return
-     *     A map of all attribute identifiers to their corresponding values,
-     *     for all attributes associated with this user, which may not be
-     *     modifiable.
-     */
-    Map<String, String> getAttributes();
-
-    /**
-     * Sets the given attributes. If an attribute within the map is not
-     * supported, it will simply be dropped. Any attributes not within the
-     * given map will be left untouched.
-     *
-     * @param attributes
-     *     A map of all attribute identifiers to their corresponding values.
-     */
-    void setAttributes(Map<String, String> attributes);
-
-    /**
      * Returns the date and time that this user was last active. If the user
      * was never active, the time that the user was last active is unknown, or
      * this information is not visible to the current user, this may be null.
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/simple/SimpleAuthenticationProvider.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/simple/SimpleAuthenticationProvider.java
index 83ac794..b29c2d7 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/simple/SimpleAuthenticationProvider.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/simple/SimpleAuthenticationProvider.java
@@ -261,6 +261,23 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+
+        // Simply return the given context, decorating nothing
+        return context;
+
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return decorate(context, authenticatedUser, credentials);
+    }
+
+    @Override
     public void shutdown() {
         // Do nothing
     }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/properties/GuacamoleHome.java b/guacamole-ext/src/main/java/org/apache/guacamole/properties/GuacamoleHome.java
deleted file mode 100644
index a26c1e0..0000000
--- a/guacamole-ext/src/main/java/org/apache/guacamole/properties/GuacamoleHome.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.guacamole.properties;
-
-import java.io.File;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Abstract representation of the Guacamole configuration directory.
- *
- * @deprecated
- */
-public class GuacamoleHome {
-
-    /**
-     * Logger for this class.
-     */
-    private static final Logger logger = LoggerFactory.getLogger(GuacamoleHome.class);
-
-    static {
-        // Warn about deprecation
-        logger.warn("GuacamoleHome is deprecated. Please use Environment instead.");
-    }
-    
-    /**
-     * GuacamoleHome is a utility class and cannot be instantiated.
-     */
-    private GuacamoleHome() {}
-
-    /**
-     * Returns the Guacamole home directory by checking, in order:
-     * the guacamole.home system property, the GUACAMOLE_HOME environment
-     * variable, and finally the .guacamole directory in the home directory of
-     * the user running the servlet container.
-     *
-     * @return The File representing the Guacamole home directory, which may
-     *         or may not exist, and may turn out to not be a directory.
-     */
-    public static File getDirectory() {
-
-        // Attempt to find Guacamole home
-        File guacHome;
-
-        // Use system property by default
-        String desiredDir = System.getProperty("guacamole.home");
-
-        // Failing that, try the GUACAMOLE_HOME environment variable
-        if (desiredDir == null) desiredDir = System.getenv("GUACAMOLE_HOME");
-
-        // If successful, use explicitly specified directory
-        if (desiredDir != null)
-            guacHome = new File(desiredDir);
-
-        // If not explicitly specified, use ~/.guacamole
-        else
-            guacHome = new File(System.getProperty("user.home"), ".guacamole");
-
-        // Return discovered directory
-        return guacHome;
-
-    }
-
-}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/properties/GuacamoleProperties.java b/guacamole-ext/src/main/java/org/apache/guacamole/properties/GuacamoleProperties.java
deleted file mode 100644
index a3eb7d1..0000000
--- a/guacamole-ext/src/main/java/org/apache/guacamole/properties/GuacamoleProperties.java
+++ /dev/null
@@ -1,214 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.guacamole.properties;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Properties;
-import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.GuacamoleServerException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Simple utility class for reading properties from the guacamole.properties
- * file. The guacamole.properties file is preferably located in the servlet
- * container's user's home directory, in a subdirectory called .guacamole, or
- * in the directory set by the system property: guacamole.home.
- *
- * If none of those locations are possible, guacamole.properties will also
- * be read from the root of the classpath.
- *
- * @deprecated
- */
-public class GuacamoleProperties {
-
-    /**
-     * Logger for this class.
-     */
-    private static final Logger logger = LoggerFactory.getLogger(GuacamoleProperties.class);
-
-    static {
-        // Warn about deprecation
-        logger.warn("GuacamoleProperties is deprecated. Please use Environment instead.");
-    }
- 
-    /**
-     * GuacamoleProperties is a utility class and cannot be instantiated.
-     */
-    private GuacamoleProperties() {}
-
-    /**
-     * The hostname of the server where guacd (the Guacamole proxy server) is
-     * running.
-     */
-    public static final StringGuacamoleProperty GUACD_HOSTNAME = new StringGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "guacd-hostname"; }
-
-    };
-
-    /**
-     * The port that guacd (the Guacamole proxy server) is listening on.
-     */
-    public static final IntegerGuacamoleProperty GUACD_PORT = new IntegerGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "guacd-port"; }
-
-    };
-
-    /**
-     * Whether guacd requires SSL/TLS on connections.
-     */
-    public static final BooleanGuacamoleProperty GUACD_SSL = new BooleanGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "guacd-ssl"; }
-
-    };
-
-    /**
-     * All properties read from guacamole.properties when this class was first
-     * used.
-     */
-    private static final Properties properties;
-
-    /**
-     * Any error encountered when reading guacamole.properties was last
-     * attempted.
-     */
-    private static GuacamoleException exception;
-
-    static {
-
-        properties = new Properties();
-
-        try {
-
-            // Attempt to find Guacamole home
-            File guacHome = GuacamoleHome.getDirectory();
-
-            InputStream stream;
-
-            // If not a directory, load from classpath
-            if (!guacHome.isDirectory()) {
-
-                // Read from classpath
-                stream = GuacamoleProperties.class.getResourceAsStream("/guacamole.properties");
-                if (stream == null)
-                    throw new IOException(
-                        "guacamole.properties not loaded from " + guacHome
-                      + " (not a directory), and guacamole.properties could"
-                      + " not be found as a resource in the classpath.");
-
-            }
-
-            // Otherwise, try to load from file
-            else
-                stream = new FileInputStream(new File(guacHome, "guacamole.properties"));
-
-            // Load properties, always close stream
-            try { properties.load(stream); }
-            finally { stream.close(); }
-
-        }
-        catch (IOException e) {
-            exception = new GuacamoleServerException("Error reading guacamole.properties", e);
-        }
-
-    }
-
-    /**
-     * Given a GuacamoleProperty, parses and returns the value set for that
-     * property in guacamole.properties, if any.
-     *
-     * @param <Type> The type that the given property is parsed into.
-     * @param property The property to read from guacamole.properties.
-     * @return The parsed value of the property as read from
-     *         guacamole.properties.
-     * @throws GuacamoleException If an error occurs while parsing the value
-     *                            for the given property in
-     *                            guacamole.properties.
-     */
-    public static <Type> Type getProperty(GuacamoleProperty<Type> property) throws GuacamoleException {
-
-        if (exception != null)
-            throw exception;
-
-        return property.parseValue(properties.getProperty(property.getName()));
-
-    }
-
-    /**
-     * Given a GuacamoleProperty, parses and returns the value set for that
-     * property in guacamole.properties, if any. If no value is found, the
-     * provided default value is returned.
-     *
-     * @param <Type> The type that the given property is parsed into.
-     * @param property The property to read from guacamole.properties.
-     * @param defaultValue The value to return if no value was given in
-     *                     guacamole.properties.
-     * @return The parsed value of the property as read from
-     *         guacamole.properties, or the provided default value if no value
-     *         was found.
-     * @throws GuacamoleException If an error occurs while parsing the value
-     *                            for the given property in
-     *                            guacamole.properties.
-     */
-    public static <Type> Type getProperty(GuacamoleProperty<Type> property,
-            Type defaultValue) throws GuacamoleException {
-
-        Type value = getProperty(property);
-        if (value == null)
-            return defaultValue;
-
-        return value;
-
-    }
-
-    /**
-     * Given a GuacamoleProperty, parses and returns the value set for that
-     * property in guacamole.properties. An exception is thrown if the value
-     * is not provided.
-     *
-     * @param <Type> The type that the given property is parsed into.
-     * @param property The property to read from guacamole.properties.
-     * @return The parsed value of the property as read from
-     *         guacamole.properties.
-     * @throws GuacamoleException If an error occurs while parsing the value
-     *                            for the given property in
-     *                            guacamole.properties, or if the property is
-     *                            not specified.
-     */
-    public static <Type> Type getRequiredProperty(GuacamoleProperty<Type> property)
-            throws GuacamoleException {
-
-        Type value = getProperty(property);
-        if (value == null)
-            throw new GuacamoleServerException("Property " + property.getName() + " is required.");
-
-        return value;
-
-    }
-}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/properties/IntegerGuacamoleProperty.java b/guacamole-ext/src/main/java/org/apache/guacamole/properties/IntegerGuacamoleProperty.java
index a9d3686..2228e2e 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/properties/IntegerGuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/properties/IntegerGuacamoleProperty.java
@@ -35,8 +35,7 @@
             return null;
 
         try {
-            Integer integer = new Integer(value);
-            return integer;
+            return Integer.valueOf(value);
         }
         catch (NumberFormatException e) {
             throw new GuacamoleServerException("Property \"" + getName() + "\" must be an integer.", e);
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/properties/LongGuacamoleProperty.java b/guacamole-ext/src/main/java/org/apache/guacamole/properties/LongGuacamoleProperty.java
index 5eb5d1f..9c25d6f 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/properties/LongGuacamoleProperty.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/properties/LongGuacamoleProperty.java
@@ -35,8 +35,7 @@
             return null;
 
         try {
-            Long longValue = new Long(value);
-            return longValue;
+            return Long.valueOf(value);
         }
         catch (NumberFormatException e) {
             throw new GuacamoleServerException("Property \"" + getName() + "\" must be an long.", e);
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/xml/DocumentHandler.java b/guacamole-ext/src/main/java/org/apache/guacamole/xml/DocumentHandler.java
index eb9bf5d..ed55290 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/xml/DocumentHandler.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/xml/DocumentHandler.java
@@ -84,6 +84,10 @@
     public void startElement(String uri, String localName, String qName,
         Attributes attributes) throws SAXException {
 
+        // If the SAX implementation does not provide the local name, the
+        // qualified name should be used instead
+        String name = localName.isEmpty() ? qName : localName;
+
         // Get current state
         DocumentHandlerState current = getCurrentState();
 
@@ -94,7 +98,7 @@
         if (current == null) {
 
             // Validate element name
-            if (!localName.equals(rootElementName))
+            if (!name.equals(rootElementName))
                 throw new SAXException("Root element must be '" + rootElementName + "'");
 
             handler = root;
@@ -103,12 +107,12 @@
         // Otherwise, get handler from parent
         else {
             TagHandler parent_handler = current.getTagHandler();
-            handler = parent_handler.childElement(localName);
+            handler = parent_handler.childElement(name);
         }
 
         // If no handler returned, the element was not expected
         if (handler == null)
-            throw new SAXException("Unexpected element: '" + localName + "'");
+            throw new SAXException("Unexpected element: '" + name + "'");
 
         // Initialize handler
         handler.init(attributes);
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
index 700bc39..8f0c5ac 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
@@ -91,14 +91,18 @@
                     "type"    : "ENUM",
                     "options" : [
                         "",
+                        "de-de-qwertz",
+                        "en-gb-qwerty",
                         "en-us-qwerty",
+                        "es-es-qwerty",
+                        "failsafe",
                         "fr-fr-azerty",
                         "fr-ch-qwertz",
-                        "de-de-qwertz",
                         "it-it-qwerty",
                         "ja-jp-qwerty",
+                        "pt-br-qwerty",
                         "sv-se-qwerty",
-                        "failsafe"
+                        "tr-tr-qwerty"
                     ]
                 },
                 {
@@ -218,6 +222,21 @@
                     "name"    : "enable-menu-animations",
                     "type"    : "BOOLEAN",
                     "options" : [ "true" ]
+                },
+                {
+                    "name"    : "disable-bitmap-caching",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "disable-offscreen-caching",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"    : "disable-glyph-caching",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
                 }
             ]
         },
@@ -276,6 +295,21 @@
                     "type"  : "TEXT"
                 },
                 {
+                    "name"  : "recording-exclude-output",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-exclude-mouse",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-include-keys",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
                     "name"  : "create-recording-path",
                     "type"  : "BOOLEAN",
                     "options" : [ "true" ]
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json
index 5bc3070..edc7bbb 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json
@@ -108,6 +108,21 @@
                     "type"  : "TEXT"
                 },
                 {
+                    "name"  : "recording-exclude-output",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-exclude-mouse",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-include-keys",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
                     "name"  : "create-recording-path",
                     "type"  : "BOOLEAN",
                     "options" : [ "true" ]
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/telnet.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/telnet.json
index ea9c9fa..15dac41 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/telnet.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/telnet.json
@@ -90,6 +90,21 @@
                     "type"  : "TEXT"
                 },
                 {
+                    "name"  : "recording-exclude-output",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-exclude-mouse",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-include-keys",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
                     "name"  : "create-recording-path",
                     "type"  : "BOOLEAN",
                     "options" : [ "true" ]
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json
index 379c8ce..3f9f60b 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json
@@ -89,6 +89,21 @@
                     "type"  : "TEXT"
                 },
                 {
+                    "name"  : "recording-exclude-output",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-exclude-mouse",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
+                    "name"  : "recording-include-keys",
+                    "type"  : "BOOLEAN",
+                    "options" : [ "true" ]
+                },
+                {
                     "name"  : "create-recording-path",
                     "type"  : "BOOLEAN",
                     "options" : [ "true" ]
diff --git a/guacamole/pom.xml b/guacamole/pom.xml
index 4784cda..9fa9fce 100644
--- a/guacamole/pom.xml
+++ b/guacamole/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole</artifactId>
     <packaging>war</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <description>
         The Guacamole web application, providing authentication and an HTML5
@@ -46,24 +46,10 @@
 
     <!-- Git repository -->
     <scm>
-        <url>https://github.com/apache/incubator-guacamole-client</url>
-        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/incubator-guacamole-client.git</connection>
+        <url>https://github.com/apache/guacamole-client</url>
+        <connection>scm:git:https://git.wip-us.apache.org/repos/asf/guacamole-client.git</connection>
     </scm>
 
-    <!-- Developers -->
-    <developers>
-        <developer>
-            <id>mike.jumper</id>
-            <name>Michael Jumper</name>
-            <email>mike.jumper@guac-dev.org</email>
-        </developer>
-        <developer>
-            <id>james.muehlner</id>
-            <name>James Muehlner</name>
-            <email>james.muehlner@guac-dev.org</email>
-        </developer>
-    </developers>
-
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
@@ -278,21 +264,21 @@
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
         <!-- Guacamole Extension API -->
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-ext</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
         </dependency>
 
         <!-- Guacamole JavaScript API -->
         <dependency>
             <groupId>org.apache.guacamole</groupId>
             <artifactId>guacamole-common-js</artifactId>
-            <version>0.9.13-incubating</version>
+            <version>0.9.14</version>
             <type>zip</type>
             <scope>runtime</scope>
         </dependency>
diff --git a/guacamole/src/licenses/DISCLAIMER b/guacamole/src/licenses/DISCLAIMER
deleted file mode 100644
index 1a9c3be..0000000
--- a/guacamole/src/licenses/DISCLAIMER
+++ /dev/null
@@ -1,7 +0,0 @@
-Apache Guacamole is an effort undergoing incubation at The Apache Software
-Foundation (ASF). Incubation is required of all newly accepted projects until a
-further review indicates that the infrastructure, communications, and decision
-making process have stabilized in a manner consistent with other successful ASF
-projects. While incubation status is not necessarily a reflection of the
-completeness or stability of the code, it does indicate that the project has
-yet to be fully endorsed by the ASF.
diff --git a/guacamole/src/licenses/NOTICE b/guacamole/src/licenses/NOTICE
index 47f2b4c..97e6130 100644
--- a/guacamole/src/licenses/NOTICE
+++ b/guacamole/src/licenses/NOTICE
@@ -1,5 +1,5 @@
 Apache Guacamole
-Copyright 2017 The Apache Software Foundation
+Copyright 2018 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java b/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java
index e723c0a..24ea196 100644
--- a/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java
+++ b/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java
@@ -28,6 +28,7 @@
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.rest.auth.DecoratedUserContext;
 import org.apache.guacamole.tunnel.UserTunnel;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -52,7 +53,7 @@
      * All UserContexts associated with this session. Each
      * AuthenticationProvider may provide its own UserContext.
      */
-    private List<UserContext> userContexts;
+    private List<DecoratedUserContext> userContexts;
 
     /**
      * All currently-active tunnels, indexed by tunnel UUID.
@@ -84,7 +85,7 @@
      */
     public GuacamoleSession(Environment environment,
             AuthenticatedUser authenticatedUser,
-            List<UserContext> userContexts)
+            List<DecoratedUserContext> userContexts)
             throws GuacamoleException {
         this.lastAccessedTime = System.currentTimeMillis();
         this.authenticatedUser = authenticatedUser;
@@ -121,7 +122,7 @@
      *     An unmodifiable list of all UserContexts associated with this
      *     session.
      */
-    public List<UserContext> getUserContexts() {
+    public List<DecoratedUserContext> getUserContexts() {
         return Collections.unmodifiableList(userContexts);
     }
 
@@ -141,12 +142,12 @@
      * @throws GuacamoleException
      *     If no such UserContext exists.
      */
-    public UserContext getUserContext(String authProviderIdentifier)
+    public DecoratedUserContext getUserContext(String authProviderIdentifier)
             throws GuacamoleException {
 
         // Locate and return the UserContext associated with the
         // AuthenticationProvider having the given identifier, if any
-        for (UserContext userContext : getUserContexts()) {
+        for (DecoratedUserContext userContext : getUserContexts()) {
 
             // Get AuthenticationProvider associated with current UserContext
             AuthenticationProvider authProvider = userContext.getAuthenticationProvider();
@@ -170,7 +171,7 @@
      * @param userContexts
      *     The List of UserContexts to associate with this session.
      */
-    public void setUserContexts(List<UserContext> userContexts) {
+    public void setUserContexts(List<DecoratedUserContext> userContexts) {
         this.userContexts = userContexts;
     }
     
diff --git a/guacamole/src/main/java/org/apache/guacamole/auth/file/FileAuthenticationProvider.java b/guacamole/src/main/java/org/apache/guacamole/auth/file/FileAuthenticationProvider.java
index 27d3411..53ae7eb 100644
--- a/guacamole/src/main/java/org/apache/guacamole/auth/file/FileAuthenticationProvider.java
+++ b/guacamole/src/main/java/org/apache/guacamole/auth/file/FileAuthenticationProvider.java
@@ -19,26 +19,22 @@
 
 package org.apache.guacamole.auth.file;
 
-import java.io.BufferedInputStream;
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.IOException;
-import java.io.InputStream;
 import java.util.Map;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.environment.LocalEnvironment;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.net.auth.simple.SimpleAuthenticationProvider;
 import org.apache.guacamole.xml.DocumentHandler;
-import org.apache.guacamole.properties.FileGuacamoleProperty;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.xml.sax.InputSource;
 import org.xml.sax.SAXException;
-import org.xml.sax.XMLReader;
-import org.xml.sax.helpers.XMLReaderFactory;
 
 /**
  * Authenticates users against a static list of username/password pairs.
@@ -70,20 +66,6 @@
     private final Environment environment;
 
     /**
-     * The XML file to read the user mapping from. This property has been
-     * deprecated, as the name "basic" is ridiculous, and providing for
-     * configurable user-mapping.xml locations is unnecessary complexity. Use
-     * GUACAMOLE_HOME/user-mapping.xml instead.
-     */
-    @Deprecated
-    public static final FileGuacamoleProperty BASIC_USER_MAPPING = new FileGuacamoleProperty() {
-
-        @Override
-        public String getName() { return "basic-user-mapping"; }
-
-    };
-
-    /**
      * The filename to use for the user mapping.
      */
     public static final String USER_MAPPING_FILENAME = "user-mapping.xml";
@@ -107,38 +89,17 @@
 
     /**
      * Returns a UserMapping containing all authorization data given within
-     * the XML file specified by the "basic-user-mapping" property in
-     * guacamole.properties. If the XML file has been modified or has not yet
-     * been read, this function may reread the file.
+     * GUACAMOLE_HOME/user-mapping.xml. If the XML file has been modified or has
+     * not yet been read, this function may reread the file.
      *
      * @return
      *     A UserMapping containing all authorization data within the user
      *     mapping XML file, or null if the file cannot be found/parsed.
      */
-    @SuppressWarnings("deprecation") // We must continue to use the "basic-user-mapping" property until it is truly no longer supported
     private UserMapping getUserMapping() {
 
-        // Get user mapping file, defaulting to GUACAMOLE_HOME/user-mapping.xml
-        File userMappingFile;
-        try {
-
-            // Continue supporting deprecated property, but warn in the logs
-            userMappingFile = environment.getProperty(BASIC_USER_MAPPING);
-            if (userMappingFile != null)
-                logger.warn("The \"basic-user-mapping\" property is deprecated. Please use the \"GUACAMOLE_HOME/user-mapping.xml\" file instead.");
-
-            // Read user mapping from GUACAMOLE_HOME
-            if (userMappingFile == null)
-                userMappingFile = new File(environment.getGuacamoleHome(), USER_MAPPING_FILENAME);
-
-        }
-
-        // Abort if property cannot be parsed
-        catch (GuacamoleException e) {
-            logger.warn("Unable to read user mapping filename from properties: {}", e.getMessage());
-            logger.debug("Error parsing user mapping property.", e);
-            return null;
-        }
+        // Read user mapping from GUACAMOLE_HOME/user-mapping.xml
+        File userMappingFile = new File(environment.getGuacamoleHome(), USER_MAPPING_FILENAME);
 
         // Abort if user mapping does not exist
         if (!userMappingFile.exists()) {
@@ -151,6 +112,22 @@
 
             logger.debug("Reading user mapping file: \"{}\"", userMappingFile);
 
+            // Set up XML parser
+            SAXParser parser;
+            try {
+                parser = SAXParserFactory.newInstance().newSAXParser();
+            }
+            catch (ParserConfigurationException e) {
+                logger.error("Unable to create XML parser for reading \"{}\": {}", USER_MAPPING_FILENAME, e.getMessage());
+                logger.debug("An instance of SAXParser could not be created.", e);
+                return null;
+            }
+            catch (SAXException e) {
+                logger.error("Unable to create XML parser for reading \"{}\": {}", USER_MAPPING_FILENAME, e.getMessage());
+                logger.debug("An instance of SAXParser could not be created.", e);
+                return null;
+            }
+
             // Parse document
             try {
 
@@ -162,14 +139,8 @@
                 DocumentHandler contentHandler = new DocumentHandler(
                         "user-mapping", userMappingHandler);
 
-                // Set up XML parser
-                XMLReader parser = XMLReaderFactory.createXMLReader();
-                parser.setContentHandler(contentHandler);
-
                 // Read and parse file
-                InputStream input = new BufferedInputStream(new FileInputStream(userMappingFile));
-                parser.parse(new InputSource(input));
-                input.close();
+                parser.parse(userMappingFile, contentHandler);
 
                 // Store mod time and user mapping
                 lastModified = userMappingFile.lastModified();
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java b/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java
index 8dfbe7f..a868931 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java
@@ -159,6 +159,35 @@
     }
 
     @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Do nothing if underlying auth provider could not be loaded
+        if (authProvider == null)
+            return context;
+
+        // Delegate to underlying auth provider
+        return authProvider.decorate(context, authenticatedUser, credentials);
+
+    }
+
+    @Override
+    public UserContext redecorate(UserContext decorated, UserContext context,
+            AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Do nothing if underlying auth provider could not be loaded
+        if (authProvider == null)
+            return context;
+
+        // Delegate to underlying auth provider
+        return authProvider.redecorate(decorated, context,
+                authenticatedUser, credentials);
+
+    }
+
+    @Override
     public void shutdown() {
         if (authProvider != null)
             authProvider.shutdown();
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
index a74c4c0..524ff57 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
@@ -60,7 +60,7 @@
     private static final List<String> ALLOWED_GUACAMOLE_VERSIONS =
         Collections.unmodifiableList(Arrays.asList(
             "*",
-            "0.9.13-incubating"
+            "0.9.14"
         ));
 
     /**
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/RESTExceptionWrapper.java b/guacamole/src/main/java/org/apache/guacamole/rest/RESTExceptionWrapper.java
index 6f1ab37..a0c756e 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/RESTExceptionWrapper.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/RESTExceptionWrapper.java
@@ -171,17 +171,11 @@
         }
 
         // Translate GuacamoleException subclasses to HTTP error codes
-        catch (GuacamoleSecurityException e) {
-            throw new APIException(Response.Status.FORBIDDEN, e);
-        }
-        catch (GuacamoleResourceNotFoundException e) {
-            throw new APIException(Response.Status.NOT_FOUND, e);
-        }
-        catch (GuacamoleClientException e) {
-            throw new APIException(Response.Status.BAD_REQUEST, e);
-        }
         catch (GuacamoleException e) {
-            throw new APIException(Response.Status.INTERNAL_SERVER_ERROR, e);
+            throw new APIException(
+                Response.Status.fromStatusCode(e.getHttpStatusCode()),
+                e
+            );
         }
 
         // Rethrow unchecked exceptions such that they are properly wrapped
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/RESTServiceModule.java b/guacamole/src/main/java/org/apache/guacamole/rest/RESTServiceModule.java
index 587d833..98c5c7a 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/RESTServiceModule.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/RESTServiceModule.java
@@ -33,6 +33,7 @@
 import org.apache.guacamole.rest.auth.TokenRESTService;
 import org.apache.guacamole.rest.auth.AuthTokenGenerator;
 import org.apache.guacamole.rest.auth.AuthenticationService;
+import org.apache.guacamole.rest.auth.DecorationService;
 import org.apache.guacamole.rest.auth.SecureRandomAuthTokenGenerator;
 import org.apache.guacamole.rest.auth.TokenSessionMap;
 import org.apache.guacamole.rest.connection.ConnectionModule;
@@ -80,6 +81,7 @@
         bind(ListenerService.class);
         bind(AuthenticationService.class);
         bind(AuthTokenGenerator.class).to(SecureRandomAuthTokenGenerator.class);
+        bind(DecorationService.class);
 
         // Automatically translate GuacamoleExceptions for REST methods
         MethodInterceptor interceptor = new RESTExceptionWrapper();
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionObjectTranslator.java
index d59f8c5..712a82f 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionObjectTranslator.java
@@ -22,6 +22,7 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
 import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 
 /**
@@ -30,7 +31,7 @@
  * toExternalObject() is implemented here.
  */
 public class ActiveConnectionObjectTranslator
-        implements DirectoryObjectTranslator<ActiveConnection, APIActiveConnection> {
+        extends DirectoryObjectTranslator<ActiveConnection, APIActiveConnection> {
 
     @Override
     public APIActiveConnection toExternalObject(ActiveConnection object)
@@ -56,4 +57,10 @@
 
     }
 
+    @Override
+    public void filterExternalObject(UserContext context,
+            APIActiveConnection object) throws GuacamoleException {
+        // Nothing to filter on ActiveConnections (no attributes)
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java
index e72e674..47ba3d4 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java
@@ -89,7 +89,7 @@
             @Assisted Directory<ActiveConnection> directory,
             @Assisted ActiveConnection activeConnection,
             DirectoryObjectTranslator<ActiveConnection, APIActiveConnection> translator) {
-        super(directory, activeConnection, translator);
+        super(userContext, directory, activeConnection, translator);
         this.userContext = userContext;
         this.activeConnection = activeConnection;
     }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java
index fb118e1..7f38857 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java
@@ -79,6 +79,12 @@
     private AuthTokenGenerator authTokenGenerator;
 
     /**
+     * Service for applying or reapplying layers of decoration.
+     */
+    @Inject
+    private DecorationService decorationService;
+
+    /**
      * The service to use to notify registered authentication listeners.
      */
     @Inject
@@ -343,26 +349,30 @@
      * @throws GuacamoleException
      *     If an error occurs while creating or updating any UserContext.
      */
-    private List<UserContext> getUserContexts(GuacamoleSession existingSession,
+    private List<DecoratedUserContext> getUserContexts(GuacamoleSession existingSession,
             AuthenticatedUser authenticatedUser, Credentials credentials)
             throws GuacamoleException {
 
-        List<UserContext> userContexts = new ArrayList<UserContext>(authProviders.size());
+        List<DecoratedUserContext> userContexts =
+                new ArrayList<DecoratedUserContext>(authProviders.size());
 
         // If UserContexts already exist, update them and add to the list
         if (existingSession != null) {
 
             // Update all old user contexts
-            List<UserContext> oldUserContexts = existingSession.getUserContexts();
-            for (UserContext oldUserContext : oldUserContexts) {
+            List<DecoratedUserContext> oldUserContexts = existingSession.getUserContexts();
+            for (DecoratedUserContext userContext : oldUserContexts) {
+
+                UserContext oldUserContext = userContext.getUndecoratedUserContext();
 
                 // Update existing UserContext
                 AuthenticationProvider authProvider = oldUserContext.getAuthenticationProvider();
-                UserContext userContext = authProvider.updateUserContext(oldUserContext, authenticatedUser, credentials);
+                UserContext updatedUserContext = authProvider.updateUserContext(oldUserContext, authenticatedUser, credentials);
 
                 // Add to available data, if successful
-                if (userContext != null)
-                    userContexts.add(userContext);
+                if (updatedUserContext != null)
+                    userContexts.add(decorationService.redecorate(userContext,
+                            updatedUserContext, authenticatedUser, credentials));
 
                 // If unsuccessful, log that this happened, as it may be a bug
                 else
@@ -384,7 +394,8 @@
 
                 // Add to available data, if successful
                 if (userContext != null)
-                    userContexts.add(userContext);
+                    userContexts.add(decorationService.decorate(userContext,
+                            authenticatedUser, credentials));
 
             }
 
@@ -428,7 +439,7 @@
 
         // Get up-to-date AuthenticatedUser and associated UserContexts
         AuthenticatedUser authenticatedUser = getAuthenticatedUser(existingSession, credentials);
-        List<UserContext> userContexts = getUserContexts(existingSession, authenticatedUser, credentials);
+        List<DecoratedUserContext> userContexts = getUserContexts(existingSession, authenticatedUser, credentials);
 
         // Update existing session, if it exists
         String authToken;
@@ -513,7 +524,7 @@
      * @throws GuacamoleException
      *     If the auth token does not correspond to any logged in user.
      */
-    public List<UserContext> getUserContexts(String authToken)
+    public List<DecoratedUserContext> getUserContexts(String authToken)
             throws GuacamoleException {
         return getGuacamoleSession(authToken).getUserContexts();
     }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java
new file mode 100644
index 0000000..24ad8b3
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java
@@ -0,0 +1,361 @@
+/*
+ * 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.rest.auth;
+
+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.DelegatingUserContext;
+import org.apache.guacamole.net.auth.UserContext;
+
+/**
+ * A UserContext which has been decorated by an AuthenticationProvider through
+ * invoking decorate() or redecorate().
+ */
+public class DecoratedUserContext extends DelegatingUserContext {
+
+    /**
+     * The original, undecorated UserContext.
+     */
+    private final UserContext undecoratedUserContext;
+
+    /**
+     * The AuthenticationProvider which applied this layer of decoration.
+     */
+    private final AuthenticationProvider decoratingAuthenticationProvider;
+
+    /**
+     * The DecoratedUserContext which applies the layer of decoration
+     * immediately beneath this DecoratedUserContext. If no further decoration
+     * has been applied, this will be null.
+     */
+    private final DecoratedUserContext decoratedUserContext;
+
+    /**
+     * Decorates a newly-created UserContext (as would be returned by
+     * getUserContext()), invoking the decorate() function of the given
+     * AuthenticationProvider to apply an additional layer of decoration. If the
+     * AuthenticationProvider originated the given UserContext, this function
+     * has no effect.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider which should be used to decorate the
+     *     given UserContext.
+     *
+     * @param userContext
+     *     The UserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @return
+     *     A UserContext instance which has been decorated (wrapped) by the
+     *     given AuthenticationProvider, or the original UserContext if the
+     *     given AuthenticationProvider originated the UserContext.
+     *
+     * @throws GuacamoleException
+     *     If the given AuthenticationProvider fails while decorating the
+     *     UserContext.
+     */
+    private static UserContext decorate(AuthenticationProvider authProvider,
+            UserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Skip the AuthenticationProvider which produced the UserContext
+        // being decorated
+        if (authProvider != userContext.getAuthenticationProvider()) {
+
+            // Apply layer of wrapping around UserContext
+            UserContext decorated = authProvider.decorate(userContext,
+                    authenticatedUser, credentials);
+
+            // Do not allow misbehaving extensions to wipe out the
+            // UserContext entirely
+            if (decorated != null)
+                return decorated;
+
+        }
+
+        return userContext;
+
+    }
+
+    /**
+     * Redecorates an updated UserContext (as would be returned by
+     * updateUserContext()), invoking the redecorate() function of the given
+     * AuthenticationProvider to apply an additional layer of decoration. If the
+     * AuthenticationProvider originated the given UserContext, this function
+     * has no effect.
+     *
+     * @param decorated
+     *     The DecoratedUserContext associated with an older version of the
+     *     given UserContext.
+     *
+     * @param userContext
+     *     The new version of the UserContext which should be decorated.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @return
+     *     A UserContext instance which has been decorated (wrapped) by the
+     *     given AuthenticationProvider, or the original UserContext if the
+     *     given AuthenticationProvider originated the UserContext.
+     *
+     * @throws GuacamoleException
+     *     If the given AuthenticationProvider fails while decorating the
+     *     UserContext.
+     */
+    private static UserContext redecorate(DecoratedUserContext decorated,
+            UserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        AuthenticationProvider authProvider = decorated.getDecoratingAuthenticationProvider();
+
+        // Skip the AuthenticationProvider which produced the UserContext
+        // being decorated
+        if (authProvider != userContext.getAuthenticationProvider()) {
+
+            // Apply next layer of wrapping around UserContext
+            UserContext redecorated = authProvider.redecorate(decorated,
+                    userContext, authenticatedUser, credentials);
+
+            // Do not allow misbehaving extensions to wipe out the
+            // UserContext entirely
+            if (redecorated != null)
+                return redecorated;
+
+        }
+
+        return userContext;
+
+    }
+
+    /**
+     * Creates a new DecoratedUserContext, invoking the the decorate() function
+     * of the given AuthenticationProvider to decorate the provided, undecorated
+     * UserContext. If the AuthenticationProvider originated the given
+     * UserContext, then the given UserContext is wrapped without any
+     * decoration.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider which should be used to decorate the
+     *     given UserContext.
+     *
+     * @param userContext
+     *     The undecorated UserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @throws GuacamoleException
+     *     If any of the given AuthenticationProviders fails while decorating
+     *     the UserContext.
+     */
+    public DecoratedUserContext(AuthenticationProvider authProvider,
+            UserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Wrap the result of invoking decorate() on the given AuthenticationProvider
+        super(decorate(authProvider, userContext, authenticatedUser, credentials));
+        this.decoratingAuthenticationProvider = authProvider;
+
+        // The wrapped UserContext is undecorated
+        this.undecoratedUserContext = userContext;
+        this.decoratedUserContext = null;
+
+    }
+
+    /**
+     * Creates a new DecoratedUserContext, invoking the the decorate() function
+     * of the given AuthenticationProvider to apply an additional layer of
+     * decoration to a DecoratedUserContext. If the AuthenticationProvider
+     * originated the given UserContext, then the given UserContext is wrapped
+     * without any decoration.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider which should be used to decorate the
+     *     given UserContext.
+     *
+     * @param userContext
+     *     The DecoratedUserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @throws GuacamoleException
+     *     If any of the given AuthenticationProviders fails while decorating
+     *     the UserContext.
+     */
+    public DecoratedUserContext(AuthenticationProvider authProvider,
+            DecoratedUserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Wrap the result of invoking decorate() on the given AuthenticationProvider
+        super(decorate(authProvider, userContext, authenticatedUser, credentials));
+        this.decoratingAuthenticationProvider = authProvider;
+
+        // The wrapped UserContext has at least one layer of decoration
+        this.undecoratedUserContext = userContext.getUndecoratedUserContext();
+        this.decoratedUserContext = userContext;
+
+    }
+
+    /**
+     * Creates a new DecoratedUserContext, invoking the the redecorate()
+     * function of the given AuthenticationProvider to reapply decoration to the
+     * provided, undecorated UserContext, which has been updated relative to a
+     * past version which was decorated. If the AuthenticationProvider
+     * originated the given UserContext, then the given UserContext is wrapped
+     * without any decoration.
+     *
+     * @param decorated
+     *     The DecoratedUserContext associated with the older version of the
+     *     given UserContext.
+     *
+     * @param userContext
+     *     The undecorated UserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @throws GuacamoleException
+     *     If any of the given AuthenticationProviders fails while decorating
+     *     the UserContext.
+     */
+    public DecoratedUserContext(DecoratedUserContext decorated,
+            UserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Wrap the result of invoking redecorate() on the given AuthenticationProvider
+        super(redecorate(decorated, userContext, authenticatedUser, credentials));
+        this.decoratingAuthenticationProvider = decorated.getDecoratingAuthenticationProvider();
+
+        // The wrapped UserContext is undecorated
+        this.undecoratedUserContext = userContext;
+        this.decoratedUserContext = null;
+
+    }
+
+    /**
+     * Creates a new DecoratedUserContext, invoking the the redecorate()
+     * function of the given AuthenticationProvider to reapply decoration to a
+     * DecoratedUserContext which already has at least one layer of decoration
+     * applied, and which is associated with a UserContext which was updated
+     * relative to a past version which was decorated. If the
+     * AuthenticationProvider originated the given UserContext, then the given
+     * UserContext is wrapped without any decoration.
+     *
+     * @param decorated
+     *     The DecoratedUserContext associated with the older version of the
+     *     UserContext wrapped within one or more layers of decoration.
+     *
+     * @param userContext
+     *     The DecoratedUserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @throws GuacamoleException
+     *     If any of the given AuthenticationProviders fails while decorating
+     *     the UserContext.
+     */
+    public DecoratedUserContext(DecoratedUserContext decorated,
+            DecoratedUserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Wrap the result of invoking redecorate() on the given AuthenticationProvider
+        super(redecorate(decorated, userContext, authenticatedUser, credentials));
+        this.decoratingAuthenticationProvider = decorated.getDecoratingAuthenticationProvider();
+
+        // The wrapped UserContext has at least one layer of decoration
+        this.undecoratedUserContext = userContext.getUndecoratedUserContext();
+        this.decoratedUserContext = userContext;
+
+    }
+
+    /**
+     * Returns the original UserContext with absolutely no layers of decoration
+     * applied.
+     *
+     * @return
+     *     The original, undecorated UserContext.
+     */
+    public UserContext getUndecoratedUserContext() {
+        return undecoratedUserContext;
+    }
+
+    /**
+     * Returns the AuthenticationProvider which applied the layer of decoration
+     * represented by this DecoratedUserContext.
+     *
+     * @return
+     *     The AuthenticationProvider which applied this layer of decoration.
+     */
+    public AuthenticationProvider getDecoratingAuthenticationProvider() {
+        return decoratingAuthenticationProvider;
+    }
+
+    /**
+     * Returns the DecoratedUserContext representing the next layer of
+     * decoration, itself decorated by this DecoratedUserContext. If no further
+     * layers of decoration exist, this will be null.
+     *
+     * @return
+     *     The DecoratedUserContext which applies the layer of decoration
+     *     immediately beneath this DecoratedUserContext, or null if no further
+     *     decoration has been applied.
+     */
+    public DecoratedUserContext getDecoratedUserContext() {
+        return decoratedUserContext;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java
new file mode 100644
index 0000000..b63b037
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java
@@ -0,0 +1,145 @@
+/*
+ * 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.rest.auth;
+
+import com.google.inject.Inject;
+import java.util.Iterator;
+import java.util.List;
+
+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;
+
+/**
+ * A service for applying or reapplying layers of decoration to UserContexts.
+ * The semantics of UserContext decoration/redecoration is defined by the
+ * AuthenticationProvider interface.
+ */
+public class DecorationService {
+
+    /**
+     * All configured authentication providers which can be used to
+     * authenticate users or retrieve data associated with authenticated users.
+     */
+    @Inject
+    private List<AuthenticationProvider> authProviders;
+
+    /**
+     * Creates a new DecoratedUserContext, invoking the the decorate() function
+     * of all AuthenticationProviders to decorate the provided UserContext.
+     * Decoration by each AuthenticationProvider will occur in the order that
+     * the AuthenticationProviders were loaded. Only AuthenticationProviders
+     * which did not originate the given UserContext will be used.
+     *
+     * @param userContext
+     *     The UserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @return
+     *     A new DecoratedUserContext which has been decorated by all
+     *     AuthenticationProviders.
+     *
+     * @throws GuacamoleException
+     *     If any AuthenticationProvider fails while decorating the UserContext.
+     */
+    public DecoratedUserContext decorate(UserContext userContext,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+
+        // Get first AuthenticationProvider in list
+        Iterator<AuthenticationProvider> current = authProviders.iterator();
+        if (!current.hasNext())
+            return null;
+
+        // Use first AuthenticationProvider to produce the root-level
+        // decorated UserContext
+        DecoratedUserContext decorated = new DecoratedUserContext(current.next(),
+                userContext, authenticatedUser, credentials);
+
+        // Repeatedly wrap the decorated UserContext with additional layers of
+        // decoration for each remaining AuthenticationProvider
+        while (current.hasNext()) {
+            decorated = new DecoratedUserContext(current.next(), decorated,
+                    authenticatedUser, credentials);
+        }
+
+        return decorated;
+
+    }
+
+    /**
+     * Creates a new DecoratedUserContext, invoking the the redecorate()
+     * function of all AuthenticationProviders to reapply decoration. Decoration
+     * by each AuthenticationProvider will occur in the order that the
+     * AuthenticationProviders were loaded. Only AuthenticationProviders which
+     * did not originate the given UserContext will be used.
+     *
+     * @param decorated
+     *     The DecoratedUserContext associated with an older version of the
+     *     given UserContext.
+     *
+     * @param userContext
+     *     The new version of the UserContext which should be decorated.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user associated with the given
+     *     UserContext.
+     *
+     * @param credentials
+     *     The credentials associated with the request which produced the given
+     *     UserContext.
+     *
+     * @return
+     *     A new DecoratedUserContext which has been decorated by all
+     *     AuthenticationProviders.
+     *
+     * @throws GuacamoleException
+     *     If any AuthenticationProvider fails while decorating the UserContext.
+     */
+    public DecoratedUserContext redecorate(DecoratedUserContext decorated,
+            UserContext userContext, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // If the given DecoratedUserContext contains further decorated layers,
+        // redecorate those first
+        DecoratedUserContext next = decorated.getDecoratedUserContext();
+        if (next != null) {
+            return new DecoratedUserContext(decorated,
+                    redecorate(next, userContext, authenticatedUser, credentials),
+                    authenticatedUser, credentials);
+        }
+
+        // If only one layer of decoration is present, simply redecorate that
+        // layer
+        return new DecoratedUserContext(decorated, userContext,
+                authenticatedUser, credentials);
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java
index 6366d28..cea0315 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/TokenRESTService.java
@@ -186,7 +186,7 @@
             throw new GuacamoleResourceNotFoundException("No such token.");
 
         // Build list of all available auth providers
-        List<UserContext> userContexts = session.getUserContexts();
+        List<DecoratedUserContext> userContexts = session.getUserContexts();
         List<String> authProviderIdentifiers = new ArrayList<String>(userContexts.size());
         for (UserContext userContext : userContexts)
             authProviderIdentifiers.add(userContext.getAuthenticationProvider().getIdentifier());
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java
index 8e6a385..e402e67 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java
@@ -20,6 +20,7 @@
 package org.apache.guacamole.rest.connection;
 
 import java.util.Collection;
+import java.util.Date;
 import java.util.Map;
 import org.codehaus.jackson.annotate.JsonIgnoreProperties;
 import org.codehaus.jackson.map.annotate.JsonSerialize;
@@ -75,7 +76,13 @@
      * The count of currently active connections using this connection.
      */
     private int activeConnections;
-    
+
+    /**
+     * The date and time that this connection was last used, or null if this
+     * connection has never been used or this information is unavailable.
+     */
+    private Date lastActive;
+
     /**
      * Create an empty APIConnection.
      */
@@ -97,6 +104,7 @@
         this.identifier = connection.getIdentifier();
         this.parentIdentifier = connection.getParentIdentifier();
         this.activeConnections = connection.getActiveConnections();
+        this.lastActive = connection.getLastActive();
         
         // Set protocol from configuration
         GuacamoleConfiguration configuration = connection.getConfiguration();
@@ -257,4 +265,27 @@
         this.sharingProfiles = sharingProfiles;
     }
 
+    /**
+     * Returns the date and time that this connection was last used, or null if
+     * this connection has never been used or this information is unavailable.
+     *
+     * @return
+     *     The date and time that this connection was last used, or null if this
+     *     connection has never been used or this information is unavailable.
+     */
+    public Date getLastActive() {
+        return lastActive;
+    }
+
+    /**
+     * Sets the date and time that this connection was last used.
+     *
+     * @param lastActive
+     *     The date and time that this connection was last used, or null if this
+     *     connection has never been used or this information is unavailable.
+     */
+    public void setLastActive(Date lastActive) {
+        this.lastActive = lastActive;
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionObjectTranslator.java
index 112c71a..416fb4a 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionObjectTranslator.java
@@ -21,6 +21,7 @@
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 
@@ -29,7 +30,7 @@
  * objects.
  */
 public class ConnectionObjectTranslator
-        implements DirectoryObjectTranslator<Connection, APIConnection> {
+        extends DirectoryObjectTranslator<Connection, APIConnection> {
 
     @Override
     public APIConnection toExternalObject(Connection object)
@@ -59,4 +60,14 @@
 
     }
 
+    @Override
+    public void filterExternalObject(UserContext userContext,
+            APIConnection object) throws GuacamoleException {
+
+        // Filter object attributes by defined schema
+        object.setAttributes(filterAttributes(
+                userContext.getConnectionAttributes(), object.getAttributes()));
+
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java
index b1ddcfc..0b63b66 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java
@@ -100,7 +100,7 @@
             @Assisted Directory<Connection> directory,
             @Assisted Connection connection,
             DirectoryObjectTranslator<Connection, APIConnection> translator) {
-        super(directory, connection, translator);
+        super(userContext, directory, connection, translator);
         this.userContext = userContext;
         this.connection = connection;
     }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupObjectTranslator.java
index 18de4dc..3c0ed38 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupObjectTranslator.java
@@ -21,6 +21,7 @@
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 
 /**
@@ -28,7 +29,7 @@
  * APIConnectionGroup objects.
  */
 public class ConnectionGroupObjectTranslator
-        implements DirectoryObjectTranslator<ConnectionGroup, APIConnectionGroup> {
+        extends DirectoryObjectTranslator<ConnectionGroup, APIConnectionGroup> {
 
     @Override
     public APIConnectionGroup toExternalObject(ConnectionGroup object)
@@ -53,4 +54,15 @@
 
     }
 
+    @Override
+    public void filterExternalObject(UserContext userContext,
+            APIConnectionGroup object) throws GuacamoleException {
+
+        // Filter object attributes by defined schema
+        object.setAttributes(filterAttributes(
+                userContext.getConnectionGroupAttributes(),
+                object.getAttributes()));
+
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java
index 9e7c986..f91b8eb 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java
@@ -79,7 +79,7 @@
             @Assisted Directory<ConnectionGroup> directory,
             @Assisted ConnectionGroup connectionGroup,
             DirectoryObjectTranslator<ConnectionGroup, APIConnectionGroup> translator) {
-        super(directory, connectionGroup, translator);
+        super(userContext, directory, connectionGroup, translator);
         this.userContext = userContext;
         this.connectionGroup = connectionGroup;
     }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java
index b5277b3..7b354a2 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java
@@ -29,6 +29,7 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Identifiable;
+import org.apache.guacamole.net.auth.UserContext;
 
 /**
  * A REST resource which abstracts the operations available on an existing
@@ -51,6 +52,12 @@
 public abstract class DirectoryObjectResource<InternalType extends Identifiable, ExternalType> {
 
     /**
+     * The UserContext associated with the Directory containing the object
+     * represented by this DirectoryObjectResource.
+     */
+    private final UserContext userContext;
+
+    /**
      * The Directory which contains the object represented by this
      * DirectoryObjectResource.
      */
@@ -71,6 +78,9 @@
      * Creates a new DirectoryObjectResource which exposes the operations
      * available for the given object.
      *
+     * @param userContext
+     *     The UserContext associated with the given Directory.
+     *
      * @param directory
      *     The Directory which contains the given object.
      *
@@ -81,8 +91,10 @@
      *     A DirectoryObjectTranslator implementation which handles the type of
      *     object given.
      */
-    public DirectoryObjectResource(Directory<InternalType> directory, InternalType object,
+    public DirectoryObjectResource(UserContext userContext,
+            Directory<InternalType> directory, InternalType object,
             DirectoryObjectTranslator<InternalType, ExternalType> translator) {
+        this.userContext = userContext;
         this.directory = directory;
         this.object = object;
         this.translator = translator;
@@ -121,6 +133,9 @@
         if (modifiedObject == null)
             throw new GuacamoleClientException("Data must be submitted when updating objects.");
 
+        // Filter/sanitize object contents
+        translator.filterExternalObject(userContext, modifiedObject);
+
         // Perform update
         translator.applyExternalChanges(object, modifiedObject);
         directory.update(object);
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectTranslator.java
index b8355bf..f3fe755 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectTranslator.java
@@ -19,8 +19,14 @@
 
 package org.apache.guacamole.rest.directory;
 
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.form.Form;
 import org.apache.guacamole.net.auth.Identifiable;
+import org.apache.guacamole.net.auth.UserContext;
 
 /**
  * Provides bidirectional conversion between REST-specific objects and the
@@ -35,7 +41,7 @@
  *     deserialized as JSON) between REST clients and resource implementations
  *     when representing the InternalType.
  */
-public interface DirectoryObjectTranslator<InternalType extends Identifiable, ExternalType> {
+public abstract class DirectoryObjectTranslator<InternalType extends Identifiable, ExternalType> {
 
     /**
      * Converts the given object to an object which is intended to be used in
@@ -51,7 +57,7 @@
      * @throws GuacamoleException
      *     If the provided object cannot be converted for any reason.
      */
-    ExternalType toExternalObject(InternalType object)
+    public abstract ExternalType toExternalObject(InternalType object)
             throws GuacamoleException;
 
     /**
@@ -69,7 +75,7 @@
      * @throws GuacamoleException
      *     If the provided object cannot be converted for any reason.
      */
-    InternalType toInternalObject(ExternalType object)
+    public abstract InternalType toInternalObject(ExternalType object)
             throws GuacamoleException;
 
     /**
@@ -87,7 +93,67 @@
      * @throws GuacamoleException
      *     If the provided modifications cannot be applied for any reason.
      */
-    void applyExternalChanges(InternalType existingObject, ExternalType object)
-            throws GuacamoleException;
+    public abstract void applyExternalChanges(InternalType existingObject,
+            ExternalType object) throws GuacamoleException;
+
+    /**
+     * Applies filtering to the contents of the given external object which
+     * came from an untrusted source. Implementations MUST sanitize the
+     * contents of the external object as necessary to guarantee that the
+     * object conforms to declared schema, such as the attributes declared for
+     * each object type at the UserContext level.
+     *
+     * @param userContext
+     *     The UserContext associated with the object being filtered.
+     *
+     * @param object
+     *     The object to modify such that it strictly conforms to the declared
+     *     schema.
+     *
+     * @throws GuacamoleException
+     *     If the object cannot be filtered due to an error.
+     */
+    public abstract void filterExternalObject(UserContext userContext,
+            ExternalType object) throws GuacamoleException;
+
+    /**
+     * Filters the given map of attribute name/value pairs, producing a new
+     * map containing only attributes defined as fields within the given schema.
+     *
+     * @param schema
+     *     The schema whose fields should be used to filter the given map of
+     *     attributes.
+     *
+     * @param attributes
+     *     The map of attribute name/value pairs to filter.
+     *
+     * @return
+     *     A new map containing only the attributes defined as fields within
+     *     the given schema.
+     */
+    public Map<String, String> filterAttributes(Collection<Form> schema,
+            Map<String, String> attributes) {
+
+        Map<String, String> filtered = new HashMap<String, String>();
+
+        // Grab all attribute value strictly for defined fields
+        for (Form form : schema) {
+            for (Field field : form.getFields()) {
+
+                // Pull the associated attribute value from given map
+                String attributeName = field.getName();
+                String attributeValue = attributes.get(attributeName);
+
+                // Include attribute value within filtered map only if
+                // (1) defined and (2) present within provided map
+                if (attributeValue != null)
+                    filtered.put(attributeName, attributeValue);
+
+            }
+        }
+
+        return filtered;
+
+    }
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
index 2af0e26..3cbd481 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
@@ -222,6 +222,9 @@
         if (object == null)
             throw new GuacamoleClientException("Data must be submitted when creating objects.");
 
+        // Filter/sanitize object contents
+        translator.filterExternalObject(userContext, object);
+
         // Create the new object within the directory
         directory.add(translator.toInternalObject(object));
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/history/APIActivityRecord.java b/guacamole/src/main/java/org/apache/guacamole/rest/history/APIActivityRecord.java
new file mode 100644
index 0000000..c1a0149
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/history/APIActivityRecord.java
@@ -0,0 +1,131 @@
+/*
+ * 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.rest.history;
+
+import java.util.Date;
+import org.apache.guacamole.net.auth.ActivityRecord;
+
+/**
+ * A activity record which may be exposed through the REST endpoints.
+ */
+public class APIActivityRecord {
+
+    /**
+     * The date and time the activity began.
+     */
+    private final Date startDate;
+
+    /**
+     * The date and time the activity ended, or null if the activity is
+     * still in progress or if the end time is unknown.
+     */
+    private final Date endDate;
+
+    /**
+     * The hostname or IP address of the remote host that performed the
+     * activity associated with this record, if known.
+     */
+    private final String remoteHost;
+
+    /**
+     * The name of the user who performed or is performing the activity
+     * associated with this record.
+     */
+    private final String username;
+
+    /**
+     * Whether the activity is still in progress.
+     */
+    private final boolean active;
+
+    /**
+     * Creates a new APIActivityRecord, copying the data from the given activity
+     * record.
+     *
+     * @param record
+     *     The record to copy data from.
+     */
+    public APIActivityRecord(ActivityRecord record) {
+        this.startDate  = record.getStartDate();
+        this.endDate    = record.getEndDate();
+        this.remoteHost = record.getRemoteHost();
+        this.username   = record.getUsername();
+        this.active     = record.isActive();
+    }
+
+    /**
+     * Returns the date and time the activity began.
+     *
+     * @return
+     *     The date and time the activity began.
+     */
+    public Date getStartDate() {
+        return startDate;
+    }
+
+    /**
+     * Returns the date and time the activity ended, if applicable.
+     *
+     * @return
+     *     The date and time the activity ended, or null if the activity is
+     *     still in progress or if the end time is unknown.
+     */
+    public Date getEndDate() {
+        return endDate;
+    }
+
+    /**
+     * Returns the hostname or IP address of the remote host that performed the
+     * activity associated with this record, if known.
+     *
+     * @return
+     *     The hostname or IP address of the remote host that performed the
+     *     activity associated with this record, or null if the remote host is
+     *     unknown.
+     */
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    /**
+     * Returns the name of the user who performed or is performing the activity
+     * associated with this record.
+     *
+     * @return
+     *     The name of the user who performed or is performing the activity
+     *     associated with this record.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Returns whether the activity associated with this record is still in
+     * progress.
+     *
+     * @return
+     *     true if the activity associated with this record is still in
+     *     progress, false otherwise.
+     */
+    public boolean isActive() {
+        return active;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecord.java b/guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecord.java
index 97c99a0..9d17fbf 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecord.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecord.java
@@ -19,13 +19,12 @@
 
 package org.apache.guacamole.rest.history;
 
-import java.util.Date;
 import org.apache.guacamole.net.auth.ConnectionRecord;
 
 /**
  * A connection record which may be exposed through the REST endpoints.
  */
-public class APIConnectionRecord {
+public class APIConnectionRecord extends APIActivityRecord {
 
     /**
      * The identifier of the connection associated with this record.
@@ -48,32 +47,6 @@
     private final String sharingProfileName;
 
     /**
-     * The date and time the connection began.
-     */
-    private final Date startDate;
-
-    /**
-     * The date and time the connection ended, or null if the connection is
-     * still running or if the end time is unknown.
-     */
-    private final Date endDate;
-
-    /**
-     * The host from which the connection originated, if known.
-     */
-    private final String remoteHost;
-
-    /**
-     * The name of the user who used or is using the connection.
-     */
-    private final String username;
-
-    /**
-     * Whether the connection is currently active.
-     */
-    private final boolean active;
-
-    /**
      * Creates a new APIConnectionRecord, copying the data from the given
      * record.
      *
@@ -81,15 +54,11 @@
      *     The record to copy data from.
      */
     public APIConnectionRecord(ConnectionRecord record) {
+        super(record);
         this.connectionIdentifier     = record.getConnectionIdentifier();
         this.connectionName           = record.getConnectionName();
         this.sharingProfileIdentifier = record.getSharingProfileIdentifier();
         this.sharingProfileName       = record.getSharingProfileName();
-        this.startDate                = record.getStartDate();
-        this.endDate                  = record.getEndDate();
-        this.remoteHost               = record.getRemoteHost();
-        this.username                 = record.getUsername();
-        this.active                   = record.isActive();
     }
 
     /**
@@ -139,58 +108,4 @@
         return sharingProfileName;
     }
 
-    /**
-     * Returns the date and time the connection began.
-     *
-     * @return
-     *     The date and time the connection began.
-     */
-    public Date getStartDate() {
-        return startDate;
-    }
-
-    /**
-     * Returns the date and time the connection ended, if applicable.
-     *
-     * @return
-     *     The date and time the connection ended, or null if the connection is
-     *     still running or if the end time is unknown.
-     */
-    public Date getEndDate() {
-        return endDate;
-    }
-
-    /**
-     * Returns the remote host from which this connection originated.
-     *
-     * @return
-     *     The remote host from which this connection originated.
-     */
-    public String getRemoteHost() {
-        return remoteHost;
-    }
-
-    /**
-     * Returns the name of the user who used or is using the connection at the
-     * times given by this connection record.
-     *
-     * @return
-     *     The name of the user who used or is using the associated connection.
-     */
-    public String getUsername() {
-        return username;
-    }
-
-    /**
-     * Returns whether the connection associated with this record is still
-     * active.
-     *
-     * @return
-     *     true if the connection associated with this record is still active,
-     *     false otherwise.
-     */
-    public boolean isActive() {
-        return active;
-    }
-
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecordSortPredicate.java b/guacamole/src/main/java/org/apache/guacamole/rest/history/APISortPredicate.java
similarity index 92%
rename from guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecordSortPredicate.java
rename to guacamole/src/main/java/org/apache/guacamole/rest/history/APISortPredicate.java
index d2281d0..c45ae9a 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecordSortPredicate.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/history/APISortPredicate.java
@@ -25,10 +25,10 @@
 import org.apache.guacamole.rest.APIException;
 
 /**
- * A sort predicate which species the property to use when sorting connection
+ * A sort predicate which species the property to use when sorting activity
  * records, along with the sort order.
  */
-public class APIConnectionRecordSortPredicate {
+public class APISortPredicate {
 
     /**
      * The prefix which will be included before the name of a sortable property
@@ -43,8 +43,8 @@
     public enum SortableProperty {
 
         /**
-         * The date that the connection associated with the connection record
-         * began (connected).
+         * The date that the activity associated with the activity record
+         * began.
          */
         startDate(ActivityRecordSet.SortableProperty.START_DATE);
 
@@ -70,7 +70,7 @@
     }
 
     /**
-     * The property to use when sorting ConnectionRecords.
+     * The property to use when sorting ActivityRecords.
      */
     private ActivityRecordSet.SortableProperty property;
 
@@ -93,7 +93,7 @@
      * @throws APIException
      *     If the provided sort predicate string is invalid.
      */
-    public APIConnectionRecordSortPredicate(String value)
+    public APISortPredicate(String value)
         throws APIException {
 
         // Parse whether sort order is descending
@@ -124,7 +124,7 @@
      * @return
      *     The ActivityRecordSet.SortableProperty which refers to the same
      *     property as the string originally provided when this
-     *     APIConnectionRecordSortPredicate was created.
+     *     APISortPredicate was created.
      */
     public ActivityRecordSet.SortableProperty getProperty() {
         return property;
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/history/HistoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/history/HistoryResource.java
index 53a8cdb..559ebd5 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/history/HistoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/history/HistoryResource.java
@@ -28,6 +28,7 @@
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.ActivityRecord;
 import org.apache.guacamole.net.auth.ActivityRecordSet;
 import org.apache.guacamole.net.auth.ConnectionRecord;
 import org.apache.guacamole.net.auth.UserContext;
@@ -88,7 +89,7 @@
     @Path("connections")
     public List<APIConnectionRecord> getConnectionHistory(
             @QueryParam("contains") List<String> requiredContents,
-            @QueryParam("order") List<APIConnectionRecordSortPredicate> sortPredicates)
+            @QueryParam("order") List<APISortPredicate> sortPredicates)
             throws GuacamoleException {
 
         // Retrieve overall connection history
@@ -101,7 +102,7 @@
         }
 
         // Sort according to specified ordering
-        for (APIConnectionRecordSortPredicate predicate : sortPredicates)
+        for (APISortPredicate predicate : sortPredicates)
             history = history.sort(predicate.getProperty(), predicate.isDescending());
 
         // Limit to maximum result size
@@ -117,4 +118,59 @@
 
     }
 
+    /**
+     * Retrieves the login history for all users, restricted by optional filter
+     * parameters.
+     *
+     * @param requiredContents
+     *     The set of strings that each must occur somewhere within the
+     *     returned user records, whether within the associated username or any
+     *     associated date. If non-empty, any user record not matching each of
+     *     the strings within the collection will be excluded from the results.
+     *
+     * @param sortPredicates
+     *     A list of predicates to apply while sorting the resulting user
+     *     records, describing the properties involved and the sort order for
+     *     those properties.
+     *
+     * @return
+     *     A list of user records, describing the start and end times of user
+     *     sessions.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the user history.
+     */
+    @GET
+    @Path("users")
+    public List<APIActivityRecord> getUserHistory(
+            @QueryParam("contains") List<String> requiredContents,
+            @QueryParam("order") List<APISortPredicate> sortPredicates)
+            throws GuacamoleException {
+
+        // Retrieve overall user history
+        ActivityRecordSet<ActivityRecord> history = userContext.getUserHistory();
+
+        // Restrict to records which contain the specified strings
+        for (String required : requiredContents) {
+            if (!required.isEmpty())
+                history = history.contains(required);
+        }
+
+        // Sort according to specified ordering
+        for (APISortPredicate predicate : sortPredicates)
+            history = history.sort(predicate.getProperty(), predicate.isDescending());
+
+        // Limit to maximum result size
+        history = history.limit(MAXIMUM_HISTORY_SIZE);
+
+        // Convert record set to collection of API user records
+        List<APIActivityRecord> apiRecords = new ArrayList<APIActivityRecord>();
+        for (ActivityRecord record : history.asCollection())
+            apiRecords.add(new APIActivityRecord(record));
+
+        // Return the converted history
+        return apiRecords;
+
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileObjectTranslator.java
index 2cbe1bf..601f219 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileObjectTranslator.java
@@ -21,6 +21,7 @@
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.SharingProfile;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 
 /**
@@ -28,7 +29,7 @@
  * APISharingProfile objects.
  */
 public class SharingProfileObjectTranslator
-        implements DirectoryObjectTranslator<SharingProfile, APISharingProfile> {
+        extends DirectoryObjectTranslator<SharingProfile, APISharingProfile> {
 
     @Override
     public APISharingProfile toExternalObject(SharingProfile object)
@@ -53,4 +54,15 @@
 
     }
 
+    @Override
+    public void filterExternalObject(UserContext userContext,
+            APISharingProfile object) throws GuacamoleException {
+
+        // Filter object attributes by defined schema
+        object.setAttributes(filterAttributes(
+                userContext.getSharingProfileAttributes(),
+                object.getAttributes()));
+
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java
index 1f18d0f..7b96de9 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java
@@ -82,7 +82,7 @@
             @Assisted Directory<SharingProfile> directory,
             @Assisted SharingProfile sharingProfile,
             DirectoryObjectTranslator<SharingProfile, APISharingProfile> translator) {
-        super(directory, sharingProfile, translator);
+        super(userContext, directory, sharingProfile, translator);
         this.userContext = userContext;
         this.sharingProfile = sharingProfile;
     }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/APIUser.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/APIUser.java
index 96e2230..e71b5d2 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/APIUser.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/APIUser.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.rest.user;
 
+import java.util.Date;
 import java.util.Map;
 import org.codehaus.jackson.annotate.JsonIgnoreProperties;
 import org.codehaus.jackson.map.annotate.JsonSerialize;
@@ -47,6 +48,12 @@
     private Map<String, String> attributes;
 
     /**
+     * The date and time that this user was last logged in, or null if this user
+     * has never logged in or this information is unavailable.
+     */
+    private Date lastActive;
+
+    /**
      * Construct a new empty APIUser.
      */
     public APIUser() {}
@@ -60,6 +67,7 @@
         // Set user information
         this.username = user.getIdentifier();
         this.password = user.getPassword();
+        this.lastActive = user.getLastActive();
 
         // Associate any attributes
         this.attributes = user.getAttributes();
@@ -122,4 +130,27 @@
         this.attributes = attributes;
     }
 
+    /**
+     * Returns the date and time that this user was last logged in, or null if
+     * this user has never logged in or this information is unavailable.
+     *
+     * @return
+     *     The date and time that this user was last logged in, or null if this
+     *     user has never logged in or this information is unavailable.
+     */
+    public Date getLastActive() {
+        return lastActive;
+    }
+
+    /**
+     * Sets the date and time that this user was last logged in.
+     *
+     * @param lastActive
+     *     The date and time that this user was last logged in, or null if this
+     *     user has never logged in or this information is unavailable.
+     */
+    public void setLastActive(Date lastActive) {
+        this.lastActive = lastActive;
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java
index 35bce66..8536b35 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java
@@ -21,13 +21,14 @@
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 
 /**
  * Translator which converts between User objects and APIUser objects.
  */
 public class UserObjectTranslator
-        implements DirectoryObjectTranslator<User, APIUser> {
+        extends DirectoryObjectTranslator<User, APIUser> {
 
     @Override
     public APIUser toExternalObject(User object)
@@ -54,4 +55,14 @@
 
     }
 
+    @Override
+    public void filterExternalObject(UserContext userContext, APIUser object)
+            throws GuacamoleException {
+
+        // Filter object attributes by defined schema
+        object.setAttributes(filterAttributes(userContext.getUserAttributes(),
+                object.getAttributes()));
+
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java
index 3f142ff..06bab9f 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java
@@ -21,8 +21,11 @@
 
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+import java.util.ArrayList;
+import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
 import javax.ws.rs.PUT;
 import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
@@ -30,6 +33,7 @@
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
+import org.apache.guacamole.net.auth.ActivityRecord;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.net.auth.User;
@@ -38,6 +42,7 @@
 import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
 import org.apache.guacamole.rest.directory.DirectoryObjectResource;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
+import org.apache.guacamole.rest.history.APIActivityRecord;
 import org.apache.guacamole.rest.permission.PermissionSetResource;
 
 /**
@@ -87,12 +92,37 @@
             @Assisted Directory<User> directory,
             @Assisted User user,
             DirectoryObjectTranslator<User, APIUser> translator) {
-        super(directory, user, translator);
+        super(userContext, directory, user, translator);
         this.userContext = userContext;
         this.directory = directory;
         this.user = user;
     }
 
+    /**
+     * Retrieves the login (session) history of a single user.
+     *
+     * @return
+     *     A list of activity records, describing the start and end times of
+     *     this user's sessions.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the user history.
+     */
+    @GET
+    @Path("history")
+    public List<APIActivityRecord> getUserHistory()
+            throws GuacamoleException {
+
+        // Retrieve the requested user's history
+        List<APIActivityRecord> apiRecords = new ArrayList<APIActivityRecord>();
+        for (ActivityRecord record : user.getHistory())
+            apiRecords.add(new APIActivityRecord(record));
+
+        // Return the converted history
+        return apiRecords;
+
+    }
+
     @Override
     public void updateObject(APIUser modifiedObject) throws GuacamoleException {
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
index 9769646..e5c5db9 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
@@ -53,17 +53,42 @@
     private static final int BUFFER_SIZE = 8192;
 
     /**
-     * Sends the given status on the given WebSocket connection and closes the
+     * Sends the given numeric Guacamole and WebSocket status
+     * on the given WebSocket connection and closes the
      * connection.
      *
-     * @param connection The WebSocket connection to close.
-     * @param guac_status The status to send.
+     * @param connection
+     *     The WebSocket connection to close.
+     *
+     * @param guacamoleStatusCode
+     *     The numeric Guacamole Status code to send.
+     *
+     * @param webSocketCode
+     *     The numeric WebSocket status code to send.
      */
-    public static void closeConnection(Connection connection,
-            GuacamoleStatus guac_status) {
+    private static void closeConnection(Connection connection,
+            int guacamoleStatusCode, int webSocketCode) {
 
-        connection.close(guac_status.getWebSocketCode(),
-                Integer.toString(guac_status.getGuacamoleStatusCode()));
+        connection.close(webSocketCode,
+                Integer.toString(guacamoleStatusCode));
+
+    }
+
+    /**
+     * Sends the given status on the given WebSocket connection
+     * and closes the connection.
+     *
+     * @param connection
+     *     The WebSocket connection to close.
+     *
+     * @param guacStatus
+     *     The status to send.
+     */
+    private static void closeConnection(Connection connection,
+            GuacamoleStatus guacStatus) {
+
+        closeConnection(connection, guacStatus.getGuacamoleStatusCode(),
+                guacStatus.getWebSocketCode());
 
     }
 
@@ -114,7 +139,8 @@
                 catch (GuacamoleException e) {
                     logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
                     logger.debug("Error connecting WebSocket tunnel.", e);
-                    closeConnection(connection, e.getStatus());
+                    closeConnection(connection, e.getStatus().getGuacamoleStatusCode(),
+                            e.getWebSocketCode());
                     return;
                 }
 
@@ -168,7 +194,8 @@
                             catch (GuacamoleClientException e) {
                                 logger.info("WebSocket connection terminated: {}", e.getMessage());
                                 logger.debug("WebSocket connection terminated due to client error.", e);
-                                closeConnection(connection, e.getStatus());
+                                closeConnection(connection, e.getStatus().getGuacamoleStatusCode(),
+                                        e.getWebSocketCode());
                             }
                             catch (GuacamoleConnectionClosedException e) {
                                 logger.debug("Connection to guacd closed.", e);
@@ -177,7 +204,8 @@
                             catch (GuacamoleException e) {
                                 logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
                                 logger.debug("Internal error during connection to guacd.", e);
-                                closeConnection(connection, e.getStatus());
+                                closeConnection(connection, e.getStatus().getGuacamoleStatusCode(),
+                                        e.getWebSocketCode());
                             }
 
                         }
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
index 5375d75..0594d06 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
@@ -57,18 +57,25 @@
     private GuacamoleTunnel tunnel;
  
     /**
-     * Sends the given status on the given WebSocket connection and closes the
+     * Sends the given numeric Guacamole and WebSocket status
+     * codes on the given WebSocket connection and closes the
      * connection.
      *
-     * @param session The outbound WebSocket connection to close.
-     * @param guac_status The status to send.
+     * @param session
+     *     The outbound WebSocket connection to close.
+     *
+     * @param guacamoleStatusCode
+     *     The numeric Guacamole status code to send.
+     *
+     * @param webSocketCode
+     *     The numeric WebSocket status code to send.
      */
-    private void closeConnection(Session session, GuacamoleStatus guac_status) {
+    private void closeConnection(Session session, int guacamoleStatusCode,
+            int webSocketCode) {
 
         try {
-            int code = guac_status.getWebSocketCode();
-            String message = Integer.toString(guac_status.getGuacamoleStatusCode());
-            session.close(new CloseStatus(code, message));
+            String message = Integer.toString(guacamoleStatusCode);
+            session.close(new CloseStatus(webSocketCode, message));
         }
         catch (IOException e) {
             logger.debug("Unable to close WebSocket connection.", e);
@@ -77,6 +84,24 @@
     }
 
     /**
+     * Sends the given status on the given WebSocket connection
+     * and closes the connection.
+     *
+     * @param session
+     *     The outbound WebSocket connection to close.
+     *
+     * @param guacStatus
+     *     The status to send.
+     */
+    private void closeConnection(Session session,
+            GuacamoleStatus guacStatus) {
+
+        closeConnection(session, guacStatus.getGuacamoleStatusCode(),
+                guacStatus.getWebSocketCode());
+
+    }
+
+    /**
      * Returns a new tunnel for the given session. How this tunnel is created
      * or retrieved is implementation-dependent.
      *
@@ -105,7 +130,7 @@
         catch (GuacamoleException e) {
             logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
             logger.debug("Error connecting WebSocket tunnel.", e);
-            closeConnection(session, e.getStatus());
+            closeConnection(session, e.getStatus().getGuacamoleStatusCode(), e.getWebSocketCode());
             return;
         }
 
@@ -159,7 +184,8 @@
                     catch (GuacamoleClientException e) {
                         logger.info("WebSocket connection terminated: {}", e.getMessage());
                         logger.debug("WebSocket connection terminated due to client error.", e);
-                        closeConnection(session, e.getStatus());
+                        closeConnection(session, e.getStatus().getGuacamoleStatusCode(),
+                                e.getWebSocketCode());
                     }
                     catch (GuacamoleConnectionClosedException e) {
                         logger.debug("Connection to guacd closed.", e);
@@ -168,7 +194,8 @@
                     catch (GuacamoleException e) {
                         logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
                         logger.debug("Internal error during connection to guacd.", e);
-                        closeConnection(session, e.getStatus());
+                        closeConnection(session, e.getStatus().getGuacamoleStatusCode(),
+                                e.getWebSocketCode());
                     }
 
                 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
index 986650e..a2e8b39 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
@@ -58,17 +58,25 @@
     private final Logger logger = LoggerFactory.getLogger(GuacamoleWebSocketTunnelServlet.class);
 
     /**
-     * Sends the given status on the given WebSocket connection and closes the
+     * Sends the given Guacamole and WebSocket numeric status
+     * on the given WebSocket connection and closes the
      * connection.
      *
-     * @param outbound The outbound WebSocket connection to close.
-     * @param guac_status The status to send.
+     * @param outbound
+     *     The outbound WebSocket connection to close.
+     *
+     * @param guacamoleStatusCode
+     *     The status to send.
+     *
+     * @param webSocketCode
+     *     The numeric WebSocket status code to send.
      */
-    public void closeConnection(WsOutbound outbound, GuacamoleStatus guac_status) {
+    private void closeConnection(WsOutbound outbound, int guacamoleStatusCode,
+            int webSocketCode) {
 
         try {
-            byte[] message = Integer.toString(guac_status.getGuacamoleStatusCode()).getBytes("UTF-8");
-            outbound.close(guac_status.getWebSocketCode(), ByteBuffer.wrap(message));
+            byte[] message = Integer.toString(guacamoleStatusCode).getBytes("UTF-8");
+            outbound.close(webSocketCode, ByteBuffer.wrap(message));
         }
         catch (IOException e) {
             logger.debug("Unable to close WebSocket tunnel.", e);
@@ -76,6 +84,24 @@
 
     }
 
+    /**
+     * Sends the given status on the given WebSocket connection
+     * and closes the connection.
+     *
+     * @param outbound
+     *     The outbound WebSocket connection to close.
+     *
+     * @param guacStatus
+     *     The status to send.
+     */
+    private void closeConnection(WsOutbound outbound,
+            GuacamoleStatus guacStatus) {
+
+        closeConnection(outbound, guacStatus.getGuacamoleStatusCode(),
+                guacStatus.getWebSocketCode());
+
+    }
+
     @Override
     protected String selectSubProtocol(List<String> subProtocols) {
 
@@ -142,7 +168,8 @@
                 catch (GuacamoleException e) {
                     logger.error("Creation of WebSocket tunnel to guacd failed: {}", e.getMessage());
                     logger.debug("Error connecting WebSocket tunnel.", e);
-                    closeConnection(outbound, e.getStatus());
+                    closeConnection(outbound, e.getStatus().getGuacamoleStatusCode(),
+                            e.getWebSocketCode());
                     return;
                 }
 
@@ -196,7 +223,8 @@
                             catch (GuacamoleClientException e) {
                                 logger.info("WebSocket connection terminated: {}", e.getMessage());
                                 logger.debug("WebSocket connection terminated due to client error.", e);
-                                closeConnection(outbound, e.getStatus());
+                                closeConnection(outbound, e.getStatus().getGuacamoleStatusCode(),
+                                        e.getWebSocketCode());
                             }
                             catch (GuacamoleConnectionClosedException e) {
                                 logger.debug("Connection to guacd closed.", e);
@@ -205,7 +233,8 @@
                             catch (GuacamoleException e) {
                                 logger.error("Connection to guacd terminated abnormally: {}", e.getMessage());
                                 logger.debug("Internal error during connection to guacd.", e);
-                                closeConnection(outbound, e.getStatus());
+                                closeConnection(outbound, e.getStatus().getGuacamoleStatusCode(),
+                                        e.getWebSocketCode());
                             }
 
                         }
diff --git a/guacamole/src/main/webapp/app/client/controllers/clientController.js b/guacamole/src/main/webapp/app/client/controllers/clientController.js
index 88cfaa0..a21c0a2 100644
--- a/guacamole/src/main/webapp/app/client/controllers/clientController.js
+++ b/guacamole/src/main/webapp/app/client/controllers/clientController.js
@@ -64,7 +64,7 @@
      */
     var MENU_DRAG_VERTICAL_TOLERANCE = 10;
 
-    /*
+    /**
      * In order to open the guacamole menu, we need to hit ctrl-alt-shift. There are
      * several possible keysysms for each key.
      */
@@ -75,6 +75,20 @@
         MENU_KEYS   = angular.extend({}, SHIFT_KEYS, ALT_KEYS, CTRL_KEYS);
 
     /**
+     * Keysym for detecting any END key presses, for the purpose of passing through
+     * the Ctrl-Alt-Del sequence to a remote system.
+     */
+    var END_KEYS = {0xFF57 : true, 0xFFB1 : true};
+
+    /**
+     * Keysym for sending the DELETE key when the Ctrl-Alt-End hotkey
+     * combo is pressed.
+     *
+     * @type Number
+     */
+    var DEL_KEY = 0xFFFF;
+
+    /**
      * All client error codes handled and passed off for translation. Any error
      * code not present in this list will be represented by the "DEFAULT"
      * translation.
@@ -267,6 +281,15 @@
     var keysCurrentlyPressed = {};
 
     /**
+     * Map of all substituted key presses.  If one key is pressed in place of another
+     * the value of the substituted key is stored in an object with the keysym of
+     * the original key.
+     *
+     * @type Object.<Number, Number>
+     */
+    var substituteKeysPressed = {};
+
+    /**
      * Map of all currently pressed keys (by keysym) to the clipboard contents
      * received from the remote desktop while those keys were pressed. All keys
      * not currently pressed will not have entries within this map.
@@ -489,23 +512,23 @@
 
     };
 
-    // Track pressed keys, opening the Guacamole menu after Ctrl+Alt+Shift
+    // Track pressed keys, opening the Guacamole menu after Ctrl+Alt+Shift, or
+    // send Ctrl-Alt-Delete when Ctrl-Alt-End is pressed.
     $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) {
 
         // Record key as pressed
         keysCurrentlyPressed[keysym] = true;   
         
-        /* 
-         * If only menu keys are pressed, and we have one keysym from each group,
-         * and one of the keys is being released, show the menu. 
-         */
-        if(checkMenuModeActive()) {
-            var currentKeysPressedKeys = Object.keys(keysCurrentlyPressed);
+        var currentKeysPressedKeys = Object.keys(keysCurrentlyPressed);
+
+        // If only menu keys are pressed, and we have one keysym from each group,
+        // and one of the keys is being released, show the menu. 
+        if (checkMenuModeActive()) {
             
             // Check that there is a key pressed for each of the required key classes
-            if(!_.isEmpty(_.pick(SHIFT_KEYS, currentKeysPressedKeys)) &&
-               !_.isEmpty(_.pick(ALT_KEYS, currentKeysPressedKeys)) &&
-               !_.isEmpty(_.pick(CTRL_KEYS, currentKeysPressedKeys))
+            if (!_.isEmpty(_.pick(SHIFT_KEYS, currentKeysPressedKeys)) &&
+                !_.isEmpty(_.pick(ALT_KEYS, currentKeysPressedKeys)) &&
+                !_.isEmpty(_.pick(CTRL_KEYS, currentKeysPressedKeys))
             ) {
         
                 // Don't send this key event through to the client
@@ -522,6 +545,27 @@
             }
         }
 
+        // If one of the End keys is pressed, and we have a one keysym from each
+        // of Ctrl and Alt groups, send Ctrl-Alt-Delete.
+        if (END_KEYS[keysym] &&
+            !_.isEmpty(_.pick(ALT_KEYS, currentKeysPressedKeys)) &&
+            !_.isEmpty(_.pick(CTRL_KEYS, currentKeysPressedKeys))
+        ) {
+
+            // Don't send this event through to the client.
+            event.preventDefault();
+
+            // Remove the original key press
+            delete keysCurrentlyPressed[keysym];
+
+            // Record the substituted key press so that it can be
+            // properly dealt with later.
+            substituteKeysPressed[keysym] = DEL_KEY;
+
+            // Send through the delete key.
+            $scope.$broadcast('guacSyntheticKeydown', DEL_KEY);
+        }
+
     });
 
     // Update pressed keys as they are released, synchronizing the clipboard
@@ -534,9 +578,18 @@
         if (clipboardData && !$scope.menu.shown)
             clipboardService.setLocalClipboard(clipboardData);
 
+        // Deal with substitute key presses
+        if (substituteKeysPressed[keysym]) {
+            event.preventDefault();
+            delete substituteKeysPressed[keysym];
+            $scope.$broadcast('guacSyntheticKeyup', substituteKeysPressed[keysym]);
+        }
+
         // Mark key as released
-        delete clipboardDataFromKey[keysym];
-        delete keysCurrentlyPressed[keysym];
+        else {
+            delete clipboardDataFromKey[keysym];
+            delete keysCurrentlyPressed[keysym];
+        }
 
     });
 
diff --git a/guacamole/src/main/webapp/app/client/templates/client.html b/guacamole/src/main/webapp/app/client/templates/client.html
index 0580c8b..0d2b688 100644
--- a/guacamole/src/main/webapp/app/client/templates/client.html
+++ b/guacamole/src/main/webapp/app/client/templates/client.html
@@ -38,7 +38,7 @@
 
     <!-- Menu -->
     <div class="menu" ng-class="{open: menu.shown}" id="guac-menu">
-        <div class="menu-content">
+        <div class="menu-content" ng-if="menu.shown">
 
             <!-- Stationary header -->
             <div class="header">
diff --git a/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js
index 06abb04..920006e 100644
--- a/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js
+++ b/guacamole/src/main/webapp/app/clipboard/services/clipboardService.js
@@ -89,6 +89,7 @@
     clipboardContent.addEventListener('cut',   stopEventPropagation);
     clipboardContent.addEventListener('copy',  stopEventPropagation);
     clipboardContent.addEventListener('paste', stopEventPropagation);
+    clipboardContent.addEventListener('input', stopEventPropagation);
 
     /**
      * A stack of past node selection ranges. A range convering the nodes
diff --git a/guacamole/src/main/webapp/app/home/styles/home.css b/guacamole/src/main/webapp/app/home/styles/home.css
index efc482b..fb01cac 100644
--- a/guacamole/src/main/webapp/app/home/styles/home.css
+++ b/guacamole/src/main/webapp/app/home/styles/home.css
@@ -50,3 +50,7 @@
     max-width: 75%;
     overflow: hidden;
 }
+
+a.home-connection {
+    display: block;
+}
diff --git a/guacamole/src/main/webapp/app/index/controllers/indexController.js b/guacamole/src/main/webapp/app/index/controllers/indexController.js
index 3a9230b..ed14233 100644
--- a/guacamole/src/main/webapp/app/index/controllers/indexController.js
+++ b/guacamole/src/main/webapp/app/index/controllers/indexController.js
@@ -82,8 +82,13 @@
 
     };
 
+    // Add default destination for input events
+    var sink = new Guacamole.InputSink();
+    $document[0].body.appendChild(sink.getElement());
+
     // Create event listeners at the global level
     var keyboard = new Guacamole.Keyboard($document[0]);
+    keyboard.listenTo(sink.getElement());
 
     // Broadcast keydown events
     keyboard.onkeydown = function onkeydown(keysym) {
diff --git a/guacamole/src/main/webapp/app/login/directives/login.js b/guacamole/src/main/webapp/app/login/directives/login.js
index cc820b8..51c366f 100644
--- a/guacamole/src/main/webapp/app/login/directives/login.js
+++ b/guacamole/src/main/webapp/app/login/directives/login.js
@@ -168,12 +168,12 @@
                     else
                         $scope.loginError = error.translatableMessage;
 
-                    // Clear all visible password fields
+                    // Clear all remaining fields that are not username fields
                     angular.forEach($scope.remainingFields, function clearEnteredValueIfPassword(field) {
 
-                        // Remove entered value only if field is a password field
-                        if (field.type === Field.Type.PASSWORD && field.name in $scope.enteredValues)
-                            $scope.enteredValues[field.name] = '';
+                        // If field is not username field, delete it.
+                        if (field.type !== Field.Type.USERNAME && field.name in $scope.enteredValues)
+                            delete $scope.enteredValues[field.name];
 
                     });
                 }
diff --git a/guacamole/src/main/webapp/app/login/styles/login.css b/guacamole/src/main/webapp/app/login/styles/login.css
index 1707827..4cb07f1 100644
--- a/guacamole/src/main/webapp/app/login/styles/login.css
+++ b/guacamole/src/main/webapp/app/login/styles/login.css
@@ -20,7 +20,7 @@
 div.login-ui {
     height: 100%;
     width: 100%;
-    position: fixed;
+    position: absolute;
     left: 0;
     top: 0;
     display: table;
diff --git a/guacamole/src/main/webapp/app/navigation/services/userPageService.js b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
index 479ae1a..749564b 100644
--- a/guacamole/src/main/webapp/app/navigation/services/userPageService.js
+++ b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
@@ -57,12 +57,24 @@
      *     A map of all root connection groups visible to the current user,
      *     where each key is the identifier of the corresponding data source.
      *
+     * @param {Object.<String, PermissionSet>} permissions
+     *     A map of all permissions granted to the current user, where each
+     *     key is the identifier of the corresponding data source.
+     *
      * @returns {PageDefinition}
      *     The user's home page.
      */
-    var generateHomePage = function generateHomePage(rootGroups) {
+    var generateHomePage = function generateHomePage(rootGroups, permissions) {
 
         var homePage = null;
+        var settingsPages = generateSettingsPages(permissions);
+
+        // If user has access to settings pages, return home page and skip
+        // evaluation for automatic connections.  The Preferences page is
+        // a Settings page and is always visible, so we look for more than
+        // one to indicate access to administrative pages.
+        if (settingsPages.length > 1)
+            return SYSTEM_HOME_PAGE;
 
         // Determine whether a connection or balancing group should serve as
         // the home page
@@ -140,13 +152,23 @@
         var deferred = $q.defer();
 
         // Resolve promise using home page derived from root connection groups
-        dataSourceService.apply(
+        var getRootGroups = dataSourceService.apply(
             connectionGroupService.getConnectionGroupTree,
             authenticationService.getAvailableDataSources(),
             ConnectionGroup.ROOT_IDENTIFIER
-        )
-        .then(function rootConnectionGroupsRetrieved(rootGroups) {
-            deferred.resolve(generateHomePage(rootGroups));
+        );
+        var getPermissionSets = dataSourceService.apply(
+            permissionService.getPermissions,
+            authenticationService.getAvailableDataSources(),
+            authenticationService.getCurrentUsername()
+        );
+
+        $q.all({
+            rootGroups : getRootGroups,
+            permissionsSets : getPermissionSets
+        })
+        .then(function rootConnectionGroupsPermissionsRetrieved(data) {
+            deferred.resolve(generateHomePage(data.rootGroups,data.permissionsSets));
         });
 
         return deferred.promise;
@@ -342,7 +364,7 @@
         var pages = [];
 
         // Get home page and settings pages
-        var homePage = generateHomePage(rootGroups);
+        var homePage = generateHomePage(rootGroups, permissions);
         var settingsPages = generateSettingsPages(permissions);
 
         // Only include the home page in the list of main pages if the user
@@ -417,4 +439,4 @@
    
     return service;
     
-}]);
\ No newline at end of file
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/tunnelService.js b/guacamole/src/main/webapp/app/rest/services/tunnelService.js
index f74088d..f8ee543 100644
--- a/guacamole/src/main/webapp/app/rest/services/tunnelService.js
+++ b/guacamole/src/main/webapp/app/rest/services/tunnelService.js
@@ -189,8 +189,14 @@
      */
     service.downloadStream = function downloadStream(tunnel, stream, mimetype, filename) {
 
+        // Work-around for IE missing window.location.origin
+        if (!$window.location.origin)
+            var streamOrigin = $window.location.protocol + '//' + $window.location.hostname + ($window.location.port ? (':' + $window.location.port) : '');
+        else
+            var streamOrigin = $window.location.origin;
+
         // Build download URL
-        var url = $window.location.origin
+        var url = streamOrigin
                 + $window.location.pathname
                 + 'api/session/tunnels/' + encodeURIComponent(tunnel)
                 + '/streams/' + encodeURIComponent(stream.index)
@@ -224,7 +230,9 @@
         // ends, in the browser does NOT fire the "load" event for downloads
         stream.onend = function downloadComplete() {
             $window.setTimeout(function cleanupIframe() {
-                document.body.removeChild(iframe);
+                if (iframe.parentElement) {
+                    document.body.removeChild(iframe);
+                }
             }, DOWNLOAD_CLEANUP_WAIT);
         };
 
@@ -267,8 +275,14 @@
 
         var deferred = $q.defer();
 
+        // Work-around for IE missing window.location.origin
+        if (!$window.location.origin)
+            var streamOrigin = $window.location.protocol + '//' + $window.location.hostname + ($window.location.port ? (':' + $window.location.port) : '');
+        else
+            var streamOrigin = $window.location.origin;
+
         // Build upload URL
-        var url = $window.location.origin
+        var url = streamOrigin
                 + $window.location.pathname
                 + 'api/session/tunnels/' + encodeURIComponent(tunnel)
                 + '/streams/' + encodeURIComponent(stream.index)
diff --git a/guacamole/src/main/webapp/app/rest/types/Connection.js b/guacamole/src/main/webapp/app/rest/types/Connection.js
index b4639b2..76ece9d 100644
--- a/guacamole/src/main/webapp/app/rest/types/Connection.js
+++ b/guacamole/src/main/webapp/app/rest/types/Connection.js
@@ -104,6 +104,15 @@
          */
         this.sharingProfiles = template.sharingProfiles;
 
+        /**
+         * The time that this connection was last used, in milliseconds since
+         * 1970-01-01 00:00:00 UTC. If this information is unknown or
+         * unavailable, this will be null.
+         *
+         * @type Number
+         */
+        this.lastActive = template.lastActive;
+
     };
 
     return Connection;
diff --git a/guacamole/src/main/webapp/app/rest/types/User.js b/guacamole/src/main/webapp/app/rest/types/User.js
index 9edd1f2..f796147 100644
--- a/guacamole/src/main/webapp/app/rest/types/User.js
+++ b/guacamole/src/main/webapp/app/rest/types/User.js
@@ -54,6 +54,15 @@
         this.password = template.password;
 
         /**
+         * The time that this user was last logged in, in milliseconds since
+         * 1970-01-01 00:00:00 UTC. If this information is unknown or
+         * unavailable, this will be null.
+         *
+         * @type Number
+         */
+        this.lastActive = template.lastActive;
+
+        /**
          * Arbitrary name/value pairs which further describe this user. The
          * semantics and validity of these attributes are dictated by the
          * extension which defines them.
diff --git a/guacamole/src/main/webapp/app/rest/types/UserCredentials.js b/guacamole/src/main/webapp/app/rest/types/UserCredentials.js
index ddf9eaf..dc6c75e 100644
--- a/guacamole/src/main/webapp/app/rest/types/UserCredentials.js
+++ b/guacamole/src/main/webapp/app/rest/types/UserCredentials.js
@@ -114,8 +114,14 @@
      */
     UserCredentials.getLink = function getLink(userCredentials) {
 
+        // Work-around for IE missing window.location.origin
+        if (!$window.location.origin)
+            var linkOrigin = $window.location.protocol + '//' + $window.location.hostname + ($window.location.port ? (':' + $window.location.port) : '');
+        else
+            var linkOrigin = $window.location.origin;
+
         // Build base link
-        var link = $window.location.origin
+        var link = linkOrigin
                  + $window.location.pathname
                  + '#/';
 
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
index cc134fa..1ac9fad 100644
--- a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
@@ -36,9 +36,11 @@
             // Required types
             var ManageableUser  = $injector.get('ManageableUser');
             var PermissionSet   = $injector.get('PermissionSet');
+            var SortOrder       = $injector.get('SortOrder');
 
             // Required services
             var $location              = $injector.get('$location');
+            var $translate             = $injector.get('$translate');
             var authenticationService  = $injector.get('authenticationService');
             var dataSourceService      = $injector.get('dataSourceService');
             var guacNotification       = $injector.get('guacNotification');
@@ -98,10 +100,38 @@
              * @type String[]
              */
             $scope.filteredUserProperties = [
+                'user.lastActive',
                 'user.username'
             ];
 
             /**
+             * The date format for use for the last active date.
+             *
+             * @type String
+             */
+            $scope.dateFormat = null;
+
+            /**
+             * SortOrder instance which stores the sort order of the listed
+             * users.
+             *
+             * @type SortOrder
+             */
+            $scope.order = new SortOrder([
+                'user.username',
+                '-user.lastActive'
+            ]);
+
+            // Get session date format
+            $translate('SETTINGS_USERS.FORMAT_DATE')
+            .then(function dateFormatReceived(retrievedDateFormat) {
+
+                // Store received date format
+                $scope.dateFormat = retrievedDateFormat;
+
+            });
+
+            /**
              * Returns whether critical data has completed being loaded.
              *
              * @returns {Boolean}
@@ -110,7 +140,8 @@
              */
             $scope.isLoaded = function isLoaded() {
 
-                return $scope.manageableUsers !== null
+                return $scope.dateFormat      !== null
+                    && $scope.manageableUsers !== null
                     && $scope.permissions     !== null;
 
             };
diff --git a/guacamole/src/main/webapp/app/settings/styles/user-list.css b/guacamole/src/main/webapp/app/settings/styles/user-list.css
new file mode 100644
index 0000000..6c0edd5
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/user-list.css
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+.settings.users table.user-list {
+    width: 100%;
+}
+
+.settings.users table.user-list th.last-active,
+.settings.users table.user-list td.last-active {
+    white-space: nowrap;
+    width: 0;
+}
+
+.settings.users table.user-list th.username,
+.settings.users table.user-list td.username {
+    width: 100%;
+}
+
+.settings.users table.user-list tr.user td.username a[href] {
+    display: block;
+    padding: .5em 1em;
+}
+
+.settings.users table.user-list tr.user td.username {
+    padding: 0;
+}
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html b/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html
index 41dac6c..67f6760 100644
--- a/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsUsers.html
@@ -21,19 +21,32 @@
     </div>
 
     <!-- List of users this user has access to -->
-    <div class="user-list">
-        <div ng-repeat="manageableUser in manageableUserPage" class="user list-item">
-            <a ng-href="#/manage/{{manageableUser.dataSource}}/users/{{manageableUser.user.username}}">
-                <div class="caption">
-                    <div class="icon user"></div>
-                    <span class="name">{{manageableUser.user.username}}</span>
-                </div>
-            </a>
-        </div>
-    </div>
+    <table class="sorted user-list">
+        <thead>
+            <tr>
+                <th guac-sort-order="order" guac-sort-property="'user.username'" class="username">
+                    {{'SETTINGS_USERS.TABLE_HEADER_USERNAME' | translate}}
+                </th>
+                <th guac-sort-order="order" guac-sort-property="'user.lastActive'" class="last-active">
+                    {{'SETTINGS_USERS.TABLE_HEADER_LAST_ACTIVE' | translate}}
+                </th>
+            </tr>
+        </thead>
+        <tbody ng-class="{loading: !isLoaded()}">
+            <tr ng-repeat="manageableUser in manageableUserPage" class="user">
+                <td class="username">
+                    <a ng-href="#/manage/{{manageableUser.dataSource}}/users/{{manageableUser.user.username}}">
+                        <div class="icon user"></div>
+                        <span class="name">{{manageableUser.user.username}}</span>
+                    </a>
+                </td>
+                <td class="last-active">{{manageableUser.user.lastActive | date : dateFormat}}</td>
+            </tr>
+        </tbody>
+    </table>
 
     <!-- Pager controls for user list -->
     <guac-pager page="manageableUserPage" page-size="25"
-                items="filteredManageableUsers | orderBy : 'user.username'"></guac-pager>
+                items="filteredManageableUsers | orderBy : order.predicate"></guac-pager>
 
 </div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/index.html b/guacamole/src/main/webapp/index.html
index 7d747cf..376245f 100644
--- a/guacamole/src/main/webapp/index.html
+++ b/guacamole/src/main/webapp/index.html
@@ -19,7 +19,8 @@
 -->
 <html ng-app="index" ng-controller="indexController">
     <head>
-        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+        <meta http-equiv="x-ua-compatible" content="IE=edge"/>
         <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=medium-dpi"/>
         <meta name="mobile-web-app-capable" content="yes"/>
         <meta name="apple-mobile-web-app-capable" content="yes"/>
diff --git a/guacamole/src/main/webapp/translations/de.json b/guacamole/src/main/webapp/translations/de.json
index 9403402..90953e5 100644
--- a/guacamole/src/main/webapp/translations/de.json
+++ b/guacamole/src/main/webapp/translations/de.json
@@ -598,9 +598,13 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Klicke oder Tippe auf einen Benutzer um diesen zu verwalten. Abhänig von Ihrer Zugriffsebene können Benutzer hinzugefügt, gelöscht bzw. dessen Passwort geändert werden.",
 
-        "SECTION_HEADER_USERS"       : "Benutzer"
+        "SECTION_HEADER_USERS"       : "Benutzer",
+
+        "TABLE_HEADER_USERNAME" : "Benutzername"
 
     },
     
diff --git a/guacamole/src/main/webapp/translations/en.json b/guacamole/src/main/webapp/translations/en.json
index 2b4fb5d..ce65753 100644
--- a/guacamole/src/main/webapp/translations/en.json
+++ b/guacamole/src/main/webapp/translations/en.json
@@ -328,6 +328,9 @@
         "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Enable font smoothing (ClearType):",
         "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Enable full-window drag:",
         "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Enable menu animations:",
+        "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "Disable bitmap caching:",
+        "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "Disable off-screen caching:",
+        "FIELD_HEADER_DISABLE_GLYPH_CACHING"      : "Disable glyph caching:",
         "FIELD_HEADER_ENABLE_PRINTING"            : "Enable printing:",
         "FIELD_HEADER_ENABLE_SFTP"     : "Enable SFTP:",
         "FIELD_HEADER_ENABLE_THEMING"             : "Enable theming:",
@@ -347,6 +350,9 @@
         "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconnection BLOB (VM ID):",
         "FIELD_HEADER_PRECONNECTION_ID"   : "RDP source ID:",
         "FIELD_HEADER_READ_ONLY"      : "Read-only:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Exclude mouse:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Include key events:",
         "FIELD_HEADER_RECORDING_NAME" : "Recording name:",
         "FIELD_HEADER_RECORDING_PATH" : "Recording path:",
         "FIELD_HEADER_RESIZE_METHOD" : "Resize method:",
@@ -386,13 +392,17 @@
 
         "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK English (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Spanish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
         "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanese (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)",
 
         "NAME" : "RDP",
 
@@ -427,6 +437,9 @@
         "FIELD_HEADER_PORT"        : "Port:",
         "FIELD_HEADER_PRIVATE_KEY" : "Private key:",
         "FIELD_HEADER_READ_ONLY"   : "Read-only:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Exclude mouse:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Include key events:",
         "FIELD_HEADER_RECORDING_NAME" : "Recording name:",
         "FIELD_HEADER_RECORDING_PATH" : "Recording path:",
         "FIELD_HEADER_SERVER_ALIVE_INTERVAL" : "Server keepalive interval:",
@@ -481,6 +494,9 @@
         "FIELD_HEADER_PASSWORD_REGEX" : "Password regular expression:",
         "FIELD_HEADER_PORT"           : "Port:",
         "FIELD_HEADER_READ_ONLY"      : "Read-only:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Exclude mouse:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Include key events:",
         "FIELD_HEADER_RECORDING_NAME" : "Recording name:",
         "FIELD_HEADER_RECORDING_PATH" : "Recording path:",
         "FIELD_HEADER_TYPESCRIPT_NAME" : "Typescript name:",
@@ -533,6 +549,9 @@
         "FIELD_HEADER_PASSWORD"         : "Password:",
         "FIELD_HEADER_PORT"             : "Port:",
         "FIELD_HEADER_READ_ONLY"        : "Read-only:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Exclude mouse:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Include key events:",
         "FIELD_HEADER_RECORDING_NAME" : "Recording name:",
         "FIELD_HEADER_RECORDING_PATH" : "Recording path:",
         "FIELD_HEADER_SFTP_DIRECTORY"             : "Default upload directory:",
@@ -675,9 +694,14 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Click or tap on a user below to manage that user. Depending on your access level, users can be added and deleted, and their passwords can be changed.",
 
-        "SECTION_HEADER_USERS"       : "Users"
+        "SECTION_HEADER_USERS"       : "Users",
+
+        "TABLE_HEADER_LAST_ACTIVE" : "Last active",
+        "TABLE_HEADER_USERNAME"    : "Username"
 
     },
     
diff --git a/guacamole/src/main/webapp/translations/es.json b/guacamole/src/main/webapp/translations/es.json
new file mode 100644
index 0000000..63f377e
--- /dev/null
+++ b/guacamole/src/main/webapp/translations/es.json
@@ -0,0 +1,735 @@
+{
+    
+    "NAME" : "Spanish",
+    
+    "APP" : {
+
+        "NAME"    : "Apache Guacamole",
+        "VERSION" : "0.9.13-incubating",
+
+        "ACTION_ACKNOWLEDGE"        : "OK",
+        "ACTION_CANCEL"             : "Cancelar",
+        "ACTION_CLONE"              : "Clonar",
+        "ACTION_CONTINUE"           : "Continuar",
+        "ACTION_DELETE"             : "Borrar",
+        "ACTION_DELETE_SESSIONS"    : "Finalizar Sesiones",
+        "ACTION_DOWNLOAD"           : "Descargar",
+        "ACTION_LOGIN"              : "Iniciar Sesión",
+        "ACTION_LOGOUT"             : "Cerrar Sesión",
+        "ACTION_MANAGE_CONNECTIONS" : "Conexiones",
+        "ACTION_MANAGE_PREFERENCES" : "Preferencias",
+        "ACTION_MANAGE_SETTINGS"    : "Configuración",
+        "ACTION_MANAGE_SESSIONS"    : "Sesiones Activas",
+        "ACTION_MANAGE_USERS"       : "Usuarios",
+        "ACTION_NAVIGATE_BACK"      : "Atrás",
+        "ACTION_NAVIGATE_HOME"      : "Inicio",
+        "ACTION_SAVE"               : "Guardar",
+        "ACTION_SEARCH"             : "Buscar",
+        "ACTION_SHARE"              : "Compartir",
+        "ACTION_UPDATE_PASSWORD"    : "Actualizar Contraseña",
+        "ACTION_VIEW_HISTORY"       : "Historial",
+
+        "DIALOG_HEADER_ERROR" : "Error",
+
+        "ERROR_PASSWORD_BLANK"    : "La contraseña no puede estar en blanco.",
+        "ERROR_PASSWORD_MISMATCH" : "Las contraseñas no coinciden.",
+        
+        "FIELD_HEADER_PASSWORD"       : "Contraseña:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Validar Contraseña:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filtros",
+
+        "FORMAT_DATE_TIME_PRECISE" : "dd-MM-yyyy HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Actualmente en uso por {USERS} {USERS, plural, one{user} other{users}}.",
+
+        "TEXT_ANONYMOUS_USER"   : "Anónimo",
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{second} other{seconds}}} minute{{VALUE, plural, one{minute} other{minutes}}} hour{{VALUE, plural, one{hour} other{hours}}} day{{VALUE, plural, one{day} other{days}}} other{}}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Limpiar",
+        "ACTION_DISCONNECT"                : "Disconnect",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Reconectar",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_SHARE"                     : "@:APP.ACTION_SHARE",
+        "ACTION_UPLOAD_FILES"              : "Subir ficheros",
+
+        "DIALOG_HEADER_CONNECTING"       : "Conectando",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Error de conexión",
+        "DIALOG_HEADER_DISCONNECTED"     : "Desconectado",
+
+        "ERROR_CLIENT_201"     : "Esta conexión se ha cerrado porque el servidor esta ocupado. Por favor espera unos minutos e intenta de nuevo.",
+        "ERROR_CLIENT_202"     : "El servidor Guacamole ha cerrado la conexión porque el escritorio remoto está tardando mucho en responder. Por favor intente de nuevo o contacte al administrador del sistema.",
+        "ERROR_CLIENT_203"     : "El servidor de escritorio remoto encontró un error y ha cerrado la conexión. Por favor, intente de nuevo o contacte al administrador del sistema.",
+        "ERROR_CLIENT_207"     : "El servidor de escritorio remoto no está disponible actualmente. Si el problema persiste, por favor notifique al administrador o compruebe los registros del sistema.",
+        "ERROR_CLIENT_208"     : "El servidor de escritorio remoto no está disponible actualmente. Si el problema persiste, por favor notifique al administrador o compruebe los registros del sistema.",
+        "ERROR_CLIENT_209"     : "El servidor de escritorio remoto ha cerrado la conexión porque hay conflicto con otra conexión. Por favor intenta de nuevo mas tarde.",
+        "ERROR_CLIENT_20A"     : "El servidor de escritorio remoto ha cerrado la conexión porque parece estar inactivo. Si esto fué inesperado, notifíquelo al administrador o verifique la configuración del sistema.",
+        "ERROR_CLIENT_20B"     : "El servidor de escritorio remoto ha forzado el cierre de la conexión. Si esto fué inesperado, notifíquelo al administrador o verifique la configuración del sistema.",
+        "ERROR_CLIENT_301"     : "Inicio fallido. Por favor vuelva a conectar e intente de nuevo.",
+        "ERROR_CLIENT_303"     : "El servidor de escritorio remoto ha denegado el acceso a esta conexión. Si necesita acceso, por favor solicítelo a un administrador para que permita el acceso de su cuenta, o compruebe la configuración del sistema.",
+        "ERROR_CLIENT_308"     : "El servidor Guacamole ha cerrado la conexión porque no ha habido respuesta del navegador por tiempo suficiente como para asumir que está desconectado. Normalmente esto es debido a problemas de red, como señal inalámbrica irregular, o simplemente velocidades de red muy lentas. Por favor revisa tu red e intente de nuevo.",
+        "ERROR_CLIENT_31D"     : "El servidor Guacamole está denegando el acceso a esta conexión porque ha agotado el límite de conexiones simultaneas por usuario. Por favor cierre una o mas conexiones e intente de nuevo.",
+        "ERROR_CLIENT_DEFAULT" : "Ha ocurrido un error interno en el servidor Guacamole y la conexión ha finalizado. Si el problema persiste, por favor notifíquelo al administrador o compruebe los registros del sistema.",
+
+        "ERROR_TUNNEL_201"     : "El servidor Guacamole ha rechazado este intento de conexión porque hay muchas conexiones activas. Por favor espere unos minutos e intente de nuevo.",
+        "ERROR_TUNNEL_202"     : "Se ha cerrado la conexión porque el servidor está tardando mucho en responder. Normalmente esto es debido a problemas de red, como señal inalámbrica irregular, o simplemente velocidades de red muy lentas. Por favor revisa tu red e intenta de nuevo.",
+        "ERROR_TUNNEL_203"     : "El servidor encontró un error y ha cerrado la conexión. Por favor intente de nuevo o contacte al administrador del sistema.",
+        "ERROR_TUNNEL_204"     : "La conexión solicitada no existe. Por favor compruebe el nombre de conexión e intente de nuevo.",
+        "ERROR_TUNNEL_205"     : "Esta conexión esta actualmente en uso y el acceso concurrente a la misma no está permitido. Por favor intente de nuevo mas tarde.",
+        "ERROR_TUNNEL_207"     : "El servidor Guacamole no esta disponible actualmente. Por favor compruebe la configuración de red e intente de nuevo.",
+        "ERROR_TUNNEL_208"     : "El servidor Guacamole no está aceptando conexiones. Por favor compruebe la conexión a red e intente de nuevo.",
+        "ERROR_TUNNEL_301"     : "No tiene permiso para acceder a esta conexión porque no ha iniciado sesión. Por favor incie sesión e intente de nuevo.",
+        "ERROR_TUNNEL_303"     : "No tiene permiso para acceder a esta conexión. Si requiere acceso, por favor solicite al administrador que le agregue a la lista de usuarios permitidos o compruebe la configuración del sistema.",
+        "ERROR_TUNNEL_308"     : "El servidor Guacamole ha cerrado la conexión porque ho ha habido respuesta desde el navegador por tiempo suficiente como para asumir que está desconectado. Normalmente esto es debido a problemas de red, como señal inalámbrica irregular, o simplemente velocidades de red muy lentas. Por favor revisa tu red e intente de nuevo.",
+        "ERROR_TUNNEL_31D"     : "El servidor Guacamole esta denegando el acceso a esta conexión porque ha agotado el límite de conexiones simultaneas por usuario. Por favor cierre una o mas conexiones e intente de nuevo.",
+        "ERROR_TUNNEL_DEFAULT" : "Ha ocurrido un error interno en el servidor Guacamole y la conexión ha finalizado. Si el problema persiste, por favor notifíquelo al administrador o compruebe los registros del sistema.",
+
+        "ERROR_UPLOAD_100"     : "La transferencia de ficheros no está habilitada o soportada. Por favor contacte al administrador o compruebe los registros del sistema.",
+        "ERROR_UPLOAD_201"     : "Se estan transfiriendo muchos ficheros actualmente. Por favor espere a que se completen las transferencias existentes e intente de nuevo.",
+        "ERROR_UPLOAD_202"     : "El fichero no se puede transferir porque el servidor de escritorio remoto está tardando demasiado en responder. Por favor intente de nuevo o contacte con el administrador del sistema.",
+        "ERROR_UPLOAD_203"     : "El servidor de escritorio remoto encontró un error durante la transferencia. Por favor intente denuevo o contacte con el administrador del sistema.",
+        "ERROR_UPLOAD_204"     : "El destino para la transferencia de fichero no existe. Por favor compruebe que exista el destino e intente de nuevo.",
+        "ERROR_UPLOAD_205"     : "El destino para la transferencia de fichero está bloqueado. Por favor espere a que finalicen las tareas en progreso e intente de nuevo.",
+        "ERROR_UPLOAD_301"     : "No tiene permiso para subir este fichero porque no ha iniciado sesión. Por favor inicie sesión e intente de nuevo.",
+        "ERROR_UPLOAD_303"     : "No tiene permiso para subir este fichero. Si necesita acceso, por favor compruebe la configuración del sistema o contacte con el administrador del sistema.",
+        "ERROR_UPLOAD_308"     : "La transferencia de ficheros se ha parado. Normalmente esto es debido a problemas de red, como señal inalámbrica irregular, o simplemente velocidades de red muy lentas. Por favor revisa tu red e intente de nuevo.",
+        "ERROR_UPLOAD_31D"     : "Se estan transfiriendo muchos ficheros actualmente. Por favor espere a que finalicen las transferencias de fichero existentes e intente de nuevo.",
+        "ERROR_UPLOAD_DEFAULT" : "Ha ocurrido un error interno en el servidor Guacamole y la conexión ha finalizado. Si el problema persiste, por favor notifíquelo al administrador o compruebe los registros del sistema.",
+
+        "HELP_CLIPBOARD"           : "Aquí aparecerá el texto copiado/cortado en Guacamole. Los cambios en el texto de abajo afectaran al portapapeles remoto.",
+        "HELP_INPUT_METHOD_NONE"   : "No se está usando un método de entrada. La entrada de teclado se acepta desde un teclado físico conectado.",
+        "HELP_INPUT_METHOD_OSK"    : "Muestra y acepta entrada desde el teclado en pantalla incorporado de Guacamole. El teclado en pantalla permite escribir combinaciones que serían imposible de otro modo (como Ctrl-Alt-Sup).",
+        "HELP_INPUT_METHOD_TEXT"   : "Permite escribir texto y emular eventos de teclado basados en el texto escrito. Este modo es necesario para dispositivos como teléfonos móviles que no tienen teclado físico.",
+        "HELP_MOUSE_MODE"          : "Determina el comportamiento del ratón remoto respecto a los toques.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Toque para clic. El clic ocurre en la ubicación del toque.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Arrastre para mover el puntero del ratón y toque para hacer clic. El clic ocurre en la ubicación del puntero.",
+        "HELP_SHARE_LINK"          : "Se está compartiendo la conexión actual y puede acceder a la misma cualquiera con el siguiente {LINKS, plural, one{link} other{links}}:",
+
+        "INFO_CONNECTION_SHARED" : "Esta conexión está compartida.",
+        "INFO_NO_FILE_TRANSFERS" : "No hay transferencia de ficheros.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Ninguno",
+        "NAME_INPUT_METHOD_OSK"    : "Teclado en pantalla",
+        "NAME_INPUT_METHOD_TEXT"   : "Entrada de Texto",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Pantalla táctil",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Portapapeles",
+        "SECTION_HEADER_DEVICES"        : "Dispositivos",
+        "SECTION_HEADER_DISPLAY"        : "Monitor",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Transferencia de ficheros",
+        "SECTION_HEADER_INPUT_METHOD"   : "Método de entrada",
+        "SECTION_HEADER_MOUSE_MODE"     : "Modo de emulación de ratón",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Ajustar automáticamente a la ventana del navegador",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Inactivo.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Conectando a Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "Ha sido desconectado.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Conectado a Guacamole. Esperando respuesta...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Reconectando en {REMAINING} {REMAINING, plural, one{second} other{seconds}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
+
+    },
+
+    "DATA_SOURCE_DEFAULT" : {
+        "NAME" : "Default (XML)"
+    },
+
+    "FORM" : {
+
+        "FIELD_PLACEHOLDER_DATE" : "DD--MM-YYYY",
+        "FIELD_PLACEHOLDER_TIME" : "HH:MM:SS",
+
+        "HELP_SHOW_PASSWORD" : "Clic para mostrar contraseña",
+        "HELP_HIDE_PASSWORD" : "Clic para esconder contraseña"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Sin conexiones recientes.",
+        
+        "PASSWORD_CHANGED" : "Contraseña cambiada.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Todas las conexiones",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Conexiones Recientes"
+
+    },
+
+    "LIST" : {
+
+        "TEXT_ANONYMOUS_USER" : "Anónimo"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Inicio de sesión inválido",
+
+        "FIELD_HEADER_USERNAME" : "Usuario",
+        "FIELD_HEADER_PASSWORD" : "Contraseña"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Borrar conexión",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Ubicación:",
+        "FIELD_HEADER_NAME"     : "Nombre:",
+        "FIELD_HEADER_PROTOCOL" : "Protocolo:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Activo Ahora",
+        "INFO_CONNECTION_NOT_USED"         : "Esta conexión no se ha usado todavía.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Editar Conexión",
+        "SECTION_HEADER_HISTORY"         : "Historial de uso",
+        "SECTION_HEADER_PARAMETERS"      : "Parámetros",
+
+        "TABLE_HEADER_HISTORY_USERNAME"   : "Usuario",
+        "TABLE_HEADER_HISTORY_START"      : "Activo Desde",
+        "TABLE_HEADER_HISTORY_DURATION"   : "Duración",
+        "TABLE_HEADER_HISTORY_REMOTEHOST" : "Host Remoto",
+
+        "TEXT_CONFIRM_DELETE"   : "Las conexiones no se pueden restaurar despues de haberlas eliminado. ¿Está seguro que quiere eliminar esta conexión?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Borrar Grupo de conexiones",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Ubicación:",
+        "FIELD_HEADER_NAME"     : "Nombre:",
+        "FIELD_HEADER_TYPE"     : "Tipo:",
+
+        "NAME_TYPE_BALANCING"       : "Balanceo",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organizativo",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Editar Grupo de conexiones",
+
+        "TEXT_CONFIRM_DELETE" : "Los Grupos de conexiones no se pueden restaurar despues de haberlos eliminado. ¿Esta seguro que quiere borrar este grupo de conexiones?"
+
+    },
+
+    "MANAGE_SHARING_PROFILE" : {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"       : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"      : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"        : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Eliminar perfil de compartir",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_NAME"               : "Nombre:",
+        "FIELD_HEADER_PRIMARY_CONNECTION" : "Conexión primaria:",
+
+        "SECTION_HEADER_EDIT_SHARING_PROFILE" : "Editar perfil de compartir",
+        "SECTION_HEADER_PARAMETERS"           : "Parámetros",
+
+        "TEXT_CONFIRM_DELETE" : "Los perfiles de compartir no se pueden restaurar despues de haberlos eliminado. ¿Esta seguro que quiere borrar este perfil?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"         : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Borrar Usuario",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administrar sistema:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Cambiar contraseña:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Crear nuevos usuarios:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Crear nuevas conexiones:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Crear nuevos grupos de conexión:",
+        "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "Crear nuevos perfiles de compartir:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Nombre de usuario:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_READ_ONLY" : "Lo siento, pero esta cuenta de usuario no puede ser editada.",
+
+        "SECTION_HEADER_CONNECTIONS" : "Conexiones",
+        "SECTION_HEADER_EDIT_USER"   : "Editar Usuario",
+        "SECTION_HEADER_PERMISSIONS" : "Permisos",
+
+        "TEXT_CONFIRM_DELETE" : "Los usuarios no se pueden restaurar despues de haberlos eliminado. ¿Esta seguro de querer eliminar este usuario?"
+
+    },
+    
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Nombre de Cliente:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Profundidad color:",
+        "FIELD_HEADER_CONSOLE"         : "Consola de Administración:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Soporte de audio en consola:",
+        "FIELD_HEADER_CREATE_DRIVE_PATH" : "Crear unidad automáticamente:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Crear ruta de grabación automáticamente:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Deshabilitar audio:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Deshabilitar autenticación:",
+        "FIELD_HEADER_DOMAIN"          : "Dominio:",
+        "FIELD_HEADER_DPI"             : "Resolución (DPI):",
+        "FIELD_HEADER_DRIVE_PATH"      : "Ruta Unidad:",
+        "FIELD_HEADER_ENABLE_AUDIO_INPUT"         : "Habilitar entrada de audio (Microfono):",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Habilitar composición de escritorio (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"               : "Habilitar unidad:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Habilitar suavizado de fuente (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Habilitar arrastre de ventana completa:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Habilitar animaciones de menú:",
+        "FIELD_HEADER_ENABLE_PRINTING"            : "Habilitar impresión:",
+        "FIELD_HEADER_ENABLE_SFTP"     : "Habilitar SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"             : "Habilitar temas:",
+        "FIELD_HEADER_ENABLE_WALLPAPER"           : "Habilitar Fondo de pantalla:",
+        "FIELD_HEADER_GATEWAY_DOMAIN"   : "Dominio:",
+        "FIELD_HEADER_GATEWAY_HOSTNAME" : "Nombre de Host:",
+        "FIELD_HEADER_GATEWAY_PASSWORD" : "Contraseña:",
+        "FIELD_HEADER_GATEWAY_PORT"     : "Puerto:",
+        "FIELD_HEADER_GATEWAY_USERNAME" : "Usuario:",
+        "FIELD_HEADER_HEIGHT"          : "Altura:",
+        "FIELD_HEADER_HOSTNAME"        : "Nombre de Host:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignorar certificado del servidor:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Programa de Inicio:",
+        "FIELD_HEADER_LOAD_BALANCE_INFO" : "Información de carga balanceada info/cookie:",
+        "FIELD_HEADER_PASSWORD"        : "Contraseña:",
+        "FIELD_HEADER_PORT"            : "Puerto:",
+        "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconexión BLOB (VM ID):",
+        "FIELD_HEADER_PRECONNECTION_ID"   : "RDP ID origen:",
+        "FIELD_HEADER_READ_ONLY"      : "Solo lectura:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nombre grabación:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ruta grabación:",
+        "FIELD_HEADER_RESIZE_METHOD" : "Método de redimensión:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parametros:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Directorio de trabajo:",
+        "FIELD_HEADER_REMOTE_APP"      : "Programa:",
+        "FIELD_HEADER_SECURITY"        : "Modo seguridad:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Disposición teclado:",
+        "FIELD_HEADER_SFTP_DIRECTORY"             : "Directorio de subida por defecto:",
+        "FIELD_HEADER_SFTP_HOSTNAME"              : "Nombre de Host:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "Intervalo Keepalive SFTP:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"            : "Frase de paso:",
+        "FIELD_HEADER_SFTP_PASSWORD"              : "Contraseña:",
+        "FIELD_HEADER_SFTP_PORT"                  : "Puerto:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY"           : "Llave Privada:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "Directorio raiz del navegador de ficheros:",
+        "FIELD_HEADER_SFTP_USERNAME"              : "Usuario:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Nombres de Canales estáticos:",
+        "FIELD_HEADER_USERNAME"        : "Usuario:",
+        "FIELD_HEADER_WIDTH"           : "Ancho:",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Color verdadero (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 colores",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_RESIZE_METHOD_DISPLAY_UPDATE" : "\"Actualizar pantalla\" canal virtual (RDP 8.1+)",
+        "FIELD_OPTION_RESIZE_METHOD_EMPTY"          : "",
+        "FIELD_OPTION_RESIZE_METHOD_RECONNECT"      : "Reconectar",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Todo",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Autenticación de nivel de red)",
+        "FIELD_OPTION_SECURITY_RDP"   : "Encriptación RDP",
+        "FIELD_OPTION_SECURITY_TLS"   : "Encriptación TLS",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "Aleman (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "Inglés US (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Español(Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Francés (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italiano (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japones (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Autenticación",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Configuración básica",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Redirección dispositivo",
+        "SECTION_HEADER_DISPLAY"            : "Visualización",
+        "SECTION_HEADER_GATEWAY"            : "Puerta de enlace remota",
+        "SECTION_HEADER_LOAD_BALANCING"     : "Balanceo de carga",
+        "SECTION_HEADER_NETWORK"            : "Red",
+        "SECTION_HEADER_PERFORMANCE"        : "Rendimiento",
+        "SECTION_HEADER_PRECONNECTION_PDU"  : "Preconexión PDU / Hyper-V",
+        "SECTION_HEADER_RECORDING"          : "Grabación de pantalla",
+        "SECTION_HEADER_REMOTEAPP"          : "Aplicación remota",
+        "SECTION_HEADER_SFTP"               : "SFTP"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_COLOR_SCHEME" : "Esquema de color:",
+        "FIELD_HEADER_COMMAND"     : "Ejecutar comando:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Crear ruta de grabación automáticamente:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Crear ruta de escritura automáticamente:",
+        "FIELD_HEADER_FONT_NAME"   : "Nombre de Fuente:",
+        "FIELD_HEADER_FONT_SIZE"   : "Tamaño de Fuente:",
+        "FIELD_HEADER_ENABLE_SFTP" : "Habilitar SFTP:",
+        "FIELD_HEADER_HOSTNAME"    : "Nombre de Host:",
+        "FIELD_HEADER_USERNAME"    : "Usuario:",
+        "FIELD_HEADER_PASSWORD"    : "Contraseña:",
+        "FIELD_HEADER_PASSPHRASE"  : "Frase de paso:",
+        "FIELD_HEADER_PORT"        : "Puerto:",
+        "FIELD_HEADER_PRIVATE_KEY" : "Llave Privada:",
+        "FIELD_HEADER_READ_ONLY"   : "Solo Lectura:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nombre de grabación:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ruta de grabación:",
+        "FIELD_HEADER_SERVER_ALIVE_INTERVAL" : "Intervalo keepalive servidor:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"   : "Directorio raiz del navegador de ficheros:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Nombre script escritura:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Ruta script escritura:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Negro sobre blanco",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Gris sobre negro",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Verde sobre negro",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Blanco sobre negro",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Autenticación",
+        "SECTION_HEADER_DISPLAY"        : "Mostrar",
+        "SECTION_HEADER_NETWORK"        : "Red",
+        "SECTION_HEADER_RECORDING"      : "Grabación de pantalla",
+        "SECTION_HEADER_SESSION"        : "Sesión / Entorno",
+        "SECTION_HEADER_TYPESCRIPT"     : "Script de Escritura (Grabación sesión texto)",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_COLOR_SCHEME"   : "Esquema de color:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Crear ruta de grabación automáticamente:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Crear ruta de escritura automáticamente:",
+        "FIELD_HEADER_FONT_NAME"      : "Nombre Fuente:",
+        "FIELD_HEADER_FONT_SIZE"      : "Tamaño Fuente:",
+        "FIELD_HEADER_HOSTNAME"       : "Nombre Host:",
+        "FIELD_HEADER_USERNAME"       : "Usuario:",
+        "FIELD_HEADER_PASSWORD"       : "Contraseña:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Contraseña expresión regular:",
+        "FIELD_HEADER_PORT"           : "Puerto:",
+        "FIELD_HEADER_READ_ONLY"      : "Solo Lectura:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nombre grabación:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ruta grabación:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Nombre script escritura:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Ruta script escritura:",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Negro sobre blanco",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Gris sobre negro",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Verde sobre negro",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Blanco sobre negro",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Autenticación",
+        "SECTION_HEADER_DISPLAY"        : "Mostrar",
+        "SECTION_HEADER_RECORDING"      : "Grabación pantalla",
+        "SECTION_HEADER_TYPESCRIPT"     : "Script de escritura (Próxima sesión de grabación)",
+        "SECTION_HEADER_NETWORK"        : "Red"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Nombre servidor Audio:",
+        "FIELD_HEADER_CLIPBOARD_ENCODING" : "Codificación:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Profundidad color:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Crear ruta de grabación automáticamente:",
+        "FIELD_HEADER_CURSOR"           : "Cursor:",
+        "FIELD_HEADER_DEST_HOST"        : "Host Destino:",
+        "FIELD_HEADER_DEST_PORT"        : "Puerto Destino:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Habilitar audio:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Habilitar SFTP:",
+        "FIELD_HEADER_HOSTNAME"         : "Nombre de Host:",
+        "FIELD_HEADER_PASSWORD"         : "Contraseña:",
+        "FIELD_HEADER_PORT"             : "Puerto:",
+        "FIELD_HEADER_READ_ONLY"        : "Solo Lectura:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nombre grabación:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ruta grabación:",
+        "FIELD_HEADER_SFTP_DIRECTORY"             : "Directorio de subida por defecto:",
+        "FIELD_HEADER_SFTP_HOSTNAME"              : "Nombre de Host:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "Interalo keepalive SFTP:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"            : "Frase de paso:",
+        "FIELD_HEADER_SFTP_PASSWORD"              : "Contraseña:",
+        "FIELD_HEADER_SFTP_PORT"                  : "Puerto:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY"           : "Llave privada:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "Directorio raiz del navegador de ficheros:",
+        "FIELD_HEADER_SFTP_USERNAME"              : "Usuario:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Invertir componentes rojo/azul:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 colores",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Color bajo(16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "Color verdadero (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "Color verdadero (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Local",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Remoto",
+
+        "FIELD_OPTION_CLIPBOARD_ENCODING_CP1252"    : "CP1252",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_EMPTY"     : "",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_ISO8859_1" : "ISO 8859-1",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_8"     : "UTF-8",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_16"    : "UTF-16",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Audio",
+        "SECTION_HEADER_AUTHENTICATION" : "Autenticación",
+        "SECTION_HEADER_CLIPBOARD"      : "Portapapeles",
+        "SECTION_HEADER_DISPLAY"        : "Monitor",
+        "SECTION_HEADER_NETWORK"        : "Red",
+        "SECTION_HEADER_RECORDING"      : "Grabación pantalla",
+        "SECTION_HEADER_REPEATER"       : "Repetidor VNC",
+        "SECTION_HEADER_SFTP"           : "SFTP"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Configuraciones"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "ACTION_DOWNLOAD" : "@:APP.ACTION_DOWNLOAD",
+        "ACTION_SEARCH"   : "@:APP.ACTION_SEARCH",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FILENAME_HISTORY_CSV" : "history.csv",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_CONNECTION_HISTORY" : "Aquí se lista el historial de las últimas conexiones y se pueden ordernar haciendo clic en los encabezados de la columna. Para buscar un registro específico, introduzca la cadena de texto a filtrar y haga clic en \"Buscar\". Solo se listaran los registros que coincidan con el filtro introducido.",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_NO_HISTORY"                  : "No hay registros coincidentes",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nombre de conexión",
+        "TABLE_HEADER_SESSION_DURATION"        : "Duración",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Host Remoto",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Activo Desde",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Usuario",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Nueva Conexión",
+        "ACTION_NEW_CONNECTION_GROUP" : "Nuevo Grupo",
+        "ACTION_NEW_SHARING_PROFILE"  : "Nuevo perfil de compartir",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Haga clic o toque en una de las conexiones de abajo para gestionar esa conexión. Dependiendo de su nivel de acceso, se pueden añadir/borrar conexiones y cambiar sus propiedades (Protocolo, Nombre de Host, Puerto, etc.) .",
+        
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Conexiones"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Idioma para mostrar:",
+        "FIELD_HEADER_PASSWORD"           : "Contraseña:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Contraseña actual:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Nueva Contraseña:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Confirmar Nueva contraseña:",
+        "FIELD_HEADER_USERNAME"           : "Usuario:",
+        
+        "HELP_DEFAULT_INPUT_METHOD" : "El método de entrada por defecto determina como se reciben en Guacamole los eventos de teclado. Es posible que sea necesario cambiar esta configuración cuando se usa un dispositivo móvil, o cuando se escribe a través de un método de entrada. Esta configuración se puede cambiar tambien en cada conexión desde el menú de Guacamole.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "El modo de emulación de ratón por defecto determina como se comportará el ratón remoto en nuevas conexiones con respecto a los toques. Esta configuración se puede cambiar tambien en cada conexión desde el menú de Guacamole.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LANGUAGE"             : "Seleccione un lenguaje diferente para cambiar el lenguaje de todos los textos en Guacamole. Las opciones disponibles dependerán de los lenguajes que esten instalados.",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Si quiere cambiar su contraseña, introduzca abajo su contraseña actual y la nueva contraseña deseada y haga clic en \"Actualizar Contraseña\". El cambio será efectivo inmediatamente.",
+
+        "INFO_PASSWORD_CHANGED" : "Contraseña Cambiada.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Método de entrada por defecto",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Modo de emulación de ratón por Defecto",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Cambiar Contraseña"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Nuevo Usuario",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_USERS" : "Haga Clic o toque un usuario abajo para gestionar dicho usuario. Dependiendo de su nivel de acceso, podrá añadir/borrar usuarios y cambiar sus contraseñas.",
+
+        "SECTION_HEADER_USERS"       : "Usuarios"
+
+    },
+    
+    "SETTINGS_SESSIONS" : {
+        
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Finalizar Sesiones",
+        
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Finalizar Sesiones",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+        
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+        
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "Aquí se listan todas las sesiones activas que tiene actualmente Guacamole. Si quiere finalizar una o mas sesiones, marque la casilla correspondiente a esa/s sesión/es y haga clic en \"Finalizar Sesiones\". Si finaliza una sesión desconectará inmediatamente al usuario de la conexión asociada.",
+        
+        "INFO_NO_SESSIONS" : "No hay sesiones activas",
+
+        "SECTION_HEADER_SESSIONS" : "Sesiones Activas",
+        
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nombre Conexión",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Host Remoto",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Activo desde",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Usuario",
+        
+        "TEXT_CONFIRM_DELETE" : "¿Está seguro que quiere finalizar las sesiones seleccionadas? Los usuarios que estan usando estas sesiones serán desconectados inmediatamente."
+
+    },
+
+    "USER_ATTRIBUTES" : {
+
+        "FIELD_HEADER_GUAC_EMAIL_ADDRESS"       : "Correo electrónico:",
+        "FIELD_HEADER_GUAC_FULL_NAME"           : "Nombre completo:",
+        "FIELD_HEADER_GUAC_ORGANIZATION"        : "Organización:",
+        "FIELD_HEADER_GUAC_ORGANIZATIONAL_ROLE" : "Puesto:"
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_VIEW_HISTORY"       : "@:APP.ACTION_VIEW_HISTORY"
+
+    }
+
+}
diff --git a/guacamole/src/main/webapp/translations/fr.json b/guacamole/src/main/webapp/translations/fr.json
index d59c384..8ebbd36 100644
--- a/guacamole/src/main/webapp/translations/fr.json
+++ b/guacamole/src/main/webapp/translations/fr.json
@@ -601,9 +601,13 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Cliquer ou appuyer sur un utilisateur en dessous pour le gérer. Selon vos permissions, les utilisateurs peuvent être ajoutés, supprimés, leur mot de passe changé.",
 
-        "SECTION_HEADER_USERS"       : "Utilisateur"
+        "SECTION_HEADER_USERS"       : "Utilisateur",
+
+        "TABLE_HEADER_USERNAME" : "Identifiant"
 
     },
     
diff --git a/guacamole/src/main/webapp/translations/it.json b/guacamole/src/main/webapp/translations/it.json
index e84e67f..5b3f641 100644
--- a/guacamole/src/main/webapp/translations/it.json
+++ b/guacamole/src/main/webapp/translations/it.json
@@ -543,9 +543,13 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Click or tap on a user below to manage that user. Depending on your access level, users can be added and deleted, and their passwords can be changed.",
 
-        "SECTION_HEADER_USERS"       : "Utenti"
+        "SECTION_HEADER_USERS"       : "Utenti",
+
+        "TABLE_HEADER_USERNAME" : "Username"
 
     },
     
diff --git a/guacamole/src/main/webapp/translations/nl.json b/guacamole/src/main/webapp/translations/nl.json
index 7cc867b..69a789a 100644
--- a/guacamole/src/main/webapp/translations/nl.json
+++ b/guacamole/src/main/webapp/translations/nl.json
@@ -631,9 +631,13 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Klik of tik op een van de onderstaande gebruikers om die te beheren. Afhankelijk van uw toegangsniveau kunnen gebruikers worden toegevoegd, verwijderd en hun wachtwoorden gewijzigd.",
 
-        "SECTION_HEADER_USERS"       : "Gebruikers"
+        "SECTION_HEADER_USERS"       : "Gebruikers",
+
+        "TABLE_HEADER_USERNAME" : "Gebruikersnaam"
 
     },
 
diff --git a/guacamole/src/main/webapp/translations/no.json b/guacamole/src/main/webapp/translations/no.json
index 8b20b5d..30ea871 100644
--- a/guacamole/src/main/webapp/translations/no.json
+++ b/guacamole/src/main/webapp/translations/no.json
@@ -612,9 +612,13 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Klikk på en bruker under for å administrere den brukeren. Avhengig av din tilgang kan brukere legges til, slettes og passordet kan endres.",
 
-        "SECTION_HEADER_USERS"       : "Brukere"
+        "SECTION_HEADER_USERS"       : "Brukere",
+
+        "TABLE_HEADER_USERNAME" : "Brukernavn"
 
     },
     
diff --git a/guacamole/src/main/webapp/translations/ru.json b/guacamole/src/main/webapp/translations/ru.json
index 0e47f60..4f04525 100644
--- a/guacamole/src/main/webapp/translations/ru.json
+++ b/guacamole/src/main/webapp/translations/ru.json
@@ -524,9 +524,13 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
         "HELP_USERS" : "Нажмите на пользователя, чтобы управлять им. В зависимости от прав доступа возможно добавление и удаление пользователей, а также изменение паролей.",
 
-        "SECTION_HEADER_USERS"       : "Пользователи"
+        "SECTION_HEADER_USERS"       : "Пользователи",
+
+        "TABLE_HEADER_USERNAME" : "Имя пользователя"
 
     },
 
diff --git a/pom.xml b/pom.xml
index 55228f5..4f7c8ed 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,9 +26,9 @@
     <groupId>org.apache.guacamole</groupId>
     <artifactId>guacamole-client</artifactId>
     <packaging>pom</packaging>
-    <version>0.9.13-incubating</version>
+    <version>0.9.14</version>
     <name>guacamole-client</name>
-    <url>http://guacamole.incubator.apache.org/</url>
+    <url>http://guacamole.apache.org/</url>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -54,8 +54,8 @@
         <module>extensions/guacamole-auth-header</module>
         <module>extensions/guacamole-auth-jdbc</module>
         <module>extensions/guacamole-auth-ldap</module>
-        <module>extensions/guacamole-auth-noauth</module>
         <module>extensions/guacamole-auth-openid</module>
+        <module>extensions/guacamole-auth-totp</module>
 
         <!-- Example web applications using the Guacamole APIs -->
         <module>doc/guacamole-example</module>
@@ -63,6 +63,15 @@
 
     </modules>
 
+    <profiles>
+        <profile>
+            <id>lgpl-extensions</id>
+            <modules>
+                <module>extensions/guacamole-auth-radius</module>
+            </modules>
+        </profile>
+    </profiles>
+
     <build>
         <plugins>
 
@@ -105,6 +114,7 @@
                         <exclude>.dockerignore</exclude>
                         <exclude>CONTRIBUTING</exclude>
                         <exclude>**/README.md</exclude>
+                        <exclude>extensions/**/*</exclude>
                     </excludes>
                 </configuration>