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

feat: Support z/OSMF scheme in Spring Cloud Gateway #3190

Merged
merged 33 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8d06192
Add /zaas/zosmf endpoint to generate zosmf JWT and LTPA tokens
weinfurt Oct 18, 2023
0e3330e
Add integration tests
weinfurt Oct 19, 2023
d185374
Move zosmfTokensResponse to common-service-core
weinfurt Oct 19, 2023
998105c
Revert unwanted change
weinfurt Oct 19, 2023
945f4a5
Change the zosmf endpoint response to be more useful.
weinfurt Oct 19, 2023
93c63bf
New zOSMF filter factory - WIP
weinfurt Oct 23, 2023
a57e114
attempt to refactor auth schema factories
pj892031 Oct 25, 2023
157155d
added implementaion for instance null case
Shobhajayanna Oct 31, 2023
4853a79
fixes
pj892031 Oct 31, 2023
c10729a
refactoring ZosmfFilterFactory
Shobhajayanna Nov 1, 2023
e262f15
fix test
pj892031 Nov 2, 2023
eab2ae2
resending serviceId + test fixes
pj892031 Nov 3, 2023
2a2d16e
PassticketTest fix + new implementation of mock services
pj892031 Nov 7, 2023
059ffd0
fix tests and new mocks
pj892031 Nov 7, 2023
989ac9e
rename AcceptanceTestWithTwoServices
pj892031 Nov 7, 2023
c0e861c
doc of MockService
pj892031 Nov 7, 2023
8ba2bc5
doc method AcceptanceTestWithMockServices#mockService(String)
pj892031 Nov 7, 2023
6f93748
tests and fixes
pj892031 Nov 8, 2023
c5e7fec
Merge branch 'v2.x.x' into reboot/SCGW/zosmf_scheme
pj892031 Nov 9, 2023
b37ce4f
fixes
pj892031 Nov 9, 2023
3ecfc45
before test refactor
pj892031 Nov 10, 2023
2e5aeb2
fix tests
pj892031 Nov 10, 2023
617f62d
Merge remote-tracking branch 'origin/v2.x.x' into reboot/SCGW/zosmf_s…
pj892031 Nov 10, 2023
af207f6
Merge remote-tracking branch 'origin/v2.x.x' into reboot/SCGW/zosmf_s…
pj892031 Nov 10, 2023
154dd8c
integration tests
pj892031 Nov 13, 2023
f094fb2
docs
pj892031 Nov 13, 2023
3b36236
code review - remove MockService.getApplication
pj892031 Nov 13, 2023
ccb1ec0
code review
pj892031 Nov 13, 2023
3fcc665
increase code coverage
pj892031 Nov 13, 2023
f2a3e12
remove unused new dependency
pj892031 Nov 13, 2023
c564784
sonar issue fix
pj892031 Nov 13, 2023
e7c399b
code review
pj892031 Nov 14, 2023
f7048d0
Merge branch 'v2.x.x' into reboot/SCGW/zosmf_scheme
pj892031 Nov 14, 2023
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
1 change: 1 addition & 0 deletions cloud-gateway-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ dependencies {
testImplementation libs.rest.assured
testImplementation libs.reactorTest
testImplementation libs.mockito.inline
testImplementation libs.mockwebserver

}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.cloudgatewayservice.filters;

import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ServerWebExchange;
import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService;
import org.zowe.apiml.constants.ApimlConstants;
import org.zowe.apiml.message.core.MessageService;
import reactor.core.publisher.Mono;

import java.net.HttpCookie;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

public abstract class AbstractAuthSchemeFactory<T, R, D> extends AbstractGatewayFilterFactory<T> {

private static final String HEADER_SERVICE_ID = "X-Service-Id";

private static final RobinRoundIterator<ServiceInstance> robinRound = new RobinRoundIterator<>();

protected final WebClient webClient;
protected final InstanceInfoService instanceInfoService;
protected final MessageService messageService;

protected AbstractAuthSchemeFactory(Class<T> configClazz, WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) {
super(configClazz);
this.webClient = webClient;
this.instanceInfoService = instanceInfoService;
this.messageService = messageService;
}

protected abstract Class<R> getResponseClass();

private Mono<List<ServiceInstance>> getZaasInstances() {
return instanceInfoService.getServiceInstance("gateway");
}

private Mono<R> requestWithHa(
Iterator<ServiceInstance> serviceInstanceIterator,
AbstractConfig config,
Function<ServiceInstance, WebClient.RequestHeadersSpec<?>> requestCreator
) {
return requestCreator.apply(serviceInstanceIterator.next())
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> Mono.empty())
.bodyToMono(getResponseClass())
.switchIfEmpty(serviceInstanceIterator.hasNext() ?
requestWithHa(serviceInstanceIterator, config, requestCreator) : Mono.empty()
);
}

protected Mono<Void> invoke(
List<ServiceInstance> serviceInstances,
AbstractConfig config,
Function<ServiceInstance, WebClient.RequestHeadersSpec<?>> requestCreator,
Function<? super R, ? extends Mono<Void>> responseProcessor
) {
Iterator<ServiceInstance> i = robinRound.getIterator(serviceInstances);
if (!i.hasNext()) {
throw new IllegalArgumentException("No ZAAS is available");
}

return requestWithHa(i, config, requestCreator).flatMap(responseProcessor);
}

@SuppressWarnings("squid:S1452")
protected abstract WebClient.RequestHeadersSpec<?> createRequest(ServerWebExchange exchange, ServiceInstance instance, D data);
pj892031 marked this conversation as resolved.
Show resolved Hide resolved
protected abstract Mono<Void> processResponse(ServerWebExchange exchange, GatewayFilterChain chain, R response);

protected WebClient.RequestHeadersSpec<?> setDefaults(AbstractConfig config, ServerWebExchange exchange, WebClient.RequestHeadersSpec<?> requestHeadersSpec) {
return requestHeadersSpec
.headers(headers -> headers.addAll(exchange.getRequest().getHeaders()))
.header(HEADER_SERVICE_ID, config.serviceId);
}

protected GatewayFilter createGatewayFilter(AbstractConfig config, D data) {
return (exchange, chain) -> getZaasInstances().flatMap(
instances -> invoke(
instances,
config,
instance -> setDefaults(config, exchange, createRequest(exchange, instance, data)),
response -> processResponse(exchange, chain, response)
)
);
}
protected ServerHttpRequest addRequestHeader(ServerWebExchange exchange, String key, String value) {
return exchange.getRequest().mutate()
.headers(headers -> headers.add(key, value))
.build();
}

protected ServerHttpRequest setRequestHeader(ServerWebExchange exchange, String headerName, String headerValue) {
return exchange.getRequest().mutate()
.header(headerName, headerValue)
.build();
}

protected ServerHttpRequest updateHeadersForError(ServerWebExchange exchange, String errorMessage) {
ServerHttpRequest request = addRequestHeader(exchange, ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage());
exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage());
return request;
}

protected ServerHttpRequest setCookie(ServerWebExchange exchange, String cookieName, String value) {
return exchange.getRequest().mutate()
.headers(headers -> {
// read all other current cookies
List<HttpCookie> cookies = Optional.ofNullable(headers.get(HttpHeaders.COOKIE))
.orElse(Collections.emptyList())
.stream()
.map(HttpCookie::parse)
.flatMap(List::stream)
.filter(c -> !StringUtils.equals(c.getName(), cookieName))
.collect(Collectors.toList());

// add the new cookie
cookies.add(new HttpCookie(cookieName, value));

// remove old cookie header in the request
headers.remove(HttpHeaders.COOKIE);

// set new cookie header in the request
cookies.stream().forEach(c -> headers.add(HttpHeaders.COOKIE, c.toString()));
}).build();
}

@Data
protected abstract static class AbstractConfig {

private String serviceId;

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,17 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.http.HttpHeaders;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ServerWebExchange;
import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService;
import org.zowe.apiml.constants.ApimlConstants;
import org.zowe.apiml.message.core.MessageService;
import org.zowe.apiml.ticket.TicketRequest;
import org.zowe.apiml.ticket.TicketResponse;
Expand All @@ -34,47 +33,44 @@
import java.util.Base64;

@Service
public class PassticketFilterFactory extends AbstractGatewayFilterFactory<PassticketFilterFactory.Config> {
private final WebClient webClient;
private final InstanceInfoService instanceInfoService;
private final MessageService messageService;
private final String ticketUrl = "%s://%s:%s/%s/api/v1/auth/ticket";
private final ObjectWriter writer = new ObjectMapper().writer();
public class PassticketFilterFactory extends AbstractAuthSchemeFactory<PassticketFilterFactory.Config, TicketResponse, String> {

private static final String TICKET_URL = "%s://%s:%s/%s/api/v1/auth/ticket";
private static final ObjectWriter WRITER = new ObjectMapper().writer();

public PassticketFilterFactory(WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) {
super(Config.class);
this.webClient = webClient;
this.instanceInfoService = instanceInfoService;
this.messageService = messageService;
super(Config.class, webClient, instanceInfoService, messageService);
}

@Override
protected Class<TicketResponse> getResponseClass() {
return TicketResponse.class;
}

@Override
protected WebClient.RequestHeadersSpec<?> createRequest(ServerWebExchange exchange, ServiceInstance instance, String requestBody) {
return webClient.post()
.uri(String.format(TICKET_URL, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase()))
.bodyValue(requestBody);
}

@Override
protected Mono<Void> processResponse(ServerWebExchange exchange, GatewayFilterChain chain, TicketResponse response) {
if (response.getTicket() == null) {
// TODO: consider throwing an exception, ZAAS is not configured properly
ServerHttpRequest request = updateHeadersForError(exchange, "Invalid or missing authentication.");
return chain.filter(exchange.mutate().request(request).build());
}
String encodedCredentials = Base64.getEncoder().encodeToString((response.getUserId() + ":" + response.getTicket()).getBytes(StandardCharsets.UTF_8));
final String headerValue = "Basic " + encodedCredentials;
ServerHttpRequest request = setRequestHeader(exchange, HttpHeaders.AUTHORIZATION, headerValue);
return chain.filter(exchange.mutate().request(request).build());
}

@Override
public GatewayFilter apply(Config config) {
try {
final String requestBody = writer.writeValueAsString(new TicketRequest(config.getApplicationName()));
return (ServerWebExchange exchange, GatewayFilterChain chain) ->
instanceInfoService.getServiceInstance("gateway").flatMap(instances -> {
for (ServiceInstance instance : instances) {
return webClient.post()
.uri(String.format(ticketUrl, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase()))
.headers(headers -> headers.addAll(exchange.getRequest().getHeaders()))
.bodyValue(requestBody)
.retrieve().onStatus(HttpStatus::is4xxClientError, (response) -> Mono.empty())
.bodyToMono(TicketResponse.class)
.flatMap(response -> {
if (response.getTicket() == null) {
ServerHttpRequest request = updateHeadersForError(exchange, "Invalid or missing authentication.");
return chain.filter(exchange.mutate().request(request).build());
}
String encodedCredentials = Base64.getEncoder().encodeToString((response.getUserId() + ":" + response.getTicket()).getBytes(StandardCharsets.UTF_8));
final String headerValue = "Basic " + encodedCredentials;
ServerHttpRequest request = addRequestHeader(exchange, HttpHeaders.AUTHORIZATION, headerValue);
return chain.filter(exchange.mutate().request(request).build());
});
}
ServerHttpRequest request = updateHeadersForError(exchange, "All gateway service instances failed to respond.");
return chain.filter(exchange.mutate().request(request).build());
});
return createGatewayFilter(config, WRITER.writeValueAsString(new TicketRequest(config.getApplicationName())));
} catch (JsonProcessingException e) {
return ((exchange, chain) -> {
ServerHttpRequest request = updateHeadersForError(exchange, e.getMessage());
Expand All @@ -83,31 +79,11 @@ public GatewayFilter apply(Config config) {
}
}

private ServerHttpRequest updateHeadersForError(ServerWebExchange exchange, String errorMessage) {
ServerHttpRequest request = addRequestHeader(exchange, ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage());
exchange.getResponse().getHeaders().add(ApimlConstants.AUTH_FAIL_HEADER, messageService.createMessage("org.zowe.apiml.security.ticket.generateFailed", errorMessage).mapToLogMessage());
return request;
}

private ServerHttpRequest addRequestHeader(ServerWebExchange exchange, String key, String value) {
return exchange.getRequest().mutate()
.headers(headers -> {
headers.add(key, value);
headers.remove(org.springframework.http.HttpHeaders.COOKIE);
}
).build();
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class Config extends AbstractAuthSchemeFactory.AbstractConfig {


public static class Config {
private String applicationName;

public String getApplicationName() {
return applicationName;
}

public void setApplicationName(String applicationName) {
this.applicationName = applicationName;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.cloudgatewayservice.filters;

import java.util.Collection;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.concurrent.atomic.AtomicInteger;

public class RobinRoundIterator<T> {

private final AtomicInteger lastIndex = new AtomicInteger(-1);

public Iterator<T> getIterator(Collection<T> input) {
int offset = lastIndex.updateAndGet(prev -> input.isEmpty() ? 0 : (prev + 1) % input.size());

return new RoundIterator(input, offset);
}

private class RoundIterator implements Iterator<T> {

private final Collection<T> collection;
private int remaining;

private Iterator<T> iteratorOriginal;

private RoundIterator(Collection<T> collection, int offset) {
this.collection = collection;
this.iteratorOriginal = collection.iterator();
this.remaining = collection.size();
for (int i = 0; i < offset; i++) {
this.iteratorOriginal.next();
}
}

@Override
public boolean hasNext() {
return remaining > 0;
}

@Override
public T next() {
if (remaining <= 0) throw new NoSuchElementException();

remaining--;
if (!iteratorOriginal.hasNext()) {
iteratorOriginal = collection.iterator();
}

return iteratorOriginal.next();
}
}

}
Loading
Loading