diff --git a/pom.xml b/pom.xml index c196b8ac..b3af6603 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,12 @@ mssql-jdbc + + com.auth0 + java-jwt + 4.4.0 + + org.simplejavamail @@ -356,6 +362,12 @@ ${spring_boot_version} test + + org.assertj + assertj-core + 3.22.0 + compile + @@ -575,6 +587,17 @@ + + org.springframework + spring-web + ${spring_version} + + + commons-logging + commons-logging + + + diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/Application.java b/src/main/java/ca/uhn/fhir/jpa/starter/Application.java index 26b34e17..b2473f35 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/Application.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/Application.java @@ -18,6 +18,7 @@ import ca.uhn.fhir.jpa.subscription.match.config.WebsocketDispatcherConfig; import ca.uhn.fhir.jpa.subscription.submit.config.SubscriptionSubmitterConfig; import ca.uhn.fhir.rest.server.RestfulServer; +import ca.uhn.fhir.rest.server.interceptor.CorsInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.boot.SpringApplication; @@ -31,8 +32,11 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Import; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.DispatcherServlet; +import java.util.Arrays; + @ServletComponentScan(basePackageClasses = {RestfulServer.class}) //, UnHapiServlet.class @SpringBootApplication(exclude = {ElasticsearchRestClientAutoConfiguration.class}) @Import({ @@ -89,11 +93,42 @@ public ServletRegistrationBean hapiServletRegistration(RestfulServer restfulServ //register FAST security interceptors DiscoveryInterceptor securityDiscoveryInterceptor = new DiscoveryInterceptor(); IdentityMatchingAuthInterceptor authInterceptor = new IdentityMatchingAuthInterceptor(securityConfig.isEnableAuthentication(), + securityConfig.getIssuer(), securityConfig.getPublicKey(), securityConfig.getIntrospectionUrl(), securityConfig.getClientId(), securityConfig.getClientSecret(), securityConfig.getProtectedEndpoints(), securityConfig.getPublicEndpoints()); restfulServer.registerInterceptor(securityDiscoveryInterceptor); restfulServer.registerInterceptor(authInterceptor); + //check if there is existing CORS configuration, if so add 'x-allow-public-access' to the allowed headers, otherwise create a new CORS interceptor + var existingCorsInterceptor = restfulServer.getInterceptorService().getAllRegisteredInterceptors().stream().filter(interceptor -> interceptor instanceof CorsInterceptor).findFirst().orElse(null); + if (existingCorsInterceptor != null) { + // Cast the interceptor to CorsInterceptor + CorsInterceptor corsInterceptor = (CorsInterceptor) existingCorsInterceptor; + + // Add custom header to the existing CORS configuration + corsInterceptor.getConfig().addAllowedHeader("x-allow-public-access"); + } + else { + // Define your CORS configuration + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedHeader("x-fhir-starter"); + config.addAllowedHeader("Origin"); + config.addAllowedHeader("Accept"); + config.addAllowedHeader("X-Requested-With"); + config.addAllowedHeader("Content-Type"); + config.addAllowedHeader("x-allow-public-access"); + + config.addAllowedOrigin("*"); + + config.addExposedHeader("Location"); + config.addExposedHeader("Content-Location"); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + + // Create the interceptor and register it + CorsInterceptor corsInterceptor = new CorsInterceptor(config); + restfulServer.registerInterceptor(corsInterceptor); + } + ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean(); beanFactory.autowireBean(restfulServer); servletRegistrationBean.setServlet(restfulServer); diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/operations/IdentityMatching.java b/src/main/java/ca/uhn/fhir/jpa/starter/operations/IdentityMatching.java index f57b110f..8d6ebd61 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/operations/IdentityMatching.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/operations/IdentityMatching.java @@ -7,7 +7,6 @@ import ca.uhn.fhir.jpa.starter.operations.models.IdentityMatchingScorer; import ca.uhn.fhir.model.base.composite.BaseIdentifierDt; import ca.uhn.fhir.model.dstu2.composite.IdentifierDt; -import ca.uhn.fhir.model.valueset.BundleTypeEnum; import ca.uhn.fhir.rest.annotation.Operation; import ca.uhn.fhir.rest.annotation.ResourceParam; import ca.uhn.fhir.rest.api.server.IBundleProvider; @@ -17,7 +16,6 @@ import ca.uhn.fhir.rest.gclient.StringClientParam; import ca.uhn.fhir.rest.param.*; import org.apache.commons.lang3.StringUtils; -import org.hl7.fhir.BundleType; import org.hl7.fhir.r4.model.*; import java.util.ArrayList; import java.util.HashSet; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/security/IdentityMatchingAuthInterceptor.java b/src/main/java/ca/uhn/fhir/jpa/starter/security/IdentityMatchingAuthInterceptor.java index bef654dc..2208b365 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/security/IdentityMatchingAuthInterceptor.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/security/IdentityMatchingAuthInterceptor.java @@ -4,9 +4,15 @@ import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.server.RequestDetails; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,11 +27,21 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; import java.util.List; @Interceptor public class IdentityMatchingAuthInterceptor { private boolean enableAuthentication = false; + + private String issuer; + + private String publicKey; private String introspectUrl; private String clientId; private String clientSecret; @@ -35,8 +51,10 @@ public class IdentityMatchingAuthInterceptor { private final Logger _logger = LoggerFactory.getLogger(IdentityMatchingAuthInterceptor.class); private final String allowPublicAccessHeader = "X-Allow-Public-Access"; - public IdentityMatchingAuthInterceptor(boolean enableAuthentication, String introspectUrl, String clientId, String clientSecret, List protectedEndpoints, List publicEndpoints) { + public IdentityMatchingAuthInterceptor(boolean enableAuthentication, String issuer, String publicKey, String introspectUrl, String clientId, String clientSecret, List protectedEndpoints, List publicEndpoints) { this.enableAuthentication = enableAuthentication; + this.issuer = issuer; + this.publicKey = publicKey; this.introspectUrl = introspectUrl; this.clientId = clientId; this.clientSecret = clientSecret; @@ -67,16 +85,18 @@ public boolean incomingRequestPostProcessed(RequestDetails details, HttpServletR throw new AuthenticationException("Not authorized (authorization header does not contain a bearer token)"); } + //if a confidential client, use the client secret and introspect endpoint to validate token if (!StringUtils.isBlank(clientSecret) && !StringUtils.isBlank(introspectUrl)) { authenticated = introspectionCheck(authHeader); - } else { - authenticated = false; - _logger.error("Failed to provide client secret and/or introspection url."); + } else { //assume public client and validate using the token and supplied public key + authenticated = validateToken(authHeader); } } catch (AuthenticationException ex) { _logger.error(ex.getMessage(), ex); _logger.info("Failed to authenticate request"); + } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) { + throw new RuntimeException(ex); } } //otherwise, allow all else { @@ -85,10 +105,7 @@ public boolean incomingRequestPostProcessed(RequestDetails details, HttpServletR } else { //public access header detected or a public access point was requested - allow request authenticated = true; - - if(publicAccessHeader != null) { - _logger.info("The 'X-Allow-Public-Access' header was detected, ignoring security configuration."); - } + _logger.info("The 'X-Allow-Public-Access' header was detected, ignoring security configuration."); } if(!authenticated) { @@ -124,4 +141,28 @@ private boolean introspectionCheck(String authHeader) throws JsonProcessingExcep return false; } } + + private boolean validateToken(String authHeader) throws NoSuchAlgorithmException, InvalidKeySpecException { + + var token = authHeader.split(" ")[1]; + + //current set up for RSA 256, change as necessary + byte[] publicBytes = Base64.decodeBase64(publicKey); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec); + + try { + Algorithm algorithm = Algorithm.RSA256(publicKey, null); + JWTVerifier verifier = JWT.require(algorithm) + .withIssuer(issuer) + .build(); //Reusable verifier instance + DecodedJWT verifiedJwt = verifier.verify(token); + + return verifiedJwt != null; + + } catch (JWTVerificationException ex){ + throw new JWTVerificationException(ex.getMessage()); + } + } } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/security/models/SecurityConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/security/models/SecurityConfig.java index 930d7e2e..c259bc05 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/security/models/SecurityConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/security/models/SecurityConfig.java @@ -8,6 +8,10 @@ public class SecurityConfig { @Value("${enable-authentication}") boolean enableAuthentication; + @Value("${issuer}") + String issuer; + @Value("${public-key}") + String publicKey; @Value("${introspection-url}") String introspectionUrl; @Value("${client-id}") @@ -21,6 +25,10 @@ public class SecurityConfig { public boolean isEnableAuthentication() { return enableAuthentication; } + public String getIssuer() { return issuer; } + + public String getPublicKey() { return publicKey; } + public String getIntrospectionUrl() { return introspectionUrl; } public String getClientId() { return clientId; }