keycloak-aplcache

Details

diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
index 5ad579e..f6b7b91 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
@@ -1,10 +1,13 @@
 package org.keycloak.authentication.authenticators.client;
 
+import org.apache.commons.codec.binary.StringUtils;
+import org.keycloak.Config;
 import org.keycloak.OAuth2Constants;
 import org.keycloak.authentication.AuthenticationFlowError;
 import org.keycloak.authentication.ClientAuthenticationFlowContext;
 import org.keycloak.models.AuthenticationExecutionModel;
 import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSessionFactory;
 import org.keycloak.protocol.oidc.OIDCLoginProtocol;
 import org.keycloak.provider.ProviderConfigProperty;
 import org.keycloak.representations.idm.CredentialRepresentation;
@@ -17,10 +20,16 @@ import javax.ws.rs.core.Response;
 import java.security.GeneralSecurityException;
 import java.security.cert.X509Certificate;
 import java.util.*;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 public class X509ClientAuthenticator extends AbstractClientAuthenticator {
 
     public static final String PROVIDER_ID = "client-x509";
+    public static final String ATTR_PREFIX = "x509";
+    public static final String ATTR_SUBJECT_DN = ATTR_PREFIX + ".subjectdn";
+
     protected static ServicesLogger logger = ServicesLogger.LOGGER;
 
     public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
@@ -38,7 +47,8 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
             return;
         }
 
-        X509Certificate[] certs = new X509Certificate[0];
+        X509Certificate[] certs = null;
+        ClientModel client = null;
         try {
             certs = provider.getCertificateChain(context.getHttpRequest());
             String client_id = null;
@@ -52,17 +62,17 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
                 client_id = formData.getFirst(OAuth2Constants.CLIENT_ID);
             }
 
+            if (client_id == null && queryParams != null) {
+                client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID);
+            }
+
             if (client_id == null) {
-                if (queryParams != null) {
-                    client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID);
-                } else {
-                    Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter");
-                    context.challenge(challengeResponse);
-                    return;
-                }
+                Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter");
+                context.challenge(challengeResponse);
+                return;
             }
 
-            ClientModel client = context.getRealm().getClientByClientId(client_id);
+            client = context.getRealm().getClientByClientId(client_id);
             if (client == null) {
                 context.failure(AuthenticationFlowError.CLIENT_NOT_FOUND, null);
                 return;
@@ -77,6 +87,7 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
         } catch (GeneralSecurityException e) {
             logger.errorf("[X509ClientCertificateAuthenticator:authenticate] Exception: %s", e.getMessage());
             context.attempted();
+            return;
         }
 
         if (certs == null || certs.length == 0) {
@@ -87,6 +98,34 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
             return;
         }
 
+        String subjectDNRegexp = client.getAttribute(ATTR_SUBJECT_DN);
+        if (subjectDNRegexp == null || subjectDNRegexp.length() == 0) {
+            logger.errorf("[X509ClientCertificateAuthenticator:authenticate] " + ATTR_SUBJECT_DN + " is null or empty");
+            context.attempted();
+            return;
+        }
+        Pattern subjectDNPattern = Pattern.compile(subjectDNRegexp);
+
+        Optional<String> matchedCertificate = Arrays.stream(certs)
+              .map(certificate -> certificate.getSubjectDN().getName())
+              .filter(subjectdn -> subjectDNPattern.matcher(subjectdn).matches())
+              .findFirst();
+
+        if (!matchedCertificate.isPresent()) {
+            // We do quite expensive operation here, so better check the logging level beforehand.
+            if (logger.isDebugEnabled()) {
+                logger.debug("[X509ClientCertificateAuthenticator:authenticate] Couldn't match any certificate for pattern " + subjectDNRegexp);
+                logger.debug("[X509ClientCertificateAuthenticator:authenticate] Available SubjectDNs: " +
+                      Arrays.stream(certs)
+                            .map(cert -> cert.getSubjectDN().getName())
+                            .collect(Collectors.toList()));
+            }
+            context.attempted();
+            return;
+        } else {
+            logger.debug("[X509ClientCertificateAuthenticator:authenticate] Matched " + matchedCertificate.get() + " certificate.");
+        }
+
         context.success();
     }
 
@@ -111,11 +150,10 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
 
     @Override
     public Map<String, Object> getAdapterConfiguration(ClientModel client) {
-        Map<String, Object> result = new HashMap<>();
-        return result;
+        return Collections.emptyMap();
     }
 
-    @Override
+   @Override
     public Set<String> getProtocolAuthenticatorMethods(String loginProtocol) {
         if (loginProtocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
             Set<String> results = new HashSet<>();
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java
index 613e0e5..00ea389 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/MutualTLSClientTest.java
@@ -2,10 +2,14 @@ package org.keycloak.testsuite.client;
 
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 import java.util.function.Supplier;
 
+import org.apache.commons.collections.map.UnmodifiableMap;
 import org.apache.http.NameValuePair;
 import org.apache.http.client.entity.UrlEncodedFormEntity;
 import org.apache.http.client.methods.HttpPost;
@@ -36,9 +40,11 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
 
    private static final String CLIENT_ID = "confidential-x509";
    private static final String DISABLED_CLIENT_ID = "confidential-disabled-x509";
+   private static final String EXACT_SUBJECT_DN_CLIENT_ID = "confidential-subjectdn-x509";
    private static final String USER = "keycloak-user@localhost";
    private static final String PASSWORD = "password";
    private static final String REALM = "test";
+   private static final String EXACT_CERTIFICATE_SUBJECT_DN = "CN=Keycloak, OU=Keycloak, O=Red Hat, L=Boston, ST=MA, C=US";
 
    @Override
    public void configureTestRealm(RealmRepresentation testRealm) {
@@ -46,11 +52,19 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
       properConfiguration.setServiceAccountsEnabled(Boolean.TRUE);
       properConfiguration.setRedirectUris(Arrays.asList("https://localhost:8543/auth/realms/master/app/auth"));
       properConfiguration.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+      properConfiguration.setAttributes(Collections.singletonMap(X509ClientAuthenticator.ATTR_SUBJECT_DN, "(.*?)(?:$)"));
 
       ClientRepresentation disabledConfiguration = KeycloakModelUtils.createClient(testRealm, DISABLED_CLIENT_ID);
       disabledConfiguration.setServiceAccountsEnabled(Boolean.TRUE);
       disabledConfiguration.setRedirectUris(Arrays.asList("https://localhost:8543/auth/realms/master/app/auth"));
       disabledConfiguration.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+      disabledConfiguration.setAttributes(Collections.singletonMap(X509ClientAuthenticator.ATTR_SUBJECT_DN, "(.*?)(?:$)"));
+
+      ClientRepresentation exactSubjectDNConfiguration = KeycloakModelUtils.createClient(testRealm, EXACT_SUBJECT_DN_CLIENT_ID);
+      exactSubjectDNConfiguration.setServiceAccountsEnabled(Boolean.TRUE);
+      exactSubjectDNConfiguration.setRedirectUris(Arrays.asList("https://localhost:8543/auth/realms/master/app/auth"));
+      exactSubjectDNConfiguration.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+      exactSubjectDNConfiguration.setAttributes(Collections.singletonMap(X509ClientAuthenticator.ATTR_SUBJECT_DN, EXACT_CERTIFICATE_SUBJECT_DN));
    }
 
    @BeforeClass
@@ -71,6 +85,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
    }
 
    @Test
+   public void testSuccessfulClientInvocationWithProperCertificateAndSubjectDN() throws Exception {
+      //given
+      Supplier<CloseableHttpClient> clientWithProperCertificate = MutualTLSUtils::newCloseableHttpClientWithDefaultKeyStoreAndTrustStore;
+
+      //when
+      OAuthClient.AccessTokenResponse token = loginAndGetAccessTokenResponse(EXACT_SUBJECT_DN_CLIENT_ID, clientWithProperCertificate);
+
+      //then
+      assertTokenObtained(token);
+   }
+
+   @Test
    public void testSuccessfulClientInvocationWithClientIdInQueryParams() throws Exception {
       //given//when
       OAuthClient.AccessTokenResponse token = null;
@@ -84,6 +110,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
    }
 
    @Test
+   public void testFailedClientInvocationWithProperCertificateAndWrongSubjectDN() throws Exception {
+      //given
+      Supplier<CloseableHttpClient> clientWithProperCertificate = MutualTLSUtils::newCloseableHttpClientWithOtherKeyStoreAndTrustStore;
+
+      //when
+      OAuthClient.AccessTokenResponse token = loginAndGetAccessTokenResponse(EXACT_SUBJECT_DN_CLIENT_ID, clientWithProperCertificate);
+
+      //then
+      assertTokenNotObtained(token);
+   }
+
+   @Test
    public void testFailedClientInvocationWithoutCertificateCertificate() throws Exception {
       //given
       Supplier<CloseableHttpClient> clientWithoutCertificate = MutualTLSUtils::newCloseableHttpClientWithoutKeyStoreAndTrustStore;
diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index 93d19b3..65017c8 100644
--- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -1506,4 +1506,6 @@ manage-members-authz-group-scope-description=Policies that decide if an admin ca
 advanced-client-settings=Advanced Settings
 advanced-client-settings.tooltip=Expand this section to configure advanced settings of this client
 tls-client-certificate-bound-access-tokens=OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled
-tls-client-certificate-bound-access-tokens.tooltip=This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.
\ No newline at end of file
+tls-client-certificate-bound-access-tokens.tooltip=This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.
+subjectdn=Subject DN
+subjectdn-tooltip=A regular expression for validating Subject DN in the Client Certificate. Use "(.*?)(?:$)" to match all kind of expressions.
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
index 7215044..f531b03 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
@@ -59,6 +59,9 @@ module.controller('ClientCredentialsCtrl', function($scope, $location, realm, cl
             case 'client-secret-jwt':
                 $scope.clientAuthenticatorConfigPartial = 'client-credentials-secret-jwt.html';
                 break;
+            case 'client-x509':
+                $scope.clientAuthenticatorConfigPartial = 'client-credentials-x509.html';
+                break;
             default:
                 $scope.currentAuthenticatorConfigProperties = clientConfigProperties[val];
                 $scope.clientAuthenticatorConfigPartial = 'client-credentials-generic.html';
@@ -127,6 +130,48 @@ module.controller('ClientSecretCtrl', function($scope, $location, ClientSecret, 
     };
 });
 
+module.controller('ClientX509Ctrl', function($scope, $location, Client, Notifications) {
+    console.log('ClientX509Ctrl invoked');
+
+    $scope.clientCopy = angular.copy($scope.client);
+    $scope.changed = false;
+
+    $scope.$watch('client', function() {
+        if (!angular.equals($scope.client, $scope.clientCopy)) {
+            $scope.changed = true;
+        }
+    }, true);
+
+    $scope.save = function() {
+        if (!$scope.client.attributes["x509.subjectdn"]) {
+            Notifications.error("The SubjectDN must not be empty.");
+        } else {
+            Client.update({
+                realm : $scope.realm.realm,
+                client : $scope.client.id
+            }, $scope.client, function() {
+                $scope.changed = false;
+                $scope.clientCopy = angular.copy($scope.client);
+                Notifications.success("Client authentication configuration has been saved to the client.");
+            }, function() {
+                Notifications.error("The SubjectDN was not changed due to a problem.");
+                $scope.subjectdn = "error";
+            });
+        }
+    };
+
+    $scope.$watch(function() {
+        return $location.path();
+    }, function() {
+        $scope.path = $location.path().substring(1).split("/");
+    });
+
+    $scope.reset = function() {
+        $scope.client.attributes["x509.subjectdn"] = $scope.clientCopy.attributes["x509.subjectdn"];
+        $location.url("/realms/" + $scope.realm.realm + "/clients/" + $scope.client.id + "/credentials");
+    };
+});
+
 module.controller('ClientSignedJWTCtrl', function($scope, $location, Client, ClientCertificate, Notifications, $route) {
     var signingKeyInfo = ClientCertificate.get({ realm : $scope.realm.realm, client : $scope.client.id, attribute: 'jwt.credential' },
         function() {
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html
new file mode 100644
index 0000000..28bafcd
--- /dev/null
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html
@@ -0,0 +1,21 @@
+<div>
+    <form class="form-horizontal no-margin-top" name="credentialForm" novalidate kc-read-only="!client.access.configure" data-ng-controller="ClientX509Ctrl">
+        <div class="form-group">
+            <label class="col-md-2 control-label" for="subjectdn"><span class="required">*</span>{{:: 'subjectdn' | translate}}</label>
+            <kc-tooltip>{{:: 'subjectdn-tooltip' | translate}}</kc-tooltip>
+            <div class="col-sm-6">
+                <div class="row">
+                    <div class="col-sm-6">
+                        <input class="form-control" type="text" id="subjectdn" data-ng-model="client.attributes['x509.subjectdn']">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <div class="col-md-10 col-md-offset-2" data-ng-show="client.access.configure">
+                <button kc-save  data-ng-disabled="!changed" data-ng-click="save()">{{:: 'save' | translate}}</button>
+                <button kc-reset data-ng-disabled="!changed" data-ng-click="reset()">{{:: 'cancel' | translate}}</button>
+            </div>
+        </div>
+    </form>
+</div>