KEYCLOAK-7635: Subject DN validation for x509ClientAuthenticator

This commit is contained in:
Sebastian Laskawiec 2018-08-09 09:55:40 +02:00 committed by Sebastien Blanc
parent 02b2a8aab0
commit 3449401ae2
5 changed files with 158 additions and 14 deletions

View file

@ -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) {
if (client_id == null && queryParams != null) {
client_id = queryParams.getFirst(OAuth2Constants.CLIENT_ID);
} else {
}
if (client_id == null) {
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,8 +150,7 @@ 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

View file

@ -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<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
@ -83,6 +109,18 @@ public class MutualTLSClientTest extends AbstractTestRealmKeycloakTest {
assertTokenObtained(token);
}
@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

View file

@ -1507,3 +1507,5 @@ 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.
subjectdn=Subject DN
subjectdn-tooltip=A regular expression for validating Subject DN in the Client Certificate. Use "(.*?)(?:$)" to match all kind of expressions.

View file

@ -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() {

View file

@ -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>