added AccountResource SPI, Provider and ProviderFactory. (#22317)

Added AccountResource SPI, Provider and ProviderFactory. updated AccountLoader to load provider(s) and check if it is compatible with the chosen theme.
This commit is contained in:
Garth 2023-10-05 15:08:01 +02:00 committed by GitHub
parent 7f2f4aae67
commit 2dfbbff343
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 248 additions and 7 deletions

View file

@ -0,0 +1,15 @@
package org.keycloak.services.resource;
import org.keycloak.provider.Provider;
import org.keycloak.theme.Theme;
import java.io.IOException;
/**
* <p>A {@link AccountResourceProvider} creates JAX-RS resource instances for the Account endpoints, allowing
* an implementor to override the behavior of the entire Account console.
*/
public interface AccountResourceProvider extends Provider {
/** Returns a JAX-RS resource instance. */
Object getResource();
}

View file

@ -0,0 +1,9 @@
package org.keycloak.services.resource;
import org.keycloak.provider.ProviderFactory;
/**
* <p>A factory that creates {@link AccountResourceProvider} instances.
*/
public interface AccountResourceProviderFactory extends ProviderFactory<AccountResourceProvider> {
}

View file

@ -0,0 +1,34 @@
package org.keycloak.services.resource;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* <p>A {@link Spi} to replace Account resources.
*
* <p>Implementors can use this {@link Spi} to override the behavior of the Account endpoints and resources by
* creating JAX-RS resources that override those served at /account by default.
*/
public class AccountResourceSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "account-resource";
}
@Override
public Class<? extends Provider> getProviderClass() {
return AccountResourceProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return AccountResourceProviderFactory.class;
}
}

View file

@ -37,6 +37,7 @@ org.keycloak.exportimport.ImportSpi
org.keycloak.timer.TimerSpi
org.keycloak.scripting.ScriptingSpi
org.keycloak.services.managers.BruteForceProtectorSpi
org.keycloak.services.resource.AccountResourceSpi
org.keycloak.services.resource.RealmResourceSPI
org.keycloak.sessions.AuthenticationSessionSpi
org.keycloak.sessions.StickySessionEncoderSpi

View file

@ -30,6 +30,8 @@ import java.util.Properties;
*/
public interface Theme {
public static final String ACCOUNT_RESOURCE_PROVIDER_KEY = "accountResourceProvider";
enum Type { LOGIN, ACCOUNT, ADMIN, EMAIL, WELCOME, COMMON };
String getName();

View file

@ -35,6 +35,7 @@ import org.keycloak.services.Urls;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.services.validation.Validation;
@ -49,7 +50,7 @@ import org.keycloak.utils.MediaType;
/**
* Created by st on 29/03/17.
*/
public class AccountConsole {
public class AccountConsole implements AccountResourceProvider {
// Used when some other context (ie. IdentityBrokerService) wants to forward error to account management and display it here
public static final String ACCOUNT_MGMT_FORWARDED_ERROR_NOTE = "ACCOUNT_MGMT_FORWARDED_ERROR";
@ -71,6 +72,7 @@ public class AccountConsole {
this.client = client;
this.theme = theme;
this.authManager = new AppAuthManager();
init();
}
public void init() {
@ -80,6 +82,14 @@ public class AccountConsole {
}
}
@Override
public Object getResource() {
return this;
}
@Override
public void close() {}
@GET
@NoCache
public Response getMainPage() throws IOException, FreeMarkerException {

View file

@ -0,0 +1,55 @@
package org.keycloak.services.resources.account;
import java.io.IOException;
import org.keycloak.Config.Scope;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.services.resource.AccountResourceProviderFactory;
import org.keycloak.theme.Theme;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.models.Constants;
public class AccountConsoleFactory implements AccountResourceProviderFactory {
@Override
public String getId() {
return "default";
}
@Override
public AccountResourceProvider create(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
ClientModel client = getAccountManagementClient(realm);
Theme theme = getTheme(session);
return new AccountConsole(session, client, theme);
}
@Override
public void init(Scope config) {}
@Override
public void postInit(KeycloakSessionFactory factory) {}
@Override
public void close() {}
static Theme getTheme(KeycloakSession session) {
try {
return session.theme().getTheme(Theme.Type.ACCOUNT);
} catch (IOException e) {
throw new InternalServerErrorException(e);
}
}
static ClientModel getAccountManagementClient(RealmModel realm) {
ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
if (client == null || !client.isEnabled()) {
throw new NotFoundException("account management not enabled");
}
return client;
}
}

View file

@ -29,6 +29,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.services.resources.Cors;
import org.keycloak.theme.Theme;
@ -78,14 +79,14 @@ public class AccountLoader {
Theme theme = getTheme(session);
UriInfo uriInfo = session.getContext().getUri();
AccountResourceProvider accountResourceProvider = getAccountResourceProvider(theme);
if (request.getHttpMethod().equals(HttpMethod.OPTIONS)) {
return new CorsPreflightService(request);
} else if ((accepts.contains(MediaType.APPLICATION_JSON_TYPE) || MediaType.APPLICATION_JSON_TYPE.equals(content)) && !uriInfo.getPath().endsWith("keycloak.json")) {
return getAccountRestService(client, null);
} else if (Profile.isFeatureEnabled(Profile.Feature.ACCOUNT2) || Profile.isFeatureEnabled(Profile.Feature.ACCOUNT3)) {
AccountConsole console = new AccountConsole(session, client, theme);
console.init();
return console;
} else if (accountResourceProvider != null) {
return accountResourceProvider.getResource();
} else {
throw new NotFoundException();
}
@ -108,6 +109,7 @@ public class AccountLoader {
}
}
private AccountRestService getAccountRestService(ClientModel client, String versionStr) {
AuthenticationManager.AuthResult authResult = new AppAuthManager.BearerTokenAuthenticator(session)
.setAudience(client.getClientId())
@ -147,4 +149,12 @@ public class AccountLoader {
return client;
}
private AccountResourceProvider getAccountResourceProvider(Theme theme) {
try {
if (theme.getProperties().containsKey(Theme.ACCOUNT_RESOURCE_PROVIDER_KEY)) {
return session.getProvider(AccountResourceProvider.class, theme.getProperties().getProperty(Theme.ACCOUNT_RESOURCE_PROVIDER_KEY));
}
} catch (IOException ignore) {}
return session.getProvider(AccountResourceProvider.class);
}
}

View file

@ -0,0 +1 @@
org.keycloak.services.resources.account.AccountConsoleFactory

View file

@ -0,0 +1,47 @@
package org.keycloak.testsuite.theme;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.services.resource.AccountResourceProviderFactory;
public class CustomAccountResourceProviderFactory implements AccountResourceProviderFactory, AccountResourceProvider {
public static final String ID = "ext-custom-account-console";
@Override
public String getId() {
return ID;
}
@Override
public AccountResourceProvider create(KeycloakSession session) {
return this;
}
@Override
public Object getResource() {
return this;
}
@GET
@NoCache
@Produces(MediaType.TEXT_HTML)
public Response getMainPage() {
return Response.ok().entity("<html><head><title>Account</title></head><body><h1>Custom Account Console</h1></body></html>").build();
}
@Override
public void init(Scope config) {}
@Override
public void postInit(KeycloakSessionFactory factory) {}
@Override
public void close() {}
}

View file

@ -1614,6 +1614,28 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
}
@Test
public void testCustomAccountResourceTheme() throws Exception {
String accountTheme = "";
try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
accountTheme = realmRep.getAccountTheme();
realmRep.setAccountTheme("custom-account-provider");
adminClient.realm("test").update(realmRep);
SimpleHttp.Response response = SimpleHttp.doGet(getAccountUrl(null), httpClient)
.header("Accept", "text/html")
.asResponse();
assertEquals(200, response.getStatus());
String html = response.asString();
assertTrue(html.contains("Custom Account Console"));
} finally {
RealmRepresentation realmRep = testRealm().toRepresentation();
realmRep.setAccountTheme(accountTheme);
testRealm().update(realmRep);
}
}
@EnableFeature(Profile.Feature.UPDATE_EMAIL)
public void testEmailWhenUpdateEmailEnabled() throws Exception {
reconnectAdminClient();

View file

@ -56,8 +56,8 @@ public class ServerInfoTest extends AbstractKeycloakTest {
assertNotNull(info.getProviders().get("authenticator"));
assertNotNull(info.getThemes());
// Not checking account themes for now as old account console is going to be removed soon, which would remove "keycloak" theme. So that is just to avoid another "test to update" when it is removed :)
assertNotNull(info.getThemes().get("account"));
Assert.assertNames(info.getThemes().get("account"), "base", "keycloak.v2", "custom-account-provider");
Assert.assertNames(info.getThemes().get("admin"), "base", "keycloak.v2");
Assert.assertNames(info.getThemes().get("email"), "base", "keycloak");
Assert.assertNames(info.getThemes().get("login"), "address", "base", "environment-agnostic", "keycloak");

View file

@ -0,0 +1,27 @@
package org.keycloak.testsuite.theme;
import java.io.IOException;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.resource.AccountResourceProvider;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.theme.CustomAccountResourceProviderFactory;
import org.keycloak.theme.Theme;
public class CustomAccountResourceProviderTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void testProviderOverride() {
testingClient.server().run(session -> {
AccountResourceProvider arp = session.getProvider(AccountResourceProvider.class, "ext-custom-account-console");
Assert.assertTrue(arp instanceof CustomAccountResourceProviderFactory);
});
}
}

View file

@ -12,6 +12,12 @@
"types": [
"login"
]
},
{
"name": "custom-account-provider",
"types": [
"account"
]
}
]
}
}

View file

@ -0,0 +1 @@
accountResourceProvider=ext-custom-account-console