Restrict access to whoami endpoint for the admin console and users with realm access

Closes #25219

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-04-19 12:17:51 +02:00 committed by Marek Posolda
parent 519421606c
commit 89d7108558
3 changed files with 187 additions and 146 deletions

View file

@ -17,16 +17,17 @@
package org.keycloak.services.resources.admin;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.ws.rs.ForbiddenException;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.http.HttpRequest;
import org.keycloak.http.HttpResponse;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.Config;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.Version;
import org.keycloak.common.util.UriUtils;
import org.keycloak.headers.SecurityHeadersProvider;
import org.keycloak.http.HttpRequest;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -48,6 +49,8 @@ import org.keycloak.urls.UrlType;
import org.keycloak.utils.MediaType;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
@ -57,7 +60,6 @@ import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
@ -191,7 +193,7 @@ public class AdminConsole {
/**
* Permission information
*
* @param headers
* @param currentRealm
* @return
*/
@Path("whoami")
@ -199,6 +201,10 @@ public class AdminConsole {
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response whoAmI(@QueryParam("currentRealm") String currentRealm) {
if (!Profile.isFeatureEnabled(Profile.Feature.ADMIN_API)) {
throw new NotFoundException();
}
RealmManager realmManager = new RealmManager(session);
AuthenticationManager.AuthResult authResult = new AppAuthManager.BearerTokenAuthenticator(session)
.setRealm(realm)
@ -207,8 +213,13 @@ public class AdminConsole {
.authenticate();
if (authResult == null) {
return Response.status(401).build();
throw new NotAuthorizedException("Bearer");
}
if (!Constants.ADMIN_CONSOLE_CLIENT_ID.equals(authResult.getToken().getIssuedFor())) {
throw new ForbiddenException("Token not valid for admin console");
}
UserModel user= authResult.getUser();
String displayName;
if ((user.getFirstName() != null && !user.getFirstName().trim().equals("")) || (user.getLastName() != null && !user.getLastName().trim().equals(""))) {
@ -237,6 +248,11 @@ public class AdminConsole {
addRealmAccess(realm, user, realmAccess);
}
if (realmAccess.isEmpty() || realmAccess.values().iterator().next().isEmpty()) {
// if the user has no access in the realm just return forbidden/403
throw new ForbiddenException("No realm access");
}
Locale locale = session.getContext().resolveLocale(user);
return Cors.builder()
@ -257,29 +273,13 @@ public class AdminConsole {
getRealmAdminAccess(realm, realm.getMasterAdminClient(), user, realmAdminAccess);
}
private static <T> HashSet<T> union(Set<T> set1, Set<T> set2) {
if (set1 == null && set2 == null) {
return null;
}
HashSet<T> res;
if (set1 instanceof HashSet) {
res = (HashSet <T>) set1;
} else {
res = set1 == null ? new HashSet<>() : new HashSet<>(set1);
}
if (set2 != null) {
res.addAll(set2);
}
return res;
}
private void getRealmAdminAccess(RealmModel realm, ClientModel client, UserModel user, Map<String, Set<String>> realmAdminAccess) {
Set<String> realmRoles = client.getRolesStream()
.filter(user::hasRole)
.map(RoleModel::getName)
.collect(Collectors.toSet());
realmAdminAccess.merge(realm.getName(), realmRoles, AdminConsole::union);
realmAdminAccess.put(realm.getName(), realmRoles);
}
/**

View file

@ -1,81 +0,0 @@
/*
* 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.testsuite.admin;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.Config;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.updaters.Creator;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.RealmBuilder;
import java.io.IOException;
import java.util.List;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class AdminConsolePermissionsCalculatedTest extends AbstractKeycloakTest {
private static final String REALM_NAME = "realm-name";
private CloseableHttpClient client;
@Before
public void before() {
client = HttpClientBuilder.create().build();
}
@After
public void after() {
try {
client.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
}
@Test
public void changeRealmTokenAlgorithm() throws Exception {
try (Keycloak adminClient = AdminClientUtil.createAdminClient(suiteContext.isAdapterCompatTesting(), suiteContext.getAuthServerInfo().getContextRoot().toString());
Creator c = Creator.create(adminClient, RealmBuilder.create().name(REALM_NAME).build())) {
AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
assertNotNull(adminClient.realms().findAll());
String whoAmiUrl = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/admin/master/console/whoami?currentRealm=master";
JsonNode jsonNode = SimpleHttpDefault.doGet(whoAmiUrl, client).auth(accessToken.getToken()).asJson();
assertTrue("Permissions for " + Config.getAdminRealm() + " realm.", jsonNode.at("/realm_access/" + Config.getAdminRealm()).isArray());
}
}
}

View file

@ -1,38 +1,42 @@
package org.keycloak.testsuite.admin;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.auth.page.AuthRealm;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.console.page.AdminConsole;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import static java.util.Arrays.asList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.keycloak.models.AdminRoles.REALM_ADMIN;
import static org.keycloak.models.Constants.ADMIN_CLI_CLIENT_ID;
import static org.keycloak.models.Constants.REALM_MANAGEMENT_CLIENT_ID;
import static org.keycloak.testsuite.util.AdminClientUtil.createAdminClient;
public class AdminConsoleWhoAmILocaleTest extends AbstractKeycloakTest {
private static final String REALM_I18N_OFF = "realm-i18n-off";
private static final String REALM_I18N_ON = "realm-i18n-on";
private static final String USER_WITHOUT_LOCALE = "user-without-locale";
private static final String USER_WITH_LOCALE = "user-with-locale";
private static final String USER_NO_ACCESS = "user-no-access";
private static final String PASSWORD = "password";
private static final String DEFAULT_LOCALE = "en";
private static final String REALM_LOCALE = "no";
@ -41,6 +45,9 @@ public class AdminConsoleWhoAmILocaleTest extends AbstractKeycloakTest {
private CloseableHttpClient client;
@Page
private AdminConsole adminConsole;
@Before
public void createHttpClient() throws Exception {
client = HttpClientBuilder.create().build();
@ -63,108 +70,223 @@ public class AdminConsoleWhoAmILocaleTest extends AbstractKeycloakTest {
realm.user(UserBuilder.create()
.username(USER_WITHOUT_LOCALE)
.password(PASSWORD)
.role(REALM_MANAGEMENT_CLIENT_ID, REALM_ADMIN));
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN));
realm.user(UserBuilder.create()
.username(USER_WITH_LOCALE)
.password(PASSWORD)
.addAttribute("locale", USER_LOCALE)
.role(REALM_MANAGEMENT_CLIENT_ID, REALM_ADMIN));
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN));
testRealms.add(realm.build());
realm = RealmBuilder.create()
.name(REALM_I18N_ON)
.internationalizationEnabled(true)
.supportedLocales(new HashSet<>(asList(REALM_LOCALE, USER_LOCALE, EXTRA_LOCALE)))
.supportedLocales(new HashSet<>(Arrays.asList(REALM_LOCALE, USER_LOCALE, EXTRA_LOCALE)))
.defaultLocale(REALM_LOCALE);
realm.user(UserBuilder.create()
.username(USER_WITHOUT_LOCALE)
.password(PASSWORD)
.role(REALM_MANAGEMENT_CLIENT_ID, REALM_ADMIN));
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN));
realm.user(UserBuilder.create()
.username(USER_WITH_LOCALE)
.password(PASSWORD)
.addAttribute("locale", USER_LOCALE)
.role(REALM_MANAGEMENT_CLIENT_ID, REALM_ADMIN));
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN));
realm.user(UserBuilder.create()
.username(USER_NO_ACCESS)
.password(PASSWORD)
.addAttribute("locale", USER_LOCALE));
testRealms.add(realm.build());
}
private String accessToken(String realmName, String username) throws Exception {
try (Keycloak adminClient = createAdminClient(true, realmName, username, PASSWORD, ADMIN_CLI_CLIENT_ID, null)) {
AccessTokenResponse accessToken = adminClient.tokenManager().getAccessToken();
assertNotNull(accessToken);
return accessToken.getToken();
}
private OAuthClient.AccessTokenResponse accessToken(String realmName, String username, String password) throws Exception {
String codeVerifier = PkceUtils.generateCodeVerifier();
oauth.realm(realmName)
.codeVerifier(codeVerifier)
.codeChallenge(PkceUtils.generateS256CodeChallenge(codeVerifier))
.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256)
.clientId(Constants.ADMIN_CONSOLE_CLIENT_ID)
.redirectUri(adminConsole.createUriBuilder().build(realmName).toASCIIString());
oauth.doLogin(username, password);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
return response;
}
private String whoAmiUrl(String realmName) {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/admin/" + realmName + "/console/whoami";
return whoAmiUrl(realmName, null);
}
private String whoAmiUrl(String realmName, String currentRealm) {
StringBuilder sb = new StringBuilder()
.append(suiteContext.getAuthServerInfo().getContextRoot().toString())
.append("/auth/admin/")
.append(realmName)
.append("/console/whoami");
if (currentRealm != null) {
sb.append("?currentRealm=").append(currentRealm);
}
return sb.toString();
}
private void checkRealmAccess(String realm, JsonNode whoAmI) {
Assert.assertNotNull(whoAmI.get("realm_access"));
Assert.assertNotNull(whoAmI.get("realm_access").get(realm));
Assert.assertTrue(whoAmI.get("realm_access").get(realm).isArray());
Assert.assertTrue(whoAmI.get("realm_access").get(realm).size() > 0);
}
@Test
public void testLocaleRealmI18nDisabledUserWithoutLocale() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_OFF, USER_WITHOUT_LOCALE, PASSWORD);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_OFF), client)
.header("Accept", "application/json")
.auth(accessToken(REALM_I18N_OFF, USER_WITHOUT_LOCALE))
.auth(response.getAccessToken())
.asJson();
assertEquals(REALM_I18N_OFF, whoAmI.get("realm").asText());
assertEquals(DEFAULT_LOCALE, whoAmI.get("locale").asText());
Assert.assertEquals(REALM_I18N_OFF, whoAmI.get("realm").asText());
Assert.assertEquals(DEFAULT_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_OFF, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmI18nDisabledUserWithLocale() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_OFF, USER_WITH_LOCALE, PASSWORD);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_OFF), client)
.header("Accept", "application/json")
.auth(accessToken(REALM_I18N_OFF, USER_WITH_LOCALE))
.auth(response.getAccessToken())
.asJson();
assertEquals(REALM_I18N_OFF, whoAmI.get("realm").asText());
assertEquals(DEFAULT_LOCALE, whoAmI.get("locale").asText());
Assert.assertEquals(REALM_I18N_OFF, whoAmI.get("realm").asText());
Assert.assertEquals(DEFAULT_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_OFF, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmI18nEnabledUserWithoutLocale() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_ON, USER_WITHOUT_LOCALE, PASSWORD);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(accessToken(REALM_I18N_ON, USER_WITHOUT_LOCALE))
.auth(response.getAccessToken())
.asJson();
assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
assertEquals(REALM_LOCALE, whoAmI.get("locale").asText());
Assert.assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
Assert.assertEquals(REALM_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_ON, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmI18nEnabledUserWithLocale() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_ON, USER_WITH_LOCALE, PASSWORD);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(accessToken(REALM_I18N_ON, USER_WITH_LOCALE))
.auth(response.getAccessToken())
.asJson();
assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
assertEquals(USER_LOCALE, whoAmI.get("locale").asText());
Assert.assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
Assert.assertEquals(USER_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_ON, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmI18nEnabledAcceptLanguageHeader() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_ON, USER_WITHOUT_LOCALE, PASSWORD);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(accessToken(REALM_I18N_ON, USER_WITHOUT_LOCALE))
.auth(response.getAccessToken())
.header("Accept-Language", EXTRA_LOCALE)
.asJson();
assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
assertEquals(EXTRA_LOCALE, whoAmI.get("locale").asText());
Assert.assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
Assert.assertEquals(EXTRA_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_ON, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmI18nEnabledKeycloakLocaleCookie() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_ON, USER_WITHOUT_LOCALE, PASSWORD);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(accessToken(REALM_I18N_ON, USER_WITHOUT_LOCALE))
.auth(response.getAccessToken())
.header("Cookie", "KEYCLOAK_LOCALE=" + EXTRA_LOCALE)
.asJson();
assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
assertEquals(EXTRA_LOCALE, whoAmI.get("locale").asText());
Assert.assertEquals(REALM_I18N_ON, whoAmI.get("realm").asText());
Assert.assertEquals(EXTRA_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_ON, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testMasterRealm() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(AuthRealm.MASTER, AuthRealm.ADMIN, AuthRealm.ADMIN);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(AuthRealm.MASTER), client)
.header("Accept", "application/json")
.auth(response.getAccessToken())
.asJson();
Assert.assertEquals(AuthRealm.MASTER, whoAmI.get("realm").asText());
Assert.assertEquals(DEFAULT_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(AuthRealm.MASTER, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testMasterRealmCurrentRealm() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(AuthRealm.MASTER, AuthRealm.ADMIN, AuthRealm.ADMIN);
JsonNode whoAmI = SimpleHttpDefault
.doGet(whoAmiUrl(AuthRealm.MASTER, REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(response.getAccessToken())
.asJson();
Assert.assertEquals(AuthRealm.MASTER, whoAmI.get("realm").asText());
Assert.assertEquals(DEFAULT_LOCALE, whoAmI.get("locale").asText());
checkRealmAccess(REALM_I18N_ON, whoAmI);
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmNoToken() throws Exception {
try (SimpleHttp.Response response = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.asResponse()) {
Assert.assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
}
}
@Test
public void testLocaleRealmUserNoAccess() throws Exception {
OAuthClient.AccessTokenResponse response = accessToken(REALM_I18N_ON, USER_NO_ACCESS, PASSWORD);
try (SimpleHttp.Response res = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(response.getAccessToken())
.asResponse()) {
Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), res.getStatus());
}
oauth.doLogout(response.getRefreshToken(), null);
}
@Test
public void testLocaleRealmTokenForOtherClient() throws Exception {
try (Keycloak adminCliClient = AdminClientUtil.createAdminClient(true, REALM_I18N_ON,
USER_WITH_LOCALE, PASSWORD, Constants.ADMIN_CLI_CLIENT_ID, null)) {
AccessTokenResponse accessToken = adminCliClient.tokenManager().getAccessToken();
Assert.assertNotNull(accessToken);
String token = accessToken.getToken();
try (SimpleHttp.Response response = SimpleHttpDefault
.doGet(whoAmiUrl(REALM_I18N_ON), client)
.header("Accept", "application/json")
.auth(token)
.asResponse()) {
Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus());
}
}
}
}