Skip to content

Commit

Permalink
feat: Cloud Gateway SAF IDT auth scheme (#3234)
Browse files Browse the repository at this point in the history
* chore: move babel to dev, modify webpack config, spring security

Signed-off-by: achmelo <[email protected]>

* read keys from common httpsconfig

Signed-off-by: achmelo <[email protected]>

* New ZAAS safIdt endpoint to generate SAF ID tokens for authenticated user.

Signed-off-by: Petr Weinfurt <[email protected]>

* IT tests for safIdt endpoint.

Signed-off-by: Petr Weinfurt <[email protected]>

* initial safidt filter impl

Signed-off-by: achmelo <[email protected]>

* Address code review comments.

Signed-off-by: Petr Weinfurt <[email protected]>

* Remove unused class

Signed-off-by: Petr Weinfurt <[email protected]>

* Fix tests

Signed-off-by: Petr Weinfurt <[email protected]>

* Fix IT tests

Signed-off-by: Petr Weinfurt <[email protected]>

* Fix unit tests

Signed-off-by: Petr Weinfurt <[email protected]>

* Add Controller Advice to handle exceptions.

Signed-off-by: Petr Weinfurt <[email protected]>

* refactor, unit tests

Signed-off-by: achmelo <[email protected]>

* Handle more exceptions.

Signed-off-by: Petr Weinfurt <[email protected]>

* safidt service in test

Signed-off-by: achmelo <[email protected]>

* Add content type and body to negative test.

Signed-off-by: Petr Weinfurt <[email protected]>

* Add content type and body to negative test.

Signed-off-by: Petr Weinfurt <[email protected]>

* Add content type and body to negative test.

Signed-off-by: Petr Weinfurt <[email protected]>

* Add negative tests with valid Okta token and no mapping.

Signed-off-by: Petr Weinfurt <[email protected]>

* Fix Rest assured RequestSpec preparation.

Signed-off-by: Petr Weinfurt <[email protected]>

* Checkout the main branch before Sonar scan to resolve issue 'Could not find ref 'v2.x.x' in refs/heads, refs/remotes/upstream or refs/remotes/origin.'

Signed-off-by: Petr Weinfurt <[email protected]>

* Fetch the main branch before Sonar scan to resolve issue 'Could not find ref 'v2.x.x' in refs/heads, refs/remotes/upstream or refs/remotes/origin.'

Signed-off-by: Petr Weinfurt <[email protected]>

* Replace deprecated sonar.login property

Signed-off-by: Petr Weinfurt <[email protected]>

* Replace deprecated sonar.login property

Signed-off-by: Petr Weinfurt <[email protected]>

* remove git fetch

Signed-off-by: Petr Weinfurt <[email protected]>

* Fetch depth 0

Signed-off-by: Petr Weinfurt <[email protected]>

* Fetch depth 0

Signed-off-by: Petr Weinfurt <[email protected]>

* Add SAF IDT request to negative IT tests

Signed-off-by: Petr Weinfurt <[email protected]>

* Handle TokenNotValid and TokenExpired exception with 401 response.

Signed-off-by: Petr Weinfurt <[email protected]>

* Handle TokenNotValid and TokenExpired exception with 401 response.

Signed-off-by: Petr Weinfurt <[email protected]>

* remove duplicated code

Signed-off-by: achmelo <[email protected]>

* delete unused imports

Signed-off-by: achmelo <[email protected]>

---------

Signed-off-by: achmelo <[email protected]>
Signed-off-by: Petr Weinfurt <[email protected]>
Co-authored-by: Petr Weinfurt <[email protected]>
  • Loading branch information
achmelo and weinfurt authored Dec 8, 2023
1 parent 2c172f9 commit 8d397b3
Show file tree
Hide file tree
Showing 9 changed files with 336 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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 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.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.zowe.apiml.cloudgatewayservice.service.InstanceInfoService;
import org.zowe.apiml.message.core.MessageService;
import org.zowe.apiml.ticket.TicketRequest;

@Service
public abstract class AbstractRequestBodyAuthSchemeFactory<R> extends AbstractAuthSchemeFactory<AbstractRequestBodyAuthSchemeFactory.Config, R, String> {
private static final ObjectWriter WRITER = new ObjectMapper().writer();

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

public abstract String getEndpointUrl(ServiceInstance instance);

@Override
protected WebClient.RequestHeadersSpec<?> createRequest(ServiceInstance instance, String requestBody) {
String url = getEndpointUrl(instance);
return webClient.post()
.uri(url).header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.bodyValue(requestBody);
}

@Override
public GatewayFilter apply(Config config) {
try {
return createGatewayFilter(config, WRITER.writeValueAsString(new TicketRequest(config.getApplicationName())));
} catch (JsonProcessingException e) {
return ((exchange, chain) -> {
ServerHttpRequest request = updateHeadersForError(exchange, e.getMessage());
return chain.filter(exchange.mutate().request(request).build());
});
}
}

@Data
@EqualsAndHashCode(callSuper = true)
public static class Config extends AbstractAuthSchemeFactory.AbstractConfig {

private String applicationName;

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,29 @@

package org.zowe.apiml.cloudgatewayservice.filters;

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.beans.factory.annotation.Qualifier;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.MediaType;
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.message.core.MessageService;
import org.zowe.apiml.ticket.TicketRequest;
import org.zowe.apiml.ticket.TicketResponse;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

@Service
public class PassticketFilterFactory extends AbstractAuthSchemeFactory<PassticketFilterFactory.Config, TicketResponse, String> {
public class PassticketFilterFactory extends AbstractRequestBodyAuthSchemeFactory<TicketResponse> {

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

public PassticketFilterFactory(@Qualifier("webClientClientCert") WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) {
super(Config.class, webClient, instanceInfoService, messageService);
super(webClient, instanceInfoService, messageService);
}

@Override
Expand All @@ -55,11 +46,8 @@ protected TicketResponse getResponseFor401() {
}

@Override
protected WebClient.RequestHeadersSpec<?> createRequest(ServiceInstance instance, String requestBody) {
return webClient.post()
.uri(String.format(TICKET_URL, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase()))
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.bodyValue(requestBody);
public String getEndpointUrl(ServiceInstance instance) {
return String.format(TICKET_URL, instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase());
}

@Override
Expand All @@ -78,23 +66,4 @@ protected Mono<Void> processResponse(ServerWebExchange exchange, GatewayFilterCh
return chain.filter(exchange);
}

@Override
public GatewayFilter apply(Config config) {
try {
return createGatewayFilter(config, WRITER.writeValueAsString(new TicketRequest(config.getApplicationName())));
} catch (JsonProcessingException e) {
return ((exchange, chain) -> {
ServerHttpRequest request = updateHeadersForError(exchange, e.getMessage());
return chain.filter(exchange.mutate().request(request).build());
});
}
}

@Data
@EqualsAndHashCode(callSuper = true)
public static class Config extends AbstractAuthSchemeFactory.AbstractConfig {

private String applicationName;

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
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.zaas.ZaasTokenResponse;
import reactor.core.publisher.Mono;

@Service
public class SafIdtFilterFactory extends AbstractRequestBodyAuthSchemeFactory<ZaasTokenResponse> {

public SafIdtFilterFactory(@Qualifier("webClientClientCert") WebClient webClient, InstanceInfoService instanceInfoService, MessageService messageService) {
super(webClient, instanceInfoService, messageService);
}

@Override
public String getEndpointUrl(ServiceInstance instance) {
return String.format("%s://%s:%d/%s/zaas/safIdt", instance.getScheme(), instance.getHost(), instance.getPort(), instance.getServiceId().toLowerCase());
}

@Override
protected Mono<Void> processResponse(ServerWebExchange exchange, GatewayFilterChain chain, ZaasTokenResponse response) {
ServerHttpRequest request;
if (response.getToken() != null) {
request = exchange.getRequest().mutate().headers(headers ->
headers.add(ApimlConstants.SAF_TOKEN_HEADER, response.getToken())
).build();
} else {
request = updateHeadersForError(exchange, "Invalid or missing authentication");
}

exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
}

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

@Override
protected ZaasTokenResponse getResponseFor401() {
return new ZaasTokenResponse();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.service.scheme;

import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.gateway.filter.FilterDefinition;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.stereotype.Component;
import org.zowe.apiml.auth.Authentication;
import org.zowe.apiml.auth.AuthenticationScheme;

@Component
public class SafIdt implements SchemeHandler {

@Override
public AuthenticationScheme getAuthenticationScheme() {
return AuthenticationScheme.SAF_IDT;
}

@Override
public void apply(ServiceInstance serviceInstance, RouteDefinition routeDefinition, Authentication auth) {
FilterDefinition filterDef = new FilterDefinition();
filterDef.setName("SafIdtFilterFactory");
filterDef.addArg("applicationName", auth.getApplid());
filterDef.addArg("serviceId", StringUtils.lowerCase(serviceInstance.getServiceId()));
routeDefinition.getFilters().add(filterDef);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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.acceptance;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.sun.net.httpserver.HttpExchange;
import org.apache.commons.io.IOUtils;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.http.HttpHeaders;
import org.zowe.apiml.auth.AuthenticationScheme;
import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTest;
import org.zowe.apiml.cloudgatewayservice.acceptance.common.AcceptanceTestWithMockServices;
import org.zowe.apiml.cloudgatewayservice.acceptance.common.MockService;
import org.zowe.apiml.constants.ApimlConstants;
import org.zowe.apiml.ticket.TicketRequest;
import org.zowe.apiml.zaas.ZaasTokenResponse;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

import static io.restassured.RestAssured.given;
import static org.apache.http.HttpStatus.SC_OK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class SafIdtSchemeTest {

private static final String SERVICE_ID = "service";
private static final String SAF_IDT = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJVU0VSIiwiZXhwIjoxNzAxMjc2NTUyfQ.";
private static final ObjectWriter WRITER = new ObjectMapper().writer();


private String getHeaderValue(HttpExchange httpExchange, String headerName) {
List<String> headerValue = Optional.ofNullable(httpExchange.getRequestHeaders().get(headerName))
.orElse(Collections.emptyList());
assertTrue(headerValue.size() <= 1);
return headerValue.isEmpty() ? null : headerValue.get(0);
}

@Nested
@AcceptanceTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GivenValidAuth extends AcceptanceTestWithMockServices {
MockService zaas;
MockService service;

@BeforeEach
void setup() throws IOException {
ZaasTokenResponse response = new ZaasTokenResponse();
response.setToken(SAF_IDT);

zaas = mockService("gateway").scope(MockService.Scope.TEST)
.addEndpoint("/gateway/zaas/safIdt")
.responseCode(200)
.assertion(he -> assertEquals("Bearer userJwt", he.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION)))
.assertion(he -> {
try {
assertEquals(WRITER.writeValueAsString(new TicketRequest("IZUDFLT")), IOUtils.toString(he.getRequestBody(), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.bodyJson(response)
.and().start();
service = mockService("service").scope(MockService.Scope.TEST)
.authenticationScheme(AuthenticationScheme.SAF_IDT).applid("IZUDFLT")
.addEndpoint("/service/test")
.assertion(he -> assertEquals(SAF_IDT, getHeaderValue(he, "x-saf-token")))
.and().start();
}

@Test
void thenReturnSAFIDtoken() {
given()
.header(HttpHeaders.AUTHORIZATION, "Bearer userJwt")
.when()
.get(basePath + "/" + SERVICE_ID + "/api/v1/test")
.then()
.statusCode(Matchers.is(SC_OK));
}
}

@Nested
@AcceptanceTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GivenNoAuth extends AcceptanceTestWithMockServices {
MockService zaas;
MockService service;

@BeforeEach
void setup() throws IOException {

zaas = mockService("gateway").scope(MockService.Scope.CLASS)
.addEndpoint("/gateway/zaas/safIdt")
.responseCode(401)
.assertion(he -> assertNull(he.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION)))
.and().start();
service = mockService("service").scope(MockService.Scope.CLASS)
.authenticationScheme(AuthenticationScheme.SAF_IDT).applid("IZUDFLT")
.addEndpoint("/service/test")
.assertion(he -> assertNull(getHeaderValue(he, "x-saf-token")))
.assertion(he -> assertEquals("Invalid or missing authentication", getHeaderValue(he, ApimlConstants.AUTH_FAIL_HEADER)))
.and().start();
}

@Test
void thenReturnError() {

given()
.when()
.get(basePath + "/" + SERVICE_ID + "/api/v1/test")
.then()
.statusCode(Matchers.is(SC_OK));
}
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ private ApimlConstants() {
public static final String PAT_HEADER_NAME = "PRIVATE-TOKEN";
public static final String AUTH_FAIL_HEADER = "X-Zowe-Auth-Failure";
public static final String HTTP_CLIENT_USE_CLIENT_CERTIFICATE = "apiml.useClientCert";
public static final String SAF_TOKEN_HEADER = "X-SAF-Token";

}
Loading

0 comments on commit 8d397b3

Please sign in to comment.