Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • fit-connect/sdk-java
1 result
Show changes
Showing
with 535 additions and 147 deletions
......@@ -86,8 +86,9 @@ public class SubmissionSubscriber implements Subscriber {
}
@Override
public ValidationResult validateCallback(String hmac, Long timestamp, String httpBody, String callbackSecret) {
return this.validationService.validateCallback(hmac, timestamp, httpBody, callbackSecret);
public ValidationResult validateCallback(final String hmac, final Long timestamp, final String httpBody, final String callbackSecret) {
LOGGER.info("Validating callback integrity");
return validationService.validateCallback(hmac, timestamp, httpBody, callbackSecret);
}
@Override
......
......@@ -38,7 +38,7 @@ public class DefaultOAuthService implements OAuthService {
this.restTemplate = restTemplate;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenUrl = authUrl;
tokenUrl = authUrl;
resetExistingToken();
}
......@@ -48,26 +48,26 @@ public class DefaultOAuthService implements OAuthService {
LOGGER.info("Current token is expired, authenticating ...");
authenticate();
}
return this.currentToken;
return currentToken;
}
private boolean tokenExpired() {
if (this.currentToken == null || this.tokenExpirationTime == null) {
if (currentToken == null || tokenExpirationTime == null) {
return true;
}
final var now = LocalDateTime.now();
return this.tokenExpirationTime.isBefore(now) || this.tokenExpirationTime.isEqual(now);
return tokenExpirationTime.isBefore(now) || tokenExpirationTime.isEqual(now);
}
private void resetExistingToken() {
this.currentToken = null;
this.tokenExpirationTime = null;
currentToken = null;
tokenExpirationTime = null;
}
private void authenticate() throws AuthenticationException {
final String requestBody = buildRequestBody(this.clientId, this.clientSecret);
this.currentToken = performTokenRequest(requestBody);
this.tokenExpirationTime = LocalDateTime.now().plusSeconds(this.currentToken.getExpiresIn());
final String requestBody = buildRequestBody(clientId, clientSecret);
currentToken = performTokenRequest(requestBody);
tokenExpirationTime = LocalDateTime.now().plusSeconds(currentToken.getExpiresIn());
}
private String buildRequestBody(final String clientId, final String clientSecret, final String... scope) {
......@@ -95,9 +95,9 @@ public class DefaultOAuthService implements OAuthService {
final HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);
try {
LOGGER.info("Sending authentication request");
return this.restTemplate.exchange(this.tokenUrl, HttpMethod.POST, entity, OAuthToken.class).getBody();
return restTemplate.exchange(tokenUrl, HttpMethod.POST, entity, OAuthToken.class).getBody();
} catch (final RestClientException e) {
LOGGER.error(e.getMessage(),e);
LOGGER.error(e.getMessage(), e);
throw new RestApiException("Could not retrieve OAuth token", e);
}
}
......
package dev.fitko.fitconnect.core.crypto;
import com.nimbusds.jose.CompressionAlgorithm;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
public class CryptoConstants {
public final static String DEFAULT_HASH_ALGORITHM = HashAlgorithm.SHA_512.getIdentifyer();
public final static String DEFAULT_SYMMETRIC_ENCRYPTION_ALGORITHM = SymmetricEncryptionAlgorithm.AES.getIdentifyer();
public final static String DEFAULT_HMAC_ALGORITHM = HmacAlgorithm.HMAC_SHA_512.getIdentifyer();
public final static EncryptionMethod DEFAULT_JWE_ENCRYPTION_METHOD = EncryptionMethod.A256GCM;
public static final JWEAlgorithm DEFAULT_JWE_ALGORITHM = JWEAlgorithm.RSA_OAEP_256;
public static final CompressionAlgorithm DEFAULT_JWE_COMPRESSION_ALGORITHM = CompressionAlgorithm.DEF;
}
package dev.fitko.fitconnect.core.crypto;
public enum HashAlgorithm {
SHA_512("SHA-512");
private final String identifyer;
HashAlgorithm(String identifyer) {
this.identifyer = identifyer;
}
public String getIdentifyer() {
return identifyer;
}
}
......@@ -13,10 +13,10 @@ import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class HashService implements MessageDigestService {
import static dev.fitko.fitconnect.core.crypto.CryptoConstants.DEFAULT_HASH_ALGORITHM;
import static dev.fitko.fitconnect.core.crypto.CryptoConstants.DEFAULT_HMAC_ALGORITHM;
static final String DEFAULT_ALGORITHM = "SHA-512"; // Currently, only SHA-512 is supported.
private static final String HMAC_SHA512 = "HmacSHA512";
public class HashService implements MessageDigestService {
private static final Logger LOGGER = LoggerFactory.getLogger(HashService.class);
......@@ -24,7 +24,7 @@ public class HashService implements MessageDigestService {
public HashService() {
try {
this.messageDigest = MessageDigest.getInstance(DEFAULT_ALGORITHM);
this.messageDigest = MessageDigest.getInstance(DEFAULT_HASH_ALGORITHM);
} catch (final NoSuchAlgorithmException e) {
throw new InitializationException(e.getMessage(), e);
}
......@@ -76,8 +76,9 @@ public class HashService implements MessageDigestService {
public String calculateHMAC(String data, String key) {
try {
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), HMAC_SHA512);
Mac mac = Mac.getInstance(HMAC_SHA512);
String hmacAlgorithm = DEFAULT_HMAC_ALGORITHM;
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), hmacAlgorithm);
Mac mac = Mac.getInstance(hmacAlgorithm);
mac.init(secretKeySpec);
return toHexString(mac.doFinal(data.getBytes()));
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
......
package dev.fitko.fitconnect.core.crypto;
public enum HmacAlgorithm {
HMAC_SHA_512("HmacSHA512");
private final String identifyer;
HmacAlgorithm(String identifyer) {
this.identifyer = identifyer;
}
public String getIdentifyer() {
return identifyer;
}
}
......@@ -10,22 +10,20 @@ import dev.fitko.fitconnect.api.exceptions.DecryptionException;
import dev.fitko.fitconnect.api.exceptions.EncryptionException;
import dev.fitko.fitconnect.api.services.crypto.CryptoService;
import dev.fitko.fitconnect.api.services.crypto.MessageDigestService;
import dev.fitko.fitconnect.core.util.StopWatch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import dev.fitko.fitconnect.core.util.StopWatch;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.security.NoSuchAlgorithmException;
import java.text.ParseException;
import static dev.fitko.fitconnect.core.crypto.CryptoConstants.*;
public class JWECryptoService implements CryptoService {
private static final Logger LOGGER = LoggerFactory.getLogger(JWECryptoService.class);
private static final JWEAlgorithm ALGORITHM = JWEAlgorithm.RSA_OAEP_256;
private static final EncryptionMethod ENCRYPTION_METHOD = EncryptionMethod.A256GCM;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final MessageDigestService messageDigestService;
......@@ -74,8 +72,8 @@ public class JWECryptoService implements CryptoService {
private String encrypt(final RSAKey publicKey, final Payload payload) throws EncryptionException {
try {
final KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(ENCRYPTION_METHOD.cekBitLength());
final KeyGenerator keyGenerator = KeyGenerator.getInstance(DEFAULT_SYMMETRIC_ENCRYPTION_ALGORITHM);
keyGenerator.init(DEFAULT_JWE_ENCRYPTION_METHOD.cekBitLength());
final SecretKey cek = keyGenerator.generateKey();
final String keyID = getIdFromPublicKey(publicKey);
return encryptPayload(publicKey, payload, cek, keyID);
......@@ -101,8 +99,8 @@ public class JWECryptoService implements CryptoService {
}
private JWEHeader getJWEHeader(final String keyID) {
return new JWEHeader.Builder(ALGORITHM, ENCRYPTION_METHOD)
.compressionAlgorithm(CompressionAlgorithm.DEF)
return new JWEHeader.Builder(DEFAULT_JWE_ALGORITHM, DEFAULT_JWE_ENCRYPTION_METHOD)
.compressionAlgorithm(DEFAULT_JWE_COMPRESSION_ALGORITHM)
.contentType("application/json")
.keyID(keyID)
.build();
......
package dev.fitko.fitconnect.core.crypto;
public enum SymmetricEncryptionAlgorithm {
AES("AES");
private final String identifyer;
SymmetricEncryptionAlgorithm(String identifyer) {
this.identifyer = identifyer;
}
public String getIdentifyer() {
return identifyer;
}
}
......@@ -20,11 +20,24 @@ import dev.fitko.fitconnect.api.services.events.EventLogVerificationService;
import dev.fitko.fitconnect.api.services.validation.ValidationService;
import java.text.ParseException;
import java.util.*;
import static com.nimbusds.jwt.JWTClaimNames.*;
import static dev.fitko.fitconnect.api.domain.model.event.EventClaimFields.*;
import static dev.fitko.fitconnect.core.util.EventLogUtil.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static com.nimbusds.jwt.JWTClaimNames.ISSUED_AT;
import static com.nimbusds.jwt.JWTClaimNames.ISSUER;
import static com.nimbusds.jwt.JWTClaimNames.JWT_ID;
import static com.nimbusds.jwt.JWTClaimNames.SUBJECT;
import static dev.fitko.fitconnect.api.domain.model.event.EventClaimFields.CLAIM_EVENTS;
import static dev.fitko.fitconnect.api.domain.model.event.EventClaimFields.CLAIM_SCHEMA;
import static dev.fitko.fitconnect.api.domain.model.event.EventClaimFields.CLAIM_TXN;
import static dev.fitko.fitconnect.api.domain.model.event.EventClaimFields.HEADER_TYPE;
import static dev.fitko.fitconnect.core.util.EventLogUtil.getAuthenticationTags;
import static dev.fitko.fitconnect.core.util.EventLogUtil.getDestinationId;
import static dev.fitko.fitconnect.core.util.EventLogUtil.getEventFromClaims;
import static dev.fitko.fitconnect.core.util.EventLogUtil.resolveIssuerType;
public class EventLogVerifier implements EventLogVerificationService {
......@@ -150,7 +163,7 @@ public class EventLogVerifier implements EventLogVerificationService {
private RSAKey getSignatureKey(final String issuer, final String keyId) throws ParseException, EventLogException {
final EventIssuer issuerType = resolveIssuerType(issuer);
if (issuerType == EventIssuer.SUBMISSION_SERVICE) {
return keyService.getSubmissionServiceSignatureKey(keyId);
return keyService.getSubmissionServicePublicKey(keyId);
} else {
return keyService.getPublicSignatureKey(getDestinationId(issuer), keyId);
}
......
package dev.fitko.fitconnect.core.http;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
public class ProxyRestTemplate extends RestTemplate {
public ProxyRestTemplate(final ClientHttpRequestFactory requestFactory) {
super(requestFactory);
this.getMessageConverters().add(new X509CRLHttpMessageConverter());
}
}
......@@ -16,25 +16,25 @@ import java.util.List;
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
public class ProxyConfig {
public class RestService {
private static final Logger LOGGER = LoggerFactory.getLogger(ProxyConfig.class);
private static final Logger LOGGER = LoggerFactory.getLogger(RestService.class);
private BuildInfo buildInfo;
private String host;
private int port;
private final BuildInfo buildInfo;
private final String proxyHost;
private final int proxyPort;
public ProxyConfig(String host, int port, BuildInfo buildInfo) {
this.host = host;
this.port = port;
public RestService(final String proxyHost, final int proxyPort, final BuildInfo buildInfo) {
this.proxyHost = proxyHost;
this.proxyPort = proxyPort;
this.buildInfo = buildInfo;
}
ProxyConfig() {
this(null, 0, new BuildInfo());
public RestService(final BuildInfo buildInfo) {
this(null, 0, buildInfo);
}
public RestTemplate proxyRestTemplate() {
public RestTemplate getRestTemplate() {
return hasProxySet() ? getProxyRestTemplate() : getDefaultRestTemplate();
}
......@@ -45,41 +45,41 @@ public class ProxyConfig {
return restTemplate;
}
private ProxyRestTemplate getProxyRestTemplate() {
private RestTemplate getProxyRestTemplate() {
LOGGER.info("Using proxy {}", this);
final var requestFactory = new SimpleClientHttpRequestFactory();
final var proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(this.host, this.port));
final var proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));
requestFactory.setProxy(proxy);
final ProxyRestTemplate proxyRestTemplate = new ProxyRestTemplate(requestFactory);
final RestTemplate proxyRestTemplate = new RestTemplate(requestFactory);
setupTemplate(proxyRestTemplate);
return proxyRestTemplate;
}
private void setMappingConverter(final RestTemplate restTemplate) {
private void setMappingConverters(final RestTemplate restTemplate) {
final MappingJackson2HttpMessageConverter jacksonMessageConverter = new MappingJackson2HttpMessageConverter();
jacksonMessageConverter.setObjectMapper(new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false));
restTemplate.getMessageConverters().add(jacksonMessageConverter);
}
private void setLoggingInterceptor(final RestTemplate restTemplate) {
private void setLoggingInterceptors(final RestTemplate restTemplate) {
final List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
interceptors.add(new ApiRequestInterceptor());
interceptors.add(new UserAgentInterceptor(this.buildInfo));
interceptors.add(new UserAgentInterceptor(buildInfo));
restTemplate.setInterceptors(interceptors);
}
private void setupTemplate(final RestTemplate restTemplate) {
setLoggingInterceptor(restTemplate);
setMappingConverter(restTemplate);
setLoggingInterceptors(restTemplate);
setMappingConverters(restTemplate);
}
boolean hasProxySet() {
return !Strings.isNullOrEmpty(this.host) && this.port > 0;
return !Strings.isNullOrEmpty(proxyHost) && proxyPort > 0;
}
@Override
public String toString() {
return String.format("ProxyConfig {host='%s', port=%d}", this.host, this.port);
return String.format("ProxyConfig {host='%s', port=%d}", proxyHost, proxyPort);
}
}
package dev.fitko.fitconnect.core.http;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.lang.NonNull;
import java.security.cert.CertificateFactory;
import java.security.cert.X509CRL;
public class X509CRLHttpMessageConverter extends AbstractHttpMessageConverter<X509CRL> {
public X509CRLHttpMessageConverter() {
super(new MediaType("application", "pkix-crl"));
}
@Override
protected boolean supports(@NonNull final Class<?> clazz) {
return X509CRL.class == clazz;
}
@Override
protected X509CRL readInternal(@NonNull final Class<? extends X509CRL> clazz, @NonNull final HttpInputMessage inputMessage)
throws HttpMessageNotReadableException {
try {
final var cf = CertificateFactory.getInstance("X.509");
return (X509CRL) cf.generateCRL(inputMessage.getBody());
} catch (final Exception e) {
throw new HttpMessageNotReadableException("CertificateFactory of type X.509 could not be created", inputMessage);
}
}
@Override
protected void writeInternal(@NonNull final X509CRL x509CRL, @NonNull final HttpOutputMessage outputMessage)
throws HttpMessageNotWritableException {
throw new HttpMessageNotWritableException("Writing X509CRL is not supported in ProxyRestTemplate");
}
}
......@@ -57,7 +57,7 @@ public class PublicKeyService implements KeyService {
public RSAKey getPublicEncryptionKey(final UUID destinationId) {
final Destination destination = submissionService.getDestination(destinationId);
final String destinationUrl = config.getDestinationsKeyEndpoint();
final ApiJwk publicKey = performRequest(destinationUrl, ApiJwk.class, destinationId, destination.getEncryptionKid());
final ApiJwk publicKey = performRequest(destinationUrl, ApiJwk.class, getHeaders(), destinationId, destination.getEncryptionKid());
final RSAKey rsaKey = toRSAKey(publicKey);
validateEncryptionKey(rsaKey);
return rsaKey;
......@@ -66,25 +66,33 @@ public class PublicKeyService implements KeyService {
@Override
public RSAKey getPublicSignatureKey(final UUID destinationId, final String keyId) {
final String destinationUrl = config.getDestinationsKeyEndpoint();
final ApiJwk signatureKey = performRequest(destinationUrl, ApiJwk.class, destinationId, keyId);
final ApiJwk signatureKey = performRequest(destinationUrl, ApiJwk.class, getHeaders(), destinationId, keyId);
final RSAKey rsaKey = toRSAKey(signatureKey);
validateSignatureKey(rsaKey);
return rsaKey;
}
@Override
public RSAKey getSubmissionServiceSignatureKey(final String keyId) {
public RSAKey getSubmissionServicePublicKey(final String keyId) {
final String submissionServiceUrl = config.getSubmissionServiceWellKnownKeysEndpoint();
final ApiJwkSet wellKnownKeys = performRequest(submissionServiceUrl, ApiJwkSet.class);
final ApiJwkSet wellKnownKeys = performRequest(submissionServiceUrl, ApiJwkSet.class, getHeaders());
final RSAKey signatureKey = filterKeysById(keyId, wellKnownKeys.getKeys());
validateSignatureKey(signatureKey);
return signatureKey;
}
@Override
public RSAKey getPortalSignatureKey(final String keyId) {
public RSAKey getPortalPublicKey(final String keyId) {
final String portalUrl = config.getSelfServicePortalWellKnownKeysEndpoint();
final ApiJwkSet wellKnownKeys = performRequest(portalUrl, ApiJwkSet.class);
final ApiJwkSet wellKnownKeys = performRequest(portalUrl, ApiJwkSet.class, getHeaders());
final RSAKey signatureKey = filterKeysById(keyId, wellKnownKeys.getKeys());
validateSignatureKey(signatureKey);
return signatureKey;
}
@Override
public RSAKey getWellKnownKeysForSubmissionUrl(final String url, final String keyId) {
final var requestUrl = !url.endsWith("/") ? url + config.getWellKnownKeysPath() : url;
final ApiJwkSet wellKnownKeys = performRequest(requestUrl, ApiJwkSet.class, getHeadersWithoutAuth(), keyId);
final RSAKey signatureKey = filterKeysById(keyId, wellKnownKeys.getKeys());
validateSignatureKey(signatureKey);
return signatureKey;
......@@ -126,8 +134,7 @@ public class PublicKeyService implements KeyService {
}
}
private <T> T performRequest(final String url, final Class<T> responseType, final Object... params) {
final HttpHeaders headers = getHeaders();
private <T> T performRequest(final String url, final Class<T> responseType, final HttpHeaders headers, final Object... params) {
final HttpEntity<String> entity = new HttpEntity<>(headers);
try {
return restTemplate.exchange(url, HttpMethod.GET, entity, responseType, params).getBody();
......@@ -136,11 +143,15 @@ public class PublicKeyService implements KeyService {
}
}
private HttpHeaders getHeaders() {
private HttpHeaders getHeadersWithoutAuth() {
final var headers = new HttpHeaders();
headers.setBearerAuth(authService.getCurrentToken().getAccessToken());
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAcceptCharset(List.of(StandardCharsets.UTF_8));
return headers;
}
private HttpHeaders getHeaders() {
final var headers = getHeadersWithoutAuth();
headers.setBearerAuth(authService.getCurrentToken().getAccessToken());
return headers;
}
}
package dev.fitko.fitconnect.core.routing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import dev.fitko.fitconnect.api.domain.model.route.Route;
import dev.fitko.fitconnect.api.domain.model.route.RouteDestination;
import dev.fitko.fitconnect.api.domain.validation.ValidationResult;
import dev.fitko.fitconnect.api.exceptions.InvalidKeyException;
import dev.fitko.fitconnect.api.exceptions.RestApiException;
import dev.fitko.fitconnect.api.exceptions.ValidationException;
import dev.fitko.fitconnect.api.services.keys.KeyService;
import dev.fitko.fitconnect.api.services.routing.RoutingVerificationService;
import dev.fitko.fitconnect.api.services.validation.ValidationService;
import dev.fitko.fitconnect.core.util.Strings;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static dev.fitko.fitconnect.api.domain.validation.ValidationResult.error;
import static dev.fitko.fitconnect.api.domain.validation.ValidationResult.ok;
public class RouteVerifier implements RoutingVerificationService {
private static final JsonMapper MAPPER = getConfiguredJsonMapper();
private final KeyService keyService;
private final ValidationService validationService;
public RouteVerifier(final KeyService keyService, final ValidationService validationService) {
this.keyService = keyService;
this.validationService = validationService;
}
@Override
public ValidationResult validateRouteDestinations(final List<Route> routes, final String requestedServiceIdentifier, final String requestedRegion) {
return routes.stream()
.map(route -> validateRoute(route, requestedServiceIdentifier, requestedRegion))
.filter(ValidationResult::hasError)
.findFirst().orElse(ValidationResult.ok());
}
private ValidationResult validateRoute(final Route route, final String requestedServiceIdentifier, final String requestedRegion) {
try {
validateDestinationSignature(route, requestedServiceIdentifier, requestedRegion);
validateDestinationParameterSignature(route);
return ok();
} catch (final ValidationException e) {
return error(e);
} catch (final InvalidKeyException e) {
return error(new ValidationException("Public signature key is invalid: " + e.getMessage()));
} catch (final RestApiException e) {
return error(new ValidationException("Could not retrieve public signature key: " + e.getMessage()));
} catch (final ParseException | JOSEException | JsonProcessingException e) {
return error(new ValidationException("Signature processing failed: " + e.getMessage()));
}
}
private void validateDestinationParameterSignature(final Route route) throws JOSEException, ParseException, JsonProcessingException {
final SignedJWT completedSignature = combineDetachedSignatureWithPayload(route);
final RSAKey publicSignatureKey = loadPublicKey(route, completedSignature);
if (!completedSignature.verify(new RSASSAVerifier(publicSignatureKey))) {
throw new ValidationException("Invalid destination parameter signature for route " + route.getDestinationId());
}
checkHeaderAlgorithm(completedSignature.getHeader());
}
private RSAKey loadPublicKey(final Route route, final SignedJWT completedSignature) {
final String keyId = completedSignature.getHeader().getKeyID();
final String submissionUrl = route.getDestinationParameters().getSubmissionUrl();
return keyService.getWellKnownKeysForSubmissionUrl(submissionUrl, keyId);
}
private SignedJWT combineDetachedSignatureWithPayload(final Route route) throws ParseException, JsonProcessingException {
final SignedJWT detachedSignature = SignedJWT.parse(route.getDestinationParametersSignature());
final Base64URL encodedDetachedPayloadPart = getBase64EncodedDetachedPayload(route);
final Base64URL headerPart = detachedSignature.getHeader().getParsedBase64URL();
final Base64URL signaturePart = detachedSignature.getSignature();
return new SignedJWT(headerPart, encodedDetachedPayloadPart, signaturePart);
}
private Base64URL getBase64EncodedDetachedPayload(final Route route) throws JsonProcessingException {
final RouteDestination detachedPayload = route.getDestinationParameters();
final String cleanedDetachedPayload = Strings.cleanNonPrintableChars(MAPPER.writeValueAsString(detachedPayload));
// FIXME email vs. eMail difference between DVDV and SubmissionAPI -> https://git.fitko.de/fit-connect/planning/-/issues/601
return Base64URL.encode(cleanedDetachedPayload.replace("eMail", "email").getBytes(StandardCharsets.UTF_8));
}
private void validateDestinationSignature(final Route route, final String requestedServiceIdentifier, final String requestedRegion) throws ParseException, JOSEException, JsonProcessingException {
final SignedJWT signature = SignedJWT.parse(route.getDestinationSignature());
final JWSHeader header = signature.getHeader();
final JWTClaimsSet claims = signature.getJWTClaimsSet();
final String submissionUrl = route.getDestinationParameters().getSubmissionUrl();
checkHeaderAlgorithm(header);
validatePayloadSchema(claims);
checkMatchingSubmissionHost(claims, submissionUrl);
checkExpectedServices(claims, requestedServiceIdentifier, requestedRegion);
validateAgainstPublicKey(signature, header.getKeyID());
}
private void validatePayloadSchema(final JWTClaimsSet claims) {
final ValidationResult validationResult = validationService.validateDestinationSchema(claims.toJSONObject());
if (validationResult.hasError()) {
throw new ValidationException(validationResult.getError().getMessage(), validationResult.getError());
}
}
private void validateAgainstPublicKey(final SignedJWT signature, final String keyId) throws JOSEException {
final RSAKey portalPublicKey = keyService.getPortalPublicKey(keyId);
if (!signature.verify(new RSASSAVerifier(portalPublicKey))) {
throw new ValidationException("Invalid destination signature for public key id " + keyId);
}
}
private void checkExpectedServices(final JWTClaimsSet claims, final String requestedServiceIdentifier, final String requestedRegion) {
final Map services = (Map) ((ArrayList<?>) (claims.getClaim("services"))).get(0);
final List<String> areaIds = mapIdentifiersToNumericIds(services, "gebietIDs");
final List<String> serviceIds = mapIdentifiersToNumericIds(services, "leistungIDs");
if (requestedRegion != null && !areaIds.contains(getIdFromIdentifier(requestedRegion))) {
throw new ValidationException("Requested region '" + requestedRegion + "' is not supported by the destinations services");
}
if (!serviceIds.contains(getIdFromIdentifier(requestedServiceIdentifier))) {
throw new ValidationException("Requested service identifier '" + requestedServiceIdentifier + "' is not supported by the destinations services");
}
}
private static List<String> mapIdentifiersToNumericIds(final Map services, final String claim) {
return ((List<String>) services.get(claim)).stream().map(RouteVerifier::getIdFromIdentifier).collect(Collectors.toList());
}
private static String getIdFromIdentifier(final String identifier) {
if (isNumericId(identifier)) {
return identifier;
}
return Arrays.stream(identifier.split(":"))
.reduce((first, second) -> second)
.orElse(null);
}
private static boolean isNumericId(final String identifier) {
return Pattern.compile("\\d+").matcher(identifier).matches();
}
private void checkHeaderAlgorithm(final JWSHeader header) {
if (!header.getAlgorithm().equals(JWSAlgorithm.PS512)) {
throw new ValidationException("Algorithm in signature header is not " + JWSAlgorithm.PS512);
}
}
private void checkMatchingSubmissionHost(final JWTClaimsSet claims, final String submissionUrl) throws ParseException {
final String submissionHostClaim = claims.getStringClaim("submissionHost");
final String submissionUrlHost = getHostFromSubmissionUrl(submissionUrl);
if (!submissionUrlHost.equals(submissionHostClaim)) {
throw new ValidationException("Submission host does not match destinationParameters submission url " + submissionHostClaim);
}
}
private static String getHostFromSubmissionUrl(final String submissionUrl) {
if (submissionUrl == null) {
throw new ValidationException("SubmissionUrl must not be null");
}
try {
return URI.create(submissionUrl).getHost();
} catch (final IllegalArgumentException e) {
throw new ValidationException("SubmissionUrl could not be parsed", e);
}
}
private static JsonMapper getConfiguredJsonMapper() {
return JsonMapper.builder()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.INDENT_OUTPUT, false)
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();
}
}
package dev.fitko.fitconnect.core.routing;
import dev.fitko.fitconnect.api.config.ApplicationConfig;
import dev.fitko.fitconnect.api.domain.model.route.AreaResult;
import dev.fitko.fitconnect.api.domain.model.route.RouteResult;
import dev.fitko.fitconnect.api.exceptions.RestApiException;
import dev.fitko.fitconnect.api.services.routing.RoutingService;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static java.util.function.Predicate.not;
public class RoutingApiService implements RoutingService {
private final RestTemplate restTemplate;
private final ApplicationConfig config;
public RoutingApiService(final ApplicationConfig config, final RestTemplate restTemplate) {
this.config = config;
this.restTemplate = restTemplate;
}
@Override
public AreaResult getAreas(final List<String> searchExpressions, final int offset, final int limit) {
final String url = config.getAreaEndpoint();
final HttpHeaders headers = getHttpHeaders(MediaType.parseMediaType("application/problem+json"));
final HttpEntity<String> entity = new HttpEntity<>(headers);
final UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(url);
searchExpressions.forEach(search -> uriBuilder.queryParam("areaSearchexpression", search));
uriBuilder.queryParam("offset", offset);
uriBuilder.queryParam("limit", limit);
try {
return restTemplate.exchange(uriBuilder.toUriString(), HttpMethod.GET, entity, AreaResult.class).getBody();
} catch (final RestClientException e) {
throw new RestApiException("Area query failed", e);
}
}
@Override
public RouteResult getRoutes(final String leikaKey, final String ars, final String ags, final String areaId, final int offset, final int limit) {
testIfSearchCriterionIsSet(ars, ags, areaId);
final String url = config.getRoutesEndpoint();
final HttpHeaders headers = getHttpHeaders(MediaType.APPLICATION_JSON);
final HttpEntity<String> entity = new HttpEntity<>(headers);
final UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(url);
uriBuilder.queryParam("leikaKey", leikaKey);
if (ars != null) {
uriBuilder.queryParam("ars", ars);
}
if (ags != null) {
uriBuilder.queryParam("ags", ags);
}
if (areaId != null) {
uriBuilder.queryParam("areaId", areaId);
}
uriBuilder.queryParam("offset", offset);
uriBuilder.queryParam("limit", limit);
try {
return restTemplate.exchange(uriBuilder.toUriString(), HttpMethod.GET, entity, RouteResult.class).getBody();
} catch (final RestClientException e) {
throw new RestApiException("Route query failed", e);
}
}
private static void testIfSearchCriterionIsSet(final String ars, final String ags, final String areaId) {
final List<String> areaSearchCriteria = Arrays.asList(ars, ags, areaId);
if(areaSearchCriteria.stream().allMatch(CriterionEmpty())){
throw new RestApiException("At least one search criterion out of ags, ars or areaId must be set");
}
if(areaSearchCriteria.stream().filter(not(CriterionEmpty())).collect(Collectors.toSet()).size() > 1){
throw new RestApiException("Only one of ars, ags or areaId must be specified.");
}
}
private static Predicate<String> CriterionEmpty() {
return criterion -> criterion == null || criterion.isEmpty();
}
private HttpHeaders getHttpHeaders(final MediaType mediaType) {
final var headers = new HttpHeaders();
headers.setAccept(List.of(mediaType));
headers.setAcceptCharset(List.of(StandardCharsets.UTF_8));
return headers;
}
}
......@@ -3,6 +3,7 @@ package dev.fitko.fitconnect.core.schema;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.fitko.fitconnect.api.config.SchemaConfig;
import dev.fitko.fitconnect.api.domain.schema.SchemaResources;
import dev.fitko.fitconnect.api.exceptions.InitializationException;
import dev.fitko.fitconnect.api.exceptions.SchemaNotFoundException;
import dev.fitko.fitconnect.api.services.schema.SchemaProvider;
......@@ -16,38 +17,50 @@ import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class SchemaResourceProvider implements SchemaProvider {
private static final Logger LOGGER = LoggerFactory.getLogger(SchemaResourceProvider.class);
private static final Pattern ALLOWED_SCHEMA_PATTERN = Pattern.compile("1.\\d+.\\d+");
private final Map<URI, String> setSchemas;
private final Map<URI, String> metadataSchemas;
private final Map<URI, String> destinationSchemas;
private static final ObjectMapper MAPPER = new ObjectMapper();
public SchemaResourceProvider(final List<String> setSchemaFiles, final List<String> metadataSchemaFiles) {
public SchemaResourceProvider(final SchemaResources schemaResources) {
setSchemas = new HashMap<>();
metadataSchemas = new HashMap<>();
populateSetSchemas(setSchemaFiles);
populateMetadataSchemas(metadataSchemaFiles);
destinationSchemas = new HashMap<>();
populateSetSchemas(schemaResources.getSetSchemaPaths());
populateMetadataSchemas(schemaResources.getMetadataSchemaPaths());
populateDestinationSchemas(schemaResources.getDestinationSchemaPaths());
}
private void populateMetadataSchemas(final List<String> metadataSchemaFiles) {
private void populateMetadataSchemas(final List<String> metadataSchemaPaths) {
LOGGER.info("Initializing metadata schemas");
getResourceFiles(metadataSchemaFiles).forEach(this::addMetadataSchema);
getResourceFiles(metadataSchemaPaths).forEach(this::addMetadataSchema);
}
private void populateSetSchemas(final List<String> setSchemaFiles) {
private void populateSetSchemas(final List<String> setSchemaPaths) {
LOGGER.info("Initializing set schemas");
getResourceFiles(setSchemaFiles).forEach(this::addSetSchema);
getResourceFiles(setSchemaPaths).forEach(this::addSetSchema);
}
private void populateDestinationSchemas(final List<String> destinationSchemaPaths) {
LOGGER.info("Initializing destination schemas");
getResourceFiles(destinationSchemaPaths).forEach(this::addDestinationSchema);
}
@Override
public boolean isAllowedSetSchema(final URI schemaUri) {
return schemaHasMajorVersion(schemaUri, "1");
return schemaVersionMatchesPattern(schemaUri, ALLOWED_SCHEMA_PATTERN);
}
@Override
......@@ -74,6 +87,15 @@ public class SchemaResourceProvider implements SchemaProvider {
return schema;
}
@Override
public String loadDestinationSchema(final URI schemaUri) throws SchemaNotFoundException {
final String schema = destinationSchemas.get(schemaUri);
if (schema == null) {
throw new SchemaNotFoundException("Destination schema " + schemaUri.toString() + " is not available.");
}
return schema;
}
private void addSetSchema(final String schema) {
setSchemas.put(readIdFromSchema(schema), schema);
}
......@@ -82,6 +104,10 @@ public class SchemaResourceProvider implements SchemaProvider {
metadataSchemas.put(readIdFromSchema(schema), schema);
}
private void addDestinationSchema(final String schema) {
destinationSchemas.put(readIdFromSchema(schema), schema);
}
private URI readIdFromSchema(final String schema) {
try {
return URI.create(MAPPER.readTree(schema).get("$id").asText());
......@@ -102,8 +128,9 @@ public class SchemaResourceProvider implements SchemaProvider {
throw new InitializationException("Could not read schema file " + schemaFile, e);
}
}
private boolean schemaHasMajorVersion(final URI schemaUri, final String majorVersion) {
private boolean schemaVersionMatchesPattern(final URI schemaUri, final Pattern pattern) {
final String schemaVersion = schemaUri.getPath().split("/")[3];
return schemaVersion.startsWith(majorVersion + ".");
return pattern.matcher(schemaVersion).matches();
}
}
......@@ -101,8 +101,7 @@ public class SubmissionApiService implements SubmissionService {
@Override
public Submission sendSubmission(final SubmitSubmission submission) {
final Map<String, Object> params = new HashMap<>();
params.put("submissionId", submission.getSubmissionId());
final Map<String, Object> params = Map.of("submissionId", submission.getSubmissionId());
final RequestSettings requestSettings = RequestSettings.builder()
.url(config.getSubmissionEndpoint())
.method(HttpMethod.PUT)
......@@ -139,8 +138,7 @@ public class SubmissionApiService implements SubmissionService {
@Override
public Submission getSubmission(final UUID submissionId) {
final Map<String, Object> params = new HashMap<>();
params.put("submissionId", submissionId);
final Map<String, Object> params = Map.of("submissionId", submissionId);
final RequestSettings requestSettings = RequestSettings.builder()
.url(config.getSubmissionEndpoint())
.method(HttpMethod.GET)
......
......@@ -5,7 +5,8 @@ package dev.fitko.fitconnect.core.util;
*/
public final class StopWatch {
private StopWatch(){}
private StopWatch() {
}
/**
* Get current system time in ms.
......@@ -20,14 +21,13 @@ public final class StopWatch {
* Formats end time based on start time (end - start) in a readable format (e.g. sec:ms)
*
* @param startTime start time of the measured call
*
* @return formatted elapsed time since start
*/
public static String stopWithFormattedTime(final long startTime){
public static String stopWithFormattedTime(final long startTime) {
return Formatter.formatMillis(stop(startTime));
}
private static long stop(final long startTime){
private static long stop(final long startTime) {
return System.currentTimeMillis() - startTime;
}
}
......@@ -7,6 +7,7 @@ public final class Strings {
/**
* Tests a given string on null or empty
*
* @param s string to test
* @return true if the string is null OR empty, false if both conditions do not apply
*/
......@@ -16,6 +17,7 @@ public final class Strings {
/**
* Tests a given string on not null and not empty
*
* @param s string to test
* @return true if the string is not null AND not empty, false if one condition does not apply
*/
......@@ -23,4 +25,14 @@ public final class Strings {
return !isNullOrEmpty(s);
}
/**
* Replace all non-printable control characters like \t, \r, \n and spaces from the given s.
*
* @param s string that should be cleaned
* @return cleaned string without non-printable chars
*/
public static String cleanNonPrintableChars(final String s) {
return s.replaceAll("\\p{C}", "");
}
}
......@@ -33,6 +33,7 @@ import org.xml.sax.XMLReader;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParserFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
......@@ -47,6 +48,7 @@ import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
......@@ -57,7 +59,9 @@ public class DefaultValidationService implements ValidationService {
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultValidationService.class);
private static final ObjectMapper MAPPER = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd"));
private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
private static final JsonSchemaFactory SCHEMA_FACTORY_DRAFT_2020 = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
private static final JsonSchemaFactory SCHEMA_FACTORY_DRAFT_2007 = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
public static final String VALID_SCHEMA_URL_EXPRESSION = "https://schema\\.fitko\\.de/fit-connect/metadata/1\\.\\d+\\.\\d+/metadata.schema.json";
private final MessageDigestService messageDigestService;
......@@ -96,7 +100,7 @@ public class DefaultValidationService implements ValidationService {
final JsonNode inputNode = MAPPER.readTree(setEventPayload);
final URI schemaUri = URI.create(inputNode.get(EventClaimFields.CLAIM_SCHEMA).asText());
if (schemaProvider.isAllowedSetSchema(schemaUri)) {
return validateJsonSchema(schemaProvider.loadLatestSetSchema(), inputNode);
return validate2020JsonSchema(schemaProvider.loadLatestSetSchema(), inputNode);
} else {
return ValidationResult.error(new SchemaNotFoundException("SET payload schema not supported: " + schemaUri));
}
......@@ -115,7 +119,19 @@ public class DefaultValidationService implements ValidationService {
try {
final String metadataJson = MAPPER.writeValueAsString(metadata);
final JsonNode inputNode = MAPPER.readTree(metadataJson);
return validateJsonSchema(schemaProvider.loadMetadataSchema(config.getMetadataSchemaWriteVersion()), inputNode);
return validate2020JsonSchema(schemaProvider.loadMetadataSchema(config.getMetadataSchemaWriteVersion()), inputNode);
} catch (final JsonProcessingException e) {
return ValidationResult.error(e);
}
}
@Override
public ValidationResult validateDestinationSchema(final Map<String, Object> destinationPayload) {
try {
final String destinationPayloadJson = MAPPER.writeValueAsString(destinationPayload);
final JsonNode inputNode = MAPPER.readTree(destinationPayloadJson);
final String schema = schemaProvider.loadDestinationSchema(config.getDestinationSchemaVersion());
return returnValidationResult(SCHEMA_FACTORY_DRAFT_2007.getSchema(schema).validate(inputNode));
} catch (final JsonProcessingException e) {
return ValidationResult.error(e);
}
......@@ -157,25 +173,25 @@ public class DefaultValidationService implements ValidationService {
}
@Override
public ValidationResult validateCallback(String hmac, Long timestamp, String httpBody, String callbackSecret) {
public ValidationResult validateCallback(final String hmac, final Long timestamp, final String httpBody, final String callbackSecret) {
ZonedDateTime providedTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
ZonedDateTime currentTimeFiveMinutesAgo = ZonedDateTime.now().minusMinutes(5);
final ZonedDateTime providedTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
final ZonedDateTime currentTimeFiveMinutesAgo = ZonedDateTime.now().minusMinutes(5);
if (providedTime.isBefore(currentTimeFiveMinutesAgo)) {
return ValidationResult.error(new ValidationException("Timestamp provided by callback is expired."));
}
String expectedHmac = this.messageDigestService.calculateHMAC(timestamp + "." + httpBody, callbackSecret);
final String expectedHmac = messageDigestService.calculateHMAC(timestamp + "." + httpBody, callbackSecret);
if (!hmac.equals(expectedHmac)) {
return ValidationResult.error(new ValidationException("HMAC provided by callback does not match the expected result."));
return ValidationResult.error(new ValidationException("HMAC provided by callback does not match the expected result."));
}
return ValidationResult.ok();
}
private ValidationResult validateJsonSchema(final String schema, final JsonNode inputNode) {
return returnValidationResult(SCHEMA_FACTORY.getSchema(schema).validate(inputNode));
private ValidationResult validate2020JsonSchema(final String schema, final JsonNode inputNode) {
return returnValidationResult(SCHEMA_FACTORY_DRAFT_2020.getSchema(schema).validate(inputNode));
}
private void validateKey(final RSAKey publicKey, final KeyOperation purpose) throws JWKValidationException, CertificateEncodingException {
......