diff --git a/pom.xml b/pom.xml index 1634a092..bd119487 100644 --- a/pom.xml +++ b/pom.xml @@ -324,6 +324,13 @@ s3 + + + com.github.mizosoft.methanol + methanol + 1.7.0 + + diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficSecurityConfiguration.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficSecurityConfiguration.java new file mode 100644 index 00000000..28180011 --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficSecurityConfiguration.java @@ -0,0 +1,31 @@ +package no.entur.uttu.ext.fintraffic.security; + +import no.entur.uttu.security.spi.UserContextService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("fintraffic") +public class FintrafficSecurityConfiguration { + + @Bean + UserContextService userContextService( + @Value("${uttu.ext.fintraffic.security.tenant-id}") String tenantId, + @Value("${uttu.ext.fintraffic.security.client-id}") String clientId, + @Value("${uttu.ext.fintraffic.security.client-secret}") String clientSecret, + @Value("${uttu.ext.fintraffic.security.scope}") String scope, + @Value("${uttu.ext.fintraffic.security.admin-role-id}") String adminRoleId, + @Value("${uttu.ext.fintraffic.security.vaco-api}") String vacoApi + ) { + return new FintrafficUserContextService( + tenantId, + clientId, + clientSecret, + scope, + adminRoleId, + vacoApi + ); + } +} diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficUserContextService.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficUserContextService.java new file mode 100644 index 00000000..57f26540 --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficUserContextService.java @@ -0,0 +1,313 @@ +package no.entur.uttu.ext.fintraffic.security; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mizosoft.methanol.FormBodyPublisher; +import com.github.mizosoft.methanol.Methanol; +import com.github.mizosoft.methanol.MutableRequest; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import no.entur.uttu.ext.fintraffic.security.model.EntraTokenResponse; +import no.entur.uttu.ext.fintraffic.security.model.Me; +import no.entur.uttu.ext.fintraffic.security.model.VacoApiResponse; +import no.entur.uttu.security.spi.UserContextService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * Fintraffic uses a combination of Entra, Microsoft Graph + * and in-house TIS VACO service specific data to determine user's access to various resources. + */ +public class FintrafficUserContextService implements UserContextService { + + private static final Logger logger = LoggerFactory.getLogger( + FintrafficUserContextService.class + ); + + /** + * Microsoft Graph authentication scope requires this as its hardcoded value. + */ + private static final String MICROSOFT_GRAPH_SCOPE = + "https://graph.microsoft.com/.default"; + /** + * Microsoft Graph API root URI. + * + * @see Microsoft Graph REST API v1.0 endpoint reference + */ + private static final String MICROSOFT_GRAPH_API_ROOT = + "https://graph.microsoft.com/v1.0"; + + /** + * Generic Microsoft Entra login URI, provided as template. Call "...".format(tenantId) to acquire + * working login URI. + * + * @see Microsoft Entra + */ + private static final String MICROSOFT_ENTRA_LOGIN_URI = + "https://login.microsoftonline.com/%s/oauth2/v2.0/token"; + + /** + * Entra tenant id for accessing any Entra or Graph protected resource within the represented tenant. + * + * @see #MICROSOFT_ENTRA_LOGIN_URI + */ + private final String tenantId; + + /** + * Entra client id for accessing Fintraffic TIS VACO API. + */ + private final String clientId; + + /** + * Entra client secret for accessing Fintraffic TIS VACO API. + */ + private final String clientSecret; + + /** + * Authentication scope for accessing Fintraffic TIS VACO API. + */ + private final String scope; + + /** + * Microsoft Entra application role's id for admin identifying users. + */ + private final String adminRoleId; + + /** + * Fintraffic TIS VACO service's API root URI. + */ + private final String vacoApi; + + /** + * Stores authentication scope to {@link EntraTokenResponse} for reusing authentication tokens. + */ + private final ConcurrentMap> authenticationTokens = + new ConcurrentHashMap<>(); + + private final Methanol httpClient; + + /** + * Internal {@link ObjectMapper} used for VACO API interactions, isolated from Spring's singleton on purpose to avoid + * accidental misconfigurations. + */ + private final ObjectMapper objectMapper; + + public FintrafficUserContextService( + String tenantId, + String clientId, + String clientSecret, + String scope, + String adminRoleId, + String vacoApi + ) { + this.tenantId = tenantId; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.scope = scope; + this.adminRoleId = adminRoleId; + this.vacoApi = vacoApi; + this.objectMapper = initializeObjectMapper(); + this.httpClient = initializeHttpClient(); + } + + private static ObjectMapper initializeObjectMapper() { + ObjectMapper om = new ObjectMapper(); + om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return om; + } + + private static Methanol initializeHttpClient() { + return Methanol + .newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(5)) + .headersTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NORMAL) + .userAgent("Entur Uttu/" + LocalDate.now().format(DateTimeFormatter.ISO_DATE)) + .build(); + } + + /** + * {@inheritDoc} + * + * @return User's preferred name defined by the preferred_username claim provided by Entra. + */ + @Override + public String getPreferredName() { + return getToken() + .map(t -> t.getClaimAsString("preferred_username")) + .orElse("unknown"); + } + + private static Optional getToken() { + if ( + SecurityContextHolder + .getContext() + .getAuthentication() instanceof JwtAuthenticationToken token && + (token.getPrincipal() instanceof Jwt jwt) + ) { + return Optional.of(jwt); + } + return Optional.empty(); + } + + @Override + public boolean isAdmin() { + return login(MICROSOFT_GRAPH_SCOPE) + .flatMap(this::msGraphAppRoleAssignments) + .map(tree -> { + JsonNode assignedAppRoles = tree.path("value"); + if (assignedAppRoles.isArray()) { + for (JsonNode assignedAppRole : assignedAppRoles) { + if ( + assignedAppRole.has("appRoleId") && + assignedAppRole.get("appRoleId").asText().equals(adminRoleId) + ) { + return true; + } + } + } + return false; + }) + .orElse(false); + } + + /** + * Hand-rolled implementation of acquiring app role assignments from MS Graph to avoid unnecessarily high amount of + * dependencies. + * + * @param serviceToken Token authorized to access {@link #MICROSOFT_GRAPH_SCOPE} + * @return Jackson {@link JsonNode} representing successful response from MS Graph or {@link Optional#empty()} if call failed. + * @see #login(String) + */ + private Optional msGraphAppRoleAssignments(EntraTokenResponse serviceToken) { + return getToken() + .flatMap(jwt -> { + MutableRequest request = MutableRequest + .GET( + MICROSOFT_GRAPH_API_ROOT + + "/users/" + + jwt.getClaimAsString("oid") + + "/appRoleAssignments" + ) + .header("Authorization", "Bearer " + serviceToken.getAccessToken()) + .header("Content-Type", "application/json"); + try { + HttpResponse response = httpClient.send( + request, + BodyHandlers.ofString() + ); + JsonNode tree = objectMapper.readTree(response.body()); + return Optional.of(tree); + } catch (IOException e) { + logger.warn("I/O error during API request", e); + } catch (InterruptedException e) { + logger.warn( + "Underlying thread interrupted during HTTP client action, interrupting current thread", + e + ); + Thread.currentThread().interrupt(); + } + return Optional.empty(); + }); + } + + @Override + public boolean hasAccessToProvider(String providerCode) { + return login(scope) + .flatMap(this::me) + .map(me -> { + Set things = me + .data() + .companies() + .stream() + .flatMap(vc -> vc.codespaces().stream()) + .collect(Collectors.toSet()); + return things.contains(providerCode); + }) + .orElse(false); + } + + private Optional login(String tokenScope) { + AtomicReference tokenContainer = + authenticationTokens.computeIfAbsent(tokenScope, t -> new AtomicReference<>(null)); + EntraTokenResponse currentToken = tokenContainer.get(); + if ( + currentToken == null || + currentToken.getValidUntil().isBefore(LocalDateTime.now().minusSeconds(10)) + ) { + try { + FormBodyPublisher formBody = FormBodyPublisher + .newBuilder() + .query("client_id", clientId) + .query("grant_type", "client_credentials") + .query("scope", tokenScope) + .query("client_secret", clientSecret) + .build(); + MutableRequest request = MutableRequest.POST( + MICROSOFT_ENTRA_LOGIN_URI.formatted(tenantId), + formBody + ); + HttpResponse response = httpClient.send(request, BodyHandlers.ofString()); + + EntraTokenResponse newToken = objectMapper.readValue( + response.body(), + EntraTokenResponse.class + ); + tokenContainer.compareAndSet(currentToken, newToken); + } catch (IOException e) { + logger.warn("I/O error during API request", e); + } catch (InterruptedException e) { + logger.warn( + "Underlying thread interrupted during HTTP client action, interrupting current thread", + e + ); + Thread.currentThread().interrupt(); + } + } + return Optional.ofNullable(tokenContainer.get()); + } + + private Optional> me(EntraTokenResponse token) { + MutableRequest request = MutableRequest + .GET(vacoApi + "/me") + .header("Authorization", "Bearer " + token.getAccessToken()) + .header("Content-Type", "application/json"); + try { + HttpResponse response = httpClient.send(request, BodyHandlers.ofString()); + return Optional.of( + objectMapper.readValue( + response.body(), + new TypeReference>() {} + ) + ); + } catch (IOException e) { + logger.warn("I/O error during API request", e); + } catch (InterruptedException e) { + logger.warn( + "Underlying thread interrupted during HTTP client action, interrupting current thread", + e + ); + Thread.currentThread().interrupt(); + } + return Optional.empty(); + } +} diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/HttpClientException.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/HttpClientException.java new file mode 100644 index 00000000..fea34f44 --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/HttpClientException.java @@ -0,0 +1,8 @@ +package no.entur.uttu.ext.fintraffic.security; + +public class HttpClientException extends RuntimeException { + + public HttpClientException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/EntraTokenResponse.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/EntraTokenResponse.java new file mode 100644 index 00000000..6126ce5f --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/EntraTokenResponse.java @@ -0,0 +1,37 @@ +package no.entur.uttu.ext.fintraffic.security.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; + +public class EntraTokenResponse { + + private final String tokenType; + + private final LocalDateTime validUntil; + + private final String accessToken; + + @JsonCreator + public EntraTokenResponse( + @JsonProperty("token_type") String tokenType, + @JsonProperty("expires_in") long expiresIn, + @JsonProperty("access_token") String accessToken + ) { + this.tokenType = tokenType; + this.validUntil = LocalDateTime.now().plusSeconds(expiresIn); + this.accessToken = accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public LocalDateTime getValidUntil() { + return validUntil; + } + + public String getAccessToken() { + return accessToken; + } +} diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/Me.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/Me.java new file mode 100644 index 00000000..b7a495e3 --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/Me.java @@ -0,0 +1,5 @@ +package no.entur.uttu.ext.fintraffic.security.model; + +import java.util.List; + +public record Me(List companies) {} diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoApiResponse.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoApiResponse.java new file mode 100644 index 00000000..0d356470 --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoApiResponse.java @@ -0,0 +1,3 @@ +package no.entur.uttu.ext.fintraffic.security.model; + +public record VacoApiResponse(D data) {} diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoCompany.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoCompany.java new file mode 100644 index 00000000..a7c8b1a4 --- /dev/null +++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoCompany.java @@ -0,0 +1,10 @@ +package no.entur.uttu.ext.fintraffic.security.model; + +import java.util.List; + +public record VacoCompany( + String businessId, + String name, + String language, + List codespaces +) {}