GUACAMOLE-1289: Refactor Duo and authentication-resumption changes to instead leverage support for updating/replacing credentials prior to auth.

This commit is contained in:
Michael Jumper
2024-04-25 12:52:27 -07:00
parent 83111616e5
commit 6dd4766da4
12 changed files with 340 additions and 486 deletions

View File

@@ -21,9 +21,11 @@ package org.apache.guacamole.auth.duo;
import com.google.inject.Guice;
import com.google.inject.Injector;
import javax.servlet.http.HttpServletRequest;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.AbstractAuthenticationProvider;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.Credentials;
import org.apache.guacamole.net.auth.UserContext;
/**
@@ -41,10 +43,16 @@ public class DuoAuthenticationProvider extends AbstractAuthenticationProvider {
public static String PROVIDER_IDENTIFER = "duo";
/**
* Injector which will manage the object graph of this authentication
* provider.
* Service for verifying the identity of users that Guacamole has otherwise
* already authenticated.
*/
private final Injector injector;
private final UserVerificationService verificationService;
/**
* Session manager for storing/retrieving the state of a user's
* authentication attempt while they are redirected to the Duo service.
*/
private final DuoAuthenticationSessionManager sessionManager;
/**
* Creates a new DuoAuthenticationProvider that verifies users
@@ -57,10 +65,13 @@ public class DuoAuthenticationProvider extends AbstractAuthenticationProvider {
public DuoAuthenticationProvider() throws GuacamoleException {
// Set up Guice injector.
injector = Guice.createInjector(
Injector injector = Guice.createInjector(
new DuoAuthenticationProviderModule(this)
);
sessionManager = injector.getInstance(DuoAuthenticationSessionManager.class);
verificationService = injector.getInstance(UserVerificationService.class);
}
@Override
@@ -69,11 +80,33 @@ public class DuoAuthenticationProvider extends AbstractAuthenticationProvider {
}
@Override
public UserContext getUserContext(AuthenticatedUser authenticatedUser)
public Credentials updateCredentials(Credentials credentials)
throws GuacamoleException {
UserVerificationService verificationService =
injector.getInstance(UserVerificationService.class);
// Ignore requests with no corresponding authentication session ID, as
// there are no credentials to reconstitute if the user has not yet
// attempted to authenticate
HttpServletRequest request = credentials.getRequest();
String duoState = request.getParameter(UserVerificationService.DUO_STATE_PARAMETER_NAME);
if (duoState == null)
return credentials;
// Ignore requests with invalid/expired authentication session IDs
DuoAuthenticationSession session = sessionManager.resume(duoState);
if (session == null)
return credentials;
// Reconstitute the originally-provided credentials from the users
// authentication attempt prior to being redirected to Duo
Credentials previousCredentials = session.getCredentials();
previousCredentials.setRequest(request);
return previousCredentials;
}
@Override
public UserContext getUserContext(AuthenticatedUser authenticatedUser)
throws GuacamoleException {
// Verify user against Duo service
verificationService.verifyAuthenticatedUser(authenticatedUser);
@@ -84,4 +117,9 @@ public class DuoAuthenticationProvider extends AbstractAuthenticationProvider {
}
@Override
public void shutdown() {
sessionManager.shutdown();
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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;
import org.apache.guacamole.net.auth.Credentials;
/**
* Representation of an in-progress Duo authentication attempt.
*/
public class DuoAuthenticationSession extends AuthenticationSession {
/**
* The credentials that the user originally provided to Guacamole prior to
* being redirected to the Duo service.
*/
private final Credentials credentials;
/**
* Creates a new AuthenticationSession representing an in-progress Duo
* authentication attempt.
*
* @param credentials
* The credentials that the user originally provided to Guacamole prior
* to being redirected to the Duo service.
*
* @param expires
* The number of milliseconds that may elapse before this session must
* be considered invalid.
*/
public DuoAuthenticationSession(Credentials credentials, long expires) {
super(expires);
this.credentials = credentials;
}
/**
* Returns the credentials that the user originally provided to Guacamole
* prior to being redirected to the Duo service.
*
* @return
* The credentials that the user originally provided to Guacamole prior
* to being redirected to the Duo service.
*/
public Credentials getCredentials() {
return credentials;
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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.Singleton;
import org.apache.guacamole.net.auth.AuthenticationSessionManager;
/**
* Manager service that temporarily stores authentication attempts while
* the Duo authentication flow is underway.
*/
@Singleton
public class DuoAuthenticationSessionManager
extends AuthenticationSessionManager<DuoAuthenticationSession> {
// Intentionally empty (the default functions inherited from the
// AuthenticationSessionManager base class are sufficient for our needs)
}

View File

@@ -37,35 +37,45 @@ import org.apache.guacamole.language.TranslatableMessage;
import org.apache.guacamole.net.auth.AuthenticatedUser;
import org.apache.guacamole.net.auth.Credentials;
import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Service for verifying the identity of a user against Duo.
*/
public class UserVerificationService {
private static final Logger logger = LoggerFactory.getLogger(UserVerificationService.class);
/**
* 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.
* The name of the HTTP parameter that Duo will use to communicate the
* result of the user's attempt to authenticate with their service. This
* parameter is provided in the GET request received when Duo redirects the
* user back to Guacamole.
*/
public 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.
* The name of the HTTP parameter that we will be using to hold the opaque
* authentication session ID. This session ID is transmitted to Duo during
* the initial redirect and received back from Duo via this parameter in
* the GET request received when Duo redirects the user back to Guacamole.
* The session ID is ultimately used to reconstitute the original
* credentials received from the user by Guacamole such that parameter
* tokens like GUAC_USERNAME and GUAC_PASSWORD can continue to work as
* expected.
*/
public 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";
/**
* Session manager for storing/retrieving the state of a user's
* authentication attempt while they are redirected to the Duo service.
*/
@Inject
private DuoAuthenticationSessionManager sessionManager;
/**
* Service for retrieving Duo configuration information.
*/
@@ -90,79 +100,115 @@ public class UserVerificationService {
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;
// Ignore anonymous users (unverifiable)
String username = authenticatedUser.getIdentifier();
if (username.equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
return;
// Obtain a Duo client for redirecting the user to the Duo service and
// verifying any received authentication code
Client duoClient;
try {
String redirectUrl = confService.getRedirectUri().toString();
String builtUrl = UriComponentsBuilder
.fromUriString(redirectUrl)
.queryParam(Credentials.RESUME_QUERY, DuoAuthenticationProvider.PROVIDER_IDENTIFER)
.build()
.toUriString();
// Set up the Duo Client
Client duoClient = new Client.Builder(
duoClient = new Client.Builder(
confService.getClientId(),
confService.getClientSecret(),
confService.getAPIHostname(),
builtUrl)
confService.getRedirectUri().toString())
.build();
}
catch (DuoException e) {
throw new GuacamoleServerException("Client for communicating with "
+ "the Duo authentication service could not be created.", e);
}
// Verify that the Duo service is healthy and available
try {
duoClient.healthCheck();
}
catch (DuoException e) {
throw new GuacamoleServerException("Duo authentication service is "
+ "not currently available (failed health check).", e);
}
// 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);
// Pull the original HTTP request used to authenticate, as well as any
// associated credentials
Credentials credentials = authenticatedUser.getCredentials();
HttpServletRequest request = credentials.getRequest();
// If no code or state is received, assume Duo MFA redirect has not occured and do it
if (duoCode == null || duoState == null) {
// Retrieve signed Duo authentication code and session state from the
// request (these will be absent if this is an initial authentication
// attempt and not a redirect back from Duo)
String duoCode = request.getParameter(DUO_CODE_PARAMETER_NAME);
String duoState = request.getParameter(DUO_STATE_PARAMETER_NAME);
// Get a new session state from the Duo client
duoState = duoClient.generateState();
long expirationTimestamp = System.currentTimeMillis() + (confService.getAuthTimeout() * 1000L);
// Redirect to Duo to obtain an authentication code if that redirect
// has not yet occurred
if (duoCode == null || duoState == null) {
// Request additional credentials
throw new TranslatableGuacamoleInsufficientCredentialsException(
"Verification using Duo is required before authentication "
+ "can continue.", "LOGIN.INFO_DUO_AUTH_REQUIRED",
new CredentialsInfo(Collections.singletonList(
new RedirectField(
DUO_CODE_PARAMETER_NAME,
new URI(duoClient.createAuthUrl(username, duoState)),
new TranslatableMessage("LOGIN.INFO_DUO_REDIRECT_PENDING")
)
)),
duoState, DuoAuthenticationProvider.PROVIDER_IDENTIFER,
DUO_STATE_PARAMETER_NAME, expirationTimestamp
);
// Store received credentials for later retrieval leveraging Duo's
// opaque session state identifier (we need to maintain these
// credentials so that things like the GUAC_USERNAME and
// GUAC_PASSWORD tokens continue to work as expected despite the
// redirect to/from the external Duo service)
duoState = duoClient.generateState();
long expirationTimestamp = System.currentTimeMillis() + (confService.getAuthTimeout() * 1000L);
sessionManager.defer(new DuoAuthenticationSession(credentials, expirationTimestamp), duoState);
// Obtain authentication URL from Duo client
String duoAuthUrlString;
try {
duoAuthUrlString = duoClient.createAuthUrl(username, duoState);
}
catch (DuoException e) {
throw new GuacamoleServerException("Duo client failed to "
+ "generate the authentication URL necessary to "
+ "redirect the authenticating user to the Duo "
+ "service.", e);
}
// Get the token from the DuoClient using the code and username, and check status
// Parse and validate URL obtained from Duo client
URI duoAuthUrl;
try {
duoAuthUrl = new URI(duoAuthUrlString);
}
catch (URISyntaxException e) {
throw new GuacamoleServerException("Authentication URL "
+ "generated by the Duo client is not actually a "
+ "valid URL and cannot be used to redirect the "
+ "authenticating user to the Duo service.", e);
}
// Request that user be redirected to the Duo service to obtain
// a Duo authentication code
throw new TranslatableGuacamoleInsufficientCredentialsException(
"Verification using Duo is required before authentication "
+ "can continue.", "LOGIN.INFO_DUO_AUTH_REQUIRED",
new CredentialsInfo(Collections.singletonList(
new RedirectField(
DUO_CODE_PARAMETER_NAME, duoAuthUrl,
new TranslatableMessage("LOGIN.INFO_DUO_REDIRECT_PENDING")
)
))
);
}
// Validate that the user has successfully verified their identify with
// the Duo service
try {
Token token = duoClient.exchangeAuthorizationCodeFor2FAResult(duoCode, username);
if (token == null
|| token.getAuth_result() == null
if (token == null || token.getAuth_result() == null
|| !DUO_TOKEN_SUCCESS_VALUE.equals(token.getAuth_result().getStatus()))
throw new TranslatableGuacamoleClientException("Provided Duo "
+ "validation code is 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);
throw new GuacamoleServerException("Duo client refused to verify "
+ "the identity of the authenticating user due to an "
+ "underlying error condition.", e);
}
}
}