ChemAxon Synergy integration workshop

Table of Contents

1. Prerequisites

Optionally check these to verify settings:

java -version
java version "1.8.x"
Java(TM) SE Runtime Environment (build 1.8.x)
Java HotSpot(TM) 64-Bit Server VM ...
spring --version
Spring CLI v1.5.3.RELEASE
git --version
git version 2.9.0.
heroku --version
heroku-cli/5.9.1-3d5ebd1 ... go1.7.5

2. Generate a Spring Boot app

2.1 Create a Spring Boot application

spring init --package-name=com.example --dependencies=web,actuator --boot-version=1.5.6.RELEASE example-synergy-app
cd example-synergy-app
./mvnw spring-boot:run

2.2 Try to open health check page

Open http://localhost:8080/health

3. Add a simple endpoint

3.1 Open the generated Maven project in your favourite IDE.

3.2 Create a welcome service

Add this class to package com.example:

package com.example;
 
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
public class HomeController {
 
@RequestMapping("/")
@ResponseBody
Object home() {
return "Hello Synergy!";
}
}

3.3 Run/restart the application

./mvnw spring-boot:run

3.4 Check welcome service in a browser

Open http://localhost:8080/

4. App info endpoint

Our application’s app-info endpoint should look like this:

{
"displayName": "Example Synergy app",
"address": "http://localhost:8080/",
"identities": [
{ "category": "service", "type": "computation" },
{ "category": "application", "type": "service" }
],
"features": [
{
"namespace": "synergy/health",
"attributes": {
"url": "http://localhost:8080/health"
}
},
{
"namespace": "synergy/icon",
"attributes": {
"url": "https://www.gravatar.com/avatar/457372368cbaf7fce1427ce46fc4b199?s=64&d=identicon"
}
},
{
"namespace": "producer-service",
"attributes": {
"url": "http://localhost:8080/produce"
}
}
]
}

We are defining here a service called producer-service, that is a sample for a custom service provided by your application, we will implement it later.

4.1 Create class AppInfoController

package com.example;
 
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
 
import javax.servlet.http.HttpServletRequest;
import javax.xml.bind.DatatypeConverter;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
 
@Controller
public class AppInfoController {
 
private static final Logger LOG = LoggerFactory.getLogger(AppInfoController.class);
 
@Value("${producer.name:#{null}}")
private String producerName;
 
@RequestMapping("/app-info")
@ResponseBody
Object appInfo(final HttpServletRequest request) throws Exception {
Supplier<UriComponentsBuilder> uriBuilder =
() -> ServletUriComponentsBuilder.fromContextPath(request) ;
 
// TODO: give the application some nice name
String applicationDisplayName = "Example Synergy app";
 
Map<String, Object> info = new LinkedHashMap<>();
info.put("displayName", applicationDisplayName);
String address = uriBuilder.get().replacePath("/").toUriString();
info.put("address", address);
 
info.put("identities", Arrays.asList(
identity("service", "computation"),
identity("application", "service")));
 
// generate a unique hash for the application
String hash = DatatypeConverter.printHexBinary(
MessageDigest.getInstance("md5").digest(address.getBytes())).toLowerCase();
 
// TODO: give the producer service some custom unique name
String producerServiceName = Optional.ofNullable(producerName).orElse(hash + "-producer");
 
info.put("features", Arrays.asList(
feature("synergy/health",
uriBuilder.get().replacePath("/health").toUriString()),
feature("synergy/icon",
String.format("https://www.gravatar.com/avatar/%s?s=64&d=identicon", hash)),
feature(producerServiceName,
uriBuilder.get().replacePath("/produce").toUriString())));
 
return info;
}
 
/** Helper methods for assembling app-info json **/
 
private static Map<String, Object> identity(final String category, final String type) {
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("category", category);
identity.put("type", type);
return identity;
}
 
private static Map<String, Object> feature(final String namespace, final String url) {
Map<String, Object> feature = new LinkedHashMap<>();
feature.put("namespace", namespace);
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("url", url);
feature.put("attributes", attributes);
return feature;
}
 
}

4.2 Run/restart the application

./mvnw spring-boot:run

4.3 Check application info in a browser

Open http://localhost:8080/app-info

5. Deploy to Heroku

5.1 Create a Git repo and commit your sources

git init
git add .
git commit -m "first commit"

5.2 Create Heroku app and deploy the application

heroku login
heroku create
git push heroku master
heroku open

6. Register app into Synergy

If you send us your deployment’s application info URL, we’ll register it for you!

7. Implement authentication

images/download/attachments/53980179/synergy-sso-direct.png

7.1 Add security dependencies

Add new dependencies in your pom.xml:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>

7.2 Enable Oauth2 SSO for Spring Boot app

It can be done by adding @EnableOAuth2Sso annotation.

Security must be disabled on health and app-info endpoints.

Create class SynergySecurityConfiguration, extend WebSecurityConfigurerAdapter and override configure method:

package com.example;
 
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 
@Configuration
@EnableOAuth2Sso
public class SynergySecurityConfiguration extends WebSecurityConfigurerAdapter {
 
@Override
public void configure(final WebSecurity web) throws Exception {
web.ignoring().antMatchers(
"/app-info",
"/health");
}
}

7.3 Set configuration required by OAuth2

Rename empty config file application.properties to application.yml.
Add these to application.yml:

security:
oauth2:
client:
clientId: [your app's client id]
clientSecret: [your app's client secret]
accessTokenUri: https://team1.synergy-dev.cxcloud.io/oauth/token
userAuthorizationUri: https://team1.synergy-dev.cxcloud.io/oauth/authorize
resource:
jwt.key-uri: https://team1.synergy-dev.cxcloud.io/public/publickey/ssh-rsa.json

Ask for your client id and client secret!

7.4 Commit & Deploy

git add .
git commit -m "configure authentication"
git push heroku master

7.5 Open your app directly on Heroku

heroku open

There should be a redirect to authenticate you when opening the application. (You my not nitice it, when already logged in to Synergy.)

8. Print user info

8.1 Extract custom token data

Create class SynergyAccessTokenConfigurer and implement JwtAccessTokenConverterConfigurer:

package com.example;
 
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
 
import org.springframework.boot.autoconfigure.security.oauth2.resource.JwtAccessTokenConverterConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.DefaultUserAuthenticationConverter;
import org.springframework.security.oauth2.provider.token.UserAuthenticationConverter;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
 
@Configuration
public class SynergyAccessTokenConfigurer implements JwtAccessTokenConverterConfigurer {
 
@Bean
UserAuthenticationConverter userAuthenticationConverter() {
return new DefaultUserAuthenticationConverter() {
@Override
public Authentication extractAuthentication(final Map<String, ?> token) {
Collection<? extends GrantedAuthority> authorities = Collections.emptyList();
// Use whole token as user principal
return new UsernamePasswordAuthenticationToken(token, "N/A", authorities);
}
};
}
 
@Override
public void configure(final JwtAccessTokenConverter converter) {
DefaultAccessTokenConverter accessTokenConverter = new DefaultAccessTokenConverter();
accessTokenConverter.setUserTokenConverter(userAuthenticationConverter());
converter.setAccessTokenConverter(accessTokenConverter);
}
 
}

8.2 Return token data in welcome service

Return the user principal in the welcome service, modify HomeController:

package com.example;
 
import java.util.LinkedHashMap;
import java.util.Map;
 
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
public class HomeController {
 
@RequestMapping("/")
@ResponseBody
Object home() {
Map<String, Object> result = new LinkedHashMap<>();
// return user principal as a json element
result.put("user", SecurityContextHolder.getContext().getAuthentication().getPrincipal());
return result;
}
}

Modified lines are highlighted.

8.3 Commit & Deploy

git add .
git commit -m "print userinfo"
git push heroku master

You should see the Synergy token in json format. (Contains information about the user who is logged in.)

9. Implement authentication of rest endpoints

Public rest enpoints must understand Synergy tokens sent in a http header.

9.1 Create resource server configuration

Create new class ResourceServerConfiguration:

package com.example;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
 
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
 
@Bean("resourceServerRequestMatcher")
public RequestMatcher resources() {
return new RequestHeaderRequestMatcher("Authorization");
}
 
@Override
public void configure(final HttpSecurity http) throws Exception {
http
.requestMatcher(resources()).authorizeRequests()
.anyRequest().authenticated();
}
 
@Override
public void configure(final ResourceServerSecurityConfigurer resources) throws Exception {
// we acccept tokens for any resourceId
resources.resourceId(null);
}
 
}

Notice here we have created a RequestMatcher to separate API calls. For now it is matched when request contains an Authorization header (which should contain the token).

9.2 Modify SynergySecurityConfiguration

package com.example;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
 
@Configuration
@EnableOAuth2Sso
public class SynergySecurityConfiguration extends WebSecurityConfigurerAdapter {
 
@Autowired
@Qualifier("resourceServerRequestMatcher")
private RequestMatcher resources;
 
@Override
protected void configure(final HttpSecurity http) throws Exception {
RequestMatcher nonResoures = new NegatedRequestMatcher(resources);
http
.requestMatcher(nonResoures).authorizeRequests()
.anyRequest().authenticated();
}
 
@Override
public void configure(final WebSecurity web) throws Exception {
web.ignoring()
.antMatchers(
"/app-info",
"/health");
}
 
}

Now SSO is only configured for requests which does not handled by resource server configuration.

10. Demonstrate application to application communication

10.1 Create ServiceController and implement a producer service

package com.example;
 
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Random;
 
import javax.servlet.http.HttpServletRequest;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
public class ServiceController {
 
private static final Logger LOG = LoggerFactory.getLogger(ServiceController.class);
 
@RequestMapping("/produce")
@ResponseBody
Object produce(final HttpServletRequest request) throws Exception {
LOG.info("produce called by: {}", SecurityContextHolder.getContext().getAuthentication());
 
Map<String, Object> result = new LinkedHashMap<>();
// TODO: add your custom data or computation to the result
result.put("data", new Random().nextInt());
result.put("producedBy", request.getRequestURL());
return result;
}
}

10.2 Create helper class for obtaining token for your application

package com.example;
 
import java.util.Arrays;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.token.DefaultAccessTokenRequest;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsAccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.stereotype.Component;
 
@Component
public class ClientCredentialsTokenProvider {
 
private final ClientCredentialsAccessTokenProvider tokenProvider = new ClientCredentialsAccessTokenProvider();
 
private final String accessTokenUri;
private final String clientId;
private final String clientSecret;
 
@Autowired
public ClientCredentialsTokenProvider(final OAuth2ProtectedResourceDetails resourceDetails) {
this(
resourceDetails.getAccessTokenUri(),
resourceDetails.getClientId(),
resourceDetails.getClientSecret());
}
 
public ClientCredentialsTokenProvider(
final String accessTokenUri,
final String clientId,
final String clientSecret) {
this.accessTokenUri = accessTokenUri;
this.clientId = clientId;
this.clientSecret = clientSecret;
}
 
public OAuth2AccessToken getToken(final String... scopes) {
ClientCredentialsResourceDetails resourceDetails = new ClientCredentialsResourceDetails();
resourceDetails.setAccessTokenUri(accessTokenUri);
resourceDetails.setClientId(clientId);
resourceDetails.setClientSecret(clientSecret);
resourceDetails.setScope(Arrays.asList(scopes));
return tokenProvider.obtainAccessToken(resourceDetails, new DefaultAccessTokenRequest());
}
}

10.3 Create RestTemplateConfiguration

It configures a rest service client to forward the current OAuth2 token in Authorization header of each request.

package com.example;
 
import java.util.Optional;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;
import org.springframework.web.client.RestTemplate;
 
@Configuration
public class RestTemplateConfiguration {
 
@Autowired
private ClientCredentialsTokenProvider tokenProvider;
 
@Bean
public RestTemplate restTemplate() {
RestTemplate template = new RestTemplate();
template.getInterceptors().add((request, body, execution) -> {
final String token;
Optional<OAuth2AuthenticationDetails> currentOAuth2Details = currentOAuth2Details();
if (currentOAuth2Details.isPresent()) {
// get token from current security context
token = currentOAuth2Details.get().getTokenValue();
} else {
// request a new token as an application (server-to-server communication)
token = tokenProvider.getToken("read").getValue();
}
 
// forward token in Authorization header
request.getHeaders().add("Authorization", "Bearer " + token);
 
return execution.execute(request, body);
});
return template;
}
 
private static Optional<OAuth2AuthenticationDetails> currentOAuth2Details() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(OAuth2Authentication.class::isInstance)
.map(OAuth2Authentication.class::cast)
.map(OAuth2Authentication::getDetails)
.map(OAuth2AuthenticationDetails.class::cast);
}
 
}

10.4 Create DiscoveryClient

It’s a client for Synergy service discovery.

package com.example;
 
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.StreamSupport;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
 
import com.fasterxml.jackson.databind.JsonNode;
 
@Component
public class DiscoveryClient {
 
@Autowired
private RestTemplate restTemplate;
 
@Value("${synergy.url}/api/discover")
private String discoveryUrl;
 
public String getFeatureUrl(final String namespace) {
Map<String, String> all = findFeature(namespace);
if (all.isEmpty()) {
throw new IllegalArgumentException("No applications found with feature " + namespace);
}
if (all.size() > 1) {
throw new IllegalArgumentException("Mutiple applications found with feature " + namespace + ": " + all.keySet());
}
return all.entrySet().iterator().next().getValue();
}
 
private Map<String, String> findFeature(final String namespace) {
Map<String, String> result = new LinkedHashMap<>();
for (JsonNode application : restTemplate.getForObject(discoveryUrl, JsonNode.class)) {
Optional<String> featureUrl = StreamSupport.stream(application.get("features").spliterator(), false)
.filter(feature -> feature.get("namespace").asText().equals(namespace))
.map(feature -> feature.get("attributes").get("url").asText())
.findFirst();
if (featureUrl.isPresent()) {
result.put(application.get("displayName").asText(), featureUrl.get());
}
}
return result;
}
}

10.5 Create App2AppCommunication

It contains an 2 endpoint for consuming another application’s producer service:

  • one using the token of the logged in user /consume

  • one is requesting a new token in the name of your application (server-to-server communication) /consume-as-app

package com.example;
 
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.FutureTask;
 
import javax.servlet.http.HttpServletRequest;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
 
import com.fasterxml.jackson.databind.JsonNode;
 
@RestController
public class App2AppCommunication {
 
private static final Logger LOG = LoggerFactory.getLogger(App2AppCommunication.class);
 
@Autowired
private RestTemplate template;
 
@Autowired
private DiscoveryClient discovery;
 
@RequestMapping("/consume/{namespace}")
public Object consumeAsUser(@PathVariable("namespace") final String namespace, final HttpServletRequest request) throws Exception {
LOG.info("consumeAsUser called by: {}", SecurityContextHolder.getContext().getAuthentication().getPrincipal());
return consume(namespace, request);
}
 
@RequestMapping("/consume-as-app/{namespace}")
public Object consumeAsApp(@PathVariable("namespace") final String namespace, final HttpServletRequest request) throws Exception {
LOG.info("consumeAsApp called by: {}", SecurityContextHolder.getContext().getAuthentication().getPrincipal());
// call consume on other thread (without security context)
FutureTask<Object> task = new FutureTask<>(() -> consume(namespace, request));
new Thread(task).start();
return task.get();
}
 
private Object consume(final String namespace, final HttpServletRequest request) throws Exception {
// discover remote service
String remoteServiceUrl = discovery.getFeatureUrl(namespace);
 
// call remote service
JsonNode producerResult
= template.getForObject(remoteServiceUrl, JsonNode.class);
 
Map<String, Object> result = new LinkedHashMap<>();
result.put("producerResult", producerResult);
result.put("consumedBy", request.getRequestURL());
return result;
}
 
}

10.6 Configure discovery service

heroku config:set synergy.url=https://team1.synergy-dev.cxcloud.io

10.7 Commit & Deploy

git add .
git commit -m "app2app communication"
git push heroku master

10.8 Open your app directly on Heroku, ask for name of another service (or use yours)

heroku open /consume/{name-of-another-service}

heroku open /consume-as-app/{name-of-another-service}

10.9 Display application logs to check communication

heroku logs -t