Added code for BAEL-4965: Securing SOAP services using Keycloa

This commit is contained in:
Bhaskara Navuluri
2021-10-25 15:16:58 +05:30
parent 02601754a6
commit 641a3e408a
15 changed files with 463 additions and 394 deletions

View File

@@ -0,0 +1,54 @@
package com.baeldung.keycloaksoap;
import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springsecurity.KeycloakConfiguration;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
@KeycloakConfiguration
@ConditionalOnProperty(name = "keycloak.enabled", havingValue = "true")
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class KeycloakSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
//@formatter:off
http
.csrf()
.disable()
.authorizeRequests()
.anyRequest()
.permitAll();
//@formatter:on
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
auth.authenticationProvider(keycloakAuthenticationProvider);
}
@Bean
@Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
}
@Bean
public KeycloakConfigResolver keycloakSpringBootConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
}

View File

@@ -0,0 +1,15 @@
package com.baeldung.keycloaksoap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class KeycloakSoapServicesApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(KeycloakSoapServicesApplication.class);
application.setAdditionalProfiles("keycloak");
application.run(args);
}
}

View File

@@ -0,0 +1,42 @@
package com.baeldung.keycloaksoap;
import com.baeldung.DeleteProductRequest;
import com.baeldung.DeleteProductResponse;
import com.baeldung.GetProductDetailsRequest;
import com.baeldung.GetProductDetailsResponse;
import com.baeldung.Product;
import org.springframework.ws.server.endpoint.annotation.Endpoint;
import org.springframework.ws.server.endpoint.annotation.PayloadRoot;
import org.springframework.ws.server.endpoint.annotation.RequestPayload;
import org.springframework.ws.server.endpoint.annotation.ResponsePayload;
import javax.annotation.security.RolesAllowed;
import java.util.Map;
@Endpoint
public class ProductsEndpoint {
private final Map<String, Product> productMap;
public ProductsEndpoint(Map<String, Product> productMap) {
this.productMap = productMap;
}
@RolesAllowed("user")
@PayloadRoot(namespace = "http://www.baeldung.com/springbootsoap/keycloak", localPart = "getProductDetailsRequest")
@ResponsePayload
public GetProductDetailsResponse getProductDetails(@RequestPayload GetProductDetailsRequest request) {
GetProductDetailsResponse response = new GetProductDetailsResponse();
response.setProduct(productMap.get(request.getId()));
return response;
}
@RolesAllowed("admin")
@PayloadRoot(namespace = "http://www.baeldung.com/springbootsoap/keycloak", localPart = "deleteProductRequest")
@ResponsePayload
public DeleteProductResponse deleteProduct(@RequestPayload DeleteProductRequest request) {
DeleteProductResponse response = new DeleteProductResponse();
response.setMessage("Success! Deleted the product with the id - "+request.getId());
return response;
}
}

View File

@@ -0,0 +1,75 @@
package com.baeldung.keycloaksoap;
import com.baeldung.Product;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.ws.config.annotation.EnableWs;
import org.springframework.ws.config.annotation.WsConfigurerAdapter;
import org.springframework.ws.transport.http.MessageDispatcherServlet;
import org.springframework.ws.wsdl.wsdl11.DefaultWsdl11Definition;
import org.springframework.xml.xsd.SimpleXsdSchema;
import org.springframework.xml.xsd.XsdSchema;
import java.util.HashMap;
import java.util.Map;
@EnableWs
@Configuration
public class WebServiceConfig extends WsConfigurerAdapter {
@Value("${ws.api.path:/ws/api/v1/*}")
private String webserviceApiPath;
@Value("${ws.port.type.name:ProductsPort}")
private String webservicePortTypeName;
@Value("${ws.target.namespace:http://www.baeldung.com/springbootsoap/keycloak}")
private String webserviceTargetNamespace;
@Value("${ws.location.uri:http://localhost:18080/ws/api/v1/}")
private String locationUri;
@Bean
public ServletRegistrationBean<MessageDispatcherServlet> messageDispatcherServlet(ApplicationContext applicationContext) {
MessageDispatcherServlet servlet = new MessageDispatcherServlet();
servlet.setApplicationContext(applicationContext);
servlet.setTransformWsdlLocations(true);
return new ServletRegistrationBean<>(servlet, webserviceApiPath);
}
@Bean(name = "products")
public DefaultWsdl11Definition defaultWsdl11Definition(XsdSchema productsSchema) {
DefaultWsdl11Definition wsdl11Definition = new DefaultWsdl11Definition();
wsdl11Definition.setPortTypeName(webservicePortTypeName);
wsdl11Definition.setTargetNamespace(webserviceTargetNamespace);
wsdl11Definition.setLocationUri(locationUri);
wsdl11Definition.setSchema(productsSchema);
return wsdl11Definition;
}
@Bean
public XsdSchema productsSchema() {
return new SimpleXsdSchema(new ClassPathResource("products.xsd"));
}
@Bean
public Map<String, Product> getProducts()
{
Map<String, Product> map = new HashMap<>();
Product foldsack= new Product();
foldsack.setId("1");
foldsack.setName("Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops");
foldsack.setDescription("Your perfect pack for everyday use and walks in the forest. ");
Product shirt= new Product();
shirt.setId("2");
shirt.setName("Mens Casual Premium Slim Fit T-Shirts");
shirt.setDescription("Slim-fitting style, contrast raglan long sleeve, three-button henley placket.");
map.put("1", foldsack);
map.put("2", shirt);
return map;
}
}

View File

@@ -0,0 +1,17 @@
server.port=18080
keycloak.enabled=true
keycloak.realm=baeldung-soap-services
keycloak.auth-server-url=http://localhost:8080/auth
keycloak.bearer-only=true
keycloak.credentials.secret=14da6f9e-261f-489a-9bf0-1441e4a9ddc4
keycloak.ssl-required=external
keycloak.resource=baeldung-soap-services
keycloak.use-resource-role-mappings=true
# Custom properties begin here
ws.api.path=/ws/api/v1/*
ws.port.type.name=ProductsPort
ws.target.namespace=http://www.baeldung.com/springbootsoap/keycloak
ws.location.uri=http://localhost:18080/ws/api/v1/

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://www.baeldung.com/springbootsoap/keycloak"
targetNamespace="http://www.baeldung.com/springbootsoap/keycloak" elementFormDefault="qualified">
<xs:element name="getProductDetailsRequest">
<xs:complexType>
<xs:sequence>
<xs:element name="id" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="deleteProductRequest">
<xs:complexType>
<xs:sequence>
<xs:element name="id" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="getProductDetailsResponse">
<xs:complexType>
<xs:sequence>
<xs:element name="product" type="tns:product"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="deleteProductResponse">
<xs:complexType>
<xs:sequence>
<xs:element name="message" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<!-- Define the complex object Product -->
<xs:complexType name="product">
<xs:sequence>
<xs:element name="id" type="xs:string"/>
<xs:element name="name" type="xs:string"/>
<xs:element name="description" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,156 @@
package com.baeldung.keycloaksoap;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The class contains Live/Integration tests.
* These tests expect that the Keycloak server is up and running on port 8080.
* The tests may fail without a Keycloak server.
*/
@DisplayName("Keycloak SOAP Webservice Unit Tests")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@AutoConfigureMockMvc
class KeycloakSoapIntegrationTest {
private static final Logger logger = LoggerFactory.getLogger(KeycloakSoapIntegrationTest.class);
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Value("${grant.type}")
private String grantType;
@Value("${client.id}")
private String clientId;
@Value("${client.secret}")
private String clientSecret;
@Value("${url}")
private String keycloakUrl;
/**
* Test a happy flow. Test the <i>janedoe</i> user.
* This user should be configured in Keycloak server with a role <i>user</i>
*/
@Test
@DisplayName("Get Products With Access Token")
void givenAccessToken_whenGetProducts_thenReturnProduct() {
HttpHeaders headers = new HttpHeaders();
headers.set("content-type", "text/xml");
headers.set("Authorization", "Bearer " + generateToken("janedoe", "password"));
HttpEntity<String> request = new HttpEntity<>(Utility.getGetProductDetailsRequest(), headers);
ResponseEntity<String> responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/ws/api/v1/", request, String.class);
assertThat(responseEntity).isNotNull();
assertThat(responseEntity.getStatusCodeValue()).isEqualTo(HttpStatus.OK.value());
assertThat(responseEntity.getBody()).isNotBlank();
assertThat(responseEntity.getBody()).containsIgnoringCase(":id>1</");
}
/**
* A negative test. Deliberately pass wrong credentials to Keycloak. Test the invalid <i>janeadoe</i> user.
* Keycloak returns Unauthorized. Assert 401 status and empty body.
*/
@Test
@DisplayName("Get Products With Wrong Access Token")
void givenWrongAccessToken_whenGetProducts_thenReturnError() {
HttpHeaders headers = new HttpHeaders();
headers.set("content-type", "text/xml");
headers.set("Authorization", "Bearer " + generateToken("janeadoe", "password"));
HttpEntity<String> request = new HttpEntity<>(Utility.getGetProductDetailsRequest(), headers);
ResponseEntity<String> responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/ws/api/v1/", request, String.class);
System.out.println("This is the URL --> " + "http://localhost:" + port + "/ws/api/v1/");
System.out.println("Body --> " + responseEntity.getBody());
System.out.println("Location Header --> " + responseEntity.getHeaders().get("Location"));
assertThat(responseEntity).isNotNull();
assertThat(responseEntity.getStatusCodeValue()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
assertThat(responseEntity.getBody()).isBlank();
}
/**
* Happy flow to test <i>deleteProduct</i> operation. Test the <i>jhondoe</i> user.
* This user should be configured in Keycloak server with a role <i>user</i>
*/
@Test
@DisplayName("Delete Product With Access Token")
void givenAccessToken_whenDeleteProduct_thenReturnSuccess() {
HttpHeaders headers = new HttpHeaders();
headers.set("content-type", "text/xml");
headers.set("Authorization", "Bearer " + generateToken("jhondoe", "password"));
HttpEntity<String> request = new HttpEntity<>(Utility.getDeleteProductsRequest(), headers);
ResponseEntity<String> responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/ws/api/v1/", request, String.class);
assertThat(responseEntity).isNotNull();
assertThat(responseEntity.getStatusCodeValue()).isEqualTo(HttpStatus.OK.value());
assertThat(responseEntity.getBody()).isNotBlank();
assertThat(responseEntity.getBody()).containsIgnoringCase("Deleted the product with the id");
}
/**
* Negative flow to test <i></i>. Test the <i>janedoe</i> user.
* Obtain the access token of <i>janedoe</i> and access the admin operation <i>deleteProduct</i>
* Assume <i>janedoe</i> has restricted access to <i>deleteProduct</i> operation
*/
@Test
@DisplayName("Delete Products With Unauthorized Access Token")
void givenUnauthorizedAccessToken_whenDeleteProduct_thenReturnUnauthorized() {
HttpHeaders headers = new HttpHeaders();
headers.set("content-type", "text/xml");
headers.set("Authorization", "Bearer " + generateToken("janedoe", "password"));
HttpEntity<String> request = new HttpEntity<>(Utility.getDeleteProductsRequest(), headers);
ResponseEntity<String> responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/ws/api/v1/", request, String.class);
assertThat(responseEntity).isNotNull();
assertThat(responseEntity.getStatusCodeValue()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value());
assertThat(responseEntity.getBody()).isNotBlank();
assertThat(responseEntity.getBody()).containsIgnoringCase("Access is denied");
}
private String generateToken(String username, String password) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("grant_type", grantType);
map.add("client_id", clientId);
map.add("client_secret", clientSecret);
map.add("username", username);
map.add("password", password);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(map, headers);
ResponseEntity<String> response = restTemplate.exchange(keycloakUrl, HttpMethod.POST, entity, String.class);
return Objects.requireNonNull(response.getBody()).contains("access_token") ? objectMapper.readTree(response.getBody()).get("access_token").asText() : "";
} catch (Exception ex) {
logger.error("There is an internal server error. Returning an empty access token", ex);
return "";
}
}
}

View File

@@ -0,0 +1,12 @@
package com.baeldung.keycloaksoap;
public class Utility {
public static String getGetProductDetailsRequest() {
return "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:key=\"http://www.baeldung.com/springbootsoap/keycloak\">\n" + " <soapenv:Header/>\n" + " <soapenv:Body>\n" + " <key:getProductDetailsRequest>\n"
+ " <key:id>1</key:id>\n" + " </key:getProductDetailsRequest>\n" + " </soapenv:Body>\n" + "</soapenv:Envelope>";
}
public static String getDeleteProductsRequest() {
return "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:key=\"http://www.baeldung.com/springbootsoap/keycloak\">\n" + " <soapenv:Header/>\n" + " <soapenv:Body>\n" + " <key:deleteProductRequest>\n"
+ " <key:id>1</key:id>\n" + " </key:deleteProductRequest>\n" + " </soapenv:Body>\n" + "</soapenv:Envelope>";
}
}

View File

@@ -0,0 +1,4 @@
grant.type=password
client.id=baeldung-soap-services
client.secret=d2ba7af8-f7d2-4c97-b4a5-3c88b59920ae
url=http://localhost:8080/auth/realms/baeldung-soap-services/protocol/openid-connect/token