Skip to content

Commit

Permalink
Fintraffic specific UserContextService which utilizes Microsoft's and…
Browse files Browse the repository at this point in the history
… Fintraffic's services for access management
  • Loading branch information
esuomi committed Jun 10, 2024
1 parent 3fd0983 commit 8447736
Show file tree
Hide file tree
Showing 8 changed files with 414 additions and 0 deletions.
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

0 comments on commit 8447736

Please sign in to comment.