UNOMI-393 Implement default field visibility provider that allows sending events and retrieving current profile (#405)
* UNOMI-393 Implement default field visibility provider that allows sending events and retrieving current profile
* UNOMI-393 Implement default field visibility provider that allows sending events and retrieving current profile
- make access to subscriptions protected
diff --git a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
index 467f57f..93254f7 100644
--- a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
+++ b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
@@ -22,6 +22,7 @@
import graphql.introspection.IntrospectionQuery;
import org.apache.unomi.graphql.schema.GraphQLSchemaUpdater;
import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
import org.apache.unomi.graphql.servlet.websocket.SubscriptionWebSocketFactory;
import org.apache.unomi.graphql.utils.GraphQLObjectMapper;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
@@ -46,10 +47,14 @@
)
public class GraphQLServlet extends WebSocketServlet {
+ public static final String SCHEMA_URL = "/schema.json";
+
private GraphQLSchemaUpdater graphQLSchemaUpdater;
private ServiceManager serviceManager;
+ private GraphQLServletSecurityValidator validator;
+
@Reference
public void setServiceManager(ServiceManager serviceManager) {
this.serviceManager = serviceManager;
@@ -63,6 +68,7 @@
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
+ this.validator = new GraphQLServletSecurityValidator();
}
private WebSocketServletFactory factory;
@@ -95,7 +101,7 @@
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String query = req.getParameter("query");
- if ("/schema.json".equals(req.getPathInfo())) {
+ if (SCHEMA_URL.equals(req.getPathInfo())) {
query = IntrospectionQuery.INTROSPECTION_QUERY;
}
String operationName = req.getParameter("operationName");
@@ -107,6 +113,9 @@
variables = GraphQLObjectMapper.getInstance().readValue(variableStr, typeRef);
}
+ if (!validator.validate(query, operationName, req, resp)) {
+ return;
+ }
setupCORSHeaders(req, resp);
executeGraphQLRequest(resp, query, operationName, variables);
}
@@ -127,6 +136,9 @@
variables = new HashMap<>();
}
+ if (!validator.validate(query, operationName, req, resp)) {
+ return;
+ }
setupCORSHeaders(req, resp);
executeGraphQLRequest(resp, query, operationName, variables);
}
diff --git a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
new file mode 100644
index 0000000..e3e36c8
--- /dev/null
+++ b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.graphql.servlet.auth;
+
+import graphql.language.Definition;
+import graphql.language.Document;
+import graphql.language.Field;
+import graphql.language.Node;
+import graphql.language.OperationDefinition;
+import graphql.parser.Parser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import static graphql.language.OperationDefinition.Operation.MUTATION;
+import static graphql.language.OperationDefinition.Operation.QUERY;
+import static graphql.language.OperationDefinition.Operation.SUBSCRIPTION;
+import static org.osgi.service.http.HttpContext.AUTHENTICATION_TYPE;
+import static org.osgi.service.http.HttpContext.REMOTE_USER;
+
+public class GraphQLServletSecurityValidator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(GraphQLServletSecurityValidator.class);
+
+ private final Parser parser;
+
+ public GraphQLServletSecurityValidator() {
+ parser = new Parser();
+ }
+
+ public boolean validate(String query, String operationName, HttpServletRequest req, HttpServletResponse res) throws IOException {
+ if (isPublicOperation(query)) {
+ return true;
+ } else if (req.getHeader("Authorization") == null) {
+ res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
+ res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ return false;
+ }
+
+ if (isAuthenticatedUser(req)) {
+ return true;
+ } else {
+ res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ return false;
+ }
+ }
+
+ private boolean isPublicOperation(String query) {
+ final Document queryDoc = parser.parseDocument(query);
+ final Definition<?> def = queryDoc.getDefinitions().get(0);
+ if (def instanceof OperationDefinition) {
+ OperationDefinition opDef = (OperationDefinition) def;
+ if (SUBSCRIPTION.equals(opDef.getOperation())) {
+ // subscriptions are not public
+ return false;
+ } else if ("IntrospectionQuery".equals(opDef.getName())) {
+ // allow introspection query
+ return true;
+ }
+
+ List<Node> children = opDef.getSelectionSet().getChildren();
+ final Field cdp = (Field) children.stream().filter((node) -> {
+ return (node instanceof Field) && "cdp".equals(((Field) node).getName());
+ }).findFirst().orElse(null);
+ if (cdp == null) {
+ // allow not a cdp namespace
+ return true;
+ }
+
+ final List<String> allowedNodeNames = new ArrayList<>();
+ if (QUERY.equals(opDef.getOperation())) {
+ allowedNodeNames.add("getProfile");
+ } else if (MUTATION.equals(opDef.getOperation())) {
+ allowedNodeNames.add("processEvents");
+ }
+
+ return cdp.getSelectionSet().getChildren().stream().allMatch((node) -> {
+ return (node instanceof Field) && allowedNodeNames.contains(((Field) node).getName());
+ });
+ }
+ return true;
+ }
+
+ private boolean isAuthenticatedUser(HttpServletRequest req) {
+ req.setAttribute(AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH);
+
+ String authHeader = req.getHeader("Authorization");
+
+ String usernameAndPassword = new String(Base64.getDecoder().decode(authHeader.substring(6).getBytes()));
+ int userNameIndex = usernameAndPassword.indexOf(":");
+ String username = usernameAndPassword.substring(0, userNameIndex);
+ String password = usernameAndPassword.substring(userNameIndex + 1);
+
+ LoginContext loginContext;
+ try {
+ loginContext = new LoginContext("karaf", callbacks -> {
+ for (Callback callback : callbacks) {
+ if (callback instanceof NameCallback) {
+ ((NameCallback) callback).setName(username);
+ } else if (callback instanceof PasswordCallback) {
+ ((PasswordCallback) callback).setPassword(password.toCharArray());
+ } else {
+ throw new UnsupportedCallbackException(callback);
+ }
+ }
+ });
+ loginContext.login();
+ Subject subject = loginContext.getSubject();
+ boolean success = subject != null;
+ if (success) {
+ req.setAttribute(REMOTE_USER, subject);
+ }
+ return success;
+ } catch (LoginException e) {
+ LOG.warn("Login failed", e);
+ return false;
+ }
+ }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
index d2d76c3..9b8ff09 100644
--- a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
@@ -25,18 +25,16 @@
import org.apache.http.util.EntityUtils;
import org.apache.unomi.graphql.utils.GraphQLObjectMapper;
import org.apache.unomi.itests.BaseIT;
-import org.apache.unomi.lifecycle.BundleWatcher;
-import org.junit.Before;
import org.junit.runner.RunWith;
import org.ops4j.pax.exam.junit.PaxExam;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerSuite;
-import org.ops4j.pax.exam.util.Filter;
import org.osgi.framework.BundleContext;
import javax.inject.Inject;
import java.io.IOException;
import java.io.InputStream;
+import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
@@ -53,13 +51,29 @@
@Inject
protected BundleContext bundleContext;
+ protected CloseableHttpResponse postAnonymous(final String resource) throws IOException {
+ return postAs(resource, null, null);
+ }
+
protected CloseableHttpResponse post(final String resource) throws IOException {
+ return postAs(resource, "karaf", "karaf");
+ }
+
+ protected CloseableHttpResponse postAs(final String resource, final String username, final String password) throws IOException {
final String resourceAsString = resourceAsString(resource);
final HttpPost request = new HttpPost(GRAPHQL_ENDPOINT);
request.setEntity(new StringEntity(resourceAsString, JSON_CONTENT_TYPE));
+ if (username != null && password != null) {
+ String basicAuth = username + ":" + password;
+ String wrap = "Basic " + new String(Base64.getEncoder().encode(basicAuth.getBytes()));
+ request.setHeader("Authorization", wrap);
+ } else {
+ request.removeHeaders("Authorization");
+ }
+
return HttpClientBuilder.create().build().execute(request);
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
new file mode 100644
index 0000000..2273b94
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.itests.graphql;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class GraphQLServletSecurityIT extends BaseGraphQLIT {
+
+ @Test
+ public void testAnonymousProcessEventsRequest() throws Exception {
+ try (CloseableHttpResponse response = postAnonymous("graphql/security/process-events.json")) {
+ final ResponseContext context = ResponseContext.parse(response.getEntity());
+
+ Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+ Assert.assertNotNull(context.getValue("data.cdp.processEvents"));
+ }
+ }
+
+ @Test
+ public void testAnonymousGetProfileRequest() throws Exception {
+ try (CloseableHttpResponse response = postAnonymous("graphql/security/get-profile.json")) {
+ final ResponseContext context = ResponseContext.parse(response.getEntity());
+
+ Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+ Assert.assertNull(context.getValue("data.cdp.getProfile"));
+ }
+ }
+
+ @Test
+ public void testAnonymousGetSegmentRequest() throws Exception {
+ try (CloseableHttpResponse response = postAnonymous("graphql/security/get-segment.json")) {
+
+ Assert.assertEquals(401, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void testAnonymousGetEventRequest() throws Exception {
+ try (CloseableHttpResponse response = postAnonymous("graphql/security/get-event.json")) {
+
+ Assert.assertEquals(401, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void testAuthenticatedWrongGetEventRequest() throws Exception {
+ try (CloseableHttpResponse response = postAs("graphql/security/get-event.json", "karaf", "wrongPassword")) {
+
+ Assert.assertEquals(401, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void testAuthenticatedGetEventRequest() throws Exception {
+ try (CloseableHttpResponse response = post("graphql/security/get-event.json")) {
+ final ResponseContext context = ResponseContext.parse(response.getEntity());
+
+ Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+ Assert.assertNull(context.getValue("data.cdp.getEvent"));
+ }
+ }
+
+ @Test
+ public void testAnonymousSubscriptionRequest() throws Exception {
+ try (CloseableHttpResponse response = postAnonymous("graphql/security/subscribe.json")) {
+
+ Assert.assertEquals(401, response.getStatusLine().getStatusCode());
+ }
+ }
+}
diff --git a/itests/src/test/resources/graphql/security/get-event.json b/itests/src/test/resources/graphql/security/get-event.json
new file mode 100644
index 0000000..30a9d80
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/get-event.json
@@ -0,0 +1,7 @@
+{
+ "operationName": "getEvent",
+ "variables": {
+ "id": "event-1"
+ },
+ "query": "query getEvent($id: String!) {\n cdp {\n getEvent(id: $id) {\n id\n cdp_profileID {\n id\n }\n }\n }\n}\n"
+}
diff --git a/itests/src/test/resources/graphql/security/get-profile.json b/itests/src/test/resources/graphql/security/get-profile.json
new file mode 100644
index 0000000..82409d0
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/get-profile.json
@@ -0,0 +1,12 @@
+{
+ "operationName": "getProfile",
+ "variables": {
+ "profileID": {
+ "client": {
+ "id": "defaultClientId"
+ },
+ "id": "1234"
+ }
+ },
+ "query": "query getProfile($profileID: CDP_ProfileIDInput!) {\n cdp {\n getProfile(profileID: $profileID) {\n firstName\n }\n }\n}\n"
+}
diff --git a/itests/src/test/resources/graphql/security/get-segment.json b/itests/src/test/resources/graphql/security/get-segment.json
new file mode 100644
index 0000000..074e014
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/get-segment.json
@@ -0,0 +1,7 @@
+{
+ "operationName": "getSegment",
+ "variables": {
+ "segmentID": "testSegment"
+ },
+ "query": "query getSegment($segmentID: ID) {\n cdp {\n getSegment(segmentID: $segmentID) {\n id\n name\n filter\n }\n }\n}\n"
+}
diff --git a/itests/src/test/resources/graphql/security/process-events.json b/itests/src/test/resources/graphql/security/process-events.json
new file mode 100644
index 0000000..a72c201
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/process-events.json
@@ -0,0 +1,21 @@
+{
+ "operationName": "updateProfile",
+ "variables": {
+ "events": [
+ {
+ "cdp_objectID": "objectId",
+ "cdp_profileID": {
+ "id": "profile-1",
+ "client": {
+ "id": "defaultClientId"
+ }
+ },
+ "cdp_profileUpdateEvent": {
+ "firstName": "Gigi",
+ "lastName": "Bergkamp"
+ }
+ }
+ ]
+ },
+ "query": "mutation updateProfile($events: [CDP_EventInput]!) {\n cdp {\n processEvents(events: $events)\n }\n}\n"
+}
diff --git a/itests/src/test/resources/graphql/security/subscribe.json b/itests/src/test/resources/graphql/security/subscribe.json
new file mode 100644
index 0000000..f57e61d
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/subscribe.json
@@ -0,0 +1,5 @@
+{
+ "variables": {},
+ "operationName": "events",
+ "query": "subscription events($filter: CDP_EventFilterInput) {\n eventListener(filter: $filter) {\n id\n cdp_profile {\n firstName\n }\n }\n}\n"
+}