diff --git a/song-server/src/main/java/bio/overture/song/server/config/SecurityConfig.java b/song-server/src/main/java/bio/overture/song/server/config/SecurityConfig.java index bbc1dff6..84d7f6f3 100644 --- a/song-server/src/main/java/bio/overture/song/server/config/SecurityConfig.java +++ b/song-server/src/main/java/bio/overture/song/server/config/SecurityConfig.java @@ -72,7 +72,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public SystemSecurity systemSecurity() { - return new SystemSecurity(scope.getSystem()); + return new SystemSecurity(scope.getSystem(), introspectionUri); } @Bean @@ -93,6 +93,7 @@ public StudySecurity studySecurity(@Autowired SystemSecurity systemSecurity) { .studyPrefix(scope.getStudy().getPrefix()) .studySuffix(scope.getStudy().getSuffix()) .systemScope(scope.getSystem()) + .introspectionUri(introspectionUri) .build(); } diff --git a/song-server/src/main/java/bio/overture/song/server/security/KeycloakAuthorizationService.java b/song-server/src/main/java/bio/overture/song/server/security/KeycloakAuthorizationService.java new file mode 100644 index 00000000..e4636f89 --- /dev/null +++ b/song-server/src/main/java/bio/overture/song/server/security/KeycloakAuthorizationService.java @@ -0,0 +1,120 @@ +package bio.overture.song.server.security; + +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.List; + +import static org.springframework.http.HttpStatus.Series.CLIENT_ERROR; +import static org.springframework.http.HttpStatus.Series.SERVER_ERROR; + +@Slf4j +@Builder +public class KeycloakAuthorizationService { + + private static final String UMA_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:uma-ticket"; + private static final String UMA_AUDIENCE = "song"; + private static final String UMA_RESPONSE_MODE = "permissions"; + + @NonNull private String introspectionUri; + + public List fetchAuthorizationGrants(String accessToken){ + // Add token to introspectionUri + val uriWithToken = + UriComponentsBuilder.fromHttpUrl(introspectionUri) + .build() + .toUri(); + + HttpEntity> request = + new HttpEntity<>(getUmaParams(), getBearerAuthHeader(accessToken)); + + // Get response from Keycloak + val template = new RestTemplate(); + template.setErrorHandler(new RestTemplateResponseErrorHandler()); + val response = + template.postForEntity( + uriWithToken, request, KeycloakPermission[].class); + + // Ensure response was OK + if ((response.getStatusCode() != HttpStatus.OK + && response.getStatusCode() != HttpStatus.MULTI_STATUS + && response.getStatusCode() != HttpStatus.UNAUTHORIZED) + || !response.hasBody()) { + throw new OAuth2IntrospectionException("Bad Response from Keycloak Server"); + } + + val isValid = validateIntrospectResponse(response.getStatusCode()); + if (!isValid) { + throw new BadOpaqueTokenException("ApiKey is revoked or expired."); + } + + return List.of(response.getBody()); + } + + private MultiValueMap getUmaParams(){ + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("grant_type", UMA_GRANT_TYPE); + map.add("audience", UMA_AUDIENCE); + map.add("response_mode", UMA_RESPONSE_MODE); + return map; + } + + private HttpHeaders getBearerAuthHeader(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setBearerAuth(token); + return headers; + } + + private boolean validateIntrospectResponse(HttpStatus status) { + if (status != HttpStatus.OK && status != HttpStatus.MULTI_STATUS) { + log.debug( + "Check Token response is unauthorized but does not list the error. Rejecting token."); + return false; + } + return true; + } + + private static class RestTemplateResponseErrorHandler + implements ResponseErrorHandler { + + @Override + public boolean hasError(ClientHttpResponse httpResponse) + throws IOException { + + return ( + httpResponse.getStatusCode().series() == CLIENT_ERROR + || httpResponse.getStatusCode().series() == SERVER_ERROR); + } + + @Override + public void handleError(ClientHttpResponse httpResponse) + throws IOException { + + if (httpResponse.getStatusCode().series() == CLIENT_ERROR) { + // throw 401 HTTP error code + throw new BadCredentialsException(httpResponse.getStatusText()); + } else { + // throw 500 HTTP error code + throw new OAuth2IntrospectionException(httpResponse.getStatusText()); + } + } + } + +} diff --git a/song-server/src/main/java/bio/overture/song/server/security/KeycloakPermission.java b/song-server/src/main/java/bio/overture/song/server/security/KeycloakPermission.java new file mode 100644 index 00000000..4f752206 --- /dev/null +++ b/song-server/src/main/java/bio/overture/song/server/security/KeycloakPermission.java @@ -0,0 +1,16 @@ +package bio.overture.song.server.security; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class KeycloakPermission { + private String rsid; + private String rsname; + private List scopes; +} diff --git a/song-server/src/main/java/bio/overture/song/server/security/StudySecurity.java b/song-server/src/main/java/bio/overture/song/server/security/StudySecurity.java index d1a3a196..c04fa35f 100644 --- a/song-server/src/main/java/bio/overture/song/server/security/StudySecurity.java +++ b/song-server/src/main/java/bio/overture/song/server/security/StudySecurity.java @@ -16,7 +16,7 @@ */ package bio.overture.song.server.security; -import static bio.overture.song.server.utils.Scopes.extractGrantedScopes; +import static bio.overture.song.server.utils.Scopes.extractGrantedScopesFromRpt; import java.util.Set; import lombok.Builder; @@ -24,7 +24,9 @@ import lombok.Value; import lombok.extern.slf4j.Slf4j; import lombok.val; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; @Slf4j @Value @@ -34,10 +36,22 @@ public class StudySecurity { @NonNull private final String studyPrefix; @NonNull private final String studySuffix; @NonNull private final String systemScope; + @NonNull private final String introspectionUri; + + @Autowired + public KeycloakAuthorizationService keycloakAuthorizationService() { + return KeycloakAuthorizationService.builder() + .introspectionUri(introspectionUri) + .build(); + } public boolean authorize(@NonNull Authentication authentication, @NonNull final String studyId) { log.info("Checking study-level authorization for studyId {}", studyId); - val grantedScopes = extractGrantedScopes(authentication); + + val authGrants = keycloakAuthorizationService() + .fetchAuthorizationGrants(((JwtAuthenticationToken) authentication).getToken().getTokenValue()); + + val grantedScopes = extractGrantedScopesFromRpt(authGrants); return verifyOneOfStudyScope(grantedScopes, studyId); } diff --git a/song-server/src/main/java/bio/overture/song/server/security/SystemSecurity.java b/song-server/src/main/java/bio/overture/song/server/security/SystemSecurity.java index 10702778..547bc4af 100644 --- a/song-server/src/main/java/bio/overture/song/server/security/SystemSecurity.java +++ b/song-server/src/main/java/bio/overture/song/server/security/SystemSecurity.java @@ -17,23 +17,38 @@ package bio.overture.song.server.security; import static bio.overture.song.server.utils.Scopes.extractGrantedScopes; +import static bio.overture.song.server.utils.Scopes.extractGrantedScopesFromRpt; import java.util.Set; import lombok.NonNull; import lombok.Value; import lombok.extern.slf4j.Slf4j; import lombok.val; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; @Slf4j @Value public class SystemSecurity { @NonNull private final String systemScope; + @NonNull private final String introspectionUri; + + @Autowired + public KeycloakAuthorizationService keycloakAuthorizationService() { + return KeycloakAuthorizationService.builder() + .introspectionUri(introspectionUri) + .build(); + } public boolean authorize(@NonNull Authentication authentication) { log.debug("Checking system-level authorization"); - val grantedScopes = extractGrantedScopes(authentication); + + val authGrants = keycloakAuthorizationService() + .fetchAuthorizationGrants(((JwtAuthenticationToken) authentication).getToken().getTokenValue()); + + val grantedScopes = extractGrantedScopesFromRpt(authGrants); return verifyOneOfSystemScope(grantedScopes); } diff --git a/song-server/src/main/java/bio/overture/song/server/utils/Scopes.java b/song-server/src/main/java/bio/overture/song/server/utils/Scopes.java index 87247fea..b63add1b 100644 --- a/song-server/src/main/java/bio/overture/song/server/utils/Scopes.java +++ b/song-server/src/main/java/bio/overture/song/server/utils/Scopes.java @@ -2,12 +2,12 @@ import static lombok.AccessLevel.PRIVATE; +import bio.overture.song.server.security.KeycloakPermission; import com.nimbusds.jose.shaded.json.JSONArray; import com.nimbusds.jose.shaded.json.JSONObject; -import java.util.Collections; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; + +import java.util.*; + import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -33,6 +33,22 @@ public static Set extractGrantedScopes(Authentication authentication) { return grantedScopes; } + public static Set extractGrantedScopesFromRpt(List permissionList) { + Set grantedScopes = new HashSet(); + + permissionList + .stream() + .filter(perm -> perm.getScopes() != null) + .forEach(permission -> { + permission.getScopes().stream().forEach(scope -> { + val fullScope = permission.getRsname() + "." + scope; + grantedScopes.add(fullScope); + }); + }); + + return grantedScopes; + } + public static long extractExpiry(Map map) { Object exp = map.get(EXP); if (exp instanceof Integer) { diff --git a/song-server/src/main/resources/application.yml b/song-server/src/main/resources/application.yml index 2810cdba..8eceb8a7 100644 --- a/song-server/src/main/resources/application.yml +++ b/song-server/src/main/resources/application.yml @@ -201,10 +201,13 @@ spring: oauth2: resourceserver: jwt: - public-key-location: http://localhost:8081/oauth/token/public_key + # EGO public key +# public-key-location: http://localhost:8081/oauth/token/public_key + # Keycloak JWT + jwk-set-uri: http://localhost/realms/myrealm/protocol/openid-connect/certs auth: server: - introspectionUri: http://localhost:8081/o/check_api_key + introspectionUri: http://localhost/realms/myrealm/protocol/openid-connect/token clientID: id clientSecret: secret scope: diff --git a/song-server/src/test/java/bio/overture/song/server/security/TestStudySecurity.java b/song-server/src/test/java/bio/overture/song/server/security/TestStudySecurity.java index 2e272580..70d53018 100644 --- a/song-server/src/test/java/bio/overture/song/server/security/TestStudySecurity.java +++ b/song-server/src/test/java/bio/overture/song/server/security/TestStudySecurity.java @@ -58,7 +58,8 @@ public void test_authorize(Map map, boolean expected) { val prefix = "PROGRAMDATA-"; val suffix = ".READ"; val scope = "DCC.READ"; - val studySecurity = new StudySecurity(prefix, suffix, scope); + val introspectorUri = "http://localhost/realms/myrealm/protocol/openid-connect/token"; + val studySecurity = new StudySecurity(prefix, suffix, scope, introspectorUri); val authentication = new AccessTokenConverterWithExpiry().extractAuthentication(map); assertEquals(expected, studySecurity.authorize(authentication, TEST_STUDY));