Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fintraffic specific UserContextService #343

Merged
merged 1 commit into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@
<artifactId>s3</artifactId>
</dependency>

<!-- Java native HTTP client extensions -->
<dependency>
<groupId>com.github.mizosoft.methanol</groupId>
<artifactId>methanol</artifactId>
<version>1.7.0</version>
</dependency>

<!-- Test-->

<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://learn.microsoft.com/en-us/entra/">Entra</a>, <a href="https://learn.microsoft.com/en-us/graph/overview">Microsoft Graph</a>
* 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 <a href="https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0">Microsoft Graph REST API v1.0 endpoint reference</a>
*/
private static final String MICROSOFT_GRAPH_API_ROOT =
"https://graph.microsoft.com/v1.0";

/**
* Generic Microsoft Entra login URI, provided as template. Call <code>"...".format(tenantId)</code> to acquire
* working login URI.
*
* @see <a href="https://learn.microsoft.com/en-us/entra/">Microsoft Entra</a>
*/
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<String, AtomicReference<EntraTokenResponse>> 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 <code>preferred_username</code> claim provided by Entra.
*/
@Override
public String getPreferredName() {
return getToken()
.map(t -> t.getClaimAsString("preferred_username"))
.orElse("unknown");
}

private static Optional<Jwt> 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<JsonNode> 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<String> 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<String> things = me
.data()
.companies()
.stream()
.flatMap(vc -> vc.codespaces().stream())
.collect(Collectors.toSet());
return things.contains(providerCode);
})
.orElse(false);
}

private Optional<EntraTokenResponse> login(String tokenScope) {
AtomicReference<EntraTokenResponse> 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<String> 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<VacoApiResponse<Me>> me(EntraTokenResponse token) {
MutableRequest request = MutableRequest
.GET(vacoApi + "/me")
.header("Authorization", "Bearer " + token.getAccessToken())
.header("Content-Type", "application/json");
try {
HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
return Optional.of(
objectMapper.readValue(
response.body(),
new TypeReference<VacoApiResponse<Me>>() {}
)
);
} 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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading