diff --git a/extensions/guacamole-auth-duo/.gitignore b/extensions/guacamole-auth-duo/.gitignore
new file mode 100644
index 000000000..42f4a1a64
--- /dev/null
+++ b/extensions/guacamole-auth-duo/.gitignore
@@ -0,0 +1,2 @@
+target/
+*~
diff --git a/extensions/guacamole-auth-duo/pom.xml b/extensions/guacamole-auth-duo/pom.xml
new file mode 100644
index 000000000..4c1fdf6a1
--- /dev/null
+++ b/extensions/guacamole-auth-duo/pom.xml
@@ -0,0 +1,100 @@
+
+ * Encodes up to three bytes of the array source and writes the + * resulting four Base64 bytes to destination. The source and + * destination arrays can be manipulated anywhere along their length by + * specifying srcOffset and destOffset. This method + * does not check to make sure your arrays are large enough to accomodate + * srcOffset + 3 for the source array or + * destOffset + 4 for the destination array. The + * actual number of significant bytes in your array is given by + * numSigBytes. + *
+ *+ * This is the lowest level of the encoding methods with all possible + * parameters. + *
+ * + * @param source + * the array to convert + * @param srcOffset + * the index where conversion begins + * @param numSigBytes + * the number of significant bytes in your array + * @param destination + * the array to hold the conversion + * @param destOffset + * the index where output will be put + * @return the destination array + * @since 1.3 + */ + private static byte[] encode3to4(byte[] source, int srcOffset, + int numSigBytes, byte[] destination, int destOffset, int options) { + + byte[] ALPHABET = getAlphabet(options); + + // 1 2 3 + // 01234567890123456789012345678901 Bit position + // --------000000001111111122222222 Array position from threeBytes + // --------| || || || | Six bit groups to index ALPHABET + // >>18 >>12 >> 6 >> 0 Right shift necessary + // 0x3f 0x3f 0x3f Additional AND + + // Create buffer with zero-padding if there are only one or two + // significant bytes passed in the array. + // We have to shift left 24 in order to flush out the 1's that appear + // when Java treats a value as negative that is cast from a byte to an + // int. + int inBuff = (numSigBytes > 0 ? ((source[srcOffset] << 24) >>> 8) : 0) + | (numSigBytes > 1 ? ((source[srcOffset + 1] << 24) >>> 16) : 0) + | (numSigBytes > 2 ? ((source[srcOffset + 2] << 24) >>> 24) : 0); + + switch (numSigBytes) { + case 3: + destination[destOffset] = ALPHABET[(inBuff >>> 18)]; + destination[destOffset + 1] = ALPHABET[(inBuff >>> 12) & 0x3f]; + destination[destOffset + 2] = ALPHABET[(inBuff >>> 6) & 0x3f]; + destination[destOffset + 3] = ALPHABET[(inBuff) & 0x3f]; + return destination; + + case 2: + destination[destOffset] = ALPHABET[(inBuff >>> 18)]; + destination[destOffset + 1] = ALPHABET[(inBuff >>> 12) & 0x3f]; + destination[destOffset + 2] = ALPHABET[(inBuff >>> 6) & 0x3f]; + destination[destOffset + 3] = EQUALS_SIGN; + return destination; + + case 1: + destination[destOffset] = ALPHABET[(inBuff >>> 18)]; + destination[destOffset + 1] = ALPHABET[(inBuff >>> 12) & 0x3f]; + destination[destOffset + 2] = EQUALS_SIGN; + destination[destOffset + 3] = EQUALS_SIGN; + return destination; + + default: + return destination; + } // end switch + } // end encode3to4 + + /** + * Performs Base64 encoding on theraw
ByteBuffer, writing it
+ * to the encoded
ByteBuffer. This is an experimental feature.
+ * Currently it does not pass along any options (such as
+ * {@link #DO_BREAK_LINES} or {@link #GZIP}.
+ *
+ * @param raw
+ * input buffer
+ * @param encoded
+ * output buffer
+ * @since 2.3
+ */
+ public static void encode(java.nio.ByteBuffer raw,
+ java.nio.ByteBuffer encoded) {
+ byte[] raw3 = new byte[3];
+ byte[] enc4 = new byte[4];
+
+ while (raw.hasRemaining()) {
+ int rem = Math.min(3, raw.remaining());
+ raw.get(raw3, 0, rem);
+ Base64.encode3to4(enc4, raw3, rem, Base64.NO_OPTIONS);
+ encoded.put(enc4);
+ } // end input remaining
+ }
+
+ /**
+ * Performs Base64 encoding on the raw
ByteBuffer, writing it
+ * to the encoded
CharBuffer. This is an experimental feature.
+ * Currently it does not pass along any options (such as
+ * {@link #DO_BREAK_LINES} or {@link #GZIP}.
+ *
+ * @param raw
+ * input buffer
+ * @param encoded
+ * output buffer
+ * @since 2.3
+ */
+ public static void encode(java.nio.ByteBuffer raw,
+ java.nio.CharBuffer encoded) {
+ byte[] raw3 = new byte[3];
+ byte[] enc4 = new byte[4];
+
+ while (raw.hasRemaining()) {
+ int rem = Math.min(3, raw.remaining());
+ raw.get(raw3, 0, rem);
+ Base64.encode3to4(enc4, raw3, rem, Base64.NO_OPTIONS);
+ for (int i = 0; i < 4; i++) {
+ encoded.put((char) (enc4[i] & 0xFF));
+ }
+ } // end input remaining
+ }
+
+ /**
+ * Serializes an object and returns the Base64-encoded version of that
+ * serialized object.
+ *
+ * + * As of v 2.3, if the object cannot be serialized or there is another + * error, the method will throw an java.io.IOException. This is new to + * v2.3! In earlier versions, it just returned a null value, but in + * retrospect that's a pretty poor way to handle it. + *
+ * + * The object is not GZip-compressed before being encoded. + * + * @param serializableObject + * The object to encode + * @return The Base64-encoded object + * @throws java.io.IOException + * if there is an error + * @throws NullPointerException + * if serializedObject is null + * @since 1.4 + */ + public static String encodeObject(java.io.Serializable serializableObject) + throws java.io.IOException { + return encodeObject(serializableObject, NO_OPTIONS); + } // end encodeObject + + /** + * Serializes an object and returns the Base64-encoded version of that + * serialized object. + * + *+ * As of v 2.3, if the object cannot be serialized or there is another + * error, the method will throw an java.io.IOException. This is new to + * v2.3! In earlier versions, it just returned a null value, but in + * retrospect that's a pretty poor way to handle it. + *
+ * + * The object is not GZip-compressed before being encoded. + *+ * Example options: + * + *
+ * GZIP: gzip-compresses object before encoding it. + * DO_BREAK_LINES: break lines at 76 characters + *+ *
+ * Example: encodeObject( myObj, Base64.GZIP )
or
+ *
+ * Example:
+ * encodeObject( myObj, Base64.GZIP | Base64.DO_BREAK_LINES )
+ *
+ * @param serializableObject
+ * The object to encode
+ * @param options
+ * Specified options
+ * @return The Base64-encoded object
+ * @see Base64#GZIP
+ * @see Base64#DO_BREAK_LINES
+ * @throws java.io.IOException
+ * if there is an error
+ * @since 2.0
+ */
+ public static String encodeObject(java.io.Serializable serializableObject,
+ int options) throws java.io.IOException {
+
+ if (serializableObject == null) {
+ throw new NullPointerException("Cannot serialize a null object.");
+ } // end if: null
+
+ // Streams
+ java.io.ByteArrayOutputStream baos = null;
+ java.io.OutputStream b64os = null;
+ java.util.zip.GZIPOutputStream gzos = null;
+ java.io.ObjectOutputStream oos = null;
+
+ try {
+ // ObjectOutputStream -> (GZIP) -> Base64 -> ByteArrayOutputStream
+ baos = new java.io.ByteArrayOutputStream();
+ b64os = new Base64.OutputStream(baos, ENCODE | options);
+ if ((options & GZIP) != 0) {
+ // Gzip
+ gzos = new java.util.zip.GZIPOutputStream(b64os);
+ oos = new java.io.ObjectOutputStream(gzos);
+ } else {
+ // Not gzipped
+ oos = new java.io.ObjectOutputStream(b64os);
+ }
+ oos.writeObject(serializableObject);
+ } // end try
+ catch (java.io.IOException e) {
+ // Catch it and then throw it immediately so that
+ // the finally{} block is called for cleanup.
+ throw e;
+ } // end catch
+ finally {
+ try {
+ oos.close();
+ } catch (Exception e) {
+ }
+ try {
+ gzos.close();
+ } catch (Exception e) {
+ }
+ try {
+ b64os.close();
+ } catch (Exception e) {
+ }
+ try {
+ baos.close();
+ } catch (Exception e) {
+ }
+ } // end finally
+
+ // Return value according to relevant encoding.
+ try {
+ return new String(baos.toByteArray(), PREFERRED_ENCODING);
+ } // end try
+ catch (java.io.UnsupportedEncodingException uue) {
+ // Fall back to some Java default
+ return new String(baos.toByteArray());
+ } // end catch
+
+ } // end encode
+
+ /**
+ * Encodes a byte array into Base64 notation. Does not GZip-compress data.
+ *
+ * @param source
+ * The data to convert
+ * @return The data in Base64-encoded form
+ * @throws NullPointerException
+ * if source array is null
+ * @since 1.4
+ */
+ public static String encodeBytes(byte[] source) {
+ // Since we're not going to have the GZIP encoding turned on,
+ // we're not going to have an java.io.IOException thrown, so
+ // we should not force the user to have to catch it.
+ String encoded = null;
+ try {
+ encoded = encodeBytes(source, 0, source.length, NO_OPTIONS);
+ } catch (java.io.IOException ex) {
+ assert false : ex.getMessage();
+ } // end catch
+ assert encoded != null;
+ return encoded;
+ } // end encodeBytes
+
+ /**
+ * Encodes a byte array into Base64 notation.
+ *
+ * Example options: + * + *
+ * GZIP: gzip-compresses object before encoding it. + * DO_BREAK_LINES: break lines at 76 characters + * Note: Technically, this makes your encoding non-compliant. + *+ *
+ * Example: encodeBytes( myData, Base64.GZIP )
or
+ *
+ * Example:
+ * encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES )
+ *
+ *
+ *
+ * As of v 2.3, if there is an error with the GZIP stream, the method will + * throw an java.io.IOException. This is new to v2.3! In earlier + * versions, it just returned a null value, but in retrospect that's a + * pretty poor way to handle it. + *
+ * + * + * @param source + * The data to convert + * @param options + * Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException + * if there is an error + * @throws NullPointerException + * if source array is null + * @since 2.0 + */ + public static String encodeBytes(byte[] source, int options) + throws java.io.IOException { + return encodeBytes(source, 0, source.length, options); + } // end encodeBytes + + /** + * Encodes a byte array into Base64 notation. Does not GZip-compress data. + * + *+ * As of v 2.3, if there is an error, the method will throw an + * java.io.IOException. This is new to v2.3! In earlier versions, it + * just returned a null value, but in retrospect that's a pretty poor way to + * handle it. + *
+ * + * + * @param source + * The data to convert + * @param off + * Offset in array where conversion should begin + * @param len + * Length of data to convert + * @return The Base64-encoded data as a String + * @throws NullPointerException + * if source array is null + * @throws IllegalArgumentException + * if source array, offset, or length are invalid + * @since 1.4 + */ + public static String encodeBytes(byte[] source, int off, int len) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes(source, off, len, NO_OPTIONS); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + /** + * Encodes a byte array into Base64 notation. + *+ * Example options: + * + *
+ * GZIP: gzip-compresses object before encoding it. + * DO_BREAK_LINES: break lines at 76 characters + * Note: Technically, this makes your encoding non-compliant. + *+ *
+ * Example: encodeBytes( myData, Base64.GZIP )
or
+ *
+ * Example:
+ * encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES )
+ *
+ *
+ *
+ * As of v 2.3, if there is an error with the GZIP stream, the method will + * throw an java.io.IOException. This is new to v2.3! In earlier + * versions, it just returned a null value, but in retrospect that's a + * pretty poor way to handle it. + *
+ * + * + * @param source + * The data to convert + * @param off + * Offset in array where conversion should begin + * @param len + * Length of data to convert + * @param options + * Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException + * if there is an error + * @throws NullPointerException + * if source array is null + * @throws IllegalArgumentException + * if source array, offset, or length are invalid + * @since 2.0 + */ + public static String encodeBytes(byte[] source, int off, int len, + int options) throws java.io.IOException { + byte[] encoded = encodeBytesToBytes(source, off, len, options); + + // Return value according to relevant encoding. + try { + return new String(encoded, PREFERRED_ENCODING); + } // end try + catch (java.io.UnsupportedEncodingException uue) { + return new String(encoded); + } // end catch + + } // end encodeBytes + + /** + * Similar to {@link #encodeBytes(byte[])} but returns a byte array instead + * of instantiating a String. This is more efficient if you're working with + * I/O streams and have large data sets to encode. + * + * + * @param source + * The data to convert + * @return The Base64-encoded data as a byte[] (of ASCII characters) + * @throws NullPointerException + * if source array is null + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes(byte[] source) { + byte[] encoded = null; + try { + encoded = encodeBytesToBytes(source, 0, source.length, + Base64.NO_OPTIONS); + } catch (java.io.IOException ex) { + assert false : "IOExceptions only come from GZipping, which is turned off: " + + ex.getMessage(); + } + return encoded; + } + + /** + * Similar to {@link #encodeBytes(byte[], int, int, int)} but returns a byte + * array instead of instantiating a String. This is more efficient if you're + * working with I/O streams and have large data sets to encode. + * + * + * @param source + * The data to convert + * @param off + * Offset in array where conversion should begin + * @param len + * Length of data to convert + * @param options + * Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException + * if there is an error + * @throws NullPointerException + * if source array is null + * @throws IllegalArgumentException + * if source array, offset, or length are invalid + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes(byte[] source, int off, int len, + int options) throws java.io.IOException { + + if (source == null) { + throw new NullPointerException("Cannot serialize a null array."); + } // end if: null + + if (off < 0) { + throw new IllegalArgumentException("Cannot have negative offset: " + + off); + } // end if: off < 0 + + if (len < 0) { + throw new IllegalArgumentException("Cannot have length offset: " + + len); + } // end if: len < 0 + + if (off + len > source.length) { + throw new IllegalArgumentException( + String + .format( + "Cannot have offset of %d and length of %d with array of length %d", + off, len, source.length)); + } // end if: off < 0 + + // Compress? + if ((options & GZIP) != 0) { + java.io.ByteArrayOutputStream baos = null; + java.util.zip.GZIPOutputStream gzos = null; + Base64.OutputStream b64os = null; + + try { + // GZip -> Base64 -> ByteArray + baos = new java.io.ByteArrayOutputStream(); + b64os = new Base64.OutputStream(baos, ENCODE | options); + gzos = new java.util.zip.GZIPOutputStream(b64os); + + gzos.write(source, off, len); + gzos.close(); + } // end try + catch (java.io.IOException e) { + // Catch it and then throw it immediately so that + // the finally{} block is called for cleanup. + throw e; + } // end catch + finally { + try { + gzos.close(); + } catch (Exception e) { + } + try { + b64os.close(); + } catch (Exception e) { + } + try { + baos.close(); + } catch (Exception e) { + } + } // end finally + + return baos.toByteArray(); + } // end if: compress + + // Else, don't compress. Better not to use streams at all then. + else { + boolean breakLines = (options & DO_BREAK_LINES) != 0; + + // int len43 = len * 4 / 3; + // byte[] outBuff = new byte[ ( len43 ) // Main 4:3 + // + ( (len % 3) > 0 ? 4 : 0 ) // Account for padding + // + (breakLines ? ( len43 / MAX_LINE_LENGTH ) : 0) ]; // New lines + // Try to determine more precisely how big the array needs to be. + // If we get it right, we don't have to do an array copy, and + // we save a bunch of memory. + int encLen = (len / 3) * 4 + (len % 3 > 0 ? 4 : 0); // Bytes needed + // for actual + // encoding + if (breakLines) { + encLen += encLen / MAX_LINE_LENGTH; // Plus extra newline + // characters + } + byte[] outBuff = new byte[encLen]; + + int d = 0; + int e = 0; + int len2 = len - 2; + int lineLength = 0; + for (; d < len2; d += 3, e += 4) { + encode3to4(source, d + off, 3, outBuff, e, options); + + lineLength += 4; + if (breakLines && lineLength >= MAX_LINE_LENGTH) { + outBuff[e + 4] = NEW_LINE; + e++; + lineLength = 0; + } // end if: end of line + } // en dfor: each piece of array + + if (d < len) { + encode3to4(source, d + off, len - d, outBuff, e, options); + e += 4; + } // end if: some padding needed + + // Only resize array if we didn't guess it right. + if (e <= outBuff.length - 1) { + // If breaking lines and the last byte falls right at + // the line length (76 bytes per line), there will be + // one extra byte, and the array will need to be resized. + // Not too bad of an estimate on array size, I'd say. + byte[] finalOut = new byte[e]; + System.arraycopy(outBuff, 0, finalOut, 0, e); + // System.err.println("Having to resize array from " + + // outBuff.length + " to " + e ); + return finalOut; + } else { + // System.err.println("No need to resize array."); + return outBuff; + } + + } // end else: don't compress + + } // end encodeBytesToBytes + + /* ******** D E C O D I N G M E T H O D S ******** */ + + /** + * Decodes four bytes from array source and writes the resulting + * bytes (up to three of them) to destination. The source and + * destination arrays can be manipulated anywhere along their length by + * specifying srcOffset and destOffset. This method + * does not check to make sure your arrays are large enough to accomodate + * srcOffset + 4 for the source array or + * destOffset + 3 for the destination array. This + * method returns the actual number of bytes that were converted from the + * Base64 encoding. + *+ * This is the lowest level of the decoding methods with all possible + * parameters. + *
+ * + * + * @param source + * the array to convert + * @param srcOffset + * the index where conversion begins + * @param destination + * the array to hold the conversion + * @param destOffset + * the index where output will be put + * @param options + * alphabet type is pulled from this (standard, url-safe, + * ordered) + * @return the number of decoded bytes converted + * @throws NullPointerException + * if source or destination arrays are null + * @throws IllegalArgumentException + * if srcOffset or destOffset are invalid or there is not enough + * room in the array. + * @since 1.3 + */ + private static int decode4to3(byte[] source, int srcOffset, + byte[] destination, int destOffset, int options) { + + // Lots of error checking and exception throwing + if (source == null) { + throw new NullPointerException("Source array was null."); + } // end if + if (destination == null) { + throw new NullPointerException("Destination array was null."); + } // end if + if (srcOffset < 0 || srcOffset + 3 >= source.length) { + throw new IllegalArgumentException( + String + .format( + "Source array with length %d cannot have offset of %d and still process four bytes.", + source.length, srcOffset)); + } // end if + if (destOffset < 0 || destOffset + 2 >= destination.length) { + throw new IllegalArgumentException( + String + .format( + "Destination array with length %d cannot have offset of %d and still store three bytes.", + destination.length, destOffset)); + } // end if + + byte[] DECODABET = getDecodabet(options); + + // Example: Dk== + if (source[srcOffset + 2] == EQUALS_SIGN) { + // Two ways to do the same thing. Don't know which way I like best. + // int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 + // ) + // | ( ( DECODABET[ source[ srcOffset + 1] ] << 24 ) >>> 12 ); + int outBuff = ((DECODABET[source[srcOffset]] & 0xFF) << 18) + | ((DECODABET[source[srcOffset + 1]] & 0xFF) << 12); + + destination[destOffset] = (byte) (outBuff >>> 16); + return 1; + } + + // Example: DkL= + else if (source[srcOffset + 3] == EQUALS_SIGN) { + // Two ways to do the same thing. Don't know which way I like best. + // int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 + // ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ); + int outBuff = ((DECODABET[source[srcOffset]] & 0xFF) << 18) + | ((DECODABET[source[srcOffset + 1]] & 0xFF) << 12) + | ((DECODABET[source[srcOffset + 2]] & 0xFF) << 6); + + destination[destOffset] = (byte) (outBuff >>> 16); + destination[destOffset + 1] = (byte) (outBuff >>> 8); + return 2; + } + + // Example: DkLE + else { + // Two ways to do the same thing. Don't know which way I like best. + // int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 + // ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ) + // | ( ( DECODABET[ source[ srcOffset + 3 ] ] << 24 ) >>> 24 ); + int outBuff = ((DECODABET[source[srcOffset]] & 0xFF) << 18) + | ((DECODABET[source[srcOffset + 1]] & 0xFF) << 12) + | ((DECODABET[source[srcOffset + 2]] & 0xFF) << 6) + | ((DECODABET[source[srcOffset + 3]] & 0xFF)); + + destination[destOffset] = (byte) (outBuff >> 16); + destination[destOffset + 1] = (byte) (outBuff >> 8); + destination[destOffset + 2] = (byte) (outBuff); + + return 3; + } + } // end decodeToBytes + + /** + * Low-level access to decoding ASCII characters in the form of a byte + * array. Ignores GUNZIP option, if it's set. This is not + * generally a recommended method, although it is used internally as part of + * the decoding process. Special case: if len = 0, an empty array is + * returned. Still, if you need more speed and reduced memory footprint (and + * aren't gzipping), consider this method. + * + * @param source + * The Base64 encoded data + * @return decoded data + * @since 2.3.1 + */ + public static byte[] decode(byte[] source) throws java.io.IOException { + byte[] decoded = null; + // try { + decoded = decode(source, 0, source.length, Base64.NO_OPTIONS); + // } catch( java.io.IOException ex ) { + // assert false : + // "IOExceptions only come from GZipping, which is turned off: " + + // ex.getMessage(); + // } + return decoded; + } + + /** + * Low-level access to decoding ASCII characters in the form of a byte + * array. Ignores GUNZIP option, if it's set. This is not + * generally a recommended method, although it is used internally as part of + * the decoding process. Special case: if len = 0, an empty array is + * returned. Still, if you need more speed and reduced memory footprint (and + * aren't gzipping), consider this method. + * + * @param source + * The Base64 encoded data + * @param off + * The offset of where to begin decoding + * @param len + * The length of characters to decode + * @param options + * Can specify options such as alphabet type to use + * @return decoded data + * @throws java.io.IOException + * If bogus characters exist in source data + * @since 1.3 + */ + public static byte[] decode(byte[] source, int off, int len, int options) + throws java.io.IOException { + + // Lots of error checking and exception throwing + if (source == null) { + throw new NullPointerException("Cannot decode null source array."); + } // end if + if (off < 0 || off + len > source.length) { + throw new IllegalArgumentException( + String + .format( + "Source array with length %d cannot have offset of %d and process %d bytes.", + source.length, off, len)); + } // end if + + if (len == 0) { + return new byte[0]; + } else if (len < 4) { + throw new IllegalArgumentException( + "Base64-encoded string must have at least four characters, but length specified was " + + len); + } // end if + + byte[] DECODABET = getDecodabet(options); + + int len34 = len * 3 / 4; // Estimate on array size + byte[] outBuff = new byte[len34]; // Upper limit on size of output + int outBuffPosn = 0; // Keep track of where we're writing + + byte[] b4 = new byte[4]; // Four byte buffer from source, eliminating + // white space + int b4Posn = 0; // Keep track of four byte input buffer + int i = 0; // Source array counter + byte sbiDecode = 0; // Special value from DECODABET + + for (i = off; i < off + len; i++) { // Loop through source + + sbiDecode = DECODABET[source[i] & 0xFF]; + + // White space, Equals sign, or legit Base64 character + // Note the values such as -5 and -9 in the + // DECODABETs at the top of the file. + if (sbiDecode >= WHITE_SPACE_ENC) { + if (sbiDecode >= EQUALS_SIGN_ENC) { + b4[b4Posn++] = source[i]; // Save non-whitespace + if (b4Posn > 3) { // Time to decode? + outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, + options); + b4Posn = 0; + + // If that was the equals sign, break out of 'for' loop + if (source[i] == EQUALS_SIGN) { + break; + } // end if: equals sign + } // end if: quartet built + } // end if: equals sign or better + } // end if: white space, equals sign or better + else { + // There's a bad input character in the Base64 stream. + throw new java.io.IOException( + String + .format( + "Bad Base64 input character decimal %d in array position %d", + ((int) source[i]) & 0xFF, i)); + } // end else: + } // each input character + + byte[] out = new byte[outBuffPosn]; + System.arraycopy(outBuff, 0, out, 0, outBuffPosn); + return out; + } // end decode + + /** + * Decodes data from Base64 notation, automatically detecting + * gzip-compressed data and decompressing it. + * + * @param s + * the string to decode + * @return the decoded data + * @throws java.io.IOException + * If there is a problem + * @since 1.4 + */ + public static byte[] decode(String s) throws java.io.IOException { + return decode(s, NO_OPTIONS); + } + + /** + * Decodes data from Base64 notation, automatically detecting + * gzip-compressed data and decompressing it. + * + * @param s + * the string to decode + * @param options + * encode options such as URL_SAFE + * @return the decoded data + * @throws java.io.IOException + * if there is an error + * @throws NullPointerException + * if s is null + * @since 1.4 + */ + public static byte[] decode(String s, int options) + throws java.io.IOException { + + if (s == null) { + throw new NullPointerException("Input string was null."); + } // end if + + byte[] bytes; + try { + bytes = s.getBytes(PREFERRED_ENCODING); + } // end try + catch (java.io.UnsupportedEncodingException uee) { + bytes = s.getBytes(); + } // end catch + // + + // Decode + bytes = decode(bytes, 0, bytes.length, options); + + // Check to see if it's gzip-compressed + // GZIP Magic Two-Byte Number: 0x8b1f (35615) + boolean dontGunzip = (options & DONT_GUNZIP) != 0; + if ((bytes != null) && (bytes.length >= 4) && (!dontGunzip)) { + + int head = ((int) bytes[0] & 0xff) | ((bytes[1] << 8) & 0xff00); + if (java.util.zip.GZIPInputStream.GZIP_MAGIC == head) { + java.io.ByteArrayInputStream bais = null; + java.util.zip.GZIPInputStream gzis = null; + java.io.ByteArrayOutputStream baos = null; + byte[] buffer = new byte[2048]; + int length = 0; + + try { + baos = new java.io.ByteArrayOutputStream(); + bais = new java.io.ByteArrayInputStream(bytes); + gzis = new java.util.zip.GZIPInputStream(bais); + + while ((length = gzis.read(buffer)) >= 0) { + baos.write(buffer, 0, length); + } // end while: reading input + + // No error? Get new bytes. + bytes = baos.toByteArray(); + + } // end try + catch (java.io.IOException e) { + e.printStackTrace(); + // Just return originally-decoded bytes + } // end catch + finally { + try { + baos.close(); + } catch (Exception e) { + } + try { + gzis.close(); + } catch (Exception e) { + } + try { + bais.close(); + } catch (Exception e) { + } + } // end finally + + } // end if: gzipped + } // end if: bytes.length >= 2 + + return bytes; + } // end decode + + /* ******** I N N E R C L A S S O U T P U T S T R E A M ******** */ + + /** + * A {@link Base64.OutputStream} will write data to another + * java.io.OutputStream, given in the constructor, and + * encode/decode to/from Base64 notation on the fly. + * + * @see Base64 + * @since 1.3 + */ + public static class OutputStream extends java.io.FilterOutputStream { + + private boolean encode; + private int position; + private byte[] buffer; + private int bufferLength; + private int lineLength; + private boolean breakLines; + private byte[] b4; // Scratch used in a few places + private boolean suspendEncoding; + private int options; // Record for later + private byte[] decodabet; // Local copies to avoid extra method calls + + /** + * Constructs a {@link Base64.OutputStream} in ENCODE mode. + * + * @param out + * the java.io.OutputStream to which data will be + * written. + * @since 1.3 + */ + public OutputStream(java.io.OutputStream out) { + this(out, ENCODE); + } // end constructor + + /** + * Constructs a {@link Base64.OutputStream} in either ENCODE or DECODE + * mode. + *+ * Valid options: + * + *
+ * ENCODE or DECODE: Encode or Decode as data is read. + * DO_BREAK_LINES: don't break lines at 76 characters + * (only meaningful when encoding) + *+ *
+ * Example: new Base64.OutputStream( out, Base64.ENCODE )
+ *
+ * @param out
+ * the java.io.OutputStream to which data will be
+ * written.
+ * @param options
+ * Specified options.
+ * @see Base64#ENCODE
+ * @see Base64#DECODE
+ * @see Base64#DO_BREAK_LINES
+ * @since 1.3
+ */
+ public OutputStream(java.io.OutputStream out, int options) {
+ super(out);
+ this.breakLines = (options & DO_BREAK_LINES) != 0;
+ this.encode = (options & ENCODE) != 0;
+ this.bufferLength = encode ? 3 : 4;
+ this.buffer = new byte[bufferLength];
+ this.position = 0;
+ this.lineLength = 0;
+ this.suspendEncoding = false;
+ this.b4 = new byte[4];
+ this.options = options;
+ this.decodabet = getDecodabet(options);
+ } // end constructor
+
+ /**
+ * Writes the byte to the output stream after converting to/from Base64
+ * notation. When encoding, bytes are buffered three at a time before
+ * the output stream actually gets a write() call. When decoding, bytes
+ * are buffered four at a time.
+ *
+ * @param theByte
+ * the byte to write
+ * @since 1.3
+ */
+ @Override
+ public void write(int theByte) throws java.io.IOException {
+ // Encoding suspended?
+ if (suspendEncoding) {
+ this.out.write(theByte);
+ return;
+ } // end if: supsended
+
+ // Encode?
+ if (encode) {
+ buffer[position++] = (byte) theByte;
+ if (position >= bufferLength) { // Enough to encode.
+
+ this.out
+ .write(encode3to4(b4, buffer, bufferLength, options));
+
+ lineLength += 4;
+ if (breakLines && lineLength >= MAX_LINE_LENGTH) {
+ this.out.write(NEW_LINE);
+ lineLength = 0;
+ } // end if: end of line
+
+ position = 0;
+ } // end if: enough to output
+ } // end if: encoding
+
+ // Else, Decoding
+ else {
+ // Meaningful Base64 character?
+ if (decodabet[theByte & 0x7f] > WHITE_SPACE_ENC) {
+ buffer[position++] = (byte) theByte;
+ if (position >= bufferLength) { // Enough to output.
+
+ int len = Base64.decode4to3(buffer, 0, b4, 0, options);
+ out.write(b4, 0, len);
+ position = 0;
+ } // end if: enough to output
+ } // end if: meaningful base64 character
+ else if (decodabet[theByte & 0x7f] != WHITE_SPACE_ENC) {
+ throw new java.io.IOException(
+ "Invalid character in Base64 data.");
+ } // end else: not white space either
+ } // end else: decoding
+ } // end write
+
+ /**
+ * Calls {@link #write(int)} repeatedly until len bytes are
+ * written.
+ *
+ * @param theBytes
+ * array from which to read bytes
+ * @param off
+ * offset for array
+ * @param len
+ * max number of bytes to read into array
+ * @since 1.3
+ */
+ @Override
+ public void write(byte[] theBytes, int off, int len)
+ throws java.io.IOException {
+ // Encoding suspended?
+ if (suspendEncoding) {
+ this.out.write(theBytes, off, len);
+ return;
+ } // end if: supsended
+
+ for (int i = 0; i < len; i++) {
+ write(theBytes[off + i]);
+ } // end for: each byte written
+
+ } // end write
+
+ /**
+ * Method added by PHIL. [Thanks, PHIL. -Rob] This pads the buffer
+ * without closing the stream.
+ *
+ * @throws java.io.IOException
+ * if there's an error.
+ */
+ public void flushBase64() throws java.io.IOException {
+ if (position > 0) {
+ if (encode) {
+ out.write(encode3to4(b4, buffer, position, options));
+ position = 0;
+ } // end if: encoding
+ else {
+ throw new java.io.IOException(
+ "Base64 input not properly padded.");
+ } // end else: decoding
+ } // end if: buffer partially full
+
+ } // end flush
+
+ /**
+ * Flushes and closes (I think, in the superclass) the stream.
+ *
+ * @since 1.3
+ */
+ @Override
+ public void close() throws java.io.IOException {
+ // 1. Ensure that pending characters are written
+ flushBase64();
+
+ // 2. Actually close the stream
+ // Base class both flushes and closes.
+ super.close();
+
+ buffer = null;
+ out = null;
+ } // end close
+
+ /**
+ * Suspends encoding of the stream. May be helpful if you need to embed
+ * a piece of base64-encoded data in a stream.
+ *
+ * @throws java.io.IOException
+ * if there's an error flushing
+ * @since 1.5.1
+ */
+ public void suspendEncoding() throws java.io.IOException {
+ flushBase64();
+ this.suspendEncoding = true;
+ } // end suspendEncoding
+
+ /**
+ * Resumes encoding of the stream. May be helpful if you need to embed a
+ * piece of base64-encoded data in a stream.
+ *
+ * @since 1.5.1
+ */
+ public void resumeEncoding() {
+ this.suspendEncoding = false;
+ } // end resumeEncoding
+
+ } // end inner class OutputStream
+
+} // end class Base64
\ No newline at end of file
diff --git a/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/DuoWeb.java b/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/DuoWeb.java
new file mode 100644
index 000000000..223a1102b
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/DuoWeb.java
@@ -0,0 +1,138 @@
+package com.duosecurity.duoweb;
+
+import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+public final class DuoWeb {
+ private static final String DUO_PREFIX = "TX";
+ private static final String APP_PREFIX = "APP";
+ private static final String AUTH_PREFIX = "AUTH";
+
+ private static final int DUO_EXPIRE = 300;
+ private static final int APP_EXPIRE = 3600;
+
+ private static final int IKEY_LEN = 20;
+ private static final int SKEY_LEN = 40;
+ private static final int AKEY_LEN = 40;
+
+ public static final String ERR_USER = "ERR|The username passed to sign_request() is invalid.";
+ public static final String ERR_IKEY = "ERR|The Duo integration key passed to sign_request() is invalid.";
+ public static final String ERR_SKEY = "ERR|The Duo secret key passed to sign_request() is invalid.";
+ public static final String ERR_AKEY = "ERR|The application secret key passed to sign_request() must be at least " + AKEY_LEN + " characters.";
+ public static final String ERR_UNKNOWN = "ERR|An unknown error has occurred.";
+
+ public static String signRequest(final String ikey, final String skey, final String akey, final String username) {
+ return signRequest(ikey, skey, akey, username, System.currentTimeMillis() / 1000);
+ }
+
+ public static String signRequest(final String ikey, final String skey, final String akey, final String username, final long time) {
+ final String duo_sig;
+ final String app_sig;
+
+ if (username.equals("")) {
+ return ERR_USER;
+ }
+ if (username.indexOf('|') != -1) {
+ return ERR_USER;
+ }
+ if (ikey.equals("") || ikey.length() != IKEY_LEN) {
+ return ERR_IKEY;
+ }
+ if (skey.equals("") || skey.length() != SKEY_LEN) {
+ return ERR_SKEY;
+ }
+ if (akey.equals("") || akey.length() < AKEY_LEN) {
+ return ERR_AKEY;
+ }
+
+ try {
+ duo_sig = signVals(skey, username, ikey, DUO_PREFIX, DUO_EXPIRE, time);
+ app_sig = signVals(akey, username, ikey, APP_PREFIX, APP_EXPIRE, time);
+ } catch (Exception e) {
+ return ERR_UNKNOWN;
+ }
+
+ return duo_sig + ":" + app_sig;
+ }
+
+ public static String verifyResponse(final String ikey, final String skey, final String akey, final String sig_response)
+ throws DuoWebException, NoSuchAlgorithmException, InvalidKeyException, IOException {
+ return verifyResponse(ikey, skey, akey, sig_response, System.currentTimeMillis() / 1000);
+ }
+
+ public static String verifyResponse(final String ikey, final String skey, final String akey, final String sig_response, final long time)
+ throws DuoWebException, NoSuchAlgorithmException, InvalidKeyException, IOException {
+ String auth_user = null;
+ String app_user = null;
+
+ final String[] sigs = sig_response.split(":");
+ final String auth_sig = sigs[0];
+ final String app_sig = sigs[1];
+
+ auth_user = parseVals(skey, auth_sig, AUTH_PREFIX, ikey, time);
+ app_user = parseVals(akey, app_sig, APP_PREFIX, ikey, time);
+
+ if (!auth_user.equals(app_user)) {
+ throw new DuoWebException("Authentication failed.");
+ }
+
+ return auth_user;
+ }
+
+ private static String signVals(final String key, final String username, final String ikey, final String prefix, final int expire, final long time)
+ throws InvalidKeyException, NoSuchAlgorithmException {
+ final long expire_ts = time + expire;
+ final String exp = Long.toString(expire_ts);
+
+ final String val = username + "|" + ikey + "|" + exp;
+ final String cookie = prefix + "|" + Base64.encodeBytes(val.getBytes());
+ final String sig = Util.hmacSign(key, cookie);
+
+ return cookie + "|" + sig;
+ }
+
+ private static String parseVals(final String key, final String val, final String prefix, final String ikey, final long time)
+ throws InvalidKeyException, NoSuchAlgorithmException, IOException, DuoWebException {
+
+ final String[] parts = val.split("\\|");
+ if (parts.length != 3) {
+ throw new DuoWebException("Invalid response");
+ }
+
+ final String u_prefix = parts[0];
+ final String u_b64 = parts[1];
+ final String u_sig = parts[2];
+
+ final String sig = Util.hmacSign(key, u_prefix + "|" + u_b64);
+ if (!Util.hmacSign(key, sig).equals(Util.hmacSign(key, u_sig))) {
+ throw new DuoWebException("Invalid response");
+ }
+
+ if (!u_prefix.equals(prefix)) {
+ throw new DuoWebException("Invalid response");
+ }
+
+ final byte[] decoded = Base64.decode(u_b64);
+ final String cookie = new String(decoded);
+
+ final String[] cookie_parts = cookie.split("\\|");
+ if (cookie_parts.length != 3) {
+ throw new DuoWebException("Invalid response");
+ }
+ final String username = cookie_parts[0];
+ final String u_ikey = cookie_parts[1];
+ final String expire = cookie_parts[2];
+
+ if (!u_ikey.equals(ikey)) {
+ throw new DuoWebException("Invalid response");
+ }
+
+ final long expire_ts = Long.parseLong(expire);
+ if (time >= expire_ts) {
+ throw new DuoWebException("Transaction has expired. Please check that the system time is correct.");
+ }
+
+ return username;
+ }
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/DuoWebException.java b/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/DuoWebException.java
new file mode 100644
index 000000000..f721df71d
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/DuoWebException.java
@@ -0,0 +1,8 @@
+package com.duosecurity.duoweb;
+
+public class DuoWebException extends Exception {
+
+ public DuoWebException(String message) {
+ super(message);
+ }
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/Util.java b/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/Util.java
new file mode 100644
index 000000000..55d771296
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/com/duosecurity/duoweb/Util.java
@@ -0,0 +1,26 @@
+package com.duosecurity.duoweb;
+
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+public class Util {
+ public static String hmacSign(String skey, String data)
+ throws NoSuchAlgorithmException, InvalidKeyException {
+ SecretKeySpec key = new SecretKeySpec(skey.getBytes(), "HmacSHA1");
+ Mac mac = Mac.getInstance("HmacSHA1");
+ mac.init(key);
+ byte[] raw = mac.doFinal(data.getBytes());
+ return bytesToHex(raw);
+ }
+
+ public static String bytesToHex(byte[] b) {
+ String result = "";
+ for (int i = 0; i < b.length; i++) {
+ result += Integer.toString((b[i] & 0xff) + 0x100, 16).substring(1);
+ }
+ return result;
+ }
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/AuthenticationProviderService.java b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/AuthenticationProviderService.java
new file mode 100644
index 000000000..c3dd8ee47
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/AuthenticationProviderService.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.duo;
+
+import com.google.inject.Inject;
+import java.util.Collections;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.guacamole.GuacamoleClientException;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.duo.conf.ConfigurationService;
+import org.apache.guacamole.auth.duo.form.DuoSignedResponseField;
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+
+/**
+ * Service providing convenience functions for the Duo AuthenticationProvider
+ * implementation.
+ */
+public class AuthenticationProviderService {
+
+ /**
+ * Service for retrieving Duo configuration information.
+ */
+ @Inject
+ private ConfigurationService confService;
+
+ /**
+ * Service for verifying users with the DuoWeb API.
+ */
+ @Inject
+ private DuoWebService duoWebService;
+
+ /**
+ * Verifies the identity of the given user via the Duo multi-factor
+ * authentication service. If a signed response from Duo has not already
+ * been provided, a signed response from Duo is requested in the
+ * form of additional expected credentials. Any provided signed response
+ * is cryptographically verified. If no signed response is present, or the
+ * signed response is invalid, an exception is thrown.
+ *
+ * @param authenticatedUser
+ * The user whose identity should be verified against Duo.
+ *
+ * @throws GuacamoleException
+ * If required Duo-specific configuration options are missing or
+ * malformed, or if the user's identity cannot be verified.
+ */
+ public void verifyAuthenticatedUser(AuthenticatedUser authenticatedUser)
+ throws GuacamoleException {
+
+ // Pull the original HTTP request used to authenticate
+ Credentials credentials = authenticatedUser.getCredentials();
+ HttpServletRequest request = credentials.getRequest();
+
+ // Ignore anonymous users
+ if (authenticatedUser.getIdentifier().equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
+ return;
+
+ // Retrieve signed Duo response from request
+ String signedResponse = request.getParameter(DuoSignedResponseField.PARAMETER_NAME);
+
+ // If no signed response, request one
+ if (signedResponse == null) {
+
+ // Create field which requests a signed response from Duo that
+ // verifies the identity of the given user via the configured
+ // Duo API endpoint
+ Field signedResponseField = new DuoSignedResponseField(
+ confService.getAPIHostname(),
+ duoWebService.createSignedRequest(authenticatedUser));
+
+ // Create an overall description of the additional credentials
+ // required to verify identity
+ CredentialsInfo expectedCredentials = new CredentialsInfo(
+ Collections.singletonList(signedResponseField));
+
+ // Request additional credentials
+ throw new GuacamoleInsufficientCredentialsException(
+ "LOGIN.INFO_DUO_AUTH_REQUIRED", expectedCredentials);
+
+ }
+
+ // If signed response does not verify this user's identity, abort auth
+ if (!duoWebService.isValidSignedResponse(authenticatedUser, signedResponse))
+ throw new GuacamoleClientException("LOGIN.INFO_DUO_VALIDATION_CODE_INCORRECT");
+
+ }
+
+}
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
new file mode 100644
index 000000000..bcf8c835f
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProvider.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.duo;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.auth.UserContext;
+
+/**
+ * AuthenticationProvider implementation which uses Duo as an additional
+ * authentication factor for users which have already been authenticated by
+ * some other AuthenticationProvider.
+ */
+public class DuoAuthenticationProvider implements AuthenticationProvider {
+
+ /**
+ * Injector which will manage the object graph of this authentication
+ * provider.
+ */
+ private final Injector injector;
+
+ /**
+ * Creates a new DuoAuthenticationProvider that verifies users
+ * using the Duo authentication service
+ *
+ * @throws GuacamoleException
+ * If a required property is missing, or an error occurs while parsing
+ * a property.
+ */
+ public DuoAuthenticationProvider() throws GuacamoleException {
+
+ // Set up Guice injector.
+ injector = Guice.createInjector(
+ new DuoAuthenticationProviderModule(this)
+ );
+
+ }
+
+ @Override
+ public String getIdentifier() {
+ return "duo";
+ }
+
+ @Override
+ public AuthenticatedUser authenticateUser(Credentials credentials)
+ throws GuacamoleException {
+ return null;
+ }
+
+ @Override
+ public AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
+ Credentials credentials) throws GuacamoleException {
+ return authenticatedUser;
+ }
+
+ @Override
+ public UserContext getUserContext(AuthenticatedUser authenticatedUser)
+ throws GuacamoleException {
+
+ AuthenticationProviderService authProviderService =
+ injector.getInstance(AuthenticationProviderService.class);
+
+ // Verify user against Duo service
+ authProviderService.verifyAuthenticatedUser(authenticatedUser);
+
+ // User has been verified, and authentication should be allowed to
+ // continue
+ return null;
+
+ }
+
+ @Override
+ public UserContext updateUserContext(UserContext context,
+ AuthenticatedUser authenticatedUser, Credentials credentials)
+ throws GuacamoleException {
+ return context;
+ }
+
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProviderModule.java b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProviderModule.java
new file mode 100644
index 000000000..c3c129cdc
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoAuthenticationProviderModule.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.duo;
+
+import com.google.inject.AbstractModule;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.duo.conf.ConfigurationService;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.environment.LocalEnvironment;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+
+/**
+ * Guice module which configures Duo-specific injections.
+ */
+public class DuoAuthenticationProviderModule extends AbstractModule {
+
+ /**
+ * Guacamole server environment.
+ */
+ private final Environment environment;
+
+ /**
+ * A reference to the DuoAuthenticationProvider on behalf of which this
+ * module has configured injection.
+ */
+ private final AuthenticationProvider authProvider;
+
+ /**
+ * Creates a new Duo authentication provider module which configures
+ * injection for the DuoAuthenticationProvider.
+ *
+ * @param authProvider
+ * The AuthenticationProvider for which injection is being configured.
+ *
+ * @throws GuacamoleException
+ * If an error occurs while retrieving the Guacamole server
+ * environment.
+ */
+ public DuoAuthenticationProviderModule(AuthenticationProvider authProvider)
+ throws GuacamoleException {
+
+ // Get local environment
+ this.environment = new LocalEnvironment();
+
+ // Store associated auth provider
+ this.authProvider = authProvider;
+
+ }
+
+ @Override
+ protected void configure() {
+
+ // Bind core implementations of guacamole-ext classes
+ bind(AuthenticationProvider.class).toInstance(authProvider);
+ bind(Environment.class).toInstance(environment);
+
+ // Bind Duo-specific services
+ bind(AuthenticationProviderService.class);
+ bind(ConfigurationService.class);
+ bind(DuoWebService.class);
+
+ }
+
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoWebService.java b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoWebService.java
new file mode 100644
index 000000000..3cdfdded2
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/DuoWebService.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.duo;
+
+import com.duosecurity.duoweb.DuoWeb;
+import com.duosecurity.duoweb.DuoWebException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.guacamole.GuacamoleClientException;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.auth.duo.conf.ConfigurationService;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+
+/**
+ * Service which wraps the DuoWeb Java API, providing predictable behavior and
+ * error handling.
+ */
+public class DuoWebService {
+
+ /**
+ * A regular expression which matches a valid signature part of a Duo
+ * signed response. A signature part may not contain pipe symbols (which
+ * act as delimiters between parts) nor colons (which act as delimiters
+ * between signatures).
+ */
+ private final String SIGNATURE_PART = "[^:|]*";
+
+ /**
+ * A regular expression which matches a valid signature within a Duo
+ * signed response. Each signature is made up of three distinct parts,
+ * separated by pipe symbols.
+ */
+ private final String SIGNATURE = SIGNATURE_PART + "\\|" + SIGNATURE_PART + "\\|" + SIGNATURE_PART;
+
+ /**
+ * A regular expression which matches a valid Duo signed response. Each
+ * response is made up of two signatures, separated by a colon.
+ */
+ private final String RESPONSE = SIGNATURE + ":" + SIGNATURE;
+
+ /**
+ * A Pattern which matches valid Duo signed responses. Strings which will
+ * be passed to DuoWeb.verifyResponse() MUST be matched against this
+ * Pattern. Strings which do not match this Pattern may cause
+ * DuoWeb.verifyResponse() to throw unchecked exceptions.
+ */
+ private final Pattern RESPONSE_PATTERN = Pattern.compile(RESPONSE);
+
+ /**
+ * Service for retrieving Duo configuration information.
+ */
+ @Inject
+ private ConfigurationService confService;
+
+ /**
+ * Creates and signs a new request to verify the identity of the given
+ * user. This request may ultimately be sent to Duo, resulting in a signed
+ * response from Duo if that verification succeeds.
+ *
+ * @param authenticatedUser
+ * The user whose identity should be verified.
+ *
+ * @return
+ * A signed user verification request which can be sent to Duo.
+ *
+ * @throws GuacamoleException
+ * If required Duo-specific configuration options are missing or
+ * invalid, or if an error occurs within the DuoWeb API which prevents
+ * generation of the signed request.
+ */
+ public String createSignedRequest(AuthenticatedUser authenticatedUser)
+ throws GuacamoleException {
+
+ // Retrieve username from externally-authenticated user
+ String username = authenticatedUser.getIdentifier();
+
+ // Retrieve Duo-specific keys from configuration
+ String ikey = confService.getIntegrationKey();
+ String skey = confService.getSecretKey();
+ String akey = confService.getApplicationKey();
+
+ // Create signed request for the provided user
+ String signedRequest = DuoWeb.signRequest(ikey, skey, akey, username);
+
+ if (DuoWeb.ERR_AKEY.equals(signedRequest))
+ throw new GuacamoleServerException("The Duo application key "
+ + "must is not valid. Duo application keys must be at "
+ + "least 40 characters long.");
+
+ if (DuoWeb.ERR_IKEY.equals(signedRequest))
+ throw new GuacamoleServerException("The provided Duo integration "
+ + "key is not valid. Integration keys must be exactly 20 "
+ + "characters long.");
+
+ if (DuoWeb.ERR_SKEY.equals(signedRequest))
+ throw new GuacamoleServerException("The provided Duo secret key "
+ + "is not valid. Secret keys must be exactly 40 "
+ + "characters long.");
+
+ if (DuoWeb.ERR_USER.equals(signedRequest))
+ throw new GuacamoleServerException("The provided username is "
+ + "not valid. Duo usernames may not be blank, nor may "
+ + "they contain pipe symbols (\"|\").");
+
+ if (DuoWeb.ERR_UNKNOWN.equals(signedRequest))
+ throw new GuacamoleServerException("An unknown error within the "
+ + "DuoWeb API prevented the signed request from being "
+ + "generated.");
+
+ // Return signed request if no error is indicated
+ return signedRequest;
+
+ }
+
+ /**
+ * Returns whether the given signed response is a valid response from Duo
+ * which verifies the identity of the given user. If the given response is
+ * invalid or does not verify the identity of the given user (including if
+ * it is a valid response which verifies the identity of a DIFFERENT user),
+ * false is returned.
+ *
+ * @param authenticatedUser
+ * The user that the given signed response should verify.
+ *
+ * @param signedResponse
+ * The signed response received from Duo in response to a signed
+ * request.
+ *
+ * @return
+ * true if the signed response is a valid response from Duo AND verifies
+ * the identity of the given user, false otherwise.
+ *
+ * @throws GuacamoleException
+ * If required Duo-specific configuration options are missing or
+ * invalid, or if an error occurs within the DuoWeb API which prevents
+ * validation of the signed response.
+ */
+ public boolean isValidSignedResponse(AuthenticatedUser authenticatedUser,
+ String signedResponse) throws GuacamoleException {
+
+ // Verify signature response format will not cause
+ // DuoWeb.verifyResponse() to fail with unchecked exceptions
+ Matcher responseMatcher = RESPONSE_PATTERN.matcher(signedResponse);
+ if (!responseMatcher.matches())
+ throw new GuacamoleClientException("Invalid Duo response format.");
+
+ // Retrieve username from externally-authenticated user
+ String username = authenticatedUser.getIdentifier();
+
+ // Retrieve Duo-specific keys from configuration
+ String ikey = confService.getIntegrationKey();
+ String skey = confService.getSecretKey();
+ String akey = confService.getApplicationKey();
+
+ // Verify validity of signed response
+ String verifiedUsername;
+ try {
+ verifiedUsername = DuoWeb.verifyResponse(ikey, skey, akey,
+ signedResponse);
+ }
+
+ // Rethrow any errors as appropriate GuacamoleExceptions
+ catch (IOException e) {
+ throw new GuacamoleClientException("Decoding of Duo response "
+ + "failed: Invalid base64 content.", e);
+ }
+ catch (NumberFormatException e) {
+ throw new GuacamoleClientException("Decoding of Duo response "
+ + "failed: Invalid expiry timestamp.", e);
+ }
+ catch (InvalidKeyException e) {
+ throw new GuacamoleServerException("Unable to produce HMAC "
+ + "signature: " + e.getMessage(), e);
+ }
+ catch (NoSuchAlgorithmException e) {
+ throw new GuacamoleServerException("Environment is missing "
+ + "support for producing HMAC-SHA1 signatures.", e);
+ }
+ catch (DuoWebException e) {
+ throw new GuacamoleClientException("Duo response verification "
+ + "failed: " + e.getMessage(), e);
+ }
+
+ // Signed response is valid iff the associated username matches the
+ // user's username
+ return username.equals(verifiedUsername);
+
+ }
+
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/conf/ConfigurationService.java b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/conf/ConfigurationService.java
new file mode 100644
index 000000000..40ccde9e0
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/conf/ConfigurationService.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.duo.conf;
+
+import com.google.inject.Inject;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
+
+/**
+ * Service for retrieving configuration information regarding the Duo
+ * authentication extension.
+ */
+public class ConfigurationService {
+
+ /**
+ * The Guacamole server environment.
+ */
+ @Inject
+ private Environment environment;
+
+ /**
+ * The property within guacamole.properties which defines the hostname
+ * of the Duo API endpoint to be used to verify user identities. This will
+ * usually be in the form "api-XXXXXXXX.duosecurity.com", where "XXXXXXXX"
+ * is some arbitrary alphanumeric value assigned by Duo and specific to
+ * your organization.
+ */
+ private static final StringGuacamoleProperty DUO_API_HOSTNAME =
+ new StringGuacamoleProperty() {
+
+ @Override
+ public String getName() { return "duo-api-hostname"; }
+
+ };
+
+ /**
+ * The property within guacamole.properties which defines the integration
+ * key received from Duo for verifying Guacamole users. This value MUST be
+ * exactly 20 characters.
+ */
+ private static final StringGuacamoleProperty DUO_INTEGRATION_KEY =
+ new StringGuacamoleProperty() {
+
+ @Override
+ public String getName() { return "duo-integration-key"; }
+
+ };
+
+ /**
+ * The property within guacamole.properties which defines the secret key
+ * received from Duo for verifying Guacamole users. This value MUST be
+ * exactly 40 characters.
+ */
+ private static final StringGuacamoleProperty DUO_SECRET_KEY =
+ new StringGuacamoleProperty() {
+
+ @Override
+ public String getName() { return "duo-secret-key"; }
+
+ };
+
+ /**
+ * The property within guacamole.properties which defines the arbitrary
+ * random key which was generated for Guacamole. Note that this value is not
+ * provided by Duo, but is expected to be generated by the administrator of
+ * the system hosting Guacamole. This value MUST be at least 40 characters.
+ */
+ private static final StringGuacamoleProperty DUO_APPLICATION_KEY =
+ new StringGuacamoleProperty() {
+
+ @Override
+ public String getName() { return "duo-application-key"; }
+
+ };
+
+ /**
+ * Returns the hostname of the Duo API endpoint to be used to verify user
+ * identities, as defined in guacamole.properties by the "duo-api-hostname"
+ * property. This will usually be in the form
+ * "api-XXXXXXXX.duosecurity.com", where "XXXXXXXX" is some arbitrary
+ * alphanumeric value assigned by Duo and specific to your organization.
+ *
+ * @return
+ * The hostname of the Duo API endpoint to be used to verify user
+ * identities.
+ *
+ * @throws GuacamoleException
+ * If the associated property within guacamole.properties is missing.
+ */
+ public String getAPIHostname() throws GuacamoleException {
+ return environment.getRequiredProperty(DUO_API_HOSTNAME);
+ }
+
+ /**
+ * Returns the integration key received from Duo for verifying Guacamole
+ * users, as defined in guacamole.properties by the "duo-integration-key"
+ * property. This value MUST be exactly 20 characters.
+ *
+ * @return
+ * The integration key received from Duo for verifying Guacamole
+ * users.
+ *
+ * @throws GuacamoleException
+ * If the associated property within guacamole.properties is missing.
+ */
+ public String getIntegrationKey() throws GuacamoleException {
+ return environment.getRequiredProperty(DUO_INTEGRATION_KEY);
+ }
+
+ /**
+ * Returns the secret key received from Duo for verifying Guacamole users,
+ * as defined in guacamole.properties by the "duo-secret-key" property. This
+ * value MUST be exactly 20 characters.
+ *
+ * @return
+ * The secret key received from Duo for verifying Guacamole users.
+ *
+ * @throws GuacamoleException
+ * If the associated property within guacamole.properties is missing.
+ */
+ public String getSecretKey() throws GuacamoleException {
+ return environment.getRequiredProperty(DUO_SECRET_KEY);
+ }
+
+ /**
+ * Returns the arbitrary random key which was generated for Guacamole, as
+ * defined in guacamole.properties by the "duo-application-key" property.
+ * Note that this value is not provided by Duo, but is expected to be
+ * generated by the administrator of the system hosting Guacamole. This
+ * value MUST be at least 40 characters.
+ *
+ * @return
+ * The arbitrary random key which was generated for Guacamole.
+ *
+ * @throws GuacamoleException
+ * If the associated property within guacamole.properties is missing.
+ */
+ public String getApplicationKey() throws GuacamoleException {
+ return environment.getRequiredProperty(DUO_APPLICATION_KEY);
+ }
+
+}
diff --git a/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/form/DuoSignedResponseField.java b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/form/DuoSignedResponseField.java
new file mode 100644
index 000000000..192cbf8b1
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/java/org/apache/guacamole/auth/duo/form/DuoSignedResponseField.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.duo.form;
+
+import org.apache.guacamole.form.Field;
+import org.codehaus.jackson.annotate.JsonProperty;
+
+/**
+ * A custom field type which uses the DuoWeb API to produce a signed response
+ * for a particular user. The signed response serves as an additional
+ * authentication factor, as it cryptographically verifies possession of the
+ * physical device associated with that user's Duo account.
+ */
+public class DuoSignedResponseField extends Field {
+
+ /**
+ * The name of the HTTP parameter which an instance of this field will
+ * populate within a user's credentials.
+ */
+ public static final String PARAMETER_NAME = "guac-duo-signed-response";
+
+ /**
+ * The unique name associated with this field type.
+ */
+ private static final String FIELD_TYPE_NAME = "GUAC_DUO_SIGNED_RESPONSE";
+
+ /**
+ * The hostname of the DuoWeb API endpoint.
+ */
+ private final String apiHost;
+
+ /**
+ * The signed request generated by a call to DuoWeb.signRequest().
+ */
+ private final String signedRequest;
+
+ /**
+ * Creates a new field which uses the DuoWeb API to prompt the user for
+ * additional credentials. The provided credentials, if valid, will
+ * ultimately be verified by Duo's service, resulting in a signed response
+ * which can be cryptographically verified.
+ *
+ * @param apiHost
+ * The hostname of the DuoWeb API endpoint.
+ *
+ * @param signedRequest
+ * A signed request generated for the user in question by a call to
+ * DuoWeb.signRequest().
+ */
+ public DuoSignedResponseField(String apiHost, String signedRequest) {
+
+ // Init base field type properties
+ super(PARAMETER_NAME, FIELD_TYPE_NAME);
+
+ // Init Duo-specific properties
+ this.apiHost = apiHost;
+ this.signedRequest = signedRequest;
+
+ }
+
+ /**
+ * Returns the hostname of the DuoWeb API endpoint.
+ *
+ * @return
+ * The hostname of the DuoWeb API endpoint.
+ */
+ @JsonProperty("apiHost")
+ public String getAPIHost() {
+ return apiHost;
+ }
+
+ /**
+ * Returns the signed request string, which must have been generated by a
+ * call to DuoWeb.signRequest().
+ *
+ * @return
+ * The signed request generated by a call to DuoWeb.signRequest().
+ */
+ public String getSignedRequest() {
+ return signedRequest;
+ }
+
+}
diff --git a/extensions/guacamole-auth-duo/src/main/resources/config/duoConfig.js b/extensions/guacamole-auth-duo/src/main/resources/config/duoConfig.js
new file mode 100644
index 000000000..43c37dc0c
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/config/duoConfig.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 Duo-specific field types.
+ */
+angular.module('guacDuo').config(['formServiceProvider',
+ function guacDuoConfig(formServiceProvider) {
+
+ // Define field for the signed response from the Duo service
+ formServiceProvider.registerFieldType('GUAC_DUO_SIGNED_RESPONSE', {
+ module : 'guacDuo',
+ controller : 'duoSignedResponseController',
+ templateUrl : 'app/ext/duo/templates/duoSignedResponseField.html'
+ });
+
+}]);
diff --git a/extensions/guacamole-auth-duo/src/main/resources/controllers/duoSignedResponseController.js b/extensions/guacamole-auth-duo/src/main/resources/controllers/duoSignedResponseController.js
new file mode 100644
index 000000000..0d10f8e3d
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/controllers/duoSignedResponseController.js
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Controller for the "GUAC_DUO_SIGNED_RESPONSE" field which uses the DuoWeb
+ * API to prompt the user for additional credentials, ultimately receiving a
+ * signed response from the Duo service.
+ */
+angular.module('guacDuo').controller('duoSignedResponseController', ['$scope',
+ function duoSignedResponseController($scope) {
+
+ /**
+ * The iframe which contains the Duo authentication interface.
+ *
+ * @type HTMLIFrameElement
+ */
+ var iframe = $('.duo-signature-response-field iframe')[0];
+
+ /**
+ * Whether the Duo interface has finished loading within the iframe.
+ *
+ * @type Boolean
+ */
+ $scope.duoInterfaceLoaded = false;
+
+ /**
+ * Submits the signed response from Duo once the user has authenticated.
+ * This is a callback invoked by the DuoWeb API after the user has been
+ * verified and the signed response has been received.
+ *
+ * @param {HTMLFormElement} form
+ * The form element provided by the DuoWeb API containing the signed
+ * response as the value of an input field named "sig_response".
+ */
+ var submitSignedResponse = function submitSignedResponse(form) {
+
+ // Update model to match received response
+ $scope.$apply(function updateModel() {
+ $scope.model = form.elements['sig_response'].value;
+ });
+
+ // Submit updated credentials
+ $(iframe).parents('form').submit();
+
+ };
+
+ // Update Duo loaded state when iframe finishes loading
+ iframe.onload = function duoLoaded() {
+ $scope.$apply(function updateLoadedState() {
+ $scope.duoInterfaceLoaded = true;
+ });
+ };
+
+ // Initialize Duo interface within iframe
+ Duo.init({
+ iframe : iframe,
+ host : $scope.field.apiHost,
+ sig_request : $scope.field.signedRequest,
+ submit_callback : submitSignedResponse
+ });
+
+}]);
diff --git a/extensions/guacamole-auth-duo/src/main/resources/duoModule.js b/extensions/guacamole-auth-duo/src/main/resources/duoModule.js
new file mode 100644
index 000000000..49a342f84
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/duoModule.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 Duo multi-factor authentication.
+ */
+angular.module('guacDuo', [
+ 'form'
+]);
+
+// Ensure the guacDuo module is loaded along with the rest of the app
+angular.module('index').requires.push('guacDuo');
diff --git a/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
new file mode 100644
index 000000000..ff8fab2a0
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
@@ -0,0 +1,35 @@
+{
+
+ "guacamoleVersion" : "0.9.10-incubating",
+
+ "name" : "Duo TFA Authentication Backend",
+ "namespace" : "duo",
+
+ "authProviders" : [
+ "org.apache.guacamole.auth.duo.DuoAuthenticationProvider"
+ ],
+
+ "translations" : [
+ "translations/en.json"
+ ],
+
+ "js" : [
+
+ "duoModule.js",
+ "controllers/duoSignedResponseController.js",
+ "config/duoConfig.js",
+
+ "lib/DuoWeb/LICENSE.js",
+ "lib/DuoWeb/Duo-Web-v2.js"
+
+ ],
+
+ "css" : [
+ "styles/duo.css"
+ ],
+
+ "resources" : {
+ "templates/duoSignedResponseField.html" : "text/html"
+ }
+
+}
diff --git a/extensions/guacamole-auth-duo/src/main/resources/lib/DuoWeb/Duo-Web-v2.js b/extensions/guacamole-auth-duo/src/main/resources/lib/DuoWeb/Duo-Web-v2.js
new file mode 100644
index 000000000..a02a95756
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/lib/DuoWeb/Duo-Web-v2.js
@@ -0,0 +1,366 @@
+/**
+ * Duo Web SDK v2
+ * Copyright 2015, Duo Security
+ */
+window.Duo = (function(document, window) {
+ var DUO_MESSAGE_FORMAT = /^(?:AUTH|ENROLL)+\|[A-Za-z0-9\+\/=]+\|[A-Za-z0-9\+\/=]+$/;
+ var DUO_ERROR_FORMAT = /^ERR\|[\w\s\.\(\)]+$/;
+
+ var iframeId = 'duo_iframe',
+ postAction = '',
+ postArgument = 'sig_response',
+ host,
+ sigRequest,
+ duoSig,
+ appSig,
+ iframe,
+ submitCallback;
+
+ function throwError(message, url) {
+ throw new Error(
+ 'Duo Web SDK error: ' + message +
+ (url ? ('\n' + 'See ' + url + ' for more information') : '')
+ );
+ }
+
+ function hyphenize(str) {
+ return str.replace(/([a-z])([A-Z])/, '$1-$2').toLowerCase();
+ }
+
+ // cross-browser data attributes
+ function getDataAttribute(element, name) {
+ if ('dataset' in element) {
+ return element.dataset[name];
+ } else {
+ return element.getAttribute('data-' + hyphenize(name));
+ }
+ }
+
+ // cross-browser event binding/unbinding
+ function on(context, event, fallbackEvent, callback) {
+ if ('addEventListener' in window) {
+ context.addEventListener(event, callback, false);
+ } else {
+ context.attachEvent(fallbackEvent, callback);
+ }
+ }
+
+ function off(context, event, fallbackEvent, callback) {
+ if ('removeEventListener' in window) {
+ context.removeEventListener(event, callback, false);
+ } else {
+ context.detachEvent(fallbackEvent, callback);
+ }
+ }
+
+ function onReady(callback) {
+ on(document, 'DOMContentLoaded', 'onreadystatechange', callback);
+ }
+
+ function offReady(callback) {
+ off(document, 'DOMContentLoaded', 'onreadystatechange', callback);
+ }
+
+ function onMessage(callback) {
+ on(window, 'message', 'onmessage', callback);
+ }
+
+ function offMessage(callback) {
+ off(window, 'message', 'onmessage', callback);
+ }
+
+ /**
+ * Parse the sig_request parameter, throwing errors if the token contains
+ * a server error or if the token is invalid.
+ *
+ * @param {String} sig Request token
+ */
+ function parseSigRequest(sig) {
+ if (!sig) {
+ // nothing to do
+ return;
+ }
+
+ // see if the token contains an error, throwing it if it does
+ if (sig.indexOf('ERR|') === 0) {
+ throwError(sig.split('|')[1]);
+ }
+
+ // validate the token
+ if (sig.indexOf(':') === -1 || sig.split(':').length !== 2) {
+ throwError(
+ 'Duo was given a bad token. This might indicate a configuration ' +
+ 'problem with one of Duo\'s client libraries.',
+ 'https://www.duosecurity.com/docs/duoweb#first-steps'
+ );
+ }
+
+ var sigParts = sig.split(':');
+
+ // hang on to the token, and the parsed duo and app sigs
+ sigRequest = sig;
+ duoSig = sigParts[0];
+ appSig = sigParts[1];
+
+ return {
+ sigRequest: sig,
+ duoSig: sigParts[0],
+ appSig: sigParts[1]
+ };
+ }
+
+ /**
+ * This function is set up to run when the DOM is ready, if the iframe was
+ * not available during `init`.
+ */
+ function onDOMReady() {
+ iframe = document.getElementById(iframeId);
+
+ if (!iframe) {
+ throw new Error(
+ 'This page does not contain an iframe for Duo to use.' +
+ 'Add an element like ' +
+ 'to this page. ' +
+ 'See https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe ' +
+ 'for more information.'
+ );
+ }
+
+ // we've got an iframe, away we go!
+ ready();
+
+ // always clean up after yourself
+ offReady(onDOMReady);
+ }
+
+ /**
+ * Validate that a MessageEvent came from the Duo service, and that it
+ * is a properly formatted payload.
+ *
+ * The Google Chrome sign-in page injects some JS into pages that also
+ * make use of postMessage, so we need to do additional validation above
+ * and beyond the origin.
+ *
+ * @param {MessageEvent} event Message received via postMessage
+ */
+ function isDuoMessage(event) {
+ return Boolean(
+ event.origin === ('https://' + host) &&
+ typeof event.data === 'string' &&
+ (
+ event.data.match(DUO_MESSAGE_FORMAT) ||
+ event.data.match(DUO_ERROR_FORMAT)
+ )
+ );
+ }
+
+ /**
+ * Validate the request token and prepare for the iframe to become ready.
+ *
+ * All options below can be passed into an options hash to `Duo.init`, or
+ * specified on the iframe using `data-` attributes.
+ *
+ * Options specified using the options hash will take precedence over
+ * `data-` attributes.
+ *
+ * Example using options hash:
+ * ```javascript
+ * Duo.init({
+ * iframe: "some_other_id",
+ * host: "api-main.duo.test",
+ * sig_request: "...",
+ * post_action: "/auth",
+ * post_argument: "resp"
+ * });
+ * ```
+ *
+ * Example using `data-` attributes:
+ * ```
+ *
+ * ```
+ *
+ * @param {Object} options
+ * @param {String} options.iframe The iframe, or id of an iframe to set up
+ * @param {String} options.host Hostname
+ * @param {String} options.sig_request Request token
+ * @param {String} [options.post_action=''] URL to POST back to after successful auth
+ * @param {String} [options.post_argument='sig_response'] Parameter name to use for response token
+ * @param {Function} [options.submit_callback] If provided, duo will not submit the form instead execute
+ * the callback function with reference to the "duo_form" form object
+ * submit_callback can be used to prevent the webpage from reloading.
+ */
+ function init(options) {
+ if (options) {
+ if (options.host) {
+ host = options.host;
+ }
+
+ if (options.sig_request) {
+ parseSigRequest(options.sig_request);
+ }
+
+ if (options.post_action) {
+ postAction = options.post_action;
+ }
+
+ if (options.post_argument) {
+ postArgument = options.post_argument;
+ }
+
+ if (options.iframe) {
+ if ('tagName' in options.iframe) {
+ iframe = options.iframe;
+ } else if (typeof options.iframe === 'string') {
+ iframeId = options.iframe;
+ }
+ }
+
+ if (typeof options.submit_callback === 'function') {
+ submitCallback = options.submit_callback;
+ }
+ }
+
+ // if we were given an iframe, no need to wait for the rest of the DOM
+ if (iframe) {
+ ready();
+ } else {
+ // try to find the iframe in the DOM
+ iframe = document.getElementById(iframeId);
+
+ // iframe is in the DOM, away we go!
+ if (iframe) {
+ ready();
+ } else {
+ // wait until the DOM is ready, then try again
+ onReady(onDOMReady);
+ }
+ }
+
+ // always clean up after yourself!
+ offReady(init);
+ }
+
+ /**
+ * This function is called when a message was received from another domain
+ * using the `postMessage` API. Check that the event came from the Duo
+ * service domain, and that the message is a properly formatted payload,
+ * then perform the post back to the primary service.
+ *
+ * @param event Event object (contains origin and data)
+ */
+ function onReceivedMessage(event) {
+ if (isDuoMessage(event)) {
+ // the event came from duo, do the post back
+ doPostBack(event.data);
+
+ // always clean up after yourself!
+ offMessage(onReceivedMessage);
+ }
+ }
+
+ /**
+ * Point the iframe at Duo, then wait for it to postMessage back to us.
+ */
+ function ready() {
+ if (!host) {
+ host = getDataAttribute(iframe, 'host');
+
+ if (!host) {
+ throwError(
+ 'No API hostname is given for Duo to use. Be sure to pass ' +
+ 'a `host` parameter to Duo.init, or through the `data-host` ' +
+ 'attribute on the iframe element.',
+ 'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe'
+ );
+ }
+ }
+
+ if (!duoSig || !appSig) {
+ parseSigRequest(getDataAttribute(iframe, 'sigRequest'));
+
+ if (!duoSig || !appSig) {
+ throwError(
+ 'No valid signed request is given. Be sure to give the ' +
+ '`sig_request` parameter to Duo.init, or use the ' +
+ '`data-sig-request` attribute on the iframe element.',
+ 'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe'
+ );
+ }
+ }
+
+ // if postAction/Argument are defaults, see if they are specified
+ // as data attributes on the iframe
+ if (postAction === '') {
+ postAction = getDataAttribute(iframe, 'postAction') || postAction;
+ }
+
+ if (postArgument === 'sig_response') {
+ postArgument = getDataAttribute(iframe, 'postArgument') || postArgument;
+ }
+
+ // point the iframe at Duo
+ iframe.src = [
+ 'https://', host, '/frame/web/v1/auth?tx=', duoSig,
+ '&parent=', encodeURIComponent(document.location.href),
+ '&v=2.3'
+ ].join('');
+
+ // listen for the 'message' event
+ onMessage(onReceivedMessage);
+ }
+
+ /**
+ * We received a postMessage from Duo. POST back to the primary service
+ * with the response token, and any additional user-supplied parameters
+ * given in form#duo_form.
+ */
+ function doPostBack(response) {
+ // create a hidden input to contain the response token
+ var input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = postArgument;
+ input.value = response + ':' + appSig;
+
+ // user may supply their own form with additional inputs
+ var form = document.getElementById('duo_form');
+
+ // if the form doesn't exist, create one
+ if (!form) {
+ form = document.createElement('form');
+
+ // insert the new form after the iframe
+ iframe.parentElement.insertBefore(form, iframe.nextSibling);
+ }
+
+ // make sure we are actually posting to the right place
+ form.method = 'POST';
+ form.action = postAction;
+
+ // add the response token input to the form
+ form.appendChild(input);
+
+ // away we go!
+ if (typeof submitCallback === "function") {
+ submitCallback.call(null, form);
+ } else {
+ form.submit();
+ }
+ }
+
+ // when the DOM is ready, initialize
+ // note that this will get cleaned up if the user calls init directly!
+ onReady(init);
+
+ return {
+ init: init,
+ _parseSigRequest: parseSigRequest,
+ _isDuoMessage: isDuoMessage,
+ _doPostBack: doPostBack
+ };
+}(document, window));
diff --git a/extensions/guacamole-auth-duo/src/main/resources/lib/DuoWeb/LICENSE.js b/extensions/guacamole-auth-duo/src/main/resources/lib/DuoWeb/LICENSE.js
new file mode 100644
index 000000000..58ead2175
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/lib/DuoWeb/LICENSE.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2011, Duo Security, Inc.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * 3. The name of the author may not be used to endorse or promote products
+ * derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
\ No newline at end of file
diff --git a/extensions/guacamole-auth-duo/src/main/resources/styles/duo.css b/extensions/guacamole-auth-duo/src/main/resources/styles/duo.css
new file mode 100644
index 000000000..36d6031f9
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/styles/duo.css
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.duo-signature-response-field iframe {
+ width: 100%;
+ max-width: 620px;
+ height: 330px;
+ border: none;
+}
+
+.duo-signature-response-field iframe {
+ opacity: 1;
+ -webkit-transition: opacity 0.125s;
+ -moz-transition: opacity 0.125s;
+ -ms-transition: opacity 0.125s;
+ -o-transition: opacity 0.125s;
+ transition: opacity 0.125s;
+}
+
+.duo-signature-response-field.loading iframe {
+ opacity: 0;
+}
diff --git a/extensions/guacamole-auth-duo/src/main/resources/templates/duoSignedResponseField.html b/extensions/guacamole-auth-duo/src/main/resources/templates/duoSignedResponseField.html
new file mode 100644
index 000000000..4658ed055
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/templates/duoSignedResponseField.html
@@ -0,0 +1,3 @@
+