From 4e6944fdd5a131d5c2aee71eb9f784b7bb6bc5cd Mon Sep 17 00:00:00 2001 From: GeorgeC Date: Tue, 17 Sep 2024 15:41:59 -0400 Subject: [PATCH] Enable open access validation and improve logging Added new configuration and logic for open access requests validation. Refactored logging statements to use placeholder-based formatting for consistency. --- .../harvard/dbmi/avillach/PicSureWarInit.java | 25 +- .../dbmi/avillach/security/JWTFilter.java | 215 +++++++++++++----- .../service/VisualizationService.java | 73 +++--- 3 files changed, 220 insertions(+), 93 deletions(-) diff --git a/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/PicSureWarInit.java b/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/PicSureWarInit.java index 43356e1b..93ea9601 100644 --- a/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/PicSureWarInit.java +++ b/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/PicSureWarInit.java @@ -4,14 +4,13 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; -import org.apache.http.impl.conn.SystemDefaultRoutePlanner; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.PostConstruct; import javax.annotation.Resource; import javax.ejb.Singleton; import javax.enterprise.context.ApplicationScoped; -import java.net.ProxySelector; @Singleton @ApplicationScoped @@ -28,6 +27,19 @@ public class PicSureWarInit { @Resource(mappedName = "java:global/defaultApplicationUUID") private String default_application_uuid; + @Resource(mappedName = "java:global/openAccessEnabled") + private String open_access_enabled_str; + + private boolean open_access_enabled; + + @Resource(mappedName = "java:global/openAccessValidateUrl") + private String open_access_validate_url; + + @PostConstruct + public void init() { + this.open_access_enabled = Boolean.parseBoolean(open_access_enabled_str); + } + // to be able to pre modified public static final ObjectMapper objectMapper = new ObjectMapper(); @@ -61,4 +73,13 @@ public String getToken_introspection_token() { public String getDefaultApplicationUUID() { return this.default_application_uuid; } + + public boolean isOpenAccessEnabled() { + return open_access_enabled; + } + + public String getOpenAccessValidateUrl() { + return this.open_access_validate_url; + } + } diff --git a/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/security/JWTFilter.java b/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/security/JWTFilter.java index 457459f5..dc201c38 100755 --- a/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/security/JWTFilter.java +++ b/pic-sure-api-war/src/main/java/edu/harvard/dbmi/avillach/security/JWTFilter.java @@ -13,6 +13,7 @@ import edu.harvard.dbmi.avillach.util.exception.ApplicationException; import edu.harvard.dbmi.avillach.util.response.PICSUREResponse; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; @@ -84,37 +85,55 @@ public void filter(ContainerRequestContext requestContext) throws IOException { } else { // Everything else goes through PSAMA token introspection String authorizationHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (authorizationHeader == null || authorizationHeader.isEmpty()) { - throw new NotAuthorizedException("No authorization header found."); - } - String token = authorizationHeader.substring(6).trim(); - - String userForLogging = null; + boolean isOpenAccessEnabled = picSureWarInit.isOpenAccessEnabled(); + if ( + (StringUtils.isBlank(authorizationHeader) && isOpenAccessEnabled) + || (StringUtils.isNotBlank(authorizationHeader) && authorizationHeader.length() <= 7 && isOpenAccessEnabled) + ) { + boolean isAuthorized = callOpenAccessValidationEndpoint(requestContext); + if (!isAuthorized) { + logger.error("User is not authorized."); + requestContext.abortWith(PICSUREResponse.unauthorizedError("User is not authorized.")); + } - try { - AuthUser authenticatedUser = null; + // There is no user associated with open access request. In order to provide traceability, + // we set the username to OPEN_ACCESS: + requestContext.setProperty("username", "OPEN_ACCESS:" + requestContext.getUriInfo().getRequestUri().getHost()); + } else { + if (authorizationHeader == null || authorizationHeader.isEmpty()) { + throw new NotAuthorizedException("No authorization header found."); + } - authenticatedUser = callTokenIntroEndpoint(requestContext, token, userIdClaim); - if (authenticatedUser == null) { - logger.error("Cannot extract a user from token: " + token); - throw new NotAuthorizedException("Cannot find or create a user"); + String token = authorizationHeader.substring(6).trim(); + if (token.isEmpty()) { + throw new NotAuthorizedException("No token found in authorization header."); } - userForLogging = authenticatedUser.getUserId(); - - // The request context wants to remember who the user is - requestContext.setProperty("username", userForLogging); - requestContext.setSecurityContext(new AuthSecurityContext(authenticatedUser, uriInfo.getRequestUri().getScheme())); - logger.info("User - " + userForLogging + " - has just passed all the authentication and authorization layers."); - } catch (NotAuthorizedException e) { - // the detail of this exception should be logged right before the exception thrown out - // logger.error("User - " + userForLogging + " - is not authorized. " + e.getChallenges()); - // we should show different response based on role - requestContext.abortWith(PICSUREResponse.unauthorizedError("User is not authorized. " + e.getChallenges())); - } catch (Exception e) { - // we should show different response based on role - e.printStackTrace(); - requestContext.abortWith(PICSUREResponse.applicationError("Inner application error, please contact system admin")); + String userForLogging = null; + try { + AuthUser authenticatedUser = null; + + authenticatedUser = callTokenIntroEndpoint(requestContext, token, userIdClaim); + if (authenticatedUser == null) { + logger.error("Cannot extract a user from token: {}", token); + throw new NotAuthorizedException("Cannot find or create a user"); + } + + userForLogging = authenticatedUser.getUserId(); + + // The request context wants to remember who the user is + requestContext.setProperty("username", userForLogging); + requestContext.setSecurityContext(new AuthSecurityContext(authenticatedUser, uriInfo.getRequestUri().getScheme())); + logger.info("User - {} - has just passed all the authentication and authorization layers.", userForLogging); + } catch (NotAuthorizedException e) { + // the detail of this exception should be logged right before the exception thrown out + logger.error("User - {} - is not authorized. {}", userForLogging, e.getChallenges()); + requestContext.abortWith(PICSUREResponse.unauthorizedError("User is not authorized. " + e.getChallenges())); + } catch (Exception e) { + logger + .error("User - {} - is not authorized {} and an Inner application error occurred.", userForLogging, e.getMessage()); + requestContext.abortWith(PICSUREResponse.applicationError("Inner application error, please contact system admin")); + } } } } @@ -147,7 +166,66 @@ private AuthUser callTokenIntroEndpoint(ContainerRequestContext requestContext, Map tokenMap = new HashMap<>(); tokenMap.put("token", token); + Map requestMap = prepareRequestMap(requestContext); + tokenMap.put("request", requestMap); + + StringEntity entity = null; + try { + entity = new StringEntity(json.writeValueAsString(tokenMap)); + } catch (IOException e) { + logger.error("callTokenIntroEndpoint() - " + e.getClass().getSimpleName() + " when composing post"); + return null; + } + post.setEntity(entity); + post.setHeader("Content-Type", "application/json"); + // Authorize into the token introspection endpoint + post.setHeader("Authorization", "Bearer " + token_introspection_token); + CloseableHttpResponse response = null; + try { + response = client.execute(post, buildHttpClientContext()); + if (response.getStatusLine().getStatusCode() != 200) { + logger.error( + "callTokenIntroEndpoint() error back from token intro host server [" + token_introspection_url + "]: " + + EntityUtils.toString(response.getEntity()) + ); + logger.info( + "This callTokenIntroEndpoint error can happen when your introspection token has expired. " + + "You can fix this by running the Configure PIC-SURE Token Introspection Token job in Jenkins." + ); + throw new ApplicationException( + "Token Introspection host server return " + response.getStatusLine().getStatusCode() + ". Please see the log" + ); + } + JsonNode responseContent = json.readTree(response.getEntity().getContent()); + if (!responseContent.get("active").asBoolean()) { + logger.error("callTokenIntroEndpoint() Token intro endpoint return invalid token, content: " + responseContent); + throw new NotAuthorizedException("Token invalid or expired"); + } + + if (responseContent.has("tokenRefreshed") && responseContent.get("tokenRefreshed").asBoolean()) { + requestContext.setProperty("refreshedToken", responseContent.get("token")); + } + + String userId = responseContent.get(userIdClaim) != null ? responseContent.get(userIdClaim).asText() : null; + String sub = responseContent.get("sub") != null ? responseContent.get("sub").asText() : null; + String email = responseContent.get("email") != null ? responseContent.get("email").asText() : null; + String roles = responseContent.get("roles") != null ? responseContent.get("roles").asText() : null; + AuthUser user = new AuthUser().setUserId(userId).setSubject(sub).setEmail(email).setRoles(roles); + return user; + } catch (IOException ex) { + logger.error("callTokenIntroEndpoint() IOException when hitting url: " + post + " with exception msg: " + ex.getMessage()); + } finally { + try { + if (response != null) response.close(); + } catch (IOException ex) { + logger.error("callTokenIntroEndpoint() IOExcpetion when closing http response: " + ex.getMessage()); + } + } + + return null; + } + private HashMap prepareRequestMap(ContainerRequestContext requestContext) { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); HashMap requestMap = new HashMap(); try { @@ -218,70 +296,85 @@ private AuthUser callTokenIntroEndpoint(ContainerRequestContext requestContext, } } } - tokenMap.put("request", requestMap); + return requestMap; } catch (JsonParseException ex) { requestMap.put("query", buffer.toString()); - tokenMap.put("request", requestMap); + return requestMap; } catch (IOException e1) { logger.error("IOException caught trying to build requestMap for auditing.", e1); throw new NotAuthorizedException( - "The request could not be properly audited. If you recieve this error multiple times, please contact an administrator." + "The request could not be properly audited. If you receive this error multiple times, please contact an administrator." ); } + } + + private boolean callOpenAccessValidationEndpoint(ContainerRequestContext requestContext) { + String openAccessValidateUrl = picSureWarInit.getOpenAccessValidateUrl(); + String token_introspection_token = picSureWarInit.getToken_introspection_token(); + + if (openAccessValidateUrl.isEmpty()) { + throw new ApplicationException("callOpenAccessValidationEndpoint - openAccessValidateUrl is empty in application properties"); + } + + Map requestMap = new HashMap<>(); + Map queryMap = prepareRequestMap(requestContext); + requestMap.put("request", queryMap); + // There is no user associated with open access request. In order to provide traceability, + // we set the username to OPEN_ACCESS: + requestMap.put("ipAddress", "OPEN_ACCESS:" + requestContext.getUriInfo().getRequestUri().getHost()); + ObjectMapper json = PicSureWarInit.objectMapper; + StringEntity entity = null; try { - entity = new StringEntity(json.writeValueAsString(tokenMap)); + entity = new StringEntity(json.writeValueAsString(requestMap)); } catch (IOException e) { - logger.error("callTokenIntroEndpoint() - " + e.getClass().getSimpleName() + " when composing post"); - return null; + logger.error("callOpenAccessValidationEndpoint() - FAILED TO parse requestMap to json", e); + return false; } + + CloseableHttpClient client = PicSureWarInit.CLOSEABLE_HTTP_CLIENT; + HttpPost post = new HttpPost(openAccessValidateUrl); post.setEntity(entity); post.setHeader("Content-Type", "application/json"); // Authorize into the token introspection endpoint post.setHeader("Authorization", "Bearer " + token_introspection_token); CloseableHttpResponse response = null; + boolean isValid = false; try { response = client.execute(post, buildHttpClientContext()); - if (response.getStatusLine().getStatusCode() != 200) { + + if (response.getStatusLine().getStatusCode() == 200) { + + // A 200 is return as long as the request is successful, the actual validation result is in the response body + JsonNode responseContent = json.readTree(response.getEntity().getContent()); + if (!responseContent.isBoolean()) { + logger.error( + "callOpenAccessValidateEndpoint() Open access validate endpoint return invalid response, content: {}", + responseContent + ); + throw new ApplicationException("Open access validate endpoint returned an invalid response"); + } + + isValid = responseContent.asBoolean(); + } else { logger.error( - "callTokenIntroEndpoint() error back from token intro host server [" + token_introspection_url + "]: " - + EntityUtils.toString(response.getEntity()) - ); - logger.info( - "This callTokenIntroEndpoint error can happen when your introspection token has expired. " - + "You can fix this by running the Configure PIC-SURE Token Introspection Token job in Jenkins." + "callOpenAccessValidateEndpoint() error returned from psama [{}]: {}", openAccessValidateUrl, + EntityUtils.toString(response.getEntity()) ); - throw new ApplicationException( - "Token Introspection host server return " + response.getStatusLine().getStatusCode() + ". Please see the log" - ); - } - JsonNode responseContent = json.readTree(response.getEntity().getContent()); - if (!responseContent.get("active").asBoolean()) { - logger.error("callTokenIntroEndpoint() Token intro endpoint return invalid token, content: " + responseContent); - throw new NotAuthorizedException("Token invalid or expired"); + throw new ApplicationException("Not able to validate open access request"); } - if (responseContent.has("tokenRefreshed") && responseContent.get("tokenRefreshed").asBoolean()) { - requestContext.setProperty("refreshedToken", responseContent.get("token")); - } - - String userId = responseContent.get(userIdClaim) != null ? responseContent.get(userIdClaim).asText() : null; - String sub = responseContent.get("sub") != null ? responseContent.get("sub").asText() : null; - String email = responseContent.get("email") != null ? responseContent.get("email").asText() : null; - String roles = responseContent.get("roles") != null ? responseContent.get("roles").asText() : null; - AuthUser user = new AuthUser().setUserId(userId).setSubject(sub).setEmail(email).setRoles(roles); - return user; } catch (IOException ex) { - logger.error("callTokenIntroEndpoint() IOException when hitting url: " + post + " with exception msg: " + ex.getMessage()); + logger.error("callOpenAccessValidateEndpoint() IOException when hitting url: {} with exception msg: {}", post, ex.getMessage()); } finally { try { if (response != null) response.close(); } catch (IOException ex) { - logger.error("callTokenIntroEndpoint() IOExcpetion when closing http response: " + ex.getMessage()); + logger.error("callOpenAccessValidateEndpoint() IOException when closing http response: {}", ex.getMessage()); } } - return null; + return isValid; } void setUserIdClaim(String userIdClaim) { diff --git a/pic-sure-resources/pic-sure-visualization-resource/src/main/java/edu/harvard/hms/dbmi/avillach/resource/visualization/service/VisualizationService.java b/pic-sure-resources/pic-sure-visualization-resource/src/main/java/edu/harvard/hms/dbmi/avillach/resource/visualization/service/VisualizationService.java index 0b5ef0c6..6a3c9043 100644 --- a/pic-sure-resources/pic-sure-visualization-resource/src/main/java/edu/harvard/hms/dbmi/avillach/resource/visualization/service/VisualizationService.java +++ b/pic-sure-resources/pic-sure-visualization-resource/src/main/java/edu/harvard/hms/dbmi/avillach/resource/visualization/service/VisualizationService.java @@ -42,7 +42,7 @@ public class VisualizationService { logger.info("Initializing properties"); } properties.init("pic-sure-visualization-resource"); - logger.info("VisualizationResource initialized ->", properties.getOrigin()); + logger.info("VisualizationResource initialized -> {}", properties.getOrigin()); threshold = "< " + properties.getTargetPicsureObfuscationThreshold(); variance = "±" + properties.getTargetPicsureObfuscationVariance(); @@ -51,7 +51,7 @@ public class VisualizationService { /** * Handles a query request from the UI. This method is called from the VisualizationResource class. * - * @param query QueryRequest - the query request + * @param query QueryRequest - the query request * @param requestSource String - the request source, Authorized or Open * @return ProcessedCrossCountsResponse */ @@ -62,7 +62,7 @@ public Response handleQuerySync(QueryRequest query, String requestSource) { } catch (Exception e) { // The exception is caught here because I don't want to modify the method signature to throw the // exception. - logger.error("Error parsing query: \n" + query, e); + logger.error("Error parsing query: \n{}", query, e); return Response.status(Response.Status.BAD_REQUEST).entity("Could not parse query.").build(); } @@ -79,24 +79,29 @@ public Response handleQuerySync(QueryRequest query, String requestSource) { } } - private Response getProcessedCrossCountResponse(Map> categoryCrossCountsMap, Map> continuousCrossCountsMap) { - if ((categoryCrossCountsMap == null || categoryCrossCountsMap.isEmpty()) && (continuousCrossCountsMap == null || continuousCrossCountsMap.isEmpty())) - return Response.ok().build(); - ProcessedCrossCountsResponse response = buildProcessedCrossCountsResponse(categoryCrossCountsMap, continuousCrossCountsMap, false, false, false); + private Response getProcessedCrossCountResponse( + Map> categoryCrossCountsMap, Map> continuousCrossCountsMap + ) { + if ( + (categoryCrossCountsMap == null || categoryCrossCountsMap.isEmpty()) + && (continuousCrossCountsMap == null || continuousCrossCountsMap.isEmpty()) + ) return Response.ok().build(); + ProcessedCrossCountsResponse response = + buildProcessedCrossCountsResponse(categoryCrossCountsMap, continuousCrossCountsMap, false, false, false); return Response.ok(response).build(); } /** - * This method determines if the data is obfuscated and if so, converts the string values to integers by removing - * the obfuscation types. If the value was obfuscated the response will be marked as obfuscated so the UI can - * display the data accordingly. + * This method determines if the data is obfuscated and if so, converts the string values to integers by removing the obfuscation types. + * If the value was obfuscated the response will be marked as obfuscated so the UI can display the data accordingly. * - * @param categoryCrossCountsMap - the categorical cross counts + * @param categoryCrossCountsMap - the categorical cross counts * @param continuousCrossCountsMap - the continuous cross counts * @return Response - the processed cross counts response */ - private Response getOpenProcessedCrossCountResponse(Map> categoryCrossCountsMap, - Map> continuousCrossCountsMap) { + private Response getOpenProcessedCrossCountResponse( + Map> categoryCrossCountsMap, Map> continuousCrossCountsMap + ) { Map> cleanedCategoricalData = new HashMap<>(); boolean isCategoricalObfuscated = false; if (categoryCrossCountsMap != null && !categoryCrossCountsMap.isEmpty()) { @@ -111,13 +116,15 @@ private Response getOpenProcessedCrossCountResponse(Map> - the cleaned categorical data @@ -164,20 +171,25 @@ private boolean isObfuscated(Map> crossCounts) { return isObfuscated; } - private ProcessedCrossCountsResponse buildProcessedCrossCountsResponse(Map> categoryCrossCountsMap, - Map> continuousCrossCountsMap, - boolean isCategoricalObfuscated, boolean isContinuousObfuscated, boolean isOpenAccess) { + private ProcessedCrossCountsResponse buildProcessedCrossCountsResponse( + Map> categoryCrossCountsMap, Map> continuousCrossCountsMap, + boolean isCategoricalObfuscated, boolean isContinuousObfuscated, boolean isOpenAccess + ) { ProcessedCrossCountsResponse response = new ProcessedCrossCountsResponse(); - response.getCategoricalData().addAll(dataProcessingServices.getCategoricalData(categoryCrossCountsMap, isCategoricalObfuscated, isOpenAccess)); - response.getContinuousData().addAll(dataProcessingServices.getContinuousData(continuousCrossCountsMap, isContinuousObfuscated, isOpenAccess)); + response.getCategoricalData() + .addAll(dataProcessingServices.getCategoricalData(categoryCrossCountsMap, isCategoricalObfuscated, isOpenAccess)); + response.getContinuousData() + .addAll(dataProcessingServices.getContinuousData(continuousCrossCountsMap, isContinuousObfuscated, isOpenAccess)); return response; } private Map> getCategoryCrossCountsMap(QueryRequest query, Query queryJson) { Map> categoryCrossCountsMap; - if ((queryJson.categoryFilters != null && queryJson.categoryFilters.size() > 0) || - (queryJson.requiredFields != null && queryJson.requiredFields.size() > 0)) { + if ( + (queryJson.categoryFilters != null && !queryJson.categoryFilters.isEmpty()) + || (queryJson.requiredFields != null && !queryJson.requiredFields.isEmpty()) + ) { categoryCrossCountsMap = hpdsServices.getAuthCrossCountsMap(query, ResultType.CATEGORICAL_CROSS_COUNT); } else { categoryCrossCountsMap = new HashMap<>(); @@ -186,13 +198,13 @@ private Map> getCategoryCrossCountsMap(QueryRequest } /** - * @param query QueryRequest + * @param query QueryRequest * @param queryJson Query * @return Map> - the continuous cross counts */ private Map> getContinuousCrossCount(QueryRequest query, Query queryJson) { Map> continuousCrossCountsMap; - if ((queryJson.numericFilters != null && queryJson.numericFilters.size() > 0)) { + if ((queryJson.numericFilters != null && !queryJson.numericFilters.isEmpty())) { continuousCrossCountsMap = hpdsServices.getAuthCrossCountsMap(query, ResultType.CONTINUOUS_CROSS_COUNT); } else { continuousCrossCountsMap = new HashMap<>(); @@ -202,8 +214,10 @@ private Map> getContinuousCrossCount(QueryRequest q private Map> getOpenCategoricalCrossCounts(QueryRequest query, Query queryJson) { Map> crossCountsMap; - if ((queryJson.categoryFilters != null && queryJson.categoryFilters.size() > 0) || - (queryJson.requiredFields != null && queryJson.requiredFields.size() > 0)) { + if ( + (queryJson.categoryFilters != null && !queryJson.categoryFilters.isEmpty()) + || (queryJson.requiredFields != null && !queryJson.requiredFields.isEmpty()) + ) { crossCountsMap = hpdsServices.getOpenCrossCountsMap(query, ResultType.CATEGORICAL_CROSS_COUNT); } else { crossCountsMap = new HashMap<>(); @@ -214,7 +228,7 @@ private Map> getOpenCategoricalCrossCounts(QueryRequ private Map> getOpenContinuousCrossCounts(QueryRequest query, Query queryJson) { Map> crossCountsMap; - if ((queryJson.numericFilters != null && queryJson.numericFilters.size() > 0)) { + if ((queryJson.numericFilters != null && !queryJson.numericFilters.isEmpty())) { crossCountsMap = hpdsServices.getOpenCrossCountsMap(query, ResultType.CONTINUOUS_CROSS_COUNT); } else { crossCountsMap = new HashMap<>(); @@ -236,8 +250,7 @@ public Response generateContinuousBin(QueryRequest continuousData) { } logger.info("Continuous data: " + continuousData.getQuery()); - Map> continuousDataMap = mapper.convertValue(continuousData.getQuery(), new TypeReference<>() { - }); + Map> continuousDataMap = mapper.convertValue(continuousData.getQuery(), new TypeReference<>() {}); Map> continuousProcessedData = dataProcessingServices.binContinuousData(continuousDataMap); return Response.ok(continuousProcessedData).build(); }