KEYCLOAK-15332 Missing CORS headers in some endpoints in Account REST API

This commit is contained in:
vmuzikar 2020-09-09 12:09:08 +02:00 committed by Bruno Oliveira da Silva
parent 540516c6a9
commit bb7ce62cd5
8 changed files with 99 additions and 84 deletions

View file

@ -10,8 +10,12 @@ import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.CredentialTypeMetadataContext;
import org.keycloak.credential.UserCredentialStoreManager;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.*;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.ErrorResponse;

View file

@ -18,6 +18,7 @@ package org.keycloak.services.resources.account;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.HttpResponse;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.events.EventBuilder;
@ -28,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.resources.Cors;
import org.keycloak.theme.Theme;
import javax.ws.rs.HttpMethod;
@ -37,6 +39,7 @@ 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.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.UriInfo;
@ -51,6 +54,11 @@ public class AccountLoader {
private KeycloakSession session;
private EventBuilder event;
@Context
private HttpRequest request;
@Context
private HttpResponse response;
private static final Logger logger = Logger.getLogger(AccountLoader.class);
public AccountLoader(KeycloakSession session, EventBuilder event) {
@ -94,6 +102,9 @@ public class AccountLoader {
@Path("{version : v\\d[0-9a-zA-Z_\\-]*}")
@Produces(MediaType.APPLICATION_JSON)
public Object getVersionedAccountRestService(final @PathParam("version") String version) {
if (request.getHttpMethod().equals(HttpMethod.OPTIONS)) {
return new CorsPreflightService(request);
}
return getAccountRestService(getAccountManagementClient(session.getContext().getRealm()), version);
}
@ -121,6 +132,9 @@ public class AccountLoader {
if (authResult == null) {
throw new NotAuthorizedException("Bearer token required");
}
Auth auth = new Auth(session.getContext().getRealm(), authResult.getToken(), authResult.getUser(), client, authResult.getSession(), false);
Cors.add(request).allowedOrigins(auth.getToken()).allowedMethods("GET", "PUT", "POST", "DELETE").auth().build(response);
if (authResult.getUser().getServiceAccountClientLink() != null) {
throw new NotAuthorizedException("Service accounts are not allowed to access this service");
@ -137,7 +151,6 @@ public class AccountLoader {
}
}
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();

View file

@ -19,8 +19,8 @@ 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.Profile;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.common.util.StringPropertyReplacer;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventStoreProvider;
@ -42,7 +42,6 @@ import org.keycloak.services.ErrorResponse;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.storage.ReadOnlyException;
@ -50,16 +49,15 @@ import org.keycloak.theme.Theme;
import org.keycloak.userprofile.LegacyUserProfileProviderFactory;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.utils.UserUpdateHelper;
import org.keycloak.userprofile.profile.representations.AccountUserRepresentationUserProfile;
import org.keycloak.userprofile.profile.DefaultUserProfileContext;
import org.keycloak.userprofile.profile.representations.AccountUserRepresentationUserProfile;
import org.keycloak.userprofile.utils.UserUpdateHelper;
import org.keycloak.userprofile.validation.UserProfileValidationResult;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.OPTIONS;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
@ -71,7 +69,6 @@ import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
@ -120,18 +117,6 @@ public class AccountRestService {
eventStore = session.getProvider(EventStoreProvider.class);
}
/**
* CORS preflight
*
* @return
*/
@Path("/")
@OPTIONS
@NoCache
public Response preflight() {
return Cors.add(request, Response.ok()).auth().preflight().build();
}
/**
* Get account information.
*
@ -141,7 +126,7 @@ public class AccountRestService {
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response account() {
public UserRepresentation account() {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
UserModel user = auth.getUser();
@ -161,7 +146,7 @@ public class AccountRestService {
copiedAttributes.remove(UserModel.USERNAME);
rep.setAttributes(copiedAttributes);
return Cors.add(request, Response.ok(rep)).auth().allowedOrigins(auth.getToken()).build();
return rep;
}
@Path("/")
@ -189,7 +174,7 @@ public class AccountRestService {
UserUpdateHelper.updateAccount(realm, user, updatedUser);
event.success();
return Cors.add(request, Response.noContent()).auth().allowedOrigins(auth.getToken()).build();
return Response.noContent().build();
} catch (ReadOnlyException e) {
return ErrorResponse.error(Messages.READ_ONLY_USER, Response.Status.BAD_REQUEST);
}
@ -270,15 +255,15 @@ public class AccountRestService {
ClientModel client = realm.getClientByClientId(clientId);
if (client == null) {
return Cors.add(request, Response.status(Response.Status.NOT_FOUND).entity("No client with clientId: " + clientId + " found.")).build();
return ErrorResponse.error("No client with clientId: " + clientId + " found.", Response.Status.NOT_FOUND);
}
UserConsentModel consent = session.users().getConsentByClient(realm, user.getId(), client.getId());
if (consent == null) {
return Cors.add(request, Response.noContent()).build();
return Response.noContent().build();
}
return Cors.add(request, Response.ok(modelToRepresentation(consent))).build();
return Response.ok(modelToRepresentation(consent)).build();
}
/**
@ -299,14 +284,14 @@ public class AccountRestService {
event.event(EventType.REVOKE_GRANT_ERROR);
String msg = String.format("No client with clientId: %s found.", clientId);
event.error(msg);
return Cors.add(request, Response.status(Response.Status.NOT_FOUND).entity(msg)).build();
return ErrorResponse.error(msg, Response.Status.NOT_FOUND);
}
session.users().revokeConsentForClient(realm, user.getId(), client.getId());
new UserSessionManager(session).revokeOfflineToken(user, client);
event.success();
return Cors.add(request, Response.noContent()).build();
return Response.noContent().build();
}
/**
@ -359,7 +344,7 @@ public class AccountRestService {
event.event(EventType.GRANT_CONSENT_ERROR);
String msg = String.format("No client with clientId: %s found.", clientId);
event.error(msg);
return Cors.add(request, Response.status(Response.Status.NOT_FOUND).entity(msg)).build();
return ErrorResponse.error(msg, Response.Status.NOT_FOUND);
}
try {
@ -371,9 +356,9 @@ public class AccountRestService {
}
event.success();
grantedConsent = session.users().getConsentByClient(realm, user.getId(), client.getId());
return Cors.add(request, Response.ok(modelToRepresentation(grantedConsent))).build();
return Response.ok(modelToRepresentation(grantedConsent)).build();
} catch (IllegalArgumentException e) {
return Cors.add(request, Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage())).build();
return ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST);
}
}
@ -416,7 +401,7 @@ public class AccountRestService {
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response applications(@QueryParam("name") String name) {
public List<ClientRepresentation> applications(@QueryParam("name") String name) {
checkAccountApiEnabled();
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_APPLICATIONS);
@ -461,7 +446,7 @@ public class AccountRestService {
}
}
return Cors.add(request, Response.ok(apps)).auth().allowedOrigins(auth.getToken()).build();
return apps;
}
private boolean matches(ClientModel client, String name) {

View file

@ -24,6 +24,7 @@ import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
@ -44,7 +45,6 @@ import org.keycloak.representations.account.DeviceRepresentation;
import org.keycloak.representations.account.SessionRepresentation;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.Cors;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -73,9 +73,8 @@ public class SessionResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response toRepresentation() {
return Cors.add(request, Response.ok(session.sessions().getUserSessions(realm, user).stream()
.map(this::toRepresentation).collect(Collectors.toList()))).auth().allowedOrigins(auth.getToken()).build();
public List<SessionRepresentation> toRepresentation() {
return session.sessions().getUserSessions(realm, user).stream().map(this::toRepresentation).collect(Collectors.toList());
}
/**
@ -87,7 +86,7 @@ public class SessionResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response devices() {
public Collection<DeviceRepresentation> devices() {
Map<String, DeviceRepresentation> reps = new HashMap<>();
List<UserSessionModel> sessions = session.sessions().getUserSessions(realm, user);
@ -117,7 +116,7 @@ public class SessionResource {
rep.addSession(createSessionRepresentation(s, device));
}
return Cors.add(request, Response.ok(reps.values())).auth().allowedOrigins(auth.getToken()).build();
return reps.values();
}
/**
@ -139,7 +138,7 @@ public class SessionResource {
}
}
return Cors.add(request, Response.noContent()).auth().allowedOrigins(auth.getToken()).build();
return Response.noContent().build();
}
/**
@ -158,7 +157,7 @@ public class SessionResource {
if (userSession != null && userSession.getUser().equals(user)) {
AuthenticationManager.backchannelLogout(session, userSession, true);
}
return Cors.add(request, Response.noContent()).auth().allowedOrigins(auth.getToken()).build();
return Response.noContent().build();
}
private SessionRepresentation createSessionRepresentation(UserSessionModel s, DeviceRepresentation device) {

View file

@ -16,7 +16,6 @@
*/
package org.keycloak.services.resources.account.resources;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@ -29,7 +28,6 @@ import java.util.stream.Collectors;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.model.PermissionTicket;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.authorization.store.PermissionTicketStore;
import org.keycloak.authorization.store.ResourceStore;
import org.keycloak.authorization.store.ScopeStore;
@ -42,7 +40,6 @@ import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.representations.idm.authorization.ScopeRepresentation;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.resources.Cors;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -69,10 +66,6 @@ public abstract class AbstractResourceService {
uriInfo = session.getContext().getUri();
}
protected Response cors(Response.ResponseBuilder response) {
return Cors.add(request, response).auth().allowedOrigins(auth.getToken()).build();
}
public static class Resource extends ResourceRepresentation {
private Client client;

View file

@ -66,8 +66,8 @@ public class ResourceService extends AbstractResourceService {
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
public Response getResource() {
return cors(Response.ok(new Resource(resource, provider)));
public Resource getResource() {
return new Resource(resource, provider);
}
/**
@ -78,7 +78,7 @@ public class ResourceService extends AbstractResourceService {
@GET
@Path("permissions")
@Produces(MediaType.APPLICATION_JSON)
public Response toPermissions() {
public Collection<Permission> toPermissions() {
Map<String, String> filters = new HashMap<>();
filters.put(PermissionTicket.OWNER, user.getId());
@ -92,7 +92,7 @@ public class ResourceService extends AbstractResourceService {
permissions = resources.iterator().next().getPermissions();
}
return cors(Response.ok(permissions));
return permissions;
}
@GET
@ -101,9 +101,9 @@ public class ResourceService extends AbstractResourceService {
public Response user(@QueryParam("value") String value) {
try {
final UserModel user = getUser(value);
return cors(Response.ok(toRepresentation(provider.getKeycloakSession(), provider.getRealm(), user)));
return Response.ok(toRepresentation(provider.getKeycloakSession(), provider.getRealm(), user)).build();
} catch (NotFoundException e) {
return cors(Response.noContent());
return Response.noContent().build();
}
}
@ -172,7 +172,7 @@ public class ResourceService extends AbstractResourceService {
}
}
return cors(Response.noContent());
return Response.noContent().build();
}
/**
@ -183,7 +183,7 @@ public class ResourceService extends AbstractResourceService {
@GET
@Path("permissions/requests")
@Produces(MediaType.APPLICATION_JSON)
public Response getPermissionRequests() {
public Collection<Permission> getPermissionRequests() {
Map<String, String> filters = new HashMap<>();
filters.put(PermissionTicket.OWNER, user.getId());
@ -196,7 +196,7 @@ public class ResourceService extends AbstractResourceService {
requests.computeIfAbsent(ticket.getRequester(), requester -> new Permission(ticket, provider)).addScope(ticket.getScope().getName());
}
return cors(Response.ok(requests.values()));
return requests.values();
}
private void grantPermission(UserModel user, String scopeId) {

View file

@ -203,10 +203,10 @@ public class ResourcesService extends AbstractResourceService {
result = result.subList(0, size - 1);
}
return cors(Response.ok().entity(result).links(createPageLinks(first, max, size)));
return Response.ok().entity(result).links(createPageLinks(first, max, size)).build();
}
return cors(Response.ok().entity(query.apply(-1, -1).collect(Collectors.toList())));
return Response.ok().entity(query.apply(-1, -1).collect(Collectors.toList())).build();
}
private Link[] createPageLinks(Integer first, Integer max, int resultSize) {

View file

@ -22,6 +22,7 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
@ -34,6 +35,10 @@ import java.io.IOException;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import static org.hamcrest.CoreMatchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@ -67,6 +72,7 @@ public class AccountRestServiceCorsTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
testRealm.setEditUsernameAllowed(false);
}
@Rule
@ -76,69 +82,84 @@ public class AccountRestServiceCorsTest extends AbstractTestRealmKeycloakTest {
public void testGetProfile() throws IOException, InterruptedException {
driver.navigate().to(VALID_CORS_URL);
doJsGet(executor, getAccountUrl(), tokenUtil.getToken(), true);
doXhr(executor, getAccountUrl(), tokenUtil.getToken(), null, true);
}
@Test
public void testGetProfileInvalidOrigin() throws IOException, InterruptedException {
driver.navigate().to(INVALID_CORS_URL);
doJsGet(executor, getAccountUrl(), tokenUtil.getToken(), false);
doXhr(executor, getAccountUrl(), tokenUtil.getToken(), null, false);
}
@Test
public void testUpdateProfile() throws IOException {
driver.navigate().to(VALID_CORS_URL);
doJsPost(executor, getAccountUrl(), tokenUtil.getToken(), "{ \"firstName\" : \"Bob\" }", true);
doXhr(executor, getAccountUrl(), tokenUtil.getToken(), "{ \"firstName\" : \"Bob\" }", true);
}
@Test
public void testUpdateProfileInvalidOrigin() throws IOException {
driver.navigate().to(INVALID_CORS_URL);
doJsPost(executor, getAccountUrl(), tokenUtil.getToken(), "{ \"firstName\" : \"Bob\" }", false);
doXhr(executor, getAccountUrl(), tokenUtil.getToken(), "{ \"firstName\" : \"Bob\" }", false);
}
@Test
public void testErrorResponse() {
driver.navigate().to(VALID_CORS_URL);
Result result = doXhr(executor, getAccountUrl(), tokenUtil.getToken(), "{ \"username\" : \"vmuzikar\" }", true);
assertEquals(400, result.getStatus());
assertThat(result.getResult(), containsString("readOnlyUsernameMessage"));
}
@Test
public void testErrorResponseInvalidOrigin() {
driver.navigate().to(INVALID_CORS_URL);
doXhr(executor, getAccountUrl(), tokenUtil.getToken(), "{ \"username\" : \"vmuzikar\" }", false);
}
@Test
public void testGetVersionedApi() {
driver.navigate().to(VALID_CORS_URL);
doXhr(executor, getAccountUrl() + "/" + AccountRestApiVersion.DEFAULT.getStrVersion(), tokenUtil.getToken(), null, true);
}
@Test
public void testGetVersionedApiInvalidOrigin() {
driver.navigate().to(INVALID_CORS_URL);
doXhr(executor, getAccountUrl() + "/" + AccountRestApiVersion.DEFAULT.getStrVersion(), tokenUtil.getToken(), null, false);
}
private String getAccountUrl() {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account";
}
private Result doJsGet(JavascriptExecutor executor, String url, String token, boolean expectAllowed) {
private Result doXhr(JavascriptExecutor executor, String url, String token, String postData, boolean expectAllowed) {
String js = "var r = new XMLHttpRequest();" +
"var r = new XMLHttpRequest();" +
"r.open('GET', '" + url + "', false);" +
"r.setRequestHeader('Accept','application/json');" +
"r.setRequestHeader('Authorization','bearer " + token + "');" +
"r.send();" +
"return r.status + ':::' + r.responseText";
return doXhr(executor, js, expectAllowed);
}
private Result doJsPost(JavascriptExecutor executor, String url, String token, String data, boolean expectAllowed) {
String js = "var r = new XMLHttpRequest();" +
"var r = new XMLHttpRequest();" +
"r.open('POST', '" + url + "', false);" +
"r.open('" + (postData == null ? "GET" : "POST") + "', '" + url + "', false);" +
"r.setRequestHeader('Accept','application/json');" +
"r.setRequestHeader('Content-Type','application/json');" +
"r.setRequestHeader('Authorization','bearer " + token + "');" +
"r.send('" + data + "');" +
"r.send(" + (postData == null ? "" : "'" + postData + "'") + ");" +
"return r.status + ':::' + r.responseText";
return doXhr(executor, js, expectAllowed);
}
private Result doXhr(JavascriptExecutor executor, String js, boolean expectAllowed) {
Result result = null;
Throwable error = null;
try {
String response = (String) executor.executeScript(js);
String r[] = response.split(":::");
String[] r = response.split(":::");
result = new Result(Integer.parseInt(r[0]), r.length == 2 ? r[1] : null);
} catch (Throwable t ) {
error = t;
}
if (result == null || (result.getStatus() != 200 && result.getStatus() != 204) || error != null) {
if (error != null) {
if (expectAllowed) {
throw new AssertionError("Cors request failed: " + WebDriverLogDumper.dumpBrowserLogs(driver));
} else {