All Downloads are FREE. Search and download functionalities are using the official Maven repository.

dev.fitko.fitconnect.core.routing.RouteVerifier Maven / Gradle / Ivy

Go to download

Library that provides client access to the FIT-Connect api-endpoints for sending, subscribing and routing

There is a newer version: 2.3.5
Show newest version
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.internal.InvalidKeyException;
import dev.fitko.fitconnect.api.exceptions.internal.RestApiException;
import dev.fitko.fitconnect.api.exceptions.internal.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.utils.Strings;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import static dev.fitko.fitconnect.api.domain.validation.ValidationResult.error;
import static dev.fitko.fitconnect.api.domain.validation.ValidationResult.ok;
import static java.util.Collections.emptyList;
import static java.util.stream.Collectors.toList;

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 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.getCause().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) throws ParseException {

        final List services = claims.getListClaim("services").stream()
                .map(service -> new RouteService((Map>) service))
                .collect(toList());

        final var serviceId = getIdFromIdentifier(requestedServiceIdentifier);
        if (services.stream().noneMatch(service -> service.hasMatchingService(serviceId))) {
            throw new ValidationException("Requested service identifier '" + requestedServiceIdentifier + "' is not supported by any of the destinations services");
        }

        // check combination of service and region - ars can be null if the requested region is an areaId or ags
        if (requestedRegion != null) {
            final var regionId = getIdFromIdentifier(requestedRegion);
            if (services.stream().noneMatch(service -> service.hasMatchingRegionAndService(regionId, serviceId))) {
                throw new ValidationException("Requested region '" + requestedRegion + "' does not match any service provided by the destination");
            }
        }
    }

    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();
    }

    static class RouteService {
        private final List regionIds;
        private final List serviceIds;

        protected RouteService(final Map> service) {
            regionIds = service.getOrDefault("gebietIDs", emptyList()).stream().map(RouteVerifier::getIdFromIdentifier).collect(toList());
            serviceIds = service.getOrDefault("leistungIDs", emptyList()).stream().map(RouteVerifier::getIdFromIdentifier).collect(toList());
        }

        public boolean hasMatchingRegionAndService(final String regionId, final String serviceId) {
            return hasMatchingRegion(regionId) && hasMatchingService(serviceId);
        }

        public boolean hasMatchingRegion(final String regionId) {
            return regionIds.stream().anyMatch(regionId::contains);
        }

        public boolean hasMatchingService(final String serviceId) {
            return serviceIds.contains(serviceId);
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy