KEYCLOAK-7635: Subject DN validation for x509ClientAuthenticator
This commit is contained in:
parent
02b2a8aab0
commit
3449401ae2
5 changed files with 158 additions and 14 deletions
|
@ -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<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<>();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
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.
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue