mirror of
https://github.com/gyurix1968/guacamole-client.git
synced 2025-09-06 13:17:41 +00:00
GUACAMOLE-1289: Update the Duo extension to the v4 API
This commit is contained in:
committed by
Alex Leitner
parent
13494baa4a
commit
e8860e4dd8
@@ -39,93 +39,9 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<enforcer.skip>true</enforcer.skip>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
|
|
||||||
<!-- Pre-cache Angular templates with maven-angular-plugin -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.keithbranton.mojo</groupId>
|
|
||||||
<artifactId>angular-maven-plugin</artifactId>
|
|
||||||
<version>0.3.4</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/duo</prefix>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
|
|
||||||
<!-- JS/CSS Minification Plugin -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.github.buckelieg</groupId>
|
|
||||||
<artifactId>minify-maven-plugin</artifactId>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>default-cli</id>
|
|
||||||
<configuration>
|
|
||||||
<charset>UTF-8</charset>
|
|
||||||
|
|
||||||
<webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
|
|
||||||
<webappTargetDir>${project.build.directory}/classes</webappTargetDir>
|
|
||||||
|
|
||||||
<cssSourceDir>/</cssSourceDir>
|
|
||||||
<cssTargetDir>/</cssTargetDir>
|
|
||||||
<cssFinalFile>duo.css</cssFinalFile>
|
|
||||||
|
|
||||||
<cssSourceFiles>
|
|
||||||
<cssSourceFile>license.txt</cssSourceFile>
|
|
||||||
</cssSourceFiles>
|
|
||||||
|
|
||||||
<cssSourceIncludes>
|
|
||||||
<cssSourceInclude>**/*.css</cssSourceInclude>
|
|
||||||
</cssSourceIncludes>
|
|
||||||
|
|
||||||
<jsSourceDir>/</jsSourceDir>
|
|
||||||
<jsTargetDir>/</jsTargetDir>
|
|
||||||
<jsFinalFile>duo.js</jsFinalFile>
|
|
||||||
|
|
||||||
<jsSourceFiles>
|
|
||||||
<jsSourceFile>license.txt</jsSourceFile>
|
|
||||||
<jsSourceFile>lib/DuoWeb/LICENSE.js</jsSourceFile>
|
|
||||||
</jsSourceFiles>
|
|
||||||
|
|
||||||
<jsSourceIncludes>
|
|
||||||
<jsSourceInclude>**/*.js</jsSourceInclude>
|
|
||||||
</jsSourceIncludes>
|
|
||||||
|
|
||||||
<!-- Do not minify and include tests -->
|
|
||||||
<jsSourceExcludes>
|
|
||||||
<jsSourceExclude>**/*.test.js</jsSourceExclude>
|
|
||||||
</jsSourceExcludes>
|
|
||||||
<jsEngine>CLOSURE</jsEngine>
|
|
||||||
|
|
||||||
<!-- Disable warnings for JSDoc annotations -->
|
|
||||||
<closureWarningLevels>
|
|
||||||
<misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
|
|
||||||
<nonStandardJsDocs>OFF</nonStandardJsDocs>
|
|
||||||
</closureWarningLevels>
|
|
||||||
|
|
||||||
</configuration>
|
|
||||||
<goals>
|
|
||||||
<goal>minify</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
||||||
<!-- Guacamole Extension API -->
|
<!-- Guacamole Extension API -->
|
||||||
@@ -156,6 +72,20 @@
|
|||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Duo SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.duosecurity</groupId>
|
||||||
|
<artifactId>duo-universal-sdk</artifactId>
|
||||||
|
<version>1.1.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- kotlin-stdlib-common -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
|
<artifactId>kotlin-stdlib-common</artifactId>
|
||||||
|
<version>1.4.10</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
@@ -21,7 +21,6 @@ package org.apache.guacamole.auth.duo;
|
|||||||
|
|
||||||
import com.google.inject.AbstractModule;
|
import com.google.inject.AbstractModule;
|
||||||
import org.apache.guacamole.GuacamoleException;
|
import org.apache.guacamole.GuacamoleException;
|
||||||
import org.apache.guacamole.auth.duo.api.DuoService;
|
|
||||||
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
|
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
|
||||||
import org.apache.guacamole.environment.Environment;
|
import org.apache.guacamole.environment.Environment;
|
||||||
import org.apache.guacamole.environment.LocalEnvironment;
|
import org.apache.guacamole.environment.LocalEnvironment;
|
||||||
@@ -74,8 +73,8 @@ public class DuoAuthenticationProviderModule extends AbstractModule {
|
|||||||
|
|
||||||
// Bind Duo-specific services
|
// Bind Duo-specific services
|
||||||
bind(ConfigurationService.class);
|
bind(ConfigurationService.class);
|
||||||
bind(DuoService.class);
|
|
||||||
bind(UserVerificationService.class);
|
bind(UserVerificationService.class);
|
||||||
|
bind(DuoAuthenticationSessionManager.class);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.apache.guacamole.net.auth.AuthenticationSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An AuthenticationSession that stores the information required for an
|
||||||
|
* in-progress Duo authentication attempt.
|
||||||
|
*/
|
||||||
|
public class DuoAuthenticationSession extends AuthenticationSession {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session state generated by the Duo client, which is used to track
|
||||||
|
* the session through the redirect and return process.
|
||||||
|
*/
|
||||||
|
private final String state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The username of the user who is authenticating with this session.
|
||||||
|
*/
|
||||||
|
private final String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new instance of this authenticaiton session, having the given length of time
|
||||||
|
* for expriation and the state generated by the Duo Client.
|
||||||
|
*
|
||||||
|
* @param expires
|
||||||
|
* The number of milliseconds before this session is invalid.
|
||||||
|
*
|
||||||
|
* @param state
|
||||||
|
* The session state, as generated by the Duo Client.
|
||||||
|
*
|
||||||
|
* @param username
|
||||||
|
* The username of the user who is attempting authentication with Duo.
|
||||||
|
*/
|
||||||
|
public DuoAuthenticationSession(long expires, String state, String username) {
|
||||||
|
super(expires);
|
||||||
|
this.state = state;
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the stored session state.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The stored session state.
|
||||||
|
*/
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -17,12 +17,18 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
package org.apache.guacamole.auth.duo;
|
||||||
* 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
|
import com.google.inject.Singleton;
|
||||||
angular.module('index').requires.push('guacDuo');
|
import org.apache.guacamole.net.auth.AuthenticationSessionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An AuthenticationSessionManager implementation that temporarily stores
|
||||||
|
* authentication attempts for Duo MFA while they are underway.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class DuoAuthenticationSessionManager extends AuthenticationSessionManager<DuoAuthenticationSession> {
|
||||||
|
|
||||||
|
// Nothing to see here.
|
||||||
|
|
||||||
|
}
|
@@ -19,16 +19,21 @@
|
|||||||
|
|
||||||
package org.apache.guacamole.auth.duo;
|
package org.apache.guacamole.auth.duo;
|
||||||
|
|
||||||
|
import com.duosecurity.Client;
|
||||||
|
import com.duosecurity.exception.DuoException;
|
||||||
|
import com.duosecurity.model.Token;
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import org.apache.guacamole.GuacamoleException;
|
import org.apache.guacamole.GuacamoleException;
|
||||||
import org.apache.guacamole.auth.duo.api.DuoService;
|
import org.apache.guacamole.GuacamoleServerException;
|
||||||
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
|
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
|
||||||
import org.apache.guacamole.auth.duo.form.DuoSignedResponseField;
|
import org.apache.guacamole.form.RedirectField;
|
||||||
import org.apache.guacamole.form.Field;
|
|
||||||
import org.apache.guacamole.language.TranslatableGuacamoleClientException;
|
import org.apache.guacamole.language.TranslatableGuacamoleClientException;
|
||||||
import org.apache.guacamole.language.TranslatableGuacamoleInsufficientCredentialsException;
|
import org.apache.guacamole.language.TranslatableGuacamoleInsufficientCredentialsException;
|
||||||
|
import org.apache.guacamole.language.TranslatableMessage;
|
||||||
import org.apache.guacamole.net.auth.AuthenticatedUser;
|
import org.apache.guacamole.net.auth.AuthenticatedUser;
|
||||||
import org.apache.guacamole.net.auth.Credentials;
|
import org.apache.guacamole.net.auth.Credentials;
|
||||||
import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
|
import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
|
||||||
@@ -38,6 +43,24 @@ import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
|
|||||||
*/
|
*/
|
||||||
public class UserVerificationService {
|
public class UserVerificationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the parameter which Duo will return in it's GET call-back
|
||||||
|
* that contains the code that the client will use to generate a token.
|
||||||
|
*/
|
||||||
|
private static final String DUO_CODE_PARAMETER_NAME = "duo_code";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the parameter that will be used in the GET call-back that
|
||||||
|
* contains the session state.
|
||||||
|
*/
|
||||||
|
private static final String DUO_STATE_PARAMETER_NAME = "state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value that will be returned in the token if Duo authentication
|
||||||
|
* was successful.
|
||||||
|
*/
|
||||||
|
private static final String DUO_TOKEN_SUCCESS_VALUE = "ALLOW";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for retrieving Duo configuration information.
|
* Service for retrieving Duo configuration information.
|
||||||
*/
|
*/
|
||||||
@@ -45,10 +68,11 @@ public class UserVerificationService {
|
|||||||
private ConfigurationService confService;
|
private ConfigurationService confService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for verifying users against Duo.
|
* The authentication session manager that temporarily stores in-progress
|
||||||
|
* authentication attempts.
|
||||||
*/
|
*/
|
||||||
@Inject
|
@Inject
|
||||||
private DuoService duoService;
|
private DuoAuthenticationSessionManager duoSessionManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the identity of the given user via the Duo multi-factor
|
* Verifies the identity of the given user via the Duo multi-factor
|
||||||
@@ -76,38 +100,68 @@ public class UserVerificationService {
|
|||||||
if (authenticatedUser.getIdentifier().equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
|
if (authenticatedUser.getIdentifier().equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Retrieve signed Duo response from request
|
String username = authenticatedUser.getIdentifier();
|
||||||
String signedResponse = request.getParameter(DuoSignedResponseField.PARAMETER_NAME);
|
|
||||||
|
|
||||||
// If no signed response, request one
|
try {
|
||||||
if (signedResponse == null) {
|
|
||||||
|
|
||||||
// Create field which requests a signed response from Duo that
|
// Set up the Duo Client
|
||||||
// verifies the identity of the given user via the configured
|
Client duoClient = new Client.Builder(
|
||||||
// Duo API endpoint
|
confService.getClientId(),
|
||||||
Field signedResponseField = new DuoSignedResponseField(
|
confService.getClientSecret(),
|
||||||
confService.getAPIHostname(),
|
confService.getAPIHostname(),
|
||||||
duoService.createSignedRequest(authenticatedUser));
|
confService.getRedirectUrl().toString())
|
||||||
|
.build();
|
||||||
|
|
||||||
// Create an overall description of the additional credentials
|
duoClient.healthCheck();
|
||||||
// required to verify identity
|
|
||||||
CredentialsInfo expectedCredentials = new CredentialsInfo(
|
|
||||||
Collections.singletonList(signedResponseField));
|
// Retrieve signed Duo Code and State from the request
|
||||||
|
String duoCode = request.getParameter(DUO_CODE_PARAMETER_NAME);
|
||||||
|
String duoState = request.getParameter(DUO_STATE_PARAMETER_NAME);
|
||||||
|
|
||||||
|
// If no code or state is received, assume Duo MFA redirect has not occured and do it.
|
||||||
|
if (duoCode == null || duoState == null) {
|
||||||
|
|
||||||
|
// Get a new session state from the Duo client
|
||||||
|
duoState = duoClient.generateState();
|
||||||
|
|
||||||
|
// Add this session
|
||||||
|
duoSessionManager.defer(new DuoAuthenticationSession(confService.getAuthTimeout(), duoState, username), duoState);
|
||||||
|
|
||||||
// Request additional credentials
|
// Request additional credentials
|
||||||
throw new TranslatableGuacamoleInsufficientCredentialsException(
|
throw new TranslatableGuacamoleInsufficientCredentialsException(
|
||||||
"Verification using Duo is required before authentication "
|
"Verification using Duo is required before authentication "
|
||||||
+ "can continue.", "LOGIN.INFO_DUO_AUTH_REQUIRED",
|
+ "can continue.", "LOGIN.INFO_DUO_AUTH_REQUIRED",
|
||||||
expectedCredentials);
|
new CredentialsInfo(Collections.singletonList(
|
||||||
|
new RedirectField(
|
||||||
|
DUO_CODE_PARAMETER_NAME,
|
||||||
|
new URI(duoClient.createAuthUrl(username, duoState)),
|
||||||
|
new TranslatableMessage("LOGIN.INFO_DUO_REDIRECT_PENDING")
|
||||||
|
)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If signed response does not verify this user's identity, abort auth
|
// Retrieve the deferred authenticaiton attempt
|
||||||
if (!duoService.isValidSignedResponse(authenticatedUser, signedResponse))
|
DuoAuthenticationSession duoSession = duoSessionManager.resume(duoState);
|
||||||
|
|
||||||
|
// Get the token from the DuoClient using the code and username, and check status
|
||||||
|
Token token = duoClient.exchangeAuthorizationCodeFor2FAResult(duoCode, duoSession.getUsername());
|
||||||
|
if (token == null
|
||||||
|
|| token.getAuth_result() == null
|
||||||
|
|| !DUO_TOKEN_SUCCESS_VALUE.equals(token.getAuth_result().getStatus()))
|
||||||
throw new TranslatableGuacamoleClientException("Provided Duo "
|
throw new TranslatableGuacamoleClientException("Provided Duo "
|
||||||
+ "validation code is incorrect.",
|
+ "validation code is incorrect.",
|
||||||
"LOGIN.INFO_DUO_VALIDATION_CODE_INCORRECT");
|
"LOGIN.INFO_DUO_VALIDATION_CODE_INCORRECT");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
catch (DuoException e) {
|
||||||
|
throw new GuacamoleServerException("Duo Client error.", e);
|
||||||
|
}
|
||||||
|
catch (URISyntaxException e) {
|
||||||
|
throw new GuacamoleServerException("Error creating URI from Duo Authentication URL.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,245 +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.duo.api;
|
|
||||||
|
|
||||||
import com.google.common.io.BaseEncoding;
|
|
||||||
import java.io.UnsupportedEncodingException;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import org.apache.guacamole.GuacamoleClientException;
|
|
||||||
import org.apache.guacamole.GuacamoleException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data which describes the identity of the user being verified by Duo.
|
|
||||||
*/
|
|
||||||
public class DuoCookie {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern which matches valid cookies. Each cookie is made up of three
|
|
||||||
* sections, separated from each other by pipe symbols ("|").
|
|
||||||
*/
|
|
||||||
private static final Pattern COOKIE_FORMAT = Pattern.compile("([^|]+)\\|([^|]+)\\|([0-9]+)");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within COOKIE_FORMAT which contains the
|
|
||||||
* username.
|
|
||||||
*/
|
|
||||||
private static final int USERNAME_GROUP = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within COOKIE_FORMAT which contains the
|
|
||||||
* integration key.
|
|
||||||
*/
|
|
||||||
private static final int INTEGRATION_KEY_GROUP = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within COOKIE_FORMAT which contains the
|
|
||||||
* expiration timestamp.
|
|
||||||
*/
|
|
||||||
private static final int EXPIRATION_TIMESTAMP_GROUP = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The username of the user being verified.
|
|
||||||
*/
|
|
||||||
private final String username;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The integration key provided by Duo and specific to this deployment of
|
|
||||||
* Guacamole.
|
|
||||||
*/
|
|
||||||
private final String integrationKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The time that this cookie expires, in seconds since midnight of
|
|
||||||
* 1970-01-01 (UTC).
|
|
||||||
*/
|
|
||||||
private final long expires;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new DuoCookie which describes the identity of a user being
|
|
||||||
* verified.
|
|
||||||
*
|
|
||||||
* @param username
|
|
||||||
* The username of the user being verified.
|
|
||||||
*
|
|
||||||
* @param integrationKey
|
|
||||||
* The integration key provided by Duo and specific to this deployment
|
|
||||||
* of Guacamole.
|
|
||||||
*
|
|
||||||
* @param expires
|
|
||||||
* The time that this cookie expires, in seconds since midnight of
|
|
||||||
* 1970-01-01 (UTC).
|
|
||||||
*/
|
|
||||||
public DuoCookie(String username, String integrationKey, long expires) {
|
|
||||||
this.username = username;
|
|
||||||
this.integrationKey = integrationKey;
|
|
||||||
this.expires = expires;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the username of the user being verified.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The username of the user being verified.
|
|
||||||
*/
|
|
||||||
public String getUsername() {
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the integration key provided by Duo and specific to this
|
|
||||||
* deployment of Guacamole.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The integration key provided by Duo and specific to this deployment
|
|
||||||
* of Guacamole.
|
|
||||||
*/
|
|
||||||
public String getIntegrationKey() {
|
|
||||||
return integrationKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the time that this cookie expires. The expiration time is
|
|
||||||
* represented in seconds since midnight of 1970-01-01 (UTC).
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The time that this cookie expires, in seconds since midnight of
|
|
||||||
* 1970-01-01 (UTC).
|
|
||||||
*/
|
|
||||||
public long getExpirationTimestamp(){
|
|
||||||
return expires;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current time as the number of seconds elapsed since
|
|
||||||
* midnight of 1970-01-01 (UTC).
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The current time as the number of seconds elapsed since midnight of
|
|
||||||
* 1970-01-01 (UTC).
|
|
||||||
*/
|
|
||||||
public static long currentTimestamp() {
|
|
||||||
return System.currentTimeMillis() / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether this cookie has expired (the current time has met or
|
|
||||||
* exceeded the expiration timestamp).
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* true if this cookie has expired, false otherwise.
|
|
||||||
*/
|
|
||||||
public boolean isExpired() {
|
|
||||||
return currentTimestamp() >= expires;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a base64-encoded Duo cookie, producing a new DuoCookie object
|
|
||||||
* containing the data therein. If the given string is not a valid Duo
|
|
||||||
* cookie, an exception is thrown. Note that the cookie may be expired, and
|
|
||||||
* must be checked for expiration prior to actual use.
|
|
||||||
*
|
|
||||||
* @param str
|
|
||||||
* The base64-encoded Duo cookie to parse.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* A new DuoCookie object containing the same data as the given
|
|
||||||
* base64-encoded Duo cookie string.
|
|
||||||
*
|
|
||||||
* @throws GuacamoleException
|
|
||||||
* If the given string is not a valid base64-encoded Duo cookie.
|
|
||||||
*/
|
|
||||||
public static DuoCookie parseDuoCookie(String str) throws GuacamoleException {
|
|
||||||
|
|
||||||
// Attempt to decode data as base64
|
|
||||||
String data;
|
|
||||||
try {
|
|
||||||
data = new String(BaseEncoding.base64().decode(str), "UTF-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bail if invalid base64 is provided
|
|
||||||
catch (IllegalArgumentException e) {
|
|
||||||
throw new GuacamoleClientException("Username is not correctly "
|
|
||||||
+ "encoded as base64.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throw hard errors if standard pieces of Java are missing
|
|
||||||
catch (UnsupportedEncodingException e) {
|
|
||||||
throw new UnsupportedOperationException("Unexpected lack of "
|
|
||||||
+ "UTF-8 support.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify format of provided data
|
|
||||||
Matcher matcher = COOKIE_FORMAT.matcher(data);
|
|
||||||
if (!matcher.matches())
|
|
||||||
throw new GuacamoleClientException("Format of base64-encoded "
|
|
||||||
+ "username is invalid.");
|
|
||||||
|
|
||||||
// Get username and key (simple strings)
|
|
||||||
String username = matcher.group(USERNAME_GROUP);
|
|
||||||
String key = matcher.group(INTEGRATION_KEY_GROUP);
|
|
||||||
|
|
||||||
// Parse expiration time
|
|
||||||
long expires;
|
|
||||||
try {
|
|
||||||
expires = Long.parseLong(matcher.group(EXPIRATION_TIMESTAMP_GROUP));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bail if expiration timestamp is not a valid long
|
|
||||||
catch (NumberFormatException e) {
|
|
||||||
throw new GuacamoleClientException("Expiration timestamp is "
|
|
||||||
+ "not valid.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return parsed cookie
|
|
||||||
return new DuoCookie(username, key, expires);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the base64-encoded string representation of this DuoCookie. The
|
|
||||||
* format used is identical to that required by the Duo service: the
|
|
||||||
* username, integration key, and expiration timestamp separated by pipe
|
|
||||||
* symbols ("|") and encoded with base64.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The base64-encoded string representation of this DuoCookie.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
// Separate each cookie field with pipe symbols
|
|
||||||
String data = username + "|" + integrationKey + "|" + expires;
|
|
||||||
|
|
||||||
// Encode resulting cookie string with base64
|
|
||||||
return BaseEncoding.base64().encode(data.getBytes("UTF-8"));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throw hard errors if standard pieces of Java are missing
|
|
||||||
catch (UnsupportedEncodingException e) {
|
|
||||||
throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,205 +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.duo.api;
|
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import org.apache.guacamole.GuacamoleException;
|
|
||||||
import org.apache.guacamole.auth.duo.conf.ConfigurationService;
|
|
||||||
import org.apache.guacamole.net.auth.AuthenticatedUser;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service which produces signed requests and parses/verifies signed responses
|
|
||||||
* as required by Duo's API.
|
|
||||||
*/
|
|
||||||
public class DuoService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logger for this class.
|
|
||||||
*/
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(DuoService.class);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern which matches valid Duo responses. Each response is made up of
|
|
||||||
* two sections, separated from each other by a colon, where each section
|
|
||||||
* is a signed Duo cookie.
|
|
||||||
*/
|
|
||||||
private static final Pattern RESPONSE_FORMAT = Pattern.compile("([^:]+):([^:]+)");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within RESPONSE_FORMAT which
|
|
||||||
* contains the DUO_RESPONSE cookie signed by the secret key.
|
|
||||||
*/
|
|
||||||
private static final int DUO_COOKIE_GROUP = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within RESPONSE_FORMAT which
|
|
||||||
* contains the APPLICATION cookie signed by the application key.
|
|
||||||
*/
|
|
||||||
private static final int APP_COOKIE_GROUP = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount of time that each generated cookie remains valid, in seconds.
|
|
||||||
*/
|
|
||||||
private static final int COOKIE_EXPIRATION_TIME = 300;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 prevents generation of the signature.
|
|
||||||
*/
|
|
||||||
public String createSignedRequest(AuthenticatedUser authenticatedUser)
|
|
||||||
throws GuacamoleException {
|
|
||||||
|
|
||||||
// Generate a cookie associating the username with the integration key
|
|
||||||
DuoCookie cookie = new DuoCookie(authenticatedUser.getIdentifier(),
|
|
||||||
confService.getIntegrationKey(),
|
|
||||||
DuoCookie.currentTimestamp() + COOKIE_EXPIRATION_TIME);
|
|
||||||
|
|
||||||
// Sign cookie with secret key
|
|
||||||
SignedDuoCookie duoCookie = new SignedDuoCookie(cookie,
|
|
||||||
SignedDuoCookie.Type.DUO_REQUEST,
|
|
||||||
confService.getSecretKey());
|
|
||||||
|
|
||||||
// Sign cookie with application key
|
|
||||||
SignedDuoCookie appCookie = new SignedDuoCookie(cookie,
|
|
||||||
SignedDuoCookie.Type.APPLICATION,
|
|
||||||
confService.getApplicationKey());
|
|
||||||
|
|
||||||
// Return signed request containing both signed cookies, separated by
|
|
||||||
// a colon (as required by Duo)
|
|
||||||
return duoCookie + ":" + appCookie;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 prevents validation of the signature.
|
|
||||||
*/
|
|
||||||
public boolean isValidSignedResponse(AuthenticatedUser authenticatedUser,
|
|
||||||
String signedResponse) throws GuacamoleException {
|
|
||||||
|
|
||||||
SignedDuoCookie duoCookie;
|
|
||||||
SignedDuoCookie appCookie;
|
|
||||||
|
|
||||||
// Retrieve username from externally-authenticated user
|
|
||||||
String username = authenticatedUser.getIdentifier();
|
|
||||||
|
|
||||||
// Retrieve Duo-specific keys from configuration
|
|
||||||
String applicationKey = confService.getApplicationKey();
|
|
||||||
String integrationKey = confService.getIntegrationKey();
|
|
||||||
String secretKey = confService.getSecretKey();
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
// Verify format of response
|
|
||||||
Matcher matcher = RESPONSE_FORMAT.matcher(signedResponse);
|
|
||||||
if (!matcher.matches()) {
|
|
||||||
logger.debug("Duo response is not in correct format.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse signed cookie defining the user verified by Duo
|
|
||||||
duoCookie = SignedDuoCookie.parseSignedDuoCookie(secretKey,
|
|
||||||
matcher.group(DUO_COOKIE_GROUP));
|
|
||||||
|
|
||||||
// Parse signed cookie defining the user this application
|
|
||||||
// originally requested
|
|
||||||
appCookie = SignedDuoCookie.parseSignedDuoCookie(applicationKey,
|
|
||||||
matcher.group(APP_COOKIE_GROUP));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simply return false if signature fails to verify
|
|
||||||
catch (GuacamoleException e) {
|
|
||||||
logger.debug("Duo signature verification failed.", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify neither cookie is expired
|
|
||||||
if (duoCookie.isExpired() || appCookie.isExpired()) {
|
|
||||||
logger.debug("Duo response contained expired cookie(s).");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the cookies in the response have the correct types
|
|
||||||
if (duoCookie.getType() != SignedDuoCookie.Type.DUO_RESPONSE
|
|
||||||
|| appCookie.getType() != SignedDuoCookie.Type.APPLICATION) {
|
|
||||||
logger.debug("Duo response did not contain correct cookie type(s).");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify integration key matches both cookies
|
|
||||||
if (!duoCookie.getIntegrationKey().equals(integrationKey)
|
|
||||||
|| !appCookie.getIntegrationKey().equals(integrationKey)) {
|
|
||||||
logger.debug("Integration key of Duo response is incorrect.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify both cookies are for the current user
|
|
||||||
if (!duoCookie.getUsername().equals(username)
|
|
||||||
|| !appCookie.getUsername().equals(username)) {
|
|
||||||
logger.debug("Username of Duo response is incorrect.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All verifications tests pass
|
|
||||||
return true;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,332 +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.duo.api;
|
|
||||||
|
|
||||||
import com.google.common.io.BaseEncoding;
|
|
||||||
import java.io.UnsupportedEncodingException;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
import org.apache.guacamole.GuacamoleClientException;
|
|
||||||
import org.apache.guacamole.GuacamoleException;
|
|
||||||
import org.apache.guacamole.GuacamoleServerException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A DuoCookie which is cryptographically signed with a provided key using
|
|
||||||
* HMAC-SHA1.
|
|
||||||
*/
|
|
||||||
public class SignedDuoCookie extends DuoCookie {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern which matches valid signed cookies. Like unsigned cookies, each
|
|
||||||
* signed cookie is made up of three sections, separated from each other by
|
|
||||||
* pipe symbols ("|").
|
|
||||||
*/
|
|
||||||
private static final Pattern SIGNED_COOKIE_FORMAT = Pattern.compile("([^|]+)\\|([^|]+)\\|([0-9a-f]+)");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within SIGNED_COOKIE_FORMAT which
|
|
||||||
* contains the cookie type prefix.
|
|
||||||
*/
|
|
||||||
private static final int PREFIX_GROUP = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within SIGNED_COOKIE_FORMAT which
|
|
||||||
* contains the cookie's base64-encoded data.
|
|
||||||
*/
|
|
||||||
private static final int DATA_GROUP = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the capturing group within SIGNED_COOKIE_FORMAT which
|
|
||||||
* contains the signature.
|
|
||||||
*/
|
|
||||||
private static final int SIGNATURE_GROUP = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The signature algorithm that should be used to sign the cookie, as
|
|
||||||
* defined by:
|
|
||||||
* http://docs.oracle.com/javase/7/docs/technotes/guides/security/StandardNames.html#Mac
|
|
||||||
*/
|
|
||||||
private static final String SIGNATURE_ALGORITHM = "HmacSHA1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of a signed Duo cookie. Each signed Duo cookie has an
|
|
||||||
* associated type which determines the prefix included in the string
|
|
||||||
* representation of that cookie. As that type is included in the data
|
|
||||||
* that is signed, different types will result in different signatures,
|
|
||||||
* even if the data portion of the cookie is otherwise identical.
|
|
||||||
*/
|
|
||||||
public enum Type {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Duo cookie which has been signed with the secret key for inclusion
|
|
||||||
* in a Duo request.
|
|
||||||
*/
|
|
||||||
DUO_REQUEST("TX"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Duo cookie which has been signed with the secret key by Duo and
|
|
||||||
* was included in a Duo response.
|
|
||||||
*/
|
|
||||||
DUO_RESPONSE("AUTH"),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Duo cookie which has been signed with the application key for
|
|
||||||
* inclusion in a Duo request. Such cookies are also included in Duo
|
|
||||||
* responses, for verification by the application.
|
|
||||||
*/
|
|
||||||
APPLICATION("APP");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The prefix associated with the Duo cookie type. This prefix will
|
|
||||||
* be included in the string representation of the cookie.
|
|
||||||
*/
|
|
||||||
private final String prefix;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Duo cookie type associated with the given string
|
|
||||||
* prefix. This prefix will be included in the string representation of
|
|
||||||
* the cookie.
|
|
||||||
*
|
|
||||||
* @param prefix
|
|
||||||
* The prefix to associated with the Duo cookie type.
|
|
||||||
*/
|
|
||||||
Type(String prefix) {
|
|
||||||
this.prefix = prefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the prefix associated with the Duo cookie type.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The prefix to associated with this Duo cookie type.
|
|
||||||
*/
|
|
||||||
public String getPrefix() {
|
|
||||||
return prefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the cookie type associated with the given prefix. If no such
|
|
||||||
* cookie type exists, null is returned.
|
|
||||||
*
|
|
||||||
* @param prefix
|
|
||||||
* The prefix of the cookie type to search for.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The cookie type associated with the given prefix, or null if no
|
|
||||||
* such cookie type exists.
|
|
||||||
*/
|
|
||||||
public static Type fromPrefix(String prefix) {
|
|
||||||
|
|
||||||
// Search through all defined cookie types for the given prefix
|
|
||||||
for (Type type : Type.values()) {
|
|
||||||
if (type.getPrefix().equals(prefix))
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No such cookie type exists
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of this Duo cookie.
|
|
||||||
*/
|
|
||||||
private final Type type;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The signature produced when the cookie was signed with HMAC-SHA1. The
|
|
||||||
* signature covers the prefix of the type and the cookie's base64-encoded
|
|
||||||
* data, separated by a pipe symbol.
|
|
||||||
*/
|
|
||||||
private final String signature;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new SignedDuoCookie which describes the identity of a user
|
|
||||||
* being verified and is cryptographically signed with HMAC-SHA1 by a given
|
|
||||||
* key.
|
|
||||||
*
|
|
||||||
* @param cookie
|
|
||||||
* The cookie defining the identity being verified.
|
|
||||||
*
|
|
||||||
* @param type
|
|
||||||
* The type of the cookie being created.
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* The key to use to generate the cryptographic signature. This key
|
|
||||||
* will not be stored within the cookie.
|
|
||||||
*
|
|
||||||
* @throws GuacamoleException
|
|
||||||
* If the given signing key is invalid.
|
|
||||||
*/
|
|
||||||
public SignedDuoCookie(DuoCookie cookie, Type type, String key)
|
|
||||||
throws GuacamoleException {
|
|
||||||
|
|
||||||
// Init underlying cookie
|
|
||||||
super(cookie.getUsername(), cookie.getIntegrationKey(),
|
|
||||||
cookie.getExpirationTimestamp());
|
|
||||||
|
|
||||||
// Store cookie type and signature
|
|
||||||
this.type = type;
|
|
||||||
this.signature = sign(key, type.getPrefix() + "|" + cookie.toString());
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs the given arbitrary string data with the given key using the
|
|
||||||
* algorithm defined by SIGNATURE_ALGORITHM. Both the data and the key will
|
|
||||||
* be interpreted as UTF-8 bytes.
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* The key which should be used to sign the given data.
|
|
||||||
*
|
|
||||||
* @param data
|
|
||||||
* The data being signed.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The signature produced by signing the given data with the given key,
|
|
||||||
* encoded as lowercase hexadecimal.
|
|
||||||
*
|
|
||||||
* @throws GuacamoleException
|
|
||||||
* If the given signing key is invalid.
|
|
||||||
*/
|
|
||||||
private static String sign(String key, String data) throws GuacamoleException {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
// Attempt to sign UTF-8 bytes of provided data
|
|
||||||
Mac mac = Mac.getInstance(SIGNATURE_ALGORITHM);
|
|
||||||
mac.init(new SecretKeySpec(key.getBytes("UTF-8"), SIGNATURE_ALGORITHM));
|
|
||||||
|
|
||||||
// Return signature as hex
|
|
||||||
return BaseEncoding.base16().lowerCase().encode(mac.doFinal(data.getBytes("UTF-8")));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-throw any errors which prevent signature
|
|
||||||
catch (InvalidKeyException e){
|
|
||||||
throw new GuacamoleServerException("Signing key is invalid.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throw hard errors if standard pieces of Java are missing
|
|
||||||
catch (UnsupportedEncodingException e) {
|
|
||||||
throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
|
|
||||||
}
|
|
||||||
catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new UnsupportedOperationException("Unexpected lack of support "
|
|
||||||
+ "for required signature algorithm "
|
|
||||||
+ "\"" + SIGNATURE_ALGORITHM + "\".", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the type of this Duo cookie. The Duo cookie type is dictated
|
|
||||||
* by the context of the cookie's use, and is included with the cookie's
|
|
||||||
* underlying data when generating the signature.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The type of this Duo cookie.
|
|
||||||
*/
|
|
||||||
public Type getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the signature produced when the cookie was signed with HMAC-SHA1.
|
|
||||||
* The signature covers the prefix of the cookie's type and the cookie's
|
|
||||||
* base64-encoded data, separated by a pipe symbol.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The signature produced when the cookie was signed with HMAC-SHA1.
|
|
||||||
*/
|
|
||||||
public String getSignature() {
|
|
||||||
return signature;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a signed Duo cookie string, such as that produced by the
|
|
||||||
* toString() function or received from the Duo service, producing a new
|
|
||||||
* SignedDuoCookie object containing the associated cookie data and
|
|
||||||
* signature. If the given string is not a valid Duo cookie, or if the
|
|
||||||
* signature is incorrect, an exception is thrown. Note that the cookie may
|
|
||||||
* be expired, and must be checked for expiration prior to actual use.
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* The key that was used to sign the Duo cookie.
|
|
||||||
*
|
|
||||||
* @param str
|
|
||||||
* The Duo cookie string to parse.
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* A new SignedDuoCookie object containing the same data and signature
|
|
||||||
* as the given Duo cookie string.
|
|
||||||
*
|
|
||||||
* @throws GuacamoleException
|
|
||||||
* If the given string is not a valid Duo cookie string, or if the
|
|
||||||
* signature of the cookie is invalid.
|
|
||||||
*/
|
|
||||||
public static SignedDuoCookie parseSignedDuoCookie(String key, String str)
|
|
||||||
throws GuacamoleException {
|
|
||||||
|
|
||||||
// Verify format of provided data
|
|
||||||
Matcher matcher = SIGNED_COOKIE_FORMAT.matcher(str);
|
|
||||||
if (!matcher.matches())
|
|
||||||
throw new GuacamoleClientException("Format of signed Duo cookie "
|
|
||||||
+ "is invalid.");
|
|
||||||
|
|
||||||
// Parse type from prefix
|
|
||||||
Type type = Type.fromPrefix(matcher.group(PREFIX_GROUP));
|
|
||||||
if (type == null)
|
|
||||||
throw new GuacamoleClientException("Invalid Duo cookie prefix.");
|
|
||||||
|
|
||||||
// Parse cookie from base64-encoded data
|
|
||||||
DuoCookie cookie = DuoCookie.parseDuoCookie(matcher.group(DATA_GROUP));
|
|
||||||
|
|
||||||
// Verify signature of cookie
|
|
||||||
SignedDuoCookie signedCookie = new SignedDuoCookie(cookie, type, key);
|
|
||||||
if (!signedCookie.getSignature().equals(matcher.group(SIGNATURE_GROUP)))
|
|
||||||
throw new GuacamoleClientException("Duo cookie has incorrect signature.");
|
|
||||||
|
|
||||||
// Cookie has valid signature and has parsed successfully
|
|
||||||
return signedCookie;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the string representation of this SignedDuoCookie. The format
|
|
||||||
* used is identical to that required by the Duo service: the type prefix,
|
|
||||||
* base64-encoded cookie data, and HMAC-SHA1 signature separated by pipe
|
|
||||||
* symbols ("|").
|
|
||||||
*
|
|
||||||
* @return
|
|
||||||
* The string representation of this SignedDuoCookie.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return type.getPrefix() + "|" + super.toString() + "|" + signature;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -20,9 +20,12 @@
|
|||||||
package org.apache.guacamole.auth.duo.conf;
|
package org.apache.guacamole.auth.duo.conf;
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
|
import java.net.URI;
|
||||||
import org.apache.guacamole.GuacamoleException;
|
import org.apache.guacamole.GuacamoleException;
|
||||||
import org.apache.guacamole.environment.Environment;
|
import org.apache.guacamole.environment.Environment;
|
||||||
|
import org.apache.guacamole.properties.IntegerGuacamoleProperty;
|
||||||
import org.apache.guacamole.properties.StringGuacamoleProperty;
|
import org.apache.guacamole.properties.StringGuacamoleProperty;
|
||||||
|
import org.apache.guacamole.properties.URIGuacamoleProperty;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for retrieving configuration information regarding the Duo
|
* Service for retrieving configuration information regarding the Duo
|
||||||
@@ -56,11 +59,11 @@ public class ConfigurationService {
|
|||||||
* key received from Duo for verifying Guacamole users. This value MUST be
|
* key received from Duo for verifying Guacamole users. This value MUST be
|
||||||
* exactly 20 characters.
|
* exactly 20 characters.
|
||||||
*/
|
*/
|
||||||
private static final StringGuacamoleProperty DUO_INTEGRATION_KEY =
|
private static final StringGuacamoleProperty DUO_CLIENT_ID =
|
||||||
new StringGuacamoleProperty() {
|
new StringGuacamoleProperty() {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() { return "duo-integration-key"; }
|
public String getName() { return "duo-client-id"; }
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,25 +72,37 @@ public class ConfigurationService {
|
|||||||
* received from Duo for verifying Guacamole users. This value MUST be
|
* received from Duo for verifying Guacamole users. This value MUST be
|
||||||
* exactly 40 characters.
|
* exactly 40 characters.
|
||||||
*/
|
*/
|
||||||
private static final StringGuacamoleProperty DUO_SECRET_KEY =
|
private static final StringGuacamoleProperty DUO_CLIENT_SECRET =
|
||||||
new StringGuacamoleProperty() {
|
new StringGuacamoleProperty() {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() { return "duo-secret-key"; }
|
public String getName() { return "duo-client-secret"; }
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The property within guacamole.properties which defines the arbitrary
|
* The property within guacamole.properties which defines the redirect URL
|
||||||
* random key which was generated for Guacamole. Note that this value is not
|
* that Duo will call after the second factor has been completed. This
|
||||||
* provided by Duo, but is expected to be generated by the administrator of
|
* should be the URL used to access Guacamole.
|
||||||
* the system hosting Guacamole. This value MUST be at least 40 characters.
|
|
||||||
*/
|
*/
|
||||||
private static final StringGuacamoleProperty DUO_APPLICATION_KEY =
|
private static final URIGuacamoleProperty DUO_REDIRECT_URL =
|
||||||
new StringGuacamoleProperty() {
|
new URIGuacamoleProperty() {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() { return "duo-application-key"; }
|
public String getName() { return "duo-redirect-url"; }
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The property that configures the timeout, in seconds, of in-progress
|
||||||
|
* Duo authentication attempts. Authentication attempts that take longer
|
||||||
|
* than this period of time will be invalidated.
|
||||||
|
*/
|
||||||
|
private static final IntegerGuacamoleProperty DUO_AUTH_TIMEOUT =
|
||||||
|
new IntegerGuacamoleProperty() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() { return "duo-auth-timeout"; }
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,51 +125,65 @@ public class ConfigurationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the integration key received from Duo for verifying Guacamole
|
* Returns the Duo client id received from Duo for verifying Guacamole
|
||||||
* users, as defined in guacamole.properties by the "duo-integration-key"
|
* users, as defined in guacamole.properties by the "duo-client-id"
|
||||||
* property. This value MUST be exactly 20 characters.
|
* property. This value MUST be exactly 20 characters.
|
||||||
*
|
*
|
||||||
* @return
|
* @return
|
||||||
* The integration key received from Duo for verifying Guacamole
|
* The client id received from Duo for verifying Guacamole users.
|
||||||
* users.
|
|
||||||
*
|
*
|
||||||
* @throws GuacamoleException
|
* @throws GuacamoleException
|
||||||
* If the associated property within guacamole.properties is missing.
|
* If the associated property within guacamole.properties is missing.
|
||||||
*/
|
*/
|
||||||
public String getIntegrationKey() throws GuacamoleException {
|
public String getClientId() throws GuacamoleException {
|
||||||
return environment.getRequiredProperty(DUO_INTEGRATION_KEY);
|
return environment.getRequiredProperty(DUO_CLIENT_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the secret key received from Duo for verifying Guacamole users,
|
* Returns the client secert received from Duo for verifying Guacamole users,
|
||||||
* as defined in guacamole.properties by the "duo-secret-key" property. This
|
* as defined in guacamole.properties by the "duo-client-secert" property.
|
||||||
* value MUST be exactly 20 characters.
|
* This value MUST be exactly 20 characters.
|
||||||
*
|
*
|
||||||
* @return
|
* @return
|
||||||
* The secret key received from Duo for verifying Guacamole users.
|
* The client secret received from Duo for verifying Guacamole users.
|
||||||
*
|
*
|
||||||
* @throws GuacamoleException
|
* @throws GuacamoleException
|
||||||
* If the associated property within guacamole.properties is missing.
|
* If the associated property within guacamole.properties is missing.
|
||||||
*/
|
*/
|
||||||
public String getSecretKey() throws GuacamoleException {
|
public String getClientSecret() throws GuacamoleException {
|
||||||
return environment.getRequiredProperty(DUO_SECRET_KEY);
|
return environment.getRequiredProperty(DUO_CLIENT_SECRET);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the arbitrary random key which was generated for Guacamole, as
|
* Return the callback URL that will be called by Duo after authentication
|
||||||
* defined in guacamole.properties by the "duo-application-key" property.
|
* with Duo has been completed. This should be the URL to return the user
|
||||||
* Note that this value is not provided by Duo, but is expected to be
|
* to the Guacamole interface, and will be a full URL.
|
||||||
* generated by the administrator of the system hosting Guacamole. This
|
|
||||||
* value MUST be at least 40 characters.
|
|
||||||
*
|
*
|
||||||
* @return
|
* @return
|
||||||
* The arbitrary random key which was generated for Guacamole.
|
* The URL for Duo to use to callback to the Guacamole interface after
|
||||||
|
* authentication has been completed.
|
||||||
*
|
*
|
||||||
* @throws GuacamoleException
|
* @throws GuacamoleException
|
||||||
* If the associated property within guacamole.properties is missing.
|
* If guacamole.properties cannot be read, or if the property is not
|
||||||
|
* defined.
|
||||||
*/
|
*/
|
||||||
public String getApplicationKey() throws GuacamoleException {
|
public URI getRedirectUrl() throws GuacamoleException {
|
||||||
return environment.getRequiredProperty(DUO_APPLICATION_KEY);
|
return environment.getRequiredProperty(DUO_REDIRECT_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the number of seconds after which in-progress authentication attempts with
|
||||||
|
* Duo should be invalidated. The default is 30 seconds.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The number of seconds after which in-progress Duo MFA attempts should
|
||||||
|
* be invalidated.
|
||||||
|
*
|
||||||
|
* @throws GuacamoleException
|
||||||
|
* If guacamole.properties cannot be parsed.
|
||||||
|
*/
|
||||||
|
public int getAuthTimeout() throws GuacamoleException {
|
||||||
|
return environment.getProperty(DUO_AUTH_TIMEOUT, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,98 +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.duo.form;
|
|
||||||
|
|
||||||
import org.apache.guacamole.form.Field;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,33 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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'
|
|
||||||
});
|
|
||||||
|
|
||||||
}]);
|
|
@@ -1,86 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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', '$element',
|
|
||||||
function duoSignedResponseController($scope, $element) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The iframe which contains the Duo authentication interface.
|
|
||||||
*
|
|
||||||
* @type HTMLIFrameElement
|
|
||||||
*/
|
|
||||||
var iframe = $element.find('iframe')[0];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The submit button which should be used to submit the login form once
|
|
||||||
* the Duo response has been received.
|
|
||||||
*
|
|
||||||
* @type HTMLInputElement
|
|
||||||
*/
|
|
||||||
var submit = $element.find('input[type="submit"]')[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
|
|
||||||
submit.click();
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
});
|
|
||||||
|
|
||||||
}]);
|
|
@@ -20,18 +20,6 @@
|
|||||||
"translations/pt.json",
|
"translations/pt.json",
|
||||||
"translations/ru.json",
|
"translations/ru.json",
|
||||||
"translations/zh.json"
|
"translations/zh.json"
|
||||||
],
|
]
|
||||||
|
|
||||||
"js" : [
|
|
||||||
"duo.min.js"
|
|
||||||
],
|
|
||||||
|
|
||||||
"css" : [
|
|
||||||
"duo.min.css"
|
|
||||||
],
|
|
||||||
|
|
||||||
"resources" : {
|
|
||||||
"templates/duoSignedResponseField.html" : "text/html"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,366 +0,0 @@
|
|||||||
/**
|
|
||||||
* Duo Web SDK v2
|
|
||||||
* Copyright 2015, Duo Security
|
|
||||||
*/
|
|
||||||
window.Duo = (function(document, window) {
|
|
||||||
var DUO_MESSAGE_FORMAT = /^(?:AUTH|ENROLL)+\|[A-Za-z0-9\+\/=]+\|[A-Za-z0-9\+\/=]+$/;
|
|
||||||
var DUO_ERROR_FORMAT = /^ERR\|[\w\s\.\(\)]+$/;
|
|
||||||
|
|
||||||
var iframeId = 'duo_iframe',
|
|
||||||
postAction = '',
|
|
||||||
postArgument = 'sig_response',
|
|
||||||
host,
|
|
||||||
sigRequest,
|
|
||||||
duoSig,
|
|
||||||
appSig,
|
|
||||||
iframe,
|
|
||||||
submitCallback;
|
|
||||||
|
|
||||||
function throwError(message, url) {
|
|
||||||
throw new Error(
|
|
||||||
'Duo Web SDK error: ' + message +
|
|
||||||
(url ? ('\n' + 'See ' + url + ' for more information') : '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hyphenize(str) {
|
|
||||||
return str.replace(/([a-z])([A-Z])/, '$1-$2').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// cross-browser data attributes
|
|
||||||
function getDataAttribute(element, name) {
|
|
||||||
if ('dataset' in element) {
|
|
||||||
return element.dataset[name];
|
|
||||||
} else {
|
|
||||||
return element.getAttribute('data-' + hyphenize(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cross-browser event binding/unbinding
|
|
||||||
function on(context, event, fallbackEvent, callback) {
|
|
||||||
if ('addEventListener' in window) {
|
|
||||||
context.addEventListener(event, callback, false);
|
|
||||||
} else {
|
|
||||||
context.attachEvent(fallbackEvent, callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function off(context, event, fallbackEvent, callback) {
|
|
||||||
if ('removeEventListener' in window) {
|
|
||||||
context.removeEventListener(event, callback, false);
|
|
||||||
} else {
|
|
||||||
context.detachEvent(fallbackEvent, callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onReady(callback) {
|
|
||||||
on(document, 'DOMContentLoaded', 'onreadystatechange', callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function offReady(callback) {
|
|
||||||
off(document, 'DOMContentLoaded', 'onreadystatechange', callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMessage(callback) {
|
|
||||||
on(window, 'message', 'onmessage', callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function offMessage(callback) {
|
|
||||||
off(window, 'message', 'onmessage', callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the sig_request parameter, throwing errors if the token contains
|
|
||||||
* a server error or if the token is invalid.
|
|
||||||
*
|
|
||||||
* @param {String} sig Request token
|
|
||||||
*/
|
|
||||||
function parseSigRequest(sig) {
|
|
||||||
if (!sig) {
|
|
||||||
// nothing to do
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// see if the token contains an error, throwing it if it does
|
|
||||||
if (sig.indexOf('ERR|') === 0) {
|
|
||||||
throwError(sig.split('|')[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate the token
|
|
||||||
if (sig.indexOf(':') === -1 || sig.split(':').length !== 2) {
|
|
||||||
throwError(
|
|
||||||
'Duo was given a bad token. This might indicate a configuration ' +
|
|
||||||
'problem with one of Duo\'s client libraries.',
|
|
||||||
'https://www.duosecurity.com/docs/duoweb#first-steps'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var sigParts = sig.split(':');
|
|
||||||
|
|
||||||
// hang on to the token, and the parsed duo and app sigs
|
|
||||||
sigRequest = sig;
|
|
||||||
duoSig = sigParts[0];
|
|
||||||
appSig = sigParts[1];
|
|
||||||
|
|
||||||
return {
|
|
||||||
sigRequest: sig,
|
|
||||||
duoSig: sigParts[0],
|
|
||||||
appSig: sigParts[1]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is set up to run when the DOM is ready, if the iframe was
|
|
||||||
* not available during `init`.
|
|
||||||
*/
|
|
||||||
function onDOMReady() {
|
|
||||||
iframe = document.getElementById(iframeId);
|
|
||||||
|
|
||||||
if (!iframe) {
|
|
||||||
throw new Error(
|
|
||||||
'This page does not contain an iframe for Duo to use.' +
|
|
||||||
'Add an element like <iframe id="duo_iframe"></iframe> ' +
|
|
||||||
'to this page. ' +
|
|
||||||
'See https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe ' +
|
|
||||||
'for more information.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we've got an iframe, away we go!
|
|
||||||
ready();
|
|
||||||
|
|
||||||
// always clean up after yourself
|
|
||||||
offReady(onDOMReady);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a MessageEvent came from the Duo service, and that it
|
|
||||||
* is a properly formatted payload.
|
|
||||||
*
|
|
||||||
* The Google Chrome sign-in page injects some JS into pages that also
|
|
||||||
* make use of postMessage, so we need to do additional validation above
|
|
||||||
* and beyond the origin.
|
|
||||||
*
|
|
||||||
* @param {MessageEvent} event Message received via postMessage
|
|
||||||
*/
|
|
||||||
function isDuoMessage(event) {
|
|
||||||
return Boolean(
|
|
||||||
event.origin === ('https://' + host) &&
|
|
||||||
typeof event.data === 'string' &&
|
|
||||||
(
|
|
||||||
event.data.match(DUO_MESSAGE_FORMAT) ||
|
|
||||||
event.data.match(DUO_ERROR_FORMAT)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the request token and prepare for the iframe to become ready.
|
|
||||||
*
|
|
||||||
* All options below can be passed into an options hash to `Duo.init`, or
|
|
||||||
* specified on the iframe using `data-` attributes.
|
|
||||||
*
|
|
||||||
* Options specified using the options hash will take precedence over
|
|
||||||
* `data-` attributes.
|
|
||||||
*
|
|
||||||
* Example using options hash:
|
|
||||||
* ```javascript
|
|
||||||
* Duo.init({
|
|
||||||
* iframe: "some_other_id",
|
|
||||||
* host: "api-main.duo.test",
|
|
||||||
* sig_request: "...",
|
|
||||||
* post_action: "/auth",
|
|
||||||
* post_argument: "resp"
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Example using `data-` attributes:
|
|
||||||
* ```
|
|
||||||
* <iframe id="duo_iframe"
|
|
||||||
* data-host="api-main.duo.test"
|
|
||||||
* data-sig-request="..."
|
|
||||||
* data-post-action="/auth"
|
|
||||||
* data-post-argument="resp"
|
|
||||||
* >
|
|
||||||
* </iframe>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {String} options.iframe The iframe, or id of an iframe to set up
|
|
||||||
* @param {String} options.host Hostname
|
|
||||||
* @param {String} options.sig_request Request token
|
|
||||||
* @param {String} [options.post_action=''] URL to POST back to after successful auth
|
|
||||||
* @param {String} [options.post_argument='sig_response'] Parameter name to use for response token
|
|
||||||
* @param {Function} [options.submit_callback] If provided, duo will not submit the form instead execute
|
|
||||||
* the callback function with reference to the "duo_form" form object
|
|
||||||
* submit_callback can be used to prevent the webpage from reloading.
|
|
||||||
*/
|
|
||||||
function init(options) {
|
|
||||||
if (options) {
|
|
||||||
if (options.host) {
|
|
||||||
host = options.host;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.sig_request) {
|
|
||||||
parseSigRequest(options.sig_request);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.post_action) {
|
|
||||||
postAction = options.post_action;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.post_argument) {
|
|
||||||
postArgument = options.post_argument;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.iframe) {
|
|
||||||
if ('tagName' in options.iframe) {
|
|
||||||
iframe = options.iframe;
|
|
||||||
} else if (typeof options.iframe === 'string') {
|
|
||||||
iframeId = options.iframe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof options.submit_callback === 'function') {
|
|
||||||
submitCallback = options.submit_callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we were given an iframe, no need to wait for the rest of the DOM
|
|
||||||
if (iframe) {
|
|
||||||
ready();
|
|
||||||
} else {
|
|
||||||
// try to find the iframe in the DOM
|
|
||||||
iframe = document.getElementById(iframeId);
|
|
||||||
|
|
||||||
// iframe is in the DOM, away we go!
|
|
||||||
if (iframe) {
|
|
||||||
ready();
|
|
||||||
} else {
|
|
||||||
// wait until the DOM is ready, then try again
|
|
||||||
onReady(onDOMReady);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// always clean up after yourself!
|
|
||||||
offReady(init);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is called when a message was received from another domain
|
|
||||||
* using the `postMessage` API. Check that the event came from the Duo
|
|
||||||
* service domain, and that the message is a properly formatted payload,
|
|
||||||
* then perform the post back to the primary service.
|
|
||||||
*
|
|
||||||
* @param event Event object (contains origin and data)
|
|
||||||
*/
|
|
||||||
function onReceivedMessage(event) {
|
|
||||||
if (isDuoMessage(event)) {
|
|
||||||
// the event came from duo, do the post back
|
|
||||||
doPostBack(event.data);
|
|
||||||
|
|
||||||
// always clean up after yourself!
|
|
||||||
offMessage(onReceivedMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Point the iframe at Duo, then wait for it to postMessage back to us.
|
|
||||||
*/
|
|
||||||
function ready() {
|
|
||||||
if (!host) {
|
|
||||||
host = getDataAttribute(iframe, 'host');
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
throwError(
|
|
||||||
'No API hostname is given for Duo to use. Be sure to pass ' +
|
|
||||||
'a `host` parameter to Duo.init, or through the `data-host` ' +
|
|
||||||
'attribute on the iframe element.',
|
|
||||||
'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!duoSig || !appSig) {
|
|
||||||
parseSigRequest(getDataAttribute(iframe, 'sigRequest'));
|
|
||||||
|
|
||||||
if (!duoSig || !appSig) {
|
|
||||||
throwError(
|
|
||||||
'No valid signed request is given. Be sure to give the ' +
|
|
||||||
'`sig_request` parameter to Duo.init, or use the ' +
|
|
||||||
'`data-sig-request` attribute on the iframe element.',
|
|
||||||
'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if postAction/Argument are defaults, see if they are specified
|
|
||||||
// as data attributes on the iframe
|
|
||||||
if (postAction === '') {
|
|
||||||
postAction = getDataAttribute(iframe, 'postAction') || postAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (postArgument === 'sig_response') {
|
|
||||||
postArgument = getDataAttribute(iframe, 'postArgument') || postArgument;
|
|
||||||
}
|
|
||||||
|
|
||||||
// point the iframe at Duo
|
|
||||||
iframe.src = [
|
|
||||||
'https://', host, '/frame/web/v1/auth?tx=', duoSig,
|
|
||||||
'&parent=', encodeURIComponent(document.location.href),
|
|
||||||
'&v=2.3'
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
// listen for the 'message' event
|
|
||||||
onMessage(onReceivedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We received a postMessage from Duo. POST back to the primary service
|
|
||||||
* with the response token, and any additional user-supplied parameters
|
|
||||||
* given in form#duo_form.
|
|
||||||
*/
|
|
||||||
function doPostBack(response) {
|
|
||||||
// create a hidden input to contain the response token
|
|
||||||
var input = document.createElement('input');
|
|
||||||
input.type = 'hidden';
|
|
||||||
input.name = postArgument;
|
|
||||||
input.value = response + ':' + appSig;
|
|
||||||
|
|
||||||
// user may supply their own form with additional inputs
|
|
||||||
var form = document.getElementById('duo_form');
|
|
||||||
|
|
||||||
// if the form doesn't exist, create one
|
|
||||||
if (!form) {
|
|
||||||
form = document.createElement('form');
|
|
||||||
|
|
||||||
// insert the new form after the iframe
|
|
||||||
iframe.parentElement.insertBefore(form, iframe.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure we are actually posting to the right place
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = postAction;
|
|
||||||
|
|
||||||
// add the response token input to the form
|
|
||||||
form.appendChild(input);
|
|
||||||
|
|
||||||
// away we go!
|
|
||||||
if (typeof submitCallback === "function") {
|
|
||||||
submitCallback.call(null, form);
|
|
||||||
} else {
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// when the DOM is ready, initialize
|
|
||||||
// note that this will get cleaned up if the user calls init directly!
|
|
||||||
onReady(init);
|
|
||||||
|
|
||||||
return {
|
|
||||||
init: init,
|
|
||||||
_parseSigRequest: parseSigRequest,
|
|
||||||
_isDuoMessage: isDuoMessage,
|
|
||||||
_doPostBack: doPostBack
|
|
||||||
};
|
|
||||||
}(document, window));
|
|
@@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
@@ -1,62 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
.duo-signature-response-field-container {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
display: table;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duo-signature-response-field {
|
|
||||||
width: 100%;
|
|
||||||
display: table-cell;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duo-signature-response-field input[type="submit"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duo-signature-response-field iframe {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 620px;
|
|
||||||
height: 330px;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.5);
|
|
||||||
display: block;
|
|
||||||
margin: 1.5em auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
@@ -1,6 +0,0 @@
|
|||||||
<div class="duo-signature-response-field-container">
|
|
||||||
<div class="duo-signature-response-field" ng-class="{ loading : !duoInterfaceLoaded }">
|
|
||||||
<iframe></iframe>
|
|
||||||
<input type="submit">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
Reference in New Issue
Block a user