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 5ad579e064..f6b7b91b00 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) { - 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; - } + if (client_id == null && queryParams != null) { + client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID); } - ClientModel client = context.getRealm().getClientByClientId(client_id); + if (client_id == null) { + Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "Missing client_id parameter"); + context.challenge(challengeResponse); + return; + } + + 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 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 getAdapterConfiguration(ClientModel client) { - Map result = new HashMap<>(); - return result; + return Collections.emptyMap(); } - @Override + @Override public Set getProtocolAuthenticatorMethods(String loginProtocol) { if (loginProtocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) { Set 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 613e0e5cf7..00ea389532 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 @@ -70,6 +84,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest { assertTokenObtained(token); } + @Test + public void testSuccessfulClientInvocationWithProperCertificateAndSubjectDN() throws Exception { + //given + Supplier 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 @@ -83,6 +109,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest { assertTokenObtained(token); } + @Test + public void testFailedClientInvocationWithProperCertificateAndWrongSubjectDN() throws Exception { + //given + Supplier clientWithProperCertificate = MutualTLSUtils::newCloseableHttpClientWithOtherKeyStoreAndTrustStore; + + //when + OAuthClient.AccessTokenResponse token = loginAndGetAccessTokenResponse(EXACT_SUBJECT_DN_CLIENT_ID, clientWithProperCertificate); + + //then + assertTokenNotObtained(token); + } + @Test public void testFailedClientInvocationWithoutCertificateCertificate() throws Exception { //given 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 93d19b3f30..65017c8258 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 7215044e96..f531b03fae 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 0000000000..28bafcd586 --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-credentials-x509.html @@ -0,0 +1,21 @@ +
+
+
+ + {{:: 'subjectdn-tooltip' | translate}} +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+