diff --git a/common/src/main/java/org/keycloak/common/enums/AccountRestApiVersion.java b/common/src/main/java/org/keycloak/common/enums/AccountRestApiVersion.java new file mode 100644 index 0000000000..f7cfd2494b --- /dev/null +++ b/common/src/main/java/org/keycloak/common/enums/AccountRestApiVersion.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.common.enums; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Vaclav Muzikar + */ +public enum AccountRestApiVersion { + V1("v1"); + + public static final AccountRestApiVersion DEFAULT = V1; + private static final Map ENUM_MAP; + + static { + Map map = new HashMap<>(); + for (AccountRestApiVersion value : AccountRestApiVersion.values()) { + map.put(value.getStrVersion(), value); + } + ENUM_MAP = Collections.unmodifiableMap(map); + } + + private final String strVersion; + + AccountRestApiVersion(String strVersion) { + this.strVersion = strVersion; + } + + public static AccountRestApiVersion get(String strVersion) { + return ENUM_MAP.get(strVersion); + } + + public String getStrVersion() { + return strVersion; + } +} diff --git a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java index 806e9dde01..75775edf1e 100755 --- a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java @@ -207,7 +207,9 @@ public class RealmsResource { public Object getAccountService(final @PathParam("realm") String name) { RealmModel realm = init(name); EventBuilder event = new EventBuilder(realm, session, clientConnection); - return new AccountLoader().getAccountService(session, event); + AccountLoader accountLoader = new AccountLoader(session, event); + ResteasyProviderFactory.getInstance().injectProperties(accountLoader); + return accountLoader; } @Path("{realm}") diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java b/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java index 9dd1de7855..785e6a0453 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountLoader.java @@ -19,6 +19,7 @@ package org.keycloak.services.resources.account; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.ResteasyProviderFactory; +import org.keycloak.common.enums.AccountRestApiVersion; import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; @@ -33,6 +34,9 @@ import javax.ws.rs.HttpMethod; import javax.ws.rs.InternalServerErrorException; import javax.ws.rs.NotAuthorizedException; import javax.ws.rs.NotFoundException; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.UriInfo; @@ -44,16 +48,20 @@ import java.util.List; */ public class AccountLoader { + private KeycloakSession session; + private EventBuilder event; + private static final Logger logger = Logger.getLogger(AccountLoader.class); - public Object getAccountService(KeycloakSession session, EventBuilder event) { - RealmModel realm = session.getContext().getRealm(); + public AccountLoader(KeycloakSession session, EventBuilder event) { + this.session = session; + this.event = event; + } - ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); - if (client == null || !client.isEnabled()) { - logger.debug("account management not enabled"); - throw new NotFoundException("account management not enabled"); - } + @Path("/") + public Object getAccountService() { + RealmModel realm = session.getContext().getRealm(); + ClientModel client = getAccountManagementClient(realm); HttpRequest request = session.getContext().getContextObject(HttpRequest.class); HttpHeaders headers = session.getContext().getRequestHeaders(); @@ -67,20 +75,7 @@ public class AccountLoader { 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")) { - AuthenticationManager.AuthResult authResult = new AppAuthManager().authenticateBearerToken(session); - if (authResult == null) { - throw new NotAuthorizedException("Bearer token required"); - } - - if (authResult.getUser().getServiceAccountClientLink() != null) { - throw new NotAuthorizedException("Service accounts are not allowed to access this service"); - } - - Auth auth = new Auth(session.getContext().getRealm(), authResult.getToken(), authResult.getUser(), client, authResult.getSession(), false); - AccountRestService accountRestService = new AccountRestService(session, auth, client, event); - ResteasyProviderFactory.getInstance().injectProperties(accountRestService); - accountRestService.init(); - return accountRestService; + return getAccountRestService(client, null); } else { if (deprecatedAccount) { AccountFormService accountFormService = new AccountFormService(realm, client, event); @@ -96,6 +91,12 @@ public class AccountLoader { } } + @Path("{version : v\\d[0-9a-zA-Z_\\-]*}") + @Produces(MediaType.APPLICATION_JSON) + public Object getVersionedAccountRestService(final @PathParam("version") String version) { + return getAccountRestService(getAccountManagementClient(session.getContext().getRealm()), version); + } + private Theme getTheme(KeycloakSession session) { try { return session.theme().getTheme(Theme.Type.ACCOUNT); @@ -112,4 +113,41 @@ public class AccountLoader { } } + private AccountRestService getAccountRestService(ClientModel client, String versionStr) { + AuthenticationManager.AuthResult authResult = new AppAuthManager().authenticateBearerToken(session); + if (authResult == null) { + throw new NotAuthorizedException("Bearer token required"); + } + + if (authResult.getUser().getServiceAccountClientLink() != null) { + throw new NotAuthorizedException("Service accounts are not allowed to access this service"); + } + + AccountRestApiVersion version; + if (versionStr == null) { + version = AccountRestApiVersion.DEFAULT; + } + else { + version = AccountRestApiVersion.get(versionStr); + if (version == null) { + throw new NotFoundException("API version not found"); + } + } + + Auth auth = new Auth(session.getContext().getRealm(), authResult.getToken(), authResult.getUser(), client, authResult.getSession(), false); + AccountRestService accountRestService = new AccountRestService(session, auth, client, event, version); + ResteasyProviderFactory.getInstance().injectProperties(accountRestService); + accountRestService.init(); + return accountRestService; + } + + private ClientModel getAccountManagementClient(RealmModel realm) { + ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); + if (client == null || !client.isEnabled()) { + logger.debug("account management not enabled"); + throw new NotFoundException("account management not enabled"); + } + return client; + } + } diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 6dcd3955ae..907d94f2a9 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -19,6 +19,7 @@ package org.keycloak.services.resources.account; import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.common.ClientConnection; +import org.keycloak.common.enums.AccountRestApiVersion; import org.keycloak.common.util.StringPropertyReplacer; import org.keycloak.events.Details; import org.keycloak.events.EventBuilder; @@ -98,8 +99,9 @@ public class AccountRestService { private final RealmModel realm; private final UserModel user; private final Locale locale; + private final AccountRestApiVersion version; - public AccountRestService(KeycloakSession session, Auth auth, ClientModel client, EventBuilder event) { + public AccountRestService(KeycloakSession session, Auth auth, ClientModel client, EventBuilder event, AccountRestApiVersion version) { this.session = session; this.auth = auth; this.realm = auth.getRealm(); @@ -107,6 +109,7 @@ public class AccountRestService { this.client = client; this.event = event; this.locale = session.getContext().resolveLocale(user); + this.version = version; } public void init() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java index 2b5c2e9c76..e9ac4f2cd8 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java @@ -43,6 +43,8 @@ import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.TokenUtil; import org.keycloak.testsuite.util.UserBuilder; +import javax.ws.rs.core.UriBuilder; + /** * @author Stian Thorgersen */ @@ -63,6 +65,8 @@ public abstract class AbstractRestServiceTest extends AbstractTestRealmKeycloakT protected String alwaysDisplayClientAppUri = APP_ROOT + "/always-display-client"; + protected String apiVersion; + @Before public void before() { httpClient = HttpClientBuilder.create().build(); @@ -75,6 +79,7 @@ public abstract class AbstractRestServiceTest extends AbstractTestRealmKeycloakT } catch (IOException e) { throw new RuntimeException(e); } + apiVersion = null; } @Override @@ -112,7 +117,14 @@ public abstract class AbstractRestServiceTest extends AbstractTestRealmKeycloakT } protected String getAccountUrl(String resource) { - return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account" + (resource != null ? "/" + resource : ""); + String url = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account"; + if (apiVersion != null) { + url += "/" + apiVersion; + } + if (resource != null) { + url += "/" + resource; + } + return url; } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java index 33cf4e6cb1..3e1fa237bf 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java @@ -17,6 +17,7 @@ package org.keycloak.testsuite.account; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import org.junit.Assert; import org.junit.Test; import org.keycloak.OAuth2Constants; @@ -27,6 +28,7 @@ import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterF import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.common.Profile; +import org.keycloak.common.enums.AccountRestApiVersion; import org.keycloak.common.util.ObjectUtil; import org.keycloak.credential.CredentialTypeMetadata; import org.keycloak.events.EventType; @@ -1202,4 +1204,22 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { assertClientRep(apps.get("offline-client"), "Offline Client", null, false, true, false, null, offlineClientAppUri); } + + @Test + public void testApiVersion() throws IOException { + apiVersion = AccountRestApiVersion.DEFAULT.getStrVersion(); + + // a smoke test to check API with version works + testUpdateProfile(); // profile endpoint is the root URL of account REST service, i.e. the URL will be like "/v1/" + testCredentialsGet(); // "/v1/credentials" + } + + @Test + public void testInvalidApiVersion() throws IOException { + apiVersion = "v2-foo"; + + SimpleHttp.Response response = SimpleHttp.doGet(getAccountUrl("credentials"), httpClient).auth(tokenUtil.getToken()).asResponse(); + assertEquals("API version not found", response.asJson().get("error").textValue()); + assertEquals(404, response.getStatus()); + } }