commit
faa726087d
|
@ -1,5 +1,5 @@
|
|||
<%@ tag pageEncoding="UTF-8" trimDirectiveWhitespaces="true"
|
||||
import="cz.muni.ics.oidc.server.elixir.GA4GHClaimSource" %>
|
||||
import="cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportAndVisaClaimSource" %>
|
||||
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
|
||||
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
|
||||
<%@ taglib prefix="o" tagdir="/WEB-INF/tags" %>
|
||||
|
@ -53,7 +53,7 @@
|
|||
<c:forEach var="subValue" items="${claim.value}">
|
||||
<c:choose>
|
||||
<c:when test="${claim.key=='ga4gh_passport_v1'}">
|
||||
<li><%= GA4GHClaimSource.parseAndVerifyVisa(
|
||||
<li><%= Ga4ghPassportAndVisaClaimSource.parseAndVerifyVisa(
|
||||
(String) jspContext.findAttribute("subValue")).getPrettyString() %></li>
|
||||
</c:when>
|
||||
<c:otherwise>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<%@ page import="cz.muni.ics.oidc.server.elixir.GA4GHClaimSource" %>
|
||||
<%@ page import="cz.muni.ics.oidc.server.ga4gh.ElixirGa4ghClaimSource" %>
|
||||
<%@ page import="java.util.ArrayList" %>
|
||||
<%@ page import="java.util.List" %>
|
||||
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" trimDirectiveWhitespaces="true"%>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package cz.muni.ics.oidc.server.elixir;
|
||||
package cz.muni.ics.oidc.server;
|
||||
|
||||
import java.io.IOException;
|
||||
import org.springframework.http.HttpRequest;
|
||||
|
@ -12,12 +12,12 @@ import org.springframework.http.client.ClientHttpResponse;
|
|||
*
|
||||
* @author Martin Kuba <makub@ics.muni.cz>
|
||||
*/
|
||||
class AddHeaderInterceptor implements ClientHttpRequestInterceptor {
|
||||
public class AddHeaderInterceptor implements ClientHttpRequestInterceptor {
|
||||
|
||||
private final String header;
|
||||
private final String value;
|
||||
|
||||
AddHeaderInterceptor(String header, String value) {
|
||||
public AddHeaderInterceptor(String header, String value) {
|
||||
this.header = header;
|
||||
this.value = value;
|
||||
}
|
|
@ -41,6 +41,17 @@ public class ClaimSourceInitContext {
|
|||
return properties.getProperty(propertyPrefix + "." + suffix, defaultValue);
|
||||
}
|
||||
|
||||
public Long getLongProperty(String suffix, Long defaultValue) {
|
||||
String propKey = propertyPrefix + '.' + suffix;
|
||||
String prop = properties.getProperty(propertyPrefix + "." + suffix);
|
||||
try {
|
||||
return Long.parseLong(prop);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Could not parse value '{}' for property '{}' as Long", prop, propKey);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public JWTSigningAndValidationService getJwtService() {
|
||||
return jwtService;
|
||||
}
|
||||
|
|
|
@ -1,591 +0,0 @@
|
|||
package cz.muni.ics.oidc.server.elixir;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import com.nimbusds.jose.JOSEObjectType;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.Payload;
|
||||
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
import com.nimbusds.jose.jwk.JWKMatcher;
|
||||
import com.nimbusds.jose.jwk.JWKSelector;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.JWTParser;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import cz.muni.ics.jwt.signer.service.JWTSigningAndValidationService;
|
||||
import cz.muni.ics.oidc.models.PerunAttribute;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSource;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
|
||||
import cz.muni.ics.oidc.server.connectors.Affiliation;
|
||||
import cz.muni.ics.openid.connect.web.JWKSetPublishingEndpoint;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.InterceptingClientHttpRequestFactory;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Class producing GA4GH Passport claim. The claim is specified in
|
||||
* https://bit.ly/ga4gh-passport-v1
|
||||
*
|
||||
* Configuration (replace [claimName] with the name of the claim):
|
||||
* <ul>
|
||||
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
|
||||
* configuration templates for such a file.</li>
|
||||
* <li><b>custom.claim.[claimName].source.bonaFideStatus.attr</b> - mapping for bonaFideStatus Attriute</li>
|
||||
* <li><b>custom.claim.[claimName].source.bonaFideStatusREMS.attr</b> - mapping for bonaFideStatus Attriute</li>
|
||||
* <li><b>custom.claim.[claimName].source.groupAffiliations.attr</b> - mapping for groupAffiliations Attriute</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Martin Kuba <makub@ics.muni.cz>
|
||||
*/
|
||||
@Slf4j
|
||||
public class GA4GHClaimSource extends ClaimSource {
|
||||
|
||||
static final String GA4GH_SCOPE = "ga4gh_passport_v1";
|
||||
private static final String GA4GH_CLAIM = "ga4gh_passport_v1";
|
||||
|
||||
private static final String BONA_FIDE_URL = "https://doi.org/10.1038/s41431-018-0219-y";
|
||||
private static final String ELIXIR_ORG_URL = "https://elixir-europe.org/";
|
||||
private static final String ELIXIR_ID = "elixir_id";
|
||||
|
||||
private final JWTSigningAndValidationService jwtService;
|
||||
private final URI jku;
|
||||
private final String issuer;
|
||||
private static final List<ClaimRepository> claimRepositories = new ArrayList<>();
|
||||
private static final Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets = new HashMap<>();
|
||||
private static final Map<URI, String> signers = new HashMap<>();
|
||||
|
||||
private final String bonaFideStatusAttr;
|
||||
private final String bonaFideStatusREMSAttr;
|
||||
private final String groupAffiliationsAttr;
|
||||
|
||||
public GA4GHClaimSource(ClaimSourceInitContext ctx) throws URISyntaxException {
|
||||
super(ctx);
|
||||
log.debug("initializing");
|
||||
//remember context
|
||||
jwtService = ctx.getJwtService();
|
||||
issuer = ctx.getPerunOidcConfig().getConfigBean().getIssuer();
|
||||
jku = new URI(issuer + JWKSetPublishingEndpoint.URL);
|
||||
// load config file
|
||||
parseConfigFile(ctx.getProperty("config_file", "/etc/mitreid/elixir/ga4gh_config.yml"));
|
||||
bonaFideStatusAttr = ctx.getProperty("bonaFideStatus.attr", null);
|
||||
bonaFideStatusREMSAttr = ctx.getProperty("bonaFideStatusREMS.attr", null);
|
||||
groupAffiliationsAttr = ctx.getProperty("groupAffiliations.attr", null);
|
||||
}
|
||||
|
||||
static void parseConfigFile(String file) {
|
||||
YAMLMapper mapper = new YAMLMapper();
|
||||
try {
|
||||
JsonNode root = mapper.readValue(new File(file), JsonNode.class);
|
||||
// prepare claim repositories
|
||||
for (JsonNode repo : root.path("repos")) {
|
||||
String name = repo.path("name").asText();
|
||||
String actionURL = repo.path("url").asText();
|
||||
JsonNode headers = repo.path("headers");
|
||||
Map<String, String> headersWithValues = new HashMap<>();
|
||||
for (JsonNode header: headers) {
|
||||
headersWithValues.put(header.path("header").asText(), header.path("value").asText());
|
||||
}
|
||||
if (actionURL == null || headersWithValues.isEmpty()) {
|
||||
log.error("claim repository " + repo + " not defined with url|auth_header|auth_value ");
|
||||
continue;
|
||||
}
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
restTemplate.setRequestFactory(
|
||||
new InterceptingClientHttpRequestFactory(restTemplate.getRequestFactory(),
|
||||
headersWithValues.entrySet()
|
||||
.stream()
|
||||
.map(e -> new AddHeaderInterceptor(e.getKey(), e.getValue()))
|
||||
.collect(Collectors.toList()))
|
||||
);
|
||||
claimRepositories.add(new ClaimRepository(name, restTemplate, actionURL));
|
||||
log.info("GA4GH Claims Repository " + name + " configured at " + actionURL);
|
||||
}
|
||||
// prepare claim signers
|
||||
for (JsonNode signer : root.path("signers")) {
|
||||
String name = signer.path("name").asText();
|
||||
String jwks = signer.path("jwks").asText();
|
||||
try {
|
||||
URL jku = new URL(jwks);
|
||||
remoteJwkSets.put(jku.toURI(), new RemoteJWKSet<>(jku));
|
||||
signers.put(jku.toURI(), name);
|
||||
log.info("JWKS Signer " + name + " added with keys " + jwks);
|
||||
} catch (MalformedURLException | URISyntaxException e) {
|
||||
log.error("cannot add to RemoteJWKSet map: " + name + " " + jwks, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
log.error("cannot read GA4GH config file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getAttrIdentifiers() {
|
||||
Set<String> set = new HashSet<>();
|
||||
if (bonaFideStatusAttr != null) {
|
||||
set.add(bonaFideStatusAttr);
|
||||
}
|
||||
if (bonaFideStatusREMSAttr != null) {
|
||||
set.add(bonaFideStatusREMSAttr);
|
||||
}
|
||||
if (groupAffiliationsAttr != null) {
|
||||
set.add(groupAffiliationsAttr);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode produceValue(ClaimSourceProduceContext pctx) {
|
||||
if (pctx.getClient() == null) {
|
||||
log.debug("client is not set");
|
||||
return JsonNodeFactory.instance.textNode("Global Alliance For Genomic Health structured claim");
|
||||
}
|
||||
if (!pctx.getClient().getScope().contains(GA4GH_SCOPE)) {
|
||||
log.debug("Client '{}' does not have scope ga4gh", pctx.getClient().getClientName());
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Affiliation> affiliations = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserExtSourcesAffiliations(pctx.getPerunUserId());
|
||||
|
||||
ArrayNode ga4gh_passport_v1 = JsonNodeFactory.instance.arrayNode();
|
||||
long now = Instant.now().getEpochSecond();
|
||||
addAffiliationAndRoles(now, pctx, ga4gh_passport_v1, affiliations);
|
||||
addAcceptedTermsAndPolicies(now, pctx, ga4gh_passport_v1);
|
||||
addResearcherStatuses(now, pctx, ga4gh_passport_v1, affiliations);
|
||||
addControlledAccessGrants(now, pctx, ga4gh_passport_v1);
|
||||
return ga4gh_passport_v1;
|
||||
}
|
||||
|
||||
|
||||
private void addAffiliationAndRoles(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
|
||||
//by=system for users with affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
|
||||
for (Affiliation affiliation : affiliations) {
|
||||
//expires 1 year after the last login from the IdP asserting the affiliation
|
||||
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
|
||||
if (expires < now) continue;
|
||||
JsonNode visa = createPassportVisa("AffiliationAndRole", pctx, affiliation.getValue(), affiliation.getSource(), "system", affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
|
||||
//by=self for members of the group 10432 "Bona Fide Researchers"
|
||||
boolean userInGroup = pctx.getPerunAdapter().isUserInGroup(pctx.getPerunUserId(), 10432L);
|
||||
if (userInGroup) {
|
||||
PerunAttribute bonaFideStatus = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
|
||||
String valueCreatedAt = bonaFideStatus.getValueCreatedAt();
|
||||
long asserted;
|
||||
if (valueCreatedAt != null) {
|
||||
asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
|
||||
} else {
|
||||
asserted = System.currentTimeMillis() / 1000L;
|
||||
}
|
||||
long expires = Instant.ofEpochSecond(asserted).atZone(ZoneId.systemDefault()).plusYears(100L).toEpochSecond();
|
||||
if (expires < now) return;
|
||||
JsonNode visa = createPassportVisa("AcceptedTermsAndPolicies", pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, "self", asserted, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatuses(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
|
||||
//by=peer for users with attribute elixirBonaFideStatusREMS
|
||||
PerunAttribute elixirBonaFideStatusREMS = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusREMSAttr);
|
||||
|
||||
String valueCreatedAt = null;
|
||||
if (elixirBonaFideStatusREMS != null) {
|
||||
valueCreatedAt = elixirBonaFideStatusREMS.getValueCreatedAt();
|
||||
}
|
||||
|
||||
if (valueCreatedAt != null) {
|
||||
long asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
|
||||
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
|
||||
if (expires > now) {
|
||||
JsonNode visa = createPassportVisa("ResearcherStatus", pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, "peer", asserted, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
//by=system for users with faculty affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
|
||||
for (Affiliation affiliation : affiliations) {
|
||||
if (affiliation.getValue().startsWith("faculty@")) {
|
||||
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
|
||||
if (expires < now) continue;
|
||||
JsonNode visa = createPassportVisa("ResearcherStatus", pctx, BONA_FIDE_URL, affiliation.getSource(), "system", affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
//by=so for users with faculty affiliation asserted by membership in a group with groupAffiliations attribute
|
||||
for (Affiliation affiliation : pctx.getPerunAdapter().getGroupAffiliations(pctx.getPerunUserId(), groupAffiliationsAttr)) {
|
||||
if (affiliation.getValue().startsWith("faculty@")) {
|
||||
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
|
||||
JsonNode visa = createPassportVisa("ResearcherStatus", pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, "so", affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String isoDate(long linuxTime) {
|
||||
return DateTimeFormatter.ISO_LOCAL_DATE.format(ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
private static String isoDateTime(long linuxTime) {
|
||||
return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
private JsonNode createPassportVisa(String type, ClaimSourceProduceContext pctx, String value, String source, String by, long asserted, long expires, JsonNode condition) {
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
if (asserted > now) {
|
||||
log.warn("visa asserted in future ! perunUserId {} sub {} type {} value {} source {} by {} asserted {}", pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(asserted));
|
||||
return null;
|
||||
}
|
||||
if (expires <= now) {
|
||||
log.warn("visa already expired ! perunUserId {} sub {} type {} value {} source {} by {} expired {}", pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(expires));
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> passportVisaObject = new HashMap<>();
|
||||
passportVisaObject.put("type", type);
|
||||
passportVisaObject.put("asserted", asserted);
|
||||
passportVisaObject.put("value", value);
|
||||
passportVisaObject.put("source", source);
|
||||
passportVisaObject.put("by", by);
|
||||
if (condition != null && !condition.isNull() && !condition.isMissingNode()) {
|
||||
passportVisaObject.put("condition", condition);
|
||||
}
|
||||
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.parse(jwtService.getDefaultSigningAlgorithm().getName()))
|
||||
.keyID(jwtService.getDefaultSignerKeyId())
|
||||
.type(JOSEObjectType.JWT)
|
||||
.jwkURL(jku)
|
||||
.build();
|
||||
JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
|
||||
.issuer(issuer)
|
||||
.issueTime(new Date())
|
||||
.expirationTime(new Date(expires * 1000L))
|
||||
.subject(pctx.getSub())
|
||||
.jwtID(UUID.randomUUID().toString())
|
||||
.claim("ga4gh_visa_v1", passportVisaObject)
|
||||
.build();
|
||||
SignedJWT myToken = new SignedJWT(jwsHeader, jwtClaimsSet);
|
||||
jwtService.signJwt(myToken);
|
||||
return JsonNodeFactory.instance.textNode(myToken.serialize());
|
||||
}
|
||||
|
||||
private void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
|
||||
Set<String> linkedIdentities = new HashSet<>();
|
||||
//call Resource Entitlement Management System
|
||||
for (ClaimRepository repo : claimRepositories) {
|
||||
callPermissionsJwtAPI(repo, Collections.singletonMap(ELIXIR_ID, pctx.getSub()), pctx, passport, linkedIdentities);
|
||||
}
|
||||
if (!linkedIdentities.isEmpty()) {
|
||||
for (String linkedIdentity : linkedIdentities) {
|
||||
JsonNode visa = createPassportVisa("LinkedIdentities", pctx, linkedIdentity, ELIXIR_ORG_URL, "system", now, now + 3600L * 24 * 365, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void callPermissionsJwtAPI(ClaimRepository repo, Map<String, String> uriVariables, ClaimSourceProduceContext pctx, ArrayNode passport, Set<String> linkedIdentities) {
|
||||
JsonNode response = callHttpJsonAPI(repo, uriVariables);
|
||||
if (response != null) {
|
||||
JsonNode visas = response.path(GA4GH_CLAIM);
|
||||
if (visas.isArray()) {
|
||||
for (JsonNode visaNode : visas) {
|
||||
if (visaNode.isTextual()) {
|
||||
PassportVisa visa = parseAndVerifyVisa(visaNode.asText());
|
||||
if (visa.isVerified()) {
|
||||
log.debug("adding a visa to passport: {}", visa);
|
||||
passport.add(passport.textNode(visa.getJwt()));
|
||||
linkedIdentities.add(visa.getLinkedIdentity());
|
||||
} else {
|
||||
log.warn("skipping visa: {}", visa);
|
||||
}
|
||||
} else {
|
||||
log.warn("element of ga4gh_passport_v1 is not a String: {}", visaNode);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("ga4gh_passport_v1 is not an array in {}", response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static PassportVisa parseAndVerifyVisa(String jwtString) {
|
||||
PassportVisa visa = new PassportVisa(jwtString);
|
||||
try {
|
||||
SignedJWT signedJWT = (SignedJWT) JWTParser.parse(jwtString);
|
||||
URI jku = signedJWT.getHeader().getJWKURL();
|
||||
if (jku == null) {
|
||||
log.error("JKU is missing in JWT header");
|
||||
return visa;
|
||||
}
|
||||
visa.setSigner(signers.get(jku));
|
||||
RemoteJWKSet<SecurityContext> remoteJWKSet = remoteJwkSets.get(jku);
|
||||
if (remoteJWKSet == null) {
|
||||
log.error("JKU {} is not among trusted key sets", jku);
|
||||
return visa;
|
||||
}
|
||||
List<JWK> keys = remoteJWKSet.get(new JWKSelector(new JWKMatcher.Builder().keyID(signedJWT.getHeader().getKeyID()).build()), null);
|
||||
RSASSAVerifier verifier = new RSASSAVerifier(((RSAKey) keys.get(0)).toRSAPublicKey());
|
||||
visa.setVerified(signedJWT.verify(verifier));
|
||||
if (visa.isVerified()) {
|
||||
processPayload(visa, signedJWT.getPayload());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("visa " + jwtString + " cannot be parsed and verified", ex);
|
||||
}
|
||||
return visa;
|
||||
}
|
||||
|
||||
static private final ObjectMapper JSON_MAPPER = new ObjectMapper();
|
||||
|
||||
static private void processPayload(PassportVisa visa, Payload payload) throws IOException {
|
||||
JsonNode doc = JSON_MAPPER.readValue(payload.toString(), JsonNode.class);
|
||||
checkVisaKey(visa, doc, "sub");
|
||||
checkVisaKey(visa, doc, "exp");
|
||||
checkVisaKey(visa, doc, "iss");
|
||||
JsonNode visa_v1 = doc.path("ga4gh_visa_v1");
|
||||
checkVisaKey(visa, visa_v1, "type");
|
||||
checkVisaKey(visa, visa_v1, "asserted");
|
||||
checkVisaKey(visa, visa_v1, "value");
|
||||
checkVisaKey(visa, visa_v1, "source");
|
||||
checkVisaKey(visa, visa_v1, "by");
|
||||
if (!visa.isVerified()) return;
|
||||
long exp = doc.get("exp").asLong();
|
||||
if (exp < Instant.now().getEpochSecond()) {
|
||||
log.warn("visa expired on " + isoDateTime(exp));
|
||||
visa.setVerified(false);
|
||||
return;
|
||||
}
|
||||
visa.setLinkedIdentity(URLEncoder.encode(doc.get("sub").asText(), "utf-8") + "," + URLEncoder.encode(doc.get("iss").asText(), "utf-8"));
|
||||
visa.setPrettyPayload(
|
||||
visa_v1.get("type").asText() + ": \"" + visa_v1.get("value").asText() + "\" asserted " + isoDate(visa_v1.get("asserted").asLong())
|
||||
);
|
||||
}
|
||||
|
||||
static private void checkVisaKey(PassportVisa visa, JsonNode jsonNode, String key) {
|
||||
if (jsonNode.path(key).isMissingNode()) {
|
||||
log.warn(key + " is missing");
|
||||
visa.setVerified(false);
|
||||
} else {
|
||||
switch (key) {
|
||||
case "sub":
|
||||
visa.setSub(jsonNode.path(key).asText());
|
||||
break;
|
||||
case "iss":
|
||||
visa.setIss(jsonNode.path(key).asText());
|
||||
break;
|
||||
case "type":
|
||||
visa.setType(jsonNode.path(key).asText());
|
||||
break;
|
||||
case "value":
|
||||
visa.setValue(jsonNode.path(key).asText());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("Duplicates")
|
||||
private static JsonNode callHttpJsonAPI(ClaimRepository repo, Map<String, String> uriVariables) {
|
||||
//get permissions data
|
||||
try {
|
||||
JsonNode result;
|
||||
//make the call
|
||||
try {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("calling Permissions API at {}", repo.getRestTemplate().getUriTemplateHandler().expand(repo.getActionURL(), uriVariables));
|
||||
}
|
||||
result = repo.getRestTemplate().getForObject(repo.getActionURL(), JsonNode.class, uriVariables);
|
||||
} catch (HttpClientErrorException ex) {
|
||||
MediaType contentType = ex.getResponseHeaders().getContentType();
|
||||
String body = ex.getResponseBodyAsString();
|
||||
log.error("HTTP ERROR " + ex.getRawStatusCode() + " URL " + repo.getActionURL() + " Content-Type: " + contentType);
|
||||
if (ex.getRawStatusCode() == 404) {
|
||||
log.warn("Got status 404 from Permissions endpoint {}, ELIXIR AAI user is not linked to user at Permissions API", repo.getActionURL());
|
||||
return null;
|
||||
}
|
||||
if ("json".equals(contentType.getSubtype())) {
|
||||
try {
|
||||
log.error(new ObjectMapper().readValue(body, JsonNode.class).path("message").asText());
|
||||
} catch (IOException e) {
|
||||
log.error("cannot parse error message from JSON", e);
|
||||
}
|
||||
} else {
|
||||
log.error("cannot make REST call, exception: {} message: {}", ex.getClass().getName(), ex.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
log.debug("Permissions API response: {}", result);
|
||||
return result;
|
||||
} catch (Exception ex) {
|
||||
log.error("Cannot get dataset permissions", ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class PassportVisa {
|
||||
String jwt;
|
||||
boolean verified = false;
|
||||
String linkedIdentity;
|
||||
String signer;
|
||||
String prettyPayload;
|
||||
private String sub;
|
||||
private String iss;
|
||||
private String type;
|
||||
private String value;
|
||||
|
||||
PassportVisa(String jwt) {
|
||||
this.jwt = jwt;
|
||||
}
|
||||
|
||||
public String getJwt() {
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
return verified;
|
||||
}
|
||||
|
||||
void setVerified(boolean verified) {
|
||||
this.verified = verified;
|
||||
}
|
||||
|
||||
String getLinkedIdentity() {
|
||||
return linkedIdentity;
|
||||
}
|
||||
|
||||
void setLinkedIdentity(String linkedIdentity) {
|
||||
this.linkedIdentity = linkedIdentity;
|
||||
}
|
||||
|
||||
void setSigner(String signer) {
|
||||
this.signer = signer;
|
||||
}
|
||||
|
||||
void setPrettyPayload(String prettyPayload) {
|
||||
this.prettyPayload = prettyPayload;
|
||||
}
|
||||
|
||||
public String getPrettyString() {
|
||||
return prettyPayload + ", signed by " + signer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PassportVisa{" +
|
||||
// "jwt='" + jwt + '\'' +
|
||||
" type=" + type +
|
||||
", sub=" + sub +
|
||||
", iss=" + iss +
|
||||
", value=" + value +
|
||||
", verified=" + verified +
|
||||
", linkedIdentity=" + linkedIdentity +
|
||||
'}';
|
||||
}
|
||||
|
||||
public void setSub(String sub) {
|
||||
this.sub = sub;
|
||||
}
|
||||
|
||||
public String getSub() {
|
||||
return sub;
|
||||
}
|
||||
|
||||
public void setIss(String iss) {
|
||||
this.iss = iss;
|
||||
}
|
||||
|
||||
public String getIss() {
|
||||
return iss;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setValue(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ClaimRepository {
|
||||
private final String name;
|
||||
private final RestTemplate restTemplate;
|
||||
private final String actionURL;
|
||||
|
||||
public ClaimRepository(String name, RestTemplate restTemplate, String actionURL) {
|
||||
this.name = name;
|
||||
this.restTemplate = restTemplate;
|
||||
this.actionURL = actionURL;
|
||||
}
|
||||
|
||||
public RestTemplate getRestTemplate() {
|
||||
return restTemplate;
|
||||
}
|
||||
|
||||
public String getActionURL() {
|
||||
return actionURL;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_PEER;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SELF;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SO;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SYSTEM;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_ACCEPTED_TERMS_AND_POLICIES;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_AFFILIATION_AND_ROLE;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_LINKED_IDENTITIES;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_RESEARCHER_STATUS;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import cz.muni.ics.oidc.models.PerunAttribute;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
|
||||
import cz.muni.ics.oidc.server.connectors.Affiliation;
|
||||
import java.net.URISyntaxException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Class producing GA4GH Passport claim. The claim is specified in
|
||||
* https://bit.ly/ga4gh-passport-v1
|
||||
*
|
||||
* Configuration (replace [claimName] with the name of the claim):
|
||||
* <ul>
|
||||
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
|
||||
* configuration templates for such a file.</li> (Passed to Ga4ghPassportAndVisaClaimSource.class)
|
||||
* <li><b>custom.claim.[claimName].source.bonaFideStatus.attr</b> - mapping for bonaFideStatus Attribute</li>
|
||||
* <li><b>custom.claim.[claimName].source.groupAffiliations.attr</b> - mapping for groupAffiliations Attribute</li>
|
||||
* <li><b>custom.claim.[claimName].source.termsAndPoliciesGroupId</b> - ID of group in which the membership represents acceptance of terms and policies</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Martin Kuba <makub@ics.muni.cz>
|
||||
*/
|
||||
@Slf4j
|
||||
public class BbmriGa4ghClaimSource extends Ga4ghPassportAndVisaClaimSource {
|
||||
|
||||
private static final String BONA_FIDE_URL = "https://doi.org/10.1038/s41431-018-0219-y";
|
||||
private static final String BBMRI_ERIC_ORG_URL = "https://www.bbmri-eric.eu/";
|
||||
private static final String BBMRI_ID = "bbmri_id";
|
||||
private static final String FACULTY_AT = "faculty@";
|
||||
|
||||
private final String bonaFideStatusAttr;
|
||||
private final String groupAffiliationsAttr;
|
||||
private final Long termsAndPoliciesGroupId;
|
||||
|
||||
public BbmriGa4ghClaimSource(ClaimSourceInitContext ctx) throws URISyntaxException {
|
||||
super(ctx, "BBMRI-ERIC");
|
||||
bonaFideStatusAttr = ctx.getProperty("bonaFideStatus.attr", null);
|
||||
groupAffiliationsAttr = ctx.getProperty("groupAffiliations.attr", null);
|
||||
//TODO: update group ID
|
||||
termsAndPoliciesGroupId = ctx.getLongProperty("termsAndPoliciesGroupId", 10432L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getAttrIdentifiers() {
|
||||
Set<String> set = new HashSet<>();
|
||||
if (bonaFideStatusAttr != null) {
|
||||
set.add(bonaFideStatusAttr);
|
||||
}
|
||||
if (groupAffiliationsAttr != null) {
|
||||
set.add(groupAffiliationsAttr);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDefaultConfigFilePath() {
|
||||
return "/etc/mitreid/bbmri/ga4gh_config.yml";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAffiliationAndRoles(long now,
|
||||
ClaimSourceProduceContext pctx,
|
||||
ArrayNode passport,
|
||||
List<Affiliation> affiliations)
|
||||
{
|
||||
//by=system for users with affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
|
||||
if (affiliations == null) {
|
||||
return;
|
||||
}
|
||||
for (Affiliation affiliation: affiliations) {
|
||||
//expires 1 year after the last login from the IdP asserting the affiliation
|
||||
long expires = Ga4ghUtils.getOneYearExpires(affiliation.getAsserted());
|
||||
if (expires < now) {
|
||||
continue;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_AFFILIATION_AND_ROLE, pctx, affiliation.getValue(),
|
||||
affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
|
||||
//by=self for members of the group 10432 "Bona Fide Researchers"
|
||||
boolean userInGroup = pctx.getPerunAdapter().isUserInGroup(pctx.getPerunUserId(), termsAndPoliciesGroupId);
|
||||
if (!userInGroup) {
|
||||
return;
|
||||
}
|
||||
long asserted = now;
|
||||
if (bonaFideStatusAttr != null) {
|
||||
PerunAttribute bonaFideStatus = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
|
||||
if (bonaFideStatus != null && bonaFideStatus.getValueCreatedAt() != null) {
|
||||
asserted = Timestamp.valueOf(bonaFideStatus.getValueCreatedAt()).getTime() / 1000L;
|
||||
}
|
||||
}
|
||||
long expires = Ga4ghUtils.getExpires(asserted, 100L);
|
||||
if (expires < now) {
|
||||
return;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_ACCEPTED_TERMS_AND_POLICIES, pctx, BONA_FIDE_URL,
|
||||
BBMRI_ERIC_ORG_URL, BY_SELF, asserted, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addResearcherStatuses(long now,
|
||||
ClaimSourceProduceContext pctx, ArrayNode passport,
|
||||
List<Affiliation> affiliations)
|
||||
{
|
||||
addResearcherStatusFromBonaFideAttribute(pctx, now, passport);
|
||||
addResearcherStatusFromAffiliation(pctx, affiliations, now, passport);
|
||||
addResearcherStatusGroupAffiliations(pctx, now, passport);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
|
||||
if (CLAIM_REPOSITORIES.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> linkedIdentities = new HashSet<>();
|
||||
for (Ga4ghClaimRepository repo: CLAIM_REPOSITORIES) {
|
||||
callPermissionsJwtAPI(repo, Collections.singletonMap(BBMRI_ID, pctx.getSub()), passport, linkedIdentities);
|
||||
}
|
||||
if (linkedIdentities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String linkedIdentity : linkedIdentities) {
|
||||
long expires = Ga4ghUtils.getOneYearExpires(now);
|
||||
JsonNode visa = createPassportVisa(TYPE_LINKED_IDENTITIES, pctx, linkedIdentity,
|
||||
BBMRI_ERIC_ORG_URL, BY_SYSTEM, now, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatusFromBonaFideAttribute(ClaimSourceProduceContext pctx,
|
||||
long now,
|
||||
ArrayNode passport)
|
||||
{
|
||||
//by=peer for users with attribute elixirBonaFideStatusREMS
|
||||
PerunAttribute bbmriBonaFideStatus = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
|
||||
|
||||
String valueCreatedAt = null;
|
||||
if (bbmriBonaFideStatus != null) {
|
||||
valueCreatedAt = bbmriBonaFideStatus.getValueCreatedAt();
|
||||
}
|
||||
|
||||
if (valueCreatedAt == null) {
|
||||
return;
|
||||
}
|
||||
long asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
|
||||
long expires = Ga4ghUtils.getOneYearExpires(asserted);
|
||||
if (expires > now) {
|
||||
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL,
|
||||
BBMRI_ERIC_ORG_URL, BY_PEER, asserted, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatusFromAffiliation(ClaimSourceProduceContext pctx,
|
||||
List<Affiliation> affiliations,
|
||||
long now,
|
||||
ArrayNode passport)
|
||||
{
|
||||
//by=system for users with faculty affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
|
||||
if (affiliations == null) {
|
||||
return;
|
||||
}
|
||||
for (Affiliation affiliation: affiliations) {
|
||||
if (!StringUtils.startsWithIgnoreCase(affiliation.getValue(), FACULTY_AT)) {
|
||||
continue;
|
||||
}
|
||||
long expires = Ga4ghUtils.getOneYearExpires(affiliation.getAsserted());
|
||||
if (expires < now) {
|
||||
continue;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL,
|
||||
affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatusGroupAffiliations(ClaimSourceProduceContext pctx, long now, ArrayNode passport) {
|
||||
//by=so for users with faculty affiliation asserted by membership in a group with groupAffiliations attribute
|
||||
List<Affiliation> groupAffiliations = pctx.getPerunAdapter()
|
||||
.getGroupAffiliations(pctx.getPerunUserId(), groupAffiliationsAttr);
|
||||
if (groupAffiliations == null) {
|
||||
return;
|
||||
}
|
||||
for (Affiliation affiliation: groupAffiliations) {
|
||||
if (!StringUtils.startsWithIgnoreCase(affiliation.getValue(), FACULTY_AT)) {
|
||||
continue;
|
||||
}
|
||||
long expires = Ga4ghUtils.getOneYearExpires(now);
|
||||
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL,
|
||||
BBMRI_ERIC_ORG_URL, BY_SO, affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_PEER;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SELF;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SO;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SYSTEM;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_ACCEPTED_TERMS_AND_POLICIES;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_AFFILIATION_AND_ROLE;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_LINKED_IDENTITIES;
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_RESEARCHER_STATUS;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import cz.muni.ics.oidc.models.PerunAttribute;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
|
||||
import cz.muni.ics.oidc.server.connectors.Affiliation;
|
||||
import java.net.URISyntaxException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Class producing GA4GH Passport claim. The claim is specified in
|
||||
* https://bit.ly/ga4gh-passport-v1
|
||||
*
|
||||
* Configuration (replace [claimName] with the name of the claim):
|
||||
* <ul>
|
||||
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
|
||||
* configuration templates for such a file.</li> (Passed to Ga4ghPassportAndVisaClaimSource.class)
|
||||
* <li><b>custom.claim.[claimName].source.bonaFideStatus.attr</b> - mapping for bonaFideStatus Attribute</li>
|
||||
* <li><b>custom.claim.[claimName].source.bonaFideStatusREMS.attr</b> - mapping for bonaFideStatus Attribute</li>
|
||||
* <li><b>custom.claim.[claimName].source.groupAffiliations.attr</b> - mapping for groupAffiliations Attribute</li>
|
||||
* <li><b>custom.claim.[claimName].source.termsAndPoliciesGroupId</b> - ID of group in which the membership represents acceptance of terms and policies</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Martin Kuba <makub@ics.muni.cz>
|
||||
*/
|
||||
@Slf4j
|
||||
public class ElixirGa4ghClaimSource extends Ga4ghPassportAndVisaClaimSource {
|
||||
|
||||
private static final String BONA_FIDE_URL = "https://doi.org/10.1038/s41431-018-0219-y";
|
||||
private static final String ELIXIR_ORG_URL = "https://elixir-europe.org/";
|
||||
private static final String ELIXIR_ID = "elixir_id";
|
||||
private static final String FACULTY_AT = "faculty@";
|
||||
|
||||
private final String bonaFideStatusAttr;
|
||||
private final String bonaFideStatusREMSAttr;
|
||||
private final String groupAffiliationsAttr;
|
||||
private final Long termsAndPoliciesGroupId;
|
||||
|
||||
public ElixirGa4ghClaimSource(ClaimSourceInitContext ctx) throws URISyntaxException {
|
||||
super(ctx, "ELIXIR");
|
||||
bonaFideStatusAttr = ctx.getProperty("bonaFideStatus.attr", null);
|
||||
bonaFideStatusREMSAttr = ctx.getProperty("bonaFideStatusREMS.attr", null);
|
||||
groupAffiliationsAttr = ctx.getProperty("groupAffiliations.attr", null);
|
||||
termsAndPoliciesGroupId = ctx.getLongProperty("termsAndPoliciesGroupId", 10432L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getAttrIdentifiers() {
|
||||
Set<String> set = new HashSet<>();
|
||||
if (bonaFideStatusAttr != null) {
|
||||
set.add(bonaFideStatusAttr);
|
||||
}
|
||||
if (bonaFideStatusREMSAttr != null) {
|
||||
set.add(bonaFideStatusREMSAttr);
|
||||
}
|
||||
if (groupAffiliationsAttr != null) {
|
||||
set.add(groupAffiliationsAttr);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDefaultConfigFilePath() {
|
||||
return "/etc/mitreid/elixir/ga4gh_config.yml";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAffiliationAndRoles(long now,
|
||||
ClaimSourceProduceContext pctx,
|
||||
ArrayNode passport,
|
||||
List<Affiliation> affiliations)
|
||||
{
|
||||
//by=system for users with affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
|
||||
if (affiliations == null) {
|
||||
return;
|
||||
}
|
||||
for (Affiliation affiliation: affiliations) {
|
||||
//expires 1 year after the last login from the IdP asserting the affiliation
|
||||
long expires = Ga4ghUtils.getOneYearExpires(affiliation.getAsserted());
|
||||
if (expires < now) {
|
||||
continue;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_AFFILIATION_AND_ROLE, pctx, affiliation.getValue(),
|
||||
affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
|
||||
//by=self for members of the group "Bona Fide Researchers"
|
||||
boolean userInGroup = pctx.getPerunAdapter().isUserInGroup(pctx.getPerunUserId(), termsAndPoliciesGroupId);
|
||||
if (!userInGroup) {
|
||||
return;
|
||||
}
|
||||
long asserted = now;
|
||||
if (bonaFideStatusAttr != null) {
|
||||
PerunAttribute bonaFideStatus = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
|
||||
if (bonaFideStatus != null && bonaFideStatus.getValueCreatedAt() != null) {
|
||||
asserted = Timestamp.valueOf(bonaFideStatus.getValueCreatedAt()).getTime() / 1000L;
|
||||
}
|
||||
}
|
||||
long expires = Ga4ghUtils.getExpires(asserted, 100L);
|
||||
if (expires < now) {
|
||||
return;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_ACCEPTED_TERMS_AND_POLICIES, pctx,
|
||||
BONA_FIDE_URL, ELIXIR_ORG_URL, BY_SELF, asserted, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addResearcherStatuses(long now,
|
||||
ClaimSourceProduceContext pctx,
|
||||
ArrayNode passport,
|
||||
List<Affiliation> affiliations)
|
||||
{
|
||||
addResearcherStatusFromBonaFideAttribute(pctx, now, passport);
|
||||
addResearcherStatusFromAffiliation(pctx, affiliations, now, passport);
|
||||
addResearcherStatusGroupAffiliations(pctx, now, passport);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
|
||||
if (CLAIM_REPOSITORIES.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> linkedIdentities = new HashSet<>();
|
||||
for (Ga4ghClaimRepository repo: CLAIM_REPOSITORIES) {
|
||||
callPermissionsJwtAPI(repo, Collections.singletonMap(ELIXIR_ID, pctx.getSub()), passport, linkedIdentities);
|
||||
}
|
||||
if (linkedIdentities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String linkedIdentity : linkedIdentities) {
|
||||
long expires = Ga4ghUtils.getOneYearExpires(now);
|
||||
JsonNode visa = createPassportVisa(TYPE_LINKED_IDENTITIES, pctx, linkedIdentity,
|
||||
ELIXIR_ORG_URL, BY_SYSTEM, now, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatusFromBonaFideAttribute(ClaimSourceProduceContext pctx,
|
||||
long now,
|
||||
ArrayNode passport)
|
||||
{
|
||||
//by=peer for users with attribute elixirBonaFideStatusREMS
|
||||
String valueCreatedAt = null;
|
||||
PerunAttribute elixirBonaFideStatusREMS = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusREMSAttr);
|
||||
if (elixirBonaFideStatusREMS != null) {
|
||||
valueCreatedAt = elixirBonaFideStatusREMS.getValueCreatedAt();
|
||||
}
|
||||
if (valueCreatedAt == null) {
|
||||
return;
|
||||
}
|
||||
long asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
|
||||
long expires = Ga4ghUtils.getOneYearExpires(asserted);
|
||||
if (expires < now) {
|
||||
return;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL,
|
||||
ELIXIR_ORG_URL, BY_PEER, asserted, expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatusFromAffiliation(ClaimSourceProduceContext pctx,
|
||||
List<Affiliation> affiliations,
|
||||
long now,
|
||||
ArrayNode passport)
|
||||
{
|
||||
//by=system for users with faculty affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
|
||||
if (affiliations == null) {
|
||||
return;
|
||||
}
|
||||
for (Affiliation affiliation: affiliations) {
|
||||
if (!StringUtils.startsWithIgnoreCase(affiliation.getValue(), FACULTY_AT)) {
|
||||
continue;
|
||||
}
|
||||
long expires = Ga4ghUtils.getOneYearExpires(affiliation.getAsserted());
|
||||
if (expires < now) {
|
||||
continue;
|
||||
}
|
||||
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL,
|
||||
affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addResearcherStatusGroupAffiliations(ClaimSourceProduceContext pctx, long now, ArrayNode passport) {
|
||||
//by=so for users with faculty affiliation asserted by membership in a group with groupAffiliations attribute
|
||||
List<Affiliation> groupAffiliations = pctx.getPerunAdapter()
|
||||
.getGroupAffiliations(pctx.getPerunUserId(), groupAffiliationsAttr);
|
||||
if (groupAffiliations == null) {
|
||||
return;
|
||||
}
|
||||
for (Affiliation affiliation: groupAffiliations) {
|
||||
if (!StringUtils.startsWithIgnoreCase(affiliation.getValue(), FACULTY_AT)) {
|
||||
continue;
|
||||
}
|
||||
long expires = Ga4ghUtils.getOneYearExpires(now);
|
||||
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL,
|
||||
ELIXIR_ORG_URL, BY_SO, affiliation.getAsserted(), expires, null);
|
||||
if (visa != null) {
|
||||
passport.add(visa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
package cz.muni.ics.oidc.server.elixir;
|
||||
|
||||
import static cz.muni.ics.oidc.server.elixir.GA4GHClaimSource.parseAndVerifyVisa;
|
||||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectWriter;
|
||||
import com.nimbusds.jose.JOSEException;
|
||||
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.JWTParser;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.text.ParseException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* This class is a command-line debugging tool. It parses JSON in GA4GH Passport format,
|
||||
|
@ -24,41 +25,38 @@ import java.time.format.DateTimeFormatter;
|
|||
*/
|
||||
public class GA4GHTokenParser {
|
||||
|
||||
static ObjectMapper jsonMapper = new ObjectMapper();
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final List<Ga4ghClaimRepository> CLAIM_REPOSITORIES = new ArrayList<>();
|
||||
private static final Map<URI, RemoteJWKSet<SecurityContext>> REMOTE_JWK_SETS = new HashMap<>();
|
||||
private static final Map<URI, String> SIGNERS = new HashMap<>();
|
||||
|
||||
public static void main(String[] args) throws IOException, ParseException, JOSEException {
|
||||
GA4GHClaimSource.parseConfigFile("ga4gh_config.yml");
|
||||
Ga4ghUtils.parseConfigFile("ga4gh_config.yml", CLAIM_REPOSITORIES, REMOTE_JWK_SETS, SIGNERS);
|
||||
String userinfo = "/tmp/ga4gh.json";
|
||||
JsonNode doc = jsonMapper.readValue(new File(userinfo), JsonNode.class);
|
||||
JsonNode ga4gh = doc.get("ga4gh_passport_v1");
|
||||
JsonNode doc = MAPPER.readValue(new File(userinfo), JsonNode.class);
|
||||
JsonNode ga4gh = doc.get(Ga4ghPassportAndVisaClaimSource.GA4GH_CLAIM);
|
||||
long startx = System.currentTimeMillis();
|
||||
System.out.println();
|
||||
for (JsonNode jwtString : ga4gh) {
|
||||
String s = jwtString.asText();
|
||||
GA4GHClaimSource.PassportVisa visa = parseAndVerifyVisa(s);
|
||||
Ga4ghPassportVisa visa = Ga4ghUtils.parseAndVerifyVisa(s, SIGNERS, REMOTE_JWK_SETS, MAPPER);
|
||||
if(!visa.isVerified()) {
|
||||
System.out.println("visa not verified: "+s);
|
||||
System.out.println("visa not verified: " + s);
|
||||
System.out.println("visa = " + visa.getPrettyString());
|
||||
|
||||
// System.exit(1);
|
||||
} else {
|
||||
System.out.println("OK: "+visa.getPrettyString());
|
||||
System.out.println("OK: " + visa.getPrettyString());
|
||||
}
|
||||
SignedJWT jwt = (SignedJWT) JWTParser.parse(s);
|
||||
ObjectWriter prettyPrinter = jsonMapper.writerWithDefaultPrettyPrinter();
|
||||
ObjectWriter prettyPrinter = MAPPER.writerWithDefaultPrettyPrinter();
|
||||
|
||||
JsonNode visaHeader = jsonMapper.readValue(jwt.getHeader().toString(), JsonNode.class);
|
||||
JsonNode visaHeader = MAPPER.readValue(jwt.getHeader().toString(), JsonNode.class);
|
||||
System.out.println(prettyPrinter.writeValueAsString(visaHeader));
|
||||
|
||||
JsonNode visaPayload = jsonMapper.readValue(jwt.getPayload().toString(), JsonNode.class);
|
||||
JsonNode visaPayload = MAPPER.readValue(jwt.getPayload().toString(), JsonNode.class);
|
||||
System.out.println(prettyPrinter.writeValueAsString(visaPayload));
|
||||
}
|
||||
long endx = System.currentTimeMillis();
|
||||
System.out.println("signature verification time: " + (endx - startx));
|
||||
|
||||
}
|
||||
|
||||
private static String isoDateTime(long linuxTime) {
|
||||
return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault()));
|
||||
}
|
||||
}
|
|
@ -1,32 +1,36 @@
|
|||
package cz.muni.ics.oidc.server.elixir;
|
||||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import cz.muni.ics.oidc.server.PerunAccessTokenEnhancer;
|
||||
import cz.muni.ics.openid.connect.model.UserInfo;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.oauth2.common.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.provider.OAuth2Authentication;
|
||||
|
||||
/**
|
||||
* Implements changes required by GA4GH specification followed by the ELIXIR AAI.
|
||||
* Implements changes required by GA4GH specification.
|
||||
*
|
||||
* @author Martin Kuba <makub@ics.muni.cz>
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
@Slf4j
|
||||
public class ElixirAccessTokenModifier implements PerunAccessTokenEnhancer.AccessTokenClaimsModifier {
|
||||
|
||||
public ElixirAccessTokenModifier() {
|
||||
}
|
||||
@NoArgsConstructor
|
||||
public class Ga4ghAccessTokenModifier implements PerunAccessTokenEnhancer.AccessTokenClaimsModifier {
|
||||
|
||||
@Override
|
||||
public void modifyClaims(String sub, JWTClaimsSet.Builder builder, OAuth2AccessToken accessToken, OAuth2Authentication authentication, UserInfo userInfo) {
|
||||
public void modifyClaims(String sub,
|
||||
JWTClaimsSet.Builder builder,
|
||||
OAuth2AccessToken accessToken,
|
||||
OAuth2Authentication authentication,
|
||||
UserInfo userInfo)
|
||||
{
|
||||
Set<String> scopes = accessToken.getScope();
|
||||
//GA4GH
|
||||
if (scopes.contains(GA4GHClaimSource.GA4GH_SCOPE)) {
|
||||
log.debug("adding claims required by GA4GH to access token");
|
||||
if (scopes.contains(ElixirGa4ghClaimSource.GA4GH_SCOPE)) {
|
||||
log.debug("Adding claims required by GA4GH to access token");
|
||||
builder.audience(Collections.singletonList(authentication.getOAuth2Request().getClientId()));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
@AllArgsConstructor
|
||||
public class Ga4ghClaimRepository {
|
||||
|
||||
private final String name;
|
||||
private final String actionURL;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.nimbusds.jose.JOSEObjectType;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import cz.muni.ics.jwt.signer.service.JWTSigningAndValidationService;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSource;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
|
||||
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
|
||||
import cz.muni.ics.oidc.server.connectors.Affiliation;
|
||||
import cz.muni.ics.openid.connect.web.JWKSetPublishingEndpoint;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
/**
|
||||
* Class producing GA4GH Passport claim. The claim is specified in
|
||||
* https://bit.ly/ga4gh-passport-v1
|
||||
*
|
||||
* Configuration (replace [claimName] with the name of the claim):
|
||||
* <ul>
|
||||
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
|
||||
* configuration templates for such a file.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Martin Kuba <makub@ics.muni.cz>
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
public abstract class Ga4ghPassportAndVisaClaimSource extends ClaimSource {
|
||||
|
||||
public static final String GA4GH_SCOPE = "ga4gh_passport_v1";
|
||||
public static final String GA4GH_CLAIM = "ga4gh_passport_v1";
|
||||
|
||||
protected static final List<Ga4ghClaimRepository> CLAIM_REPOSITORIES = new ArrayList<>();
|
||||
protected static final Map<URI, RemoteJWKSet<SecurityContext>> REMOTE_JWK_SETS = new HashMap<>();
|
||||
protected static final Map<URI, String> SIGNERS = new HashMap<>();
|
||||
protected static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final JWTSigningAndValidationService jwtService;
|
||||
private final URI jku;
|
||||
private final String issuer;
|
||||
|
||||
public Ga4ghPassportAndVisaClaimSource(ClaimSourceInitContext ctx, String implType) throws URISyntaxException {
|
||||
super(ctx);
|
||||
log.debug("Initializing GA4GH Passports and Visa Claim Source - (version by {})", implType);
|
||||
//remember context
|
||||
jwtService = ctx.getJwtService();
|
||||
issuer = ctx.getPerunOidcConfig().getConfigBean().getIssuer();
|
||||
jku = new URI(issuer + JWKSetPublishingEndpoint.URL);
|
||||
// load config file
|
||||
String configFile = ctx.getProperty("config_file", getDefaultConfigFilePath());
|
||||
Ga4ghUtils.parseConfigFile(configFile, CLAIM_REPOSITORIES, REMOTE_JWK_SETS, SIGNERS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode produceValue(ClaimSourceProduceContext pctx) {
|
||||
if (pctx.getClient() == null) {
|
||||
log.debug("Client is not set");
|
||||
return JsonNodeFactory.instance.textNode("Global Alliance For Genomic Health structured claim");
|
||||
}
|
||||
if (!pctx.getClient().getScope().contains(GA4GH_SCOPE)) {
|
||||
log.debug("Client '{}' does not have scope ga4gh", pctx.getClient().getClientName());
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Affiliation> affiliations = pctx.getPerunAdapter()
|
||||
.getAdapterRpc()
|
||||
.getUserExtSourcesAffiliations(pctx.getPerunUserId());
|
||||
|
||||
ArrayNode ga4gh_passport_v1 = JsonNodeFactory.instance.arrayNode();
|
||||
long now = Instant.now().getEpochSecond();
|
||||
addAffiliationAndRoles(now, pctx, ga4gh_passport_v1, affiliations);
|
||||
addAcceptedTermsAndPolicies(now, pctx, ga4gh_passport_v1);
|
||||
addResearcherStatuses(now, pctx, ga4gh_passport_v1, affiliations);
|
||||
addControlledAccessGrants(now, pctx, ga4gh_passport_v1);
|
||||
return ga4gh_passport_v1;
|
||||
}
|
||||
|
||||
public static Ga4ghPassportVisa parseAndVerifyVisa(String subValue) {
|
||||
return Ga4ghUtils.parseAndVerifyVisa(subValue, SIGNERS, REMOTE_JWK_SETS, MAPPER);
|
||||
}
|
||||
|
||||
protected abstract String getDefaultConfigFilePath();
|
||||
|
||||
protected abstract void addAffiliationAndRoles(long now, ClaimSourceProduceContext pctx,
|
||||
ArrayNode passport, List<Affiliation> affiliations);
|
||||
|
||||
protected abstract void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport);
|
||||
|
||||
protected abstract void addResearcherStatuses(long now, ClaimSourceProduceContext pctx,
|
||||
ArrayNode passport, List<Affiliation> affiliations);
|
||||
|
||||
protected abstract void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport);
|
||||
|
||||
protected JsonNode createPassportVisa(String type, ClaimSourceProduceContext pctx, String value, String source,
|
||||
String by, long asserted, long expires, JsonNode condition)
|
||||
{
|
||||
long now = System.currentTimeMillis() / 1000L;
|
||||
if (asserted > now) {
|
||||
log.warn("Visa asserted in future, it will be ignored!");
|
||||
log.debug("Visa information: perunUserId={}, sub={}, type={}, value={}, source={}, by={}, asserted={}",
|
||||
pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(asserted));
|
||||
return null;
|
||||
}
|
||||
if (expires <= now) {
|
||||
log.warn("Visa is expired, it will be ignored!");
|
||||
log.debug("Visa information: perunUserId={}, sub={}, type={}, value={}, source={}, by={}, expired={}",
|
||||
pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(expires));
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> passportVisaObject = new HashMap<>();
|
||||
passportVisaObject.put(Ga4ghPassportVisa.TYPE, type);
|
||||
passportVisaObject.put(Ga4ghPassportVisa.ASSERTED, asserted);
|
||||
passportVisaObject.put(Ga4ghPassportVisa.VALUE, value);
|
||||
passportVisaObject.put(Ga4ghPassportVisa.SOURCE, source);
|
||||
passportVisaObject.put(Ga4ghPassportVisa.BY, by);
|
||||
if (condition != null && !condition.isNull() && !condition.isMissingNode()) {
|
||||
passportVisaObject.put(Ga4ghPassportVisa.CONDITION, condition);
|
||||
}
|
||||
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.parse(jwtService.getDefaultSigningAlgorithm().getName()))
|
||||
.keyID(jwtService.getDefaultSignerKeyId())
|
||||
.type(JOSEObjectType.JWT)
|
||||
.jwkURL(jku)
|
||||
.build();
|
||||
JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
|
||||
.issuer(issuer)
|
||||
.issueTime(new Date())
|
||||
.expirationTime(new Date(expires * 1000L))
|
||||
.subject(pctx.getSub())
|
||||
.jwtID(UUID.randomUUID().toString())
|
||||
.claim(Ga4ghPassportVisa.GA4GH_VISA_V1, passportVisaObject)
|
||||
.build();
|
||||
SignedJWT myToken = new SignedJWT(jwsHeader, jwtClaimsSet);
|
||||
jwtService.signJwt(myToken);
|
||||
return JsonNodeFactory.instance.textNode(myToken.serialize());
|
||||
}
|
||||
|
||||
protected void callPermissionsJwtAPI(Ga4ghClaimRepository repo,
|
||||
Map<String, String> uriVariables,
|
||||
ArrayNode passport,
|
||||
Set<String> linkedIdentities)
|
||||
{
|
||||
JsonNode response = callHttpJsonAPI(repo, uriVariables);
|
||||
if (response != null) {
|
||||
JsonNode visas = response.path(GA4GH_CLAIM);
|
||||
if (visas.isArray()) {
|
||||
for (JsonNode visaNode : visas) {
|
||||
if (visaNode.isTextual()) {
|
||||
Ga4ghPassportVisa visa = Ga4ghUtils.parseAndVerifyVisa(visaNode.asText(), SIGNERS, REMOTE_JWK_SETS, MAPPER);
|
||||
if (visa.isVerified()) {
|
||||
log.debug("Adding a visa to passport: {}", visa);
|
||||
passport.add(passport.textNode(visa.getJwt()));
|
||||
linkedIdentities.add(visa.getLinkedIdentity());
|
||||
} else {
|
||||
log.warn("Skipping visa: {}", visa);
|
||||
}
|
||||
} else {
|
||||
log.warn("Element of {} is not a String: {}", GA4GH_CLAIM, visaNode);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.warn("{} is not an array in {}", GA4GH_CLAIM, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("Duplicates")
|
||||
private static JsonNode callHttpJsonAPI(Ga4ghClaimRepository repo, Map<String, String> uriVariables) {
|
||||
//get permissions data
|
||||
try {
|
||||
JsonNode result;
|
||||
try {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Calling Permissions API at {}", repo.getRestTemplate().getUriTemplateHandler().expand(repo.getActionURL(), uriVariables));
|
||||
}
|
||||
result = repo.getRestTemplate().getForObject(repo.getActionURL(), JsonNode.class, uriVariables);
|
||||
} catch (HttpClientErrorException ex) {
|
||||
MediaType contentType = ex.getResponseHeaders().getContentType();
|
||||
String body = ex.getResponseBodyAsString();
|
||||
log.error("HTTP ERROR: {}, URL: {}, Content-Type: {}", ex.getRawStatusCode(),
|
||||
repo.getActionURL(), contentType);
|
||||
if (ex.getRawStatusCode() == 404) {
|
||||
log.warn("Got status 404 from Permissions endpoint {}, ELIXIR AAI user is not linked to user at Permissions API",
|
||||
repo.getActionURL());
|
||||
return null;
|
||||
}
|
||||
if ("json".equals(contentType.getSubtype())) {
|
||||
try {
|
||||
log.error(new ObjectMapper().readValue(body, JsonNode.class).path("message").asText());
|
||||
} catch (IOException e) {
|
||||
log.error("cannot parse error message from JSON", e);
|
||||
}
|
||||
} else {
|
||||
log.error("cannot make REST call, exception: {} message: {}", ex.getClass().getName(), ex.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
log.debug("Permissions API response: {}", result);
|
||||
return result;
|
||||
} catch (Exception ex) {
|
||||
log.error("Cannot get dataset permissions", ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
public class Ga4ghPassportVisa {
|
||||
|
||||
public static final String GA4GH_VISA_V1 = "ga4gh_visa_v1";
|
||||
|
||||
public static final String TYPE_AFFILIATION_AND_ROLE = "AffiliationAndRole";
|
||||
public static final String TYPE_ACCEPTED_TERMS_AND_POLICIES = "AcceptedTermsAndPolicies";
|
||||
public static final String TYPE_RESEARCHER_STATUS = "ResearcherStatus";
|
||||
public static final String TYPE_LINKED_IDENTITIES = "LinkedIdentities";
|
||||
|
||||
public static final String BY_SYSTEM = "system";
|
||||
public static final String BY_SO = "so";
|
||||
public static final String BY_PEER = "peer";
|
||||
public static final String BY_SELF = "self";
|
||||
|
||||
public static final String SUB = "sub";
|
||||
public static final String EXP = "exp";
|
||||
public static final String ISS = "iss";
|
||||
public static final String TYPE = "type";
|
||||
public static final String ASSERTED = "asserted";
|
||||
public static final String VALUE = "value";
|
||||
public static final String SOURCE = "source";
|
||||
public static final String BY = "by";
|
||||
public static final String CONDITION = "condition";
|
||||
|
||||
private boolean verified = false;
|
||||
private String linkedIdentity;
|
||||
private String sub;
|
||||
private String iss;
|
||||
private String type;
|
||||
private String value;
|
||||
|
||||
@ToString.Exclude
|
||||
private String signer;
|
||||
|
||||
@ToString.Exclude
|
||||
private String jwt;
|
||||
|
||||
@ToString.Exclude
|
||||
private String prettyPayload;
|
||||
|
||||
public Ga4ghPassportVisa(String jwt) {
|
||||
this.jwt = jwt;
|
||||
}
|
||||
|
||||
public String getPrettyString() {
|
||||
return prettyPayload + ", signed by " + signer;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,222 @@
|
|||
package cz.muni.ics.oidc.server.ga4gh;
|
||||
|
||||
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportAndVisaClaimSource.GA4GH_CLAIM;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import com.nimbusds.jose.Payload;
|
||||
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
import com.nimbusds.jose.jwk.JWKMatcher;
|
||||
import com.nimbusds.jose.jwk.JWKSelector;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.JWTParser;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import cz.muni.ics.oidc.server.AddHeaderInterceptor;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.client.InterceptingClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Slf4j
|
||||
public class Ga4ghUtils {
|
||||
|
||||
public static final String CONF_KEY_REPOS = "repos";
|
||||
public static final String CONF_KEY_NAME = "name";
|
||||
public static final String CONF_KEY_URL = "url";
|
||||
public static final String CONF_KEY_HEADERS = "headers";
|
||||
public static final String CONF_KEY_HEADER = "header";
|
||||
public static final String CONF_KEY_VALUE = "value";
|
||||
public static final String CONF_KEY_SIGNERS = "signers";
|
||||
public static final String CONF_KEY_JWKS = "jwks";
|
||||
|
||||
public static void parseConfigFile(String file,
|
||||
List<Ga4ghClaimRepository> claimRepositories,
|
||||
Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets,
|
||||
Map<URI, String> signers)
|
||||
{
|
||||
YAMLMapper mapper = new YAMLMapper();
|
||||
try {
|
||||
JsonNode root = mapper.readValue(new File(file), JsonNode.class);
|
||||
// prepare claim repositories
|
||||
for (JsonNode repo : root.path(CONF_KEY_REPOS)) {
|
||||
initializeRepo(repo, claimRepositories);
|
||||
}
|
||||
// prepare claim signers
|
||||
for (JsonNode signer : root.path(CONF_KEY_SIGNERS)) {
|
||||
initializeSigner(signer, signers, remoteJwkSets);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
log.error("cannot read GA4GH config file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Ga4ghPassportVisa parseAndVerifyVisa(String jwtString,
|
||||
Map<URI, String> signers,
|
||||
Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets,
|
||||
ObjectMapper mapper)
|
||||
{
|
||||
Ga4ghPassportVisa visa = new Ga4ghPassportVisa(jwtString);
|
||||
try {
|
||||
SignedJWT signedJWT = (SignedJWT) JWTParser.parse(jwtString);
|
||||
URI jku = signedJWT.getHeader().getJWKURL();
|
||||
if (jku == null) {
|
||||
log.error("JKU is missing in JWT header");
|
||||
return visa;
|
||||
}
|
||||
visa.setSigner(signers.get(jku));
|
||||
RemoteJWKSet<SecurityContext> remoteJWKSet = remoteJwkSets.get(jku);
|
||||
if (remoteJWKSet == null) {
|
||||
log.error("JKU '{}' is not among trusted key sets", jku);
|
||||
return visa;
|
||||
}
|
||||
List<JWK> keys = remoteJWKSet.get(new JWKSelector(
|
||||
new JWKMatcher.Builder().keyID(signedJWT.getHeader().getKeyID()).build()), null);
|
||||
RSASSAVerifier verifier = new RSASSAVerifier(((RSAKey) keys.get(0)).toRSAPublicKey());
|
||||
visa.setVerified(signedJWT.verify(verifier));
|
||||
if (visa.isVerified()) {
|
||||
Ga4ghUtils.processPayload(mapper, visa, signedJWT.getPayload());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.error("Visa '{}' cannot be parsed and verified", jwtString, ex);
|
||||
}
|
||||
return visa;
|
||||
}
|
||||
|
||||
public static void processPayload(ObjectMapper mapper, Ga4ghPassportVisa visa, Payload payload)
|
||||
throws IOException
|
||||
{
|
||||
JsonNode doc = mapper.readValue(payload.toString(), JsonNode.class);
|
||||
checkVisaKey(visa, doc, Ga4ghPassportVisa.SUB);
|
||||
checkVisaKey(visa, doc, Ga4ghPassportVisa.EXP);
|
||||
checkVisaKey(visa, doc, Ga4ghPassportVisa.ISS);
|
||||
JsonNode visa_v1 = doc.path(Ga4ghPassportVisa.GA4GH_VISA_V1);
|
||||
if (visa_v1.isMissingNode() || visa_v1.isNull() || visa_v1.isEmpty()) {
|
||||
log.warn("Nothing available in '{}', considering visa as not verified", Ga4ghPassportVisa.GA4GH_VISA_V1);
|
||||
visa.setVerified(false);
|
||||
return;
|
||||
}
|
||||
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.TYPE);
|
||||
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.ASSERTED);
|
||||
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.VALUE);
|
||||
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.SOURCE);
|
||||
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.BY);
|
||||
if (!visa.isVerified()) {
|
||||
return;
|
||||
}
|
||||
long exp = doc.get(Ga4ghPassportVisa.EXP).asLong();
|
||||
if (exp < Instant.now().getEpochSecond()) {
|
||||
log.warn("visa expired on {}", isoDateTime(exp));
|
||||
visa.setVerified(false);
|
||||
return;
|
||||
}
|
||||
visa.setLinkedIdentity(URLEncoder.encode(doc.get(Ga4ghPassportVisa.SUB).asText(), "utf-8") +
|
||||
',' + URLEncoder.encode(doc.get(Ga4ghPassportVisa.ISS).asText(), "utf-8"));
|
||||
visa.setPrettyPayload(
|
||||
visa_v1.get(Ga4ghPassportVisa.TYPE).asText() + ": '"
|
||||
+ visa_v1.get(Ga4ghPassportVisa.VALUE).asText() + "' asserted at '"
|
||||
+ isoDate(visa_v1.get(Ga4ghPassportVisa.ASSERTED).asLong()) + '\''
|
||||
);
|
||||
}
|
||||
|
||||
public static long getOneYearExpires(long asserted) {
|
||||
return getExpires(asserted, 1L);
|
||||
}
|
||||
|
||||
public static long getExpires(long asserted, long addYears) {
|
||||
return Instant.ofEpochSecond(asserted).atZone(ZoneId.systemDefault()).plusYears(addYears).toEpochSecond();
|
||||
}
|
||||
|
||||
private static void initializeSigner(JsonNode signer,
|
||||
Map<URI, String> signers,
|
||||
Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets)
|
||||
{
|
||||
String name = signer.path(CONF_KEY_NAME).asText();
|
||||
String jwks = signer.path(CONF_KEY_JWKS).asText();
|
||||
try {
|
||||
URL jku = new URL(jwks);
|
||||
remoteJwkSets.put(jku.toURI(), new RemoteJWKSet<>(jku));
|
||||
signers.put(jku.toURI(), name);
|
||||
log.info("JWKS Signer '{}' added with keys '{}'", name, jwks);
|
||||
} catch (MalformedURLException | URISyntaxException e) {
|
||||
log.error("cannot add to RemoteJWKSet map: '{}' -> '{}'", name, jwks, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void initializeRepo(JsonNode repo, List<Ga4ghClaimRepository> claimRepositories) {
|
||||
String name = repo.path(CONF_KEY_NAME).asText();
|
||||
String actionURL = repo.path(CONF_KEY_URL).asText();
|
||||
JsonNode headers = repo.path(CONF_KEY_HEADERS);
|
||||
Map<String, String> headersWithValues = new HashMap<>();
|
||||
for (JsonNode header: headers) {
|
||||
headersWithValues.put(header.path(CONF_KEY_HEADER).asText(), header.path(CONF_KEY_VALUE).asText());
|
||||
}
|
||||
if (actionURL == null || headersWithValues.isEmpty()) {
|
||||
log.error("claim repository '{}' not defined with url|auth_header|auth_value", repo);
|
||||
return;
|
||||
}
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
restTemplate.setRequestFactory(
|
||||
new InterceptingClientHttpRequestFactory(restTemplate.getRequestFactory(),
|
||||
headersWithValues.entrySet()
|
||||
.stream()
|
||||
.map(e -> new AddHeaderInterceptor(e.getKey(), e.getValue()))
|
||||
.collect(Collectors.toList()))
|
||||
);
|
||||
claimRepositories.add(new Ga4ghClaimRepository(name, actionURL, restTemplate));
|
||||
log.info("GA4GH Claims Repository '{}' configured at '{}'", name, actionURL);
|
||||
}
|
||||
|
||||
private static void checkVisaKey(Ga4ghPassportVisa visa, JsonNode jsonNode, String key) {
|
||||
if (jsonNode.path(key).isMissingNode()) {
|
||||
log.warn("Key '{}' is missing in the Visa, therefore cannot be verified", key);
|
||||
visa.setVerified(false);
|
||||
} else {
|
||||
switch (key) {
|
||||
case Ga4ghPassportVisa.SUB:
|
||||
visa.setSub(jsonNode.path(key).asText());
|
||||
break;
|
||||
case Ga4ghPassportVisa.ISS:
|
||||
visa.setIss(jsonNode.path(key).asText());
|
||||
break;
|
||||
case Ga4ghPassportVisa.TYPE:
|
||||
visa.setType(jsonNode.path(key).asText());
|
||||
break;
|
||||
case Ga4ghPassportVisa.VALUE:
|
||||
visa.setValue(jsonNode.path(key).asText());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String isoDate(long linuxTime) {
|
||||
return isoFormat(linuxTime, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
|
||||
private static String isoDateTime(long linuxTime) {
|
||||
return isoFormat(linuxTime, DateTimeFormatter.ISO_DATE_TIME);
|
||||
}
|
||||
|
||||
private static String isoFormat(long linuxTime, DateTimeFormatter formatter) {
|
||||
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault());
|
||||
return formatter.format(zdt);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue