KEYCLOAK-1749 Rotate registration access token, add registration access token to admin console
This commit is contained in:
parent
bad0a95123
commit
62c5bc0e91
20 changed files with 187 additions and 50 deletions
|
@ -55,9 +55,10 @@ public class ClientRegistration {
|
|||
return resultStream != null ? deserialize(resultStream, AdapterConfig.class) : null;
|
||||
}
|
||||
|
||||
public void update(ClientRepresentation client) throws ClientRegistrationException {
|
||||
public ClientRepresentation update(ClientRepresentation client) throws ClientRegistrationException {
|
||||
String content = serialize(client);
|
||||
httpUtil.doPut(content, DEFAULT, client.getClientId());
|
||||
InputStream resultStream = httpUtil.doPut(content, DEFAULT, client.getClientId());
|
||||
return resultStream != null ? deserialize(resultStream, ClientRepresentation.class) : null;
|
||||
}
|
||||
|
||||
public void delete(ClientRepresentation client) throws ClientRegistrationException {
|
||||
|
|
|
@ -80,7 +80,9 @@ class HttpUtil {
|
|||
responseStream.close();
|
||||
return null;
|
||||
} else {
|
||||
responseStream.close();
|
||||
if (responseStream != null) {
|
||||
responseStream.close();
|
||||
}
|
||||
throw new HttpErrorException(response.getStatusLine());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
@ -88,7 +90,7 @@ class HttpUtil {
|
|||
}
|
||||
}
|
||||
|
||||
void doPut(String content, String... path) throws ClientRegistrationException {
|
||||
InputStream doPut(String content, String... path) throws ClientRegistrationException {
|
||||
try {
|
||||
HttpPut request = new HttpPut(getUrl(baseUri, path));
|
||||
|
||||
|
@ -100,10 +102,20 @@ class HttpUtil {
|
|||
|
||||
HttpResponse response = httpClient.execute(request);
|
||||
if (response.getEntity() != null) {
|
||||
response.getEntity().getContent().close();
|
||||
response.getEntity().getContent();
|
||||
}
|
||||
|
||||
if (response.getStatusLine().getStatusCode() != 200) {
|
||||
InputStream responseStream = null;
|
||||
if (response.getEntity() != null) {
|
||||
responseStream = response.getEntity().getContent();
|
||||
}
|
||||
|
||||
if (response.getStatusLine().getStatusCode() == 200) {
|
||||
return responseStream;
|
||||
} else {
|
||||
if (responseStream != null) {
|
||||
responseStream.close();
|
||||
}
|
||||
throw new HttpErrorException(response.getStatusLine());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
|
|
@ -270,7 +270,10 @@ client-certificate-import=Client Certificate Import
|
|||
import-client-certificate=Import Client Certificate
|
||||
jwt-import.key-alias.tooltip=Archive alias for your certificate.
|
||||
secret=Secret
|
||||
regenerate-secret=Regenerate Secret
|
||||
regenerate-secret=Regenerate Secretsecret=Secret
|
||||
registrationAccessToken=Registration access token
|
||||
registrationAccessToken.regenerate=Regenerate registration access token
|
||||
registrationAccessToken.tooltip=The registration access token provides access for clients to the client registration service.
|
||||
add-role=Add Role
|
||||
role-name=Role Name
|
||||
composite=Composite
|
||||
|
|
|
@ -30,7 +30,7 @@ module.controller('ClientRoleListCtrl', function($scope, $location, realm, clien
|
|||
});
|
||||
});
|
||||
|
||||
module.controller('ClientCredentialsCtrl', function($scope, $location, realm, client, clientAuthenticatorProviders, clientConfigProperties, Client) {
|
||||
module.controller('ClientCredentialsCtrl', function($scope, $location, realm, client, clientAuthenticatorProviders, clientConfigProperties, Client, ClientRegistrationAccessToken, Notifications) {
|
||||
$scope.realm = realm;
|
||||
$scope.client = angular.copy(client);
|
||||
$scope.clientAuthenticatorProviders = clientAuthenticatorProviders;
|
||||
|
@ -68,6 +68,17 @@ module.controller('ClientCredentialsCtrl', function($scope, $location, realm, cl
|
|||
}
|
||||
}, true);
|
||||
|
||||
$scope.regenerateRegistrationAccessToken = function() {
|
||||
var secret = ClientRegistrationAccessToken.update({ realm : $scope.realm.realm, client : $scope.client.id },
|
||||
function(data) {
|
||||
Notifications.success('The registration access token has been updated.');
|
||||
$scope.client['registrationAccessToken'] = data.registrationAccessToken;
|
||||
},
|
||||
function() {
|
||||
Notifications.error('Failed to update the registration access token');
|
||||
}
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
module.controller('ClientSecretCtrl', function($scope, $location, ClientSecret, Notifications) {
|
||||
|
|
|
@ -981,6 +981,17 @@ module.factory('ClientSecret', function($resource) {
|
|||
});
|
||||
});
|
||||
|
||||
module.factory('ClientRegistrationAccessToken', function($resource) {
|
||||
return $resource(authUrl + '/admin/realms/:realm/clients/:client/registration-access-token', {
|
||||
realm : '@realm',
|
||||
client : '@client'
|
||||
}, {
|
||||
update : {
|
||||
method : 'POST'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.factory('ClientOrigins', function($resource) {
|
||||
return $resource(authUrl + '/admin/realms/:realm/clients/:client/allowed-origins', {
|
||||
realm : '@realm',
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div>
|
||||
<form class="form-horizontal" name="credentialForm" novalidate kc-read-only="!access.manageClients" data-ng-show="currentAuthenticatorConfigProperties.length > 0" data-ng-controller="ClientGenericCredentialsCtrl">
|
||||
<form class="form-horizontal no-margin-top" name="credentialForm" novalidate kc-read-only="!access.manageClients" data-ng-show="currentAuthenticatorConfigProperties.length > 0" data-ng-controller="ClientGenericCredentialsCtrl">
|
||||
<fieldset>
|
||||
<kc-provider-config realm="realm" config="client.attributes" properties="currentAuthenticatorConfigProperties"></kc-provider-config>
|
||||
</fieldset>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div>
|
||||
<form class="form-horizontal" name="keyForm" novalidate kc-read-only="!access.manageClients" data-ng-controller="ClientSignedJWTCtrl">
|
||||
<form class="form-horizontal no-margin-top" name="keyForm" novalidate kc-read-only="!access.manageClients" data-ng-controller="ClientSignedJWTCtrl">
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="signingCert">{{:: 'certificate' | translate}}</label>
|
||||
<kc-tooltip>{{:: 'certificate.tooltip' | translate}}</kc-tooltip>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div>
|
||||
<form class="form-horizontal" name="credentialForm" novalidate kc-read-only="!access.manageClients" data-ng-controller="ClientSecretCtrl">
|
||||
<form class="form-horizontal no-margin-top" name="credentialForm" novalidate kc-read-only="!access.manageClients" data-ng-controller="ClientSecretCtrl">
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="secret">{{:: 'secret' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
|
|
|
@ -28,6 +28,11 @@
|
|||
<div data-ng-include="resourceUrl + '/partials/' + clientAuthenticatorConfigPartial">
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div data-ng-include="resourceUrl + '/partials/client-registration-access-token.html'">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<kc-menu></kc-menu>
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<div>
|
||||
<form class="form-horizontal" name="registrationAccessTokenForm" novalidate kc-read-only="!access.manageClients">
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="registrationAccessToken">{{:: 'registrationAccessToken' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<input readonly kc-select-action="click" class="form-control" type="text" id="registrationAccessToken" name="registrationAccessToken" data-ng-model="client.registrationAccessToken">
|
||||
</div>
|
||||
<div class="col-sm-6" data-ng-show="access.manageClients">
|
||||
<button type="submit" data-ng-click="regenerateRegistrationAccessToken()" class="btn btn-default">{{:: 'registrationAccessToken.regenerate' | translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'registrationAccessToken.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
|
@ -22,6 +22,11 @@ table {
|
|||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.no-margin-top {
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*********** Loading ***********/
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.keycloak.models.utils;
|
||||
|
||||
import org.bouncycastle.openssl.PEMWriter;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.AuthenticationFlowModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
|
@ -26,12 +27,7 @@ import org.keycloak.common.util.PemUtils;
|
|||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.security.Key;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.*;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
|
@ -47,6 +43,8 @@ import java.util.UUID;
|
|||
*/
|
||||
public final class KeycloakModelUtils {
|
||||
|
||||
private static final int RANDOM_PASSWORD_BYTES = 32;
|
||||
|
||||
private KeycloakModelUtils() {
|
||||
}
|
||||
|
||||
|
@ -178,12 +176,22 @@ public final class KeycloakModelUtils {
|
|||
return rep;
|
||||
}
|
||||
|
||||
public static UserCredentialModel generateSecret(ClientModel app) {
|
||||
public static UserCredentialModel generateSecret(ClientModel client) {
|
||||
UserCredentialModel secret = UserCredentialModel.generateSecret();
|
||||
app.setSecret(secret.getValue());
|
||||
client.setSecret(secret.getValue());
|
||||
return secret;
|
||||
}
|
||||
|
||||
public static void generateRegistrationAccessToken(ClientModel client) {
|
||||
client.setRegistrationSecret(generatePassword());
|
||||
}
|
||||
|
||||
public static String generatePassword() {
|
||||
byte[] buf = new byte[RANDOM_PASSWORD_BYTES];
|
||||
new SecureRandom().nextBytes(buf);
|
||||
return Base64Url.encode(buf);
|
||||
}
|
||||
|
||||
public static String getDefaultClientAuthenticatorType() {
|
||||
return "client-secret";
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ public class ClientRegAuth {
|
|||
private AccessToken.Access bearerRealmAccess;
|
||||
|
||||
private boolean authenticated = false;
|
||||
private boolean registrationAccessToken = false;
|
||||
|
||||
public ClientRegAuth(KeycloakSession session, EventBuilder event) {
|
||||
this.session = session;
|
||||
|
@ -48,6 +49,7 @@ public class ClientRegAuth {
|
|||
if (split[1].indexOf('.') == -1) {
|
||||
token = split[1];
|
||||
authenticated = true;
|
||||
registrationAccessToken = true;
|
||||
} else {
|
||||
AuthenticationManager.AuthResult authResult = new AppAuthManager().authenticateBearerToken(session, realm);
|
||||
bearerRealmAccess = authResult.getToken().getResourceAccess(Constants.REALM_MANAGEMENT_CLIENT_ID);
|
||||
|
@ -59,6 +61,10 @@ public class ClientRegAuth {
|
|||
return authenticated;
|
||||
}
|
||||
|
||||
public boolean isRegistrationAccessToken() {
|
||||
return registrationAccessToken;
|
||||
}
|
||||
|
||||
public void requireCreate() {
|
||||
if (!authenticated) {
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.keycloak.services.clientregistration;
|
||||
|
||||
import org.jboss.resteasy.spi.NotFoundException;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.services.ErrorResponseException;
|
||||
|
@ -28,6 +29,11 @@ public class ClientRegistrationService {
|
|||
checkSsl();
|
||||
|
||||
ClientRegistrationProvider provider = session.getProvider(ClientRegistrationProvider.class, providerId);
|
||||
|
||||
if (provider == null) {
|
||||
throw new NotFoundException("Client registration provider not found");
|
||||
}
|
||||
|
||||
provider.setEvent(event);
|
||||
provider.setAuth(new ClientRegAuth(session, event));
|
||||
return provider;
|
||||
|
|
|
@ -5,6 +5,7 @@ import org.keycloak.events.EventType;
|
|||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelDuplicateException;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.models.utils.RepresentationToModel;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
|
@ -38,7 +39,7 @@ public class DefaultClientRegistrationProvider implements ClientRegistrationProv
|
|||
|
||||
try {
|
||||
ClientModel clientModel = RepresentationToModel.createClient(session, session.getContext().getRealm(), client, true);
|
||||
clientModel.setRegistrationSecret(TokenGenerator.createRegistrationAccessToken());
|
||||
KeycloakModelUtils.generateRegistrationAccessToken(clientModel);
|
||||
|
||||
client = ModelToRepresentation.toRepresentation(clientModel);
|
||||
URI uri = session.getContext().getUri().getAbsolutePathBuilder().path(clientModel.getId()).build();
|
||||
|
@ -59,6 +60,10 @@ public class DefaultClientRegistrationProvider implements ClientRegistrationProv
|
|||
ClientModel client = session.getContext().getRealm().getClientByClientId(clientId);
|
||||
auth.requireView(client);
|
||||
|
||||
if (auth.isRegistrationAccessToken()) {
|
||||
KeycloakModelUtils.generateRegistrationAccessToken(client);
|
||||
}
|
||||
|
||||
event.client(client.getClientId()).success();
|
||||
return Response.ok(ModelToRepresentation.toRepresentation(client)).build();
|
||||
}
|
||||
|
@ -74,8 +79,14 @@ public class DefaultClientRegistrationProvider implements ClientRegistrationProv
|
|||
|
||||
RepresentationToModel.updateClient(rep, client);
|
||||
|
||||
if (auth.isRegistrationAccessToken()) {
|
||||
KeycloakModelUtils.generateRegistrationAccessToken(client);
|
||||
}
|
||||
|
||||
rep = ModelToRepresentation.toRepresentation(client);
|
||||
|
||||
event.client(client.getClientId()).success();
|
||||
return Response.status(Response.Status.OK).build();
|
||||
return Response.ok(rep).build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
package org.keycloak.services.clientregistration;
|
||||
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
public class TokenGenerator {
|
||||
|
||||
private static final int REGISTRATION_ACCESS_TOKEN_BYTES = 32;
|
||||
|
||||
private TokenGenerator() {
|
||||
}
|
||||
|
||||
public String createInitialAccessToken() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String createRegistrationAccessToken() {
|
||||
byte[] buf = new byte[REGISTRATION_ACCESS_TOKEN_BYTES];
|
||||
new SecureRandom().nextBytes(buf);
|
||||
return Base64Url.encode(buf);
|
||||
}
|
||||
|
||||
}
|
|
@ -15,7 +15,6 @@ import org.keycloak.representations.idm.ClientRepresentation;
|
|||
import org.keycloak.services.ErrorResponse;
|
||||
import org.keycloak.services.clientregistration.ClientRegAuth;
|
||||
import org.keycloak.services.clientregistration.ClientRegistrationProvider;
|
||||
import org.keycloak.services.clientregistration.TokenGenerator;
|
||||
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.POST;
|
||||
|
|
|
@ -214,6 +214,24 @@ public class ClientResource {
|
|||
return rep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new registration access token for the client
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@Path("registration-access-token")
|
||||
@POST
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public ClientRepresentation regenerateRegistrationAccessToken() {
|
||||
auth.requireManage();
|
||||
|
||||
KeycloakModelUtils.generateRegistrationAccessToken(client);
|
||||
ClientRepresentation rep = ModelToRepresentation.toRepresentation(client);
|
||||
adminEvent.operation(OperationType.ACTION).resourcePath(uriInfo).representation(rep).success();
|
||||
return rep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client secret
|
||||
*
|
||||
|
|
|
@ -5,6 +5,7 @@ import org.junit.Test;
|
|||
import org.keycloak.client.registration.Auth;
|
||||
import org.keycloak.client.registration.ClientRegistrationException;
|
||||
import org.keycloak.client.registration.HttpErrorException;
|
||||
import org.keycloak.common.enums.SslRequired;
|
||||
import org.keycloak.representations.adapters.config.AdapterConfig;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
|
||||
|
@ -20,11 +21,14 @@ public class AdapterInstallationConfigTest extends AbstractClientRegistrationTes
|
|||
private ClientRepresentation client;
|
||||
private ClientRepresentation client2;
|
||||
private ClientRepresentation clientPublic;
|
||||
private String publicKey;
|
||||
|
||||
@Before
|
||||
public void before() throws Exception {
|
||||
super.before();
|
||||
|
||||
publicKey = adminClient.realm(REALM_NAME).toRepresentation().getPublicKey();
|
||||
|
||||
client = new ClientRepresentation();
|
||||
client.setEnabled(true);
|
||||
client.setClientId("RegistrationAccessTokenTest");
|
||||
|
@ -66,6 +70,16 @@ public class AdapterInstallationConfigTest extends AbstractClientRegistrationTes
|
|||
|
||||
AdapterConfig config = reg.getAdapterConfig(client.getClientId());
|
||||
assertNotNull(config);
|
||||
|
||||
assertEquals(testContext.getAuthServerContextRoot() + "/auth", config.getAuthServerUrl());
|
||||
assertEquals("test", config.getRealm());
|
||||
|
||||
assertEquals(1, config.getCredentials().size());
|
||||
assertEquals(client.getSecret(), config.getCredentials().get("secret"));
|
||||
|
||||
assertEquals(publicKey, config.getRealmKey());
|
||||
assertEquals(client.getClientId(), config.getResource());
|
||||
assertEquals(SslRequired.EXTERNAL.name().toLowerCase(), config.getSslRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -98,6 +112,14 @@ public class AdapterInstallationConfigTest extends AbstractClientRegistrationTes
|
|||
|
||||
AdapterConfig config = reg.getAdapterConfig(clientPublic.getClientId());
|
||||
assertNotNull(config);
|
||||
|
||||
assertEquals("test", config.getRealm());
|
||||
|
||||
assertEquals(0, config.getCredentials().size());
|
||||
|
||||
assertEquals(publicKey, config.getRealmKey());
|
||||
assertEquals(clientPublic.getClientId(), config.getResource());
|
||||
assertEquals(SslRequired.EXTERNAL.name().toLowerCase(), config.getSslRequired());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -33,10 +33,33 @@ public class RegistrationAccessTokenTest extends AbstractClientRegistrationTest
|
|||
reg.auth(Auth.token(client.getRegistrationAccessToken()));
|
||||
}
|
||||
|
||||
private ClientRepresentation assertRead(String id, String registrationAccess, boolean expectSuccess) throws ClientRegistrationException {
|
||||
if (expectSuccess) {
|
||||
reg.auth(Auth.token(registrationAccess));
|
||||
ClientRepresentation rep = reg.get(client.getClientId());
|
||||
assertNotNull(rep);
|
||||
return rep;
|
||||
} else {
|
||||
reg.auth(Auth.token(registrationAccess));
|
||||
try {
|
||||
reg.get(client.getClientId());
|
||||
fail("Expected 403");
|
||||
} catch (ClientRegistrationException e) {
|
||||
assertEquals(403, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getClientWithRegistrationToken() throws ClientRegistrationException {
|
||||
ClientRepresentation rep = reg.get(client.getClientId());
|
||||
assertNotNull(rep);
|
||||
assertNotEquals(client.getRegistrationAccessToken(), rep.getRegistrationAccessToken());
|
||||
|
||||
// check registration access token is updated
|
||||
assertRead(client.getClientId(), client.getRegistrationAccessToken(), false);
|
||||
assertRead(client.getClientId(), rep.getRegistrationAccessToken(), true);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -53,9 +76,14 @@ public class RegistrationAccessTokenTest extends AbstractClientRegistrationTest
|
|||
@Test
|
||||
public void updateClientWithRegistrationToken() throws ClientRegistrationException {
|
||||
client.setRootUrl("http://newroot");
|
||||
reg.update(client);
|
||||
ClientRepresentation rep = reg.update(client);
|
||||
|
||||
assertEquals("http://newroot", getClient(client.getId()).getRootUrl());
|
||||
assertNotEquals(client.getRegistrationAccessToken(), rep.getRegistrationAccessToken());
|
||||
|
||||
// check registration access token is updated
|
||||
assertRead(client.getClientId(), client.getRegistrationAccessToken(), false);
|
||||
assertRead(client.getClientId(), rep.getRegistrationAccessToken(), true);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
Loading…
Reference in a new issue