KEYCLOAK-1749 Rotate registration access token, add registration access token to admin console

This commit is contained in:
Stian Thorgersen 2015-11-17 09:36:42 +01:00
parent bad0a95123
commit 62c5bc0e91
20 changed files with 187 additions and 50 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,6 +22,11 @@ table {
margin-top: 20px;
}
.no-margin-top {
margin-top: 0px !important;
}
/*********** Loading ***********/

View file

@ -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";
}

View file

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

View file

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

View file

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

View file

@ -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);
}
}

View file

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

View file

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

View file

@ -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());
}
}

View file

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