Skip to content

Commit

Permalink
Updated dependencies and small refactor (PR #14)
Browse files Browse the repository at this point in the history
* Update dependencies

* Refactor code

* Refactor readme
  • Loading branch information
Ansa89 authored Jun 10, 2023
1 parent cfc3a88 commit 041cdc8
Show file tree
Hide file tree
Showing 7 changed files with 60 additions and 87 deletions.
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# DuoUniversalKeycloakAuthenticator
Authenticator for [Keycloak](https://github.com/keycloak/keycloak) that uses Duo's [Java Universal Prompt SDK](https://github.com/duosecurity/duo_universal_java) to challenge the user for Duo MFA as part of a Keycloak login flow.

This has been tested against Keycloak 18.0.0 (Quarkus) and Java 11.0.15. It may work against other versions of Keycloak and Java as well but is untested.
This has been tested against Keycloak 18.0.0 (Quarkus) and Java 11.0.15. It may work against other versions of Keycloak and Java as well but is untested.

## How to use
### Install the authenticator extension
1. Build or download the pre-built "DuoUniversalKeycloakAuthenticator-jar-with-dependencies.jar" JAR file.
2. Copy this JAR file to the deployments folder on the Keycloak server. The exact location of this folder may be different depending on the installation configuration. For example, in the Quarkus (Keycloak 17.0+ default) docker image, the path is `/opt/keycloak/providers`. In the legacy Docker image using WildFly, the path is `/opt/jboss/keycloak/standalone/deployments`.
2. Copy this JAR file to the deployments folder on the Keycloak server. The exact location of this folder may be different depending on the installation configuration. For example, in the Quarkus (Keycloak 17.0+ default) docker image, the path is `/opt/keycloak/providers`. In the legacy Docker image using WildFly, the path is `/opt/jboss/keycloak/standalone/deployments`.
3. Restart the Keycloak application server.

### Configure the authenticator
1. First, create a new application in the Duo Admin Panel. The application should be of the type "Web SDK".
1. First, create a new application in the Duo Admin Panel. The application should be of the type "Web SDK".
![Creating new application in Duo Portal!](https://raw.githubusercontent.com/instipod/DuoUniversalKeycloakAuthenticator/master/documentation/duo-create-1.png "Step 1 in Duo Admin")
2. Add the "Duo Universal MFA" authenticator to a spot in the Keycloak authentication flow.
![Creating new authenticator in Keycloak!](https://raw.githubusercontent.com/instipod/DuoUniversalKeycloakAuthenticator/master/documentation/keycloak-create-1.png "Step 1 in Keycloak Admin")
Expand All @@ -24,11 +24,14 @@ This has been tested against Keycloak 18.0.0 (Quarkus) and Java 11.0.15. It may

For each different client, add a new config line next to Client Overrides in the format of `{Keycloak Client ID},{Duo Client ID},{Duo Client Secret},{Duo API Hostname}`.

You can retrieve the Keycloak Client ID by looking at the end of the admin URL when editing a client. For example: `http://localhost:8080/auth/admin/master/console/#/realms/master/clients/f181f907-ce3f-49fd-97c5-eb3eafe275a7` is client ID `f181f907-ce3f-49fd-97c5-eb3eafe275a7`.
You can retrieve the Keycloak Client ID by looking at the end of the admin URL when editing a client. For example: `http://localhost:8080/auth/admin/master/console/#/realms/master/clients/f181f907-ce3f-49fd-97c5-eb3eafe275a7` is client ID `f181f907-ce3f-49fd-97c5-eb3eafe275a7`.

## Building on your computer
You should be able to build and package this project using Maven. The maven package command will compile the source code and build the JAR files for you. You will need to use the output JAR that includes dependencies as otherwise Keycloak won't be able to find the embedded libraries.

`mvn clean package`

## Building
You should be able to build and package this project using Maven. The maven package command will compile the source code and build the JAR files for you. You will need to use the output JAR that includes dependencies as otherwise Keycloak won't be able to find the embedded libraries.
## Building using Docker
You should be able to build and package this project using Docker. The docker run command will compile the source code and build the JAR files for you. You will need to use the output JAR that includes dependencies as otherwise Keycloak won't be able to find the embedded libraries.

`mvn clean package`
`docker run --rm -it -v $(pwd):/project_src -w /project_src maven:3-eclipse-temurin-11-alpine mvn clean package`
5 changes: 0 additions & 5 deletions build/Dockerfile

This file was deleted.

17 changes: 0 additions & 17 deletions build/README.md

This file was deleted.

16 changes: 8 additions & 8 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

<groupId>com.instipod</groupId>
<artifactId>DuoUniversalKeycloakAuthenticator</artifactId>
<version>1.0.4-SNAPSHOT</version>
<version>1.0.6-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<keycloak.version>21.0.1</keycloak.version>
<keycloak.version>21.1.1</keycloak.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -71,20 +71,20 @@
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<finalName>DuoUniversalKeycloakAuthenticator</finalName>
<finalName>DuoUniversalKeycloakAuthenticator-${project.version}_${keycloak.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<version>3.11.0</version>
<configuration>
<source>11</source>
<target>11</target>
Expand All @@ -93,7 +93,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<version>3.6.0</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,16 @@
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.*;
import org.keycloak.models.utils.FormMessage;

import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.net.URI;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class DuoUniversalAuthenticator implements Authenticator {
public static final DuoUniversalAuthenticator SINGLETON = new DuoUniversalAuthenticator();
Expand All @@ -33,7 +29,7 @@ private String getRedirectUrl(AuthenticationFlowContext context) {

private String getRedirectUrl(AuthenticationFlowContext context, Boolean forceToken) {
if (context.getExecution().isAlternative()) {
//We only need to shim in an alternative case, as the user may be able to "try another way"
// We only need to shim in an alternative case, as the user may be able to "try another way"
return getRedirectUrlShim(context, forceToken);
} else {
return getRedirectUrlRefresh(context);
Expand All @@ -48,7 +44,7 @@ private String getRedirectUrlShim(AuthenticationFlowContext context, Boolean for
MultivaluedMap<String, String> queryParams = context.getHttpRequest().getUri().getQueryParameters();
String sessionCode;
if (queryParams.containsKey("duo_code") && queryParams.containsKey("session_code") && !forceToken) {
//Duo requires the same session_code as the first redirect in order to retrieve the token
// Duo requires the same session_code as the first redirect in order to retrieve the token
sessionCode = queryParams.getFirst("session_code");
} else {
sessionCode = context.generateAccessCode();
Expand All @@ -67,14 +63,14 @@ private String getRedirectUrlShim(AuthenticationFlowContext context, Boolean for
private Client initDuoClient(AuthenticationFlowContext context, String redirectUrl) throws DuoException {
AuthenticatorConfigModel authConfig = context.getAuthenticatorConfig();

//default values
// default values
String clientId = authConfig.getConfig().get(DuoUniversalAuthenticatorFactory.DUO_INTEGRATION_KEY);
String secret = authConfig.getConfig().get(DuoUniversalAuthenticatorFactory.DUO_SECRET_KEY);
String hostname = authConfig.getConfig().get(DuoUniversalAuthenticatorFactory.DUO_API_HOSTNAME);

String overrides = authConfig.getConfig().get(DuoUniversalAuthenticatorFactory.DUO_CUSTOM_CLIENT_IDS);
if (overrides != null && !overrides.equalsIgnoreCase("")) {
//multivalue string seperator is ##
// multivalue string seperator is ##
String[] overridesSplit = overrides.split("##");
for (String override : overridesSplit) {
String[] parts = override.split(",");
Expand All @@ -85,13 +81,13 @@ private Client initDuoClient(AuthenticationFlowContext context, String redirectU
} else {
duoHostname = parts[3];
}
//valid entries have 3 or 4 parts: keycloak client id, duo id, duo secret, (optional) api hostname
// valid entries have 3 or 4 parts: keycloak client id, duo id, duo secret, (optional) api hostname
String keycloakClient = parts[0];
String duoId = parts[1];
String duoSecret = parts[2];

if (keycloakClient.equalsIgnoreCase(context.getAuthenticationSession().getClient().getId())) {
//found a specific client override
// found a specific client override
clientId = duoId;
secret = duoSecret;
hostname = duoHostname;
Expand Down Expand Up @@ -120,19 +116,19 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) {
}

if (authConfigMap.getOrDefault(DuoUniversalAuthenticatorFactory.DUO_API_HOSTNAME, "none").equalsIgnoreCase("none")) {
//authenticator not configured
// authenticator not configured
logger.error("Duo Authenticator is missing API hostname configuration! All authentications will fail.");
authenticationFlowContext.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
if (authConfigMap.getOrDefault(DuoUniversalAuthenticatorFactory.DUO_INTEGRATION_KEY, "none").equalsIgnoreCase("none")) {
//authenticator not configured
// authenticator not configured
logger.error("Duo Authenticator is missing Integration Key configuration! All authentications will fail.");
authenticationFlowContext.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
if (authConfigMap.getOrDefault(DuoUniversalAuthenticatorFactory.DUO_SECRET_KEY, "none").equalsIgnoreCase("none")) {
//authenticator not configured
// authenticator not configured
logger.error("Duo Authenticator is missing Secret Key configuration! All authentications will fail.");
authenticationFlowContext.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
Expand All @@ -141,7 +137,7 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) {

UserModel user = authenticationFlowContext.getUser();
if (user == null) {
//no username
// no username
logger.error("Received a flow request with no user! Returning internal error.");
authenticationFlowContext.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
Expand All @@ -154,15 +150,15 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) {
return;
}

//determine the user desire
//if a duo state is set, assume it is the second request
// determine the user desire
// if a duo state is set, assume it is the second request
boolean firstRequest = !(authenticationFlowContext.getAuthenticationSession().getAuthNote("DUO_STATE") != null && !authenticationFlowContext.getAuthenticationSession().getAuthNote("DUO_STATE").isEmpty());

if (firstRequest) {
//send client to duo to authenticate
// send client to duo to authenticate
this.startDuoProcess(authenticationFlowContext, username);
} else {
//handle duo response
// handle duo response
String loginState = authenticationFlowContext.getAuthenticationSession().getAuthNote("DUO_STATE");
String loginUsername = authenticationFlowContext.getAuthenticationSession().getAuthNote("DUO_USERNAME");

Expand Down Expand Up @@ -190,12 +186,12 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) {
}

if (!loginState.equalsIgnoreCase(state)) {
//sanity check the session
// sanity check the session
this.startDuoProcess(authenticationFlowContext, username);
return;
}
if (!username.equalsIgnoreCase(loginUsername)) {
//sanity check the session
// sanity check the session
this.startDuoProcess(authenticationFlowContext, username);
return;
}
Expand All @@ -207,7 +203,7 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) {
authenticationFlowContext.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, provider.createErrorPage(Response.Status.FORBIDDEN));
}
} else {
//missing required information
// missing required information
logger.warn("Received a Duo callback that was missing information. Starting over.");
this.startDuoProcess(authenticationFlowContext, username);
}
Expand All @@ -216,7 +212,7 @@ public void authenticate(AuthenticationFlowContext authenticationFlowContext) {

private void startDuoProcess(AuthenticationFlowContext authenticationFlowContext, String username) {
AuthenticatorConfigModel authConfig = authenticationFlowContext.getAuthenticatorConfig();
//authConfig should be safe at this point, as it will be checked in the calling method
// authConfig should be safe at this point, as it will be checked in the calling method

String redirectUrl = getRedirectUrl(authenticationFlowContext, true);
Client duoClient;
Expand All @@ -225,10 +221,10 @@ private void startDuoProcess(AuthenticationFlowContext authenticationFlowContext
duoClient = this.initDuoClient(authenticationFlowContext, redirectUrl);
duoClient.healthCheck();
} catch (DuoException exception) {
//Duo is not available
// Duo is not available
logger.warn("Authentication against Duo failed with exception: " + exception);
if (authConfig.getConfig().getOrDefault(DuoUniversalAuthenticatorFactory.DUO_FAIL_SAFE, "false").equalsIgnoreCase("false")) {
//fail secure, deny login
// fail secure, deny login
authenticationFlowContext.failure(AuthenticationFlowError.INVALID_CREDENTIALS);
} else {
authenticationFlowContext.success();
Expand All @@ -245,7 +241,7 @@ private void startDuoProcess(AuthenticationFlowContext authenticationFlowContext
authenticationFlowContext.challenge(Response.temporaryRedirect(new URI(startingUrl)).build());
} catch (Exception exception) {
if (authConfig.getConfig().getOrDefault(DuoUniversalAuthenticatorFactory.DUO_FAIL_SAFE, "true").equalsIgnoreCase("false")) {
//fail secure, deny login
// fail secure, deny login
authenticationFlowContext.failure(AuthenticationFlowError.INVALID_CREDENTIALS);
} else {
authenticationFlowContext.success();
Expand All @@ -254,17 +250,12 @@ private void startDuoProcess(AuthenticationFlowContext authenticationFlowContext
}

private boolean duoRequired(String duoGroups, UserModel user) {
if (duoGroups == null) return true;
if (duoGroups == "none") return true;
if (duoGroups != null && duoGroups.isEmpty()) return true;
List<String> groups = Arrays.asList(duoGroups.split(","));
List<String> groupNames = user.getGroupsStream().map(GroupModel::getName).collect(Collectors.toList());
for (String group : groupNames) {
if (groups.contains(group)) {
return true;
}
if (duoGroups == null || duoGroups.strip().equals("") || duoGroups.strip().equals("none")) {
return true;
}
return false;

List<String> groups = Arrays.asList(duoGroups.split(","));
return user.getGroupsStream().anyMatch(g -> groups.contains(g.getName()));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public class DuoUniversalAuthenticatorFactory implements org.keycloak.authentica
protected static final String DUO_CUSTOM_CLIENT_IDS = "duoClientIds";

private final static List<ProviderConfigProperty> commonConfig;
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};

static {
commonConfig = Collections.unmodifiableList(ProviderConfigurationBuilder.create()
Expand Down Expand Up @@ -50,10 +55,6 @@ public boolean isConfigurable() {
return true;
}

private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.ALTERNATIVE, AuthenticationExecutionModel.Requirement.DISABLED
};

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
Expand Down Expand Up @@ -81,17 +82,17 @@ public Authenticator create(KeycloakSession keycloakSession) {

@Override
public void init(Config.Scope scope) {
//noop
// noop
}

@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
//noop
// noop
}

@Override
public void close() {
//noop
// noop
}

@Override
Expand Down
Loading

0 comments on commit 041cdc8

Please sign in to comment.