Flowable OAuth2 Resource Server
Flowable provides a set of Spring Boot starters to help you embed the different engines (i.e., BPMN, DMN and CMMN) and to expose their RESTful APIs.
In this post, I'll walk you through the steps I followed to create a RESTful API (Resource Server) that embeds Flowable's BPMN engine, exposes the BPMN engine's RESTful API and leverages Spring Security’s support for OAuth 2.0 and Jason Web Tokens (JWTs).
Spring Boot
Getting Started
According to the Spring Boot Getting Started guide you should use the Spring Initializr to bootstrap your application:
I added the following dependencies: Spring Boot Starter Security, Spring Boot Starter Web, Spring Security OAuth2 Resource Server, Spring Security OAuth2 JOSE (Javascript Object Signing and Encryption), Spring Security Config, Spring Boot Starter HAETOAS, Evo Inflector, Spring Boot Starter Data Rest, Spring Boot Starter Data JPA, H2 Database and Lombok.
Flowable
In order to embed Flowable's BPMN engine and expose the BPMN engine's RESTful API we need to update the project's pom.xml:
<dependencies>
...
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter-app</artifactId>
<version>${flowable.version}</version>
</dependency>
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-configurator</artifactId>
<version>${flowable.version}</version>
</dependency>
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter-process-rest</artifactId>
<version>${flowable.version}</version>
</dependency>
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-form-spring-configurator</artifactId>
<version>${flowable.version}</version>
</dependency>
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-ldap</artifactId>
<version>${flowable.version}</version>
</dependency>
<!--
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter-rest</artifactId>
<version>${flowable.version}</version>
</dependency>
-->
</dependencies>
By default, certain folders on the classpath are automatically scanned:
- /apps: Looks for all files ending with .bar and deploys them
- /cases: Looks for all files ending with .cmmn, .cmmn11, .cmmn.xml or .cmmn11.xml and deploys them
- /dmn: Looks for all files ending with .dmn, .dmn11, .dmn.xml or .dmn12.xml and deploys them
- /forms: Looks for all files ending with .form and deploys them
- /processes: Looks for all files ending with .bpmn20.xml or .bpmn and deploys them
For example:
├── /src
└── /main
└── /java
└── /resources
└── /apps
├── hr-app.bar
└── /processes
├── application.properties
└── /test
Spring Security
I extended Spring Security's WebSecurityConfigurerAdapter:
package org.serendipity.restapi.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.Collections;
@EnableWebSecurity
@Profile({"dev", "test", "prod"})
@Slf4j
public class DefaultSecurityConfig extends WebSecurityConfigurerAdapter {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and()
.authorizeRequests()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.antMatchers("/h2-console/**").permitAll()
.antMatchers("/docs/**").permitAll()
.anyRequest().authenticated();
http.csrf().ignoringAntMatchers("/h2-console/**");
http.headers().frameOptions().sameOrigin();
http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
}
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.applyPermitDefaultValues();
configuration.setAllowedOrigins(Collections.singletonList("*"));
configuration.setAllowedMethods(Arrays.asList("POST", "GET", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
I updated the application.properties:
spring.profiles.active=@spring.profiles.active@
spring.main.banner-mode=off
spring.jpa.open-in-view=false
server.port=3001
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:10001/auth/realms/development/protocol/openid-connect/certs
spring.data.rest.base-path=/api
# Logging
logging.level.root=INFO
logging.level.org.flowable=WARN
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.springframework.security=WARN
To point to my Authorization Server's JWK Set Uri.
For example:
curl http://localhost:10001/auth/realms/development/protocol/openid-connect/certs
Sample output:
{
"keys": [
{
"kid": "5E-jGIlDdpgjnhJzbdS3XXeZWZCmRUB85Snfp5IwyjI",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "t3609odH5dIcPZUd4yFTjilR89MigdbgwnzFII82hzeazIsZivoSVWTfWG4620-zYN5PyFPKsaW_0IF7MPCeDXgRZ0odiizRQdhczZQA-zVrlqxy93SqRpjqRd_F3bOmwQZAsdepQefGNrTIArpq62s5ycUQTf-qbzsnCQugb5_SRa7u1VJaBgb0jTM-L304TSiW1vUFg2Th4fyQqVL4xrJuBPrrJCBs9nx9GPJDD8fXZtDMLlXRYhzZjW8zfqHSyA46JCDbK4-Tt6Ra6dNNKz8n2leSbUcf9NgBsiGHn23SnkM5GzbKbI-i_oQTvueP2psZzm8_Oyi3KYLtjNwBWw",
"e": "AQAB",
"x5c": [
"MIICrTCCAZUCBgFvIWzNBjANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9TZXJlbmRpcGl0eSBDRVAwHhcNMTkxMjIwMDM0NzU2WhcNMjkxMjIwMDM0OTM2WjAaMRgwFgYDVQQDDA9TZXJlbmRpcGl0eSBDRVAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3frT2h0fl0hw9lR3jIVOOKVHz0yKB1uDCfMUgjzaHN5rMixmK+hJVZN9YbjrbT7Ng3k/IU8qxpb/QgXsw8J4NeBFnSh2KLNFB2FzNlAD7NWuWrHL3dKpGmOpF38Xds6bBBkCx16lB58Y2tMgCumrraznJxRBN/6pvOycJC6Bvn9JFru7VUloGBvSNMz4vfThNKJbW9QWDZOHh/JCpUvjGsm4E+uskIGz2fH0Y8kMPx9dm0MwuVdFiHNmNbzN+odLIDjokINsrj5O3pFrp000rPyfaV5JtRx/02AGyIYefbdKeQzkbNspsj6L+hBO+54/amxnObz87KLcpgu2M3AFbAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFVnIzwaPDVTvQ3q1QRczCBG348SqpDV1rrh5omMTwiqDI6uVWqgz7ij4I6XkS2w3B+eccxecE5cio0ZkRoxT1Ft4gZt5rzOIVch0nGIJp7NgTd5OKXE3BfJ0hcji+QPMosB7VKBWC82zu/n13DLoNzVn0qPUvh/1cvg/3cjDE5WKqPzaJleoRSdQhATMo5PyyqRkNVaqObuFWJffrErJNRFfeFXUFQvstSxcOmdKkuGZcMlLKbfug5qfAHK+0xCL/nOCMZizEhnAZqA7qzXqaM7NMUg48XN6x0z1Vxz5IDDfpVlN9TbDnNiSGiPJLENY5rm31kDjm9iHp4FrHF8FzI="
],
"x5t": "bHB0yas9UeCu3AzfcE0Uu8omPhY",
"x5t#S256": "otHl46Z-oZbRfHnTezn5gtr1rjHUhmWTvGYV3g_q8lc"
}
]
}
Now we can build the application:
mvn clean
mvn package
And launch it:
java -jar target/serendipity-rest-api-core-0.0.1-SNAPSHOT.jar
We can use Postman to obtain an access token from Keycloak:
We can also use Postman to test the application's RESTful API:
package org.serendipity.restapi.controller;
import lombok.extern.slf4j.Slf4j;
import org.serendipity.restapi.assembler.IndividualModelAssembler;
import org.serendipity.restapi.entity.Individual;
import org.serendipity.restapi.model.IndividualModel;
import org.serendipity.restapi.repository.IndividualRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.rest.webmvc.BasePathAwareController;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.PagedModel;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
@BasePathAwareController
@Slf4j
public class IndividualController extends Controller<Individual, IndividualRepository, IndividualModelAssembler> {
public IndividualController(IndividualRepository repository,
IndividualModelAssembler assembler,
PagedResourcesAssembler<Individual> pagedResourcesAssembler) {
super(repository, assembler, pagedResourcesAssembler);
}
@GetMapping("/individuals")
@PreAuthorize("hasAuthority('SCOPE_individual:read')")
public ResponseEntity<PagedModel<IndividualModel>> findAll(
Pageable pageable) throws ResponseStatusException {
try {
Page<Individual> entities = repository.findAll(pageable);
PagedModel<IndividualModel> models = pagedResourcesAssembler.toModel(entities, assembler);
return ResponseEntity.ok(models);
} catch (Exception e) {
log.error("{}", e.getLocalizedMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
@GetMapping("/individuals/{id}")
@PreAuthorize("hasAuthority('SCOPE_individual:read')")
public ResponseEntity<IndividualModel> findById(
@PathVariable("id") final Long id) throws ResponseStatusException {
try {
Individual entity = repository.findById(id).orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND));
IndividualModel model = assembler.toModel(entity);
logInfo(entity, model);
return ResponseEntity.ok(model);
} catch (Exception e) {
log.error("{}", e.getLocalizedMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
@GetMapping("/individuals/search/findByFamilyNameStartsWith")
@PreAuthorize("hasAuthority('SCOPE_individual:read')")
public ResponseEntity<PagedModel<IndividualModel>> findByFamilyNameStartsWith(
@RequestParam("name") final String name, Pageable pageable) throws ResponseStatusException {
try {
Page<Individual> entities = repository.findByNameFamilyNameStartsWith(name, pageable);
PagedModel<IndividualModel> models = pagedResourcesAssembler.toModel(entities, assembler);
return ResponseEntity.ok(models);
} catch (Exception e) {
log.error("{}", e.getLocalizedMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
@PostMapping("/individuals")
@PreAuthorize("hasAuthority('SCOPE_individual:post')")
public ResponseEntity<IndividualModel> create(
@RequestBody Individual individual) throws ResponseStatusException {
try {
Individual entity = repository.save(individual);
IndividualModel model = assembler.toModel(entity);
logInfo(entity, model);
return ResponseEntity.created(linkTo(methodOn(IndividualController.class).findById(entity.getId())).toUri()).body(model);
} catch (Exception e) {
log.error("{}", e.getLocalizedMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
@PatchMapping("/individuals/{id}")
@PreAuthorize("hasAuthority('SCOPE_individual:patch')")
public ResponseEntity<IndividualModel> update(
@PathVariable("id") final Long id, @RequestBody Individual individual) throws ResponseStatusException {
try {
individual.setId(id);
repository.save(individual);
Link link = linkTo(methodOn(IndividualController.class).findById(id)).withSelfRel();
return ResponseEntity.noContent().location(new URI(link.getHref())).build();
} catch (Exception e) {
log.error("{}", e.getLocalizedMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
@DeleteMapping("/individuals/{id}")
@PreAuthorize("hasAuthority('SCOPE_individual:delete')")
public ResponseEntity<IndividualModel> delete(
@PathVariable("id") final Long id) throws ResponseStatusException {
try {
repository.deleteById(id);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("{}", e.getLocalizedMessage());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
}
For example:
And to test the BPMN engine's RESTful API:
We can also navigate to the H2 console:
Flowable UI Applications
- Flowable Identity Management
- Flowable Modeler
- Flowable Task
- Flowable Admin
You can download the Flowable open source distribution from the Flowable web site.
Externalised Configuration
The Flowable Web applications take advantage of Spring Boot's support for externalised configuration:
spring.main.banner-mode=off
spring.jpa.open-in-view=false
# Logging
logging.level.root=INFO
logging.level.org.flowable=WARN
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.springframework.security=WARN
# Spring Datasource - Postgres
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/serendipity
spring.datasource.username=admin
spring.datasource.password=secret
# Spring JPA - Postgres
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update
# Spring Datasource - H2
# spring.datasource.driver-class-name=org.h2.Driver
# spring.datasource.url=jdbc:h2:~/serendipity-db/db;AUTO_SERVER=TRUE;AUTO_SERVER_PORT=9091;DB_CLOSE_DELAY=-1
# spring.datasource.username=admin
# spring.datasource.password=secret
# Spring JPA - H2
# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# spring.jpa.hibernate.ddl-auto=update
# H2 Console
# spring.h2.console.enabled=false
# spring.h2.console.path=/h2-console
# spring.h2.console.settings.trace=false
# spring.h2.console.settings.web-allow-others=false
# Default Flowable Admin Accounts - see: flowable.ldif
flowable.idm.app.admin.user-id=flowable
flowable.idm.app.admin.password=test
flowable.idm.app.admin.first-name=
flowable.idm.app.admin.last-name=Administrator
flowable.idm.app.admin.email=admin@serendipity.org.au
flowable.common.app.idm-admin.user=flowable
flowable.common.app.idm-admin.password=test
flowable.modeler.app.deployment-api-url=http://localhost:9999/flowable-task/app-api
# LDAP
flowable.idm.ldap.enabled=true
flowable.idm.ldap.server=ldap://localhost
flowable.idm.ldap.port=389
flowable.idm.ldap.user=cn=admin,dc=flowable,dc=org
flowable.idm.ldap.password=secret
flowable.idm.ldap.base-dn=dc=flowable,dc=org
flowable.idm.ldap.user-base-dn=ou=users,dc=flowable,dc=org
flowable.idm.ldap.group-base-dn=ou=groups,dc=flowable,dc=org
flowable.idm.ldap.query.user-by-id=(&(objectClass=inetOrgPerson)(uid={0}))
flowable.idm.ldap.query.user-by-full-name-like=(&(objectClass=inetOrgPerson)(|({0}=*{1}*)({2}=*{3}*)))
flowable.idm.ldap.query.all-users=(objectClass=inetOrgPerson)
flowable.idm.ldap.query.groups-for-user=(&(objectClass=groupOfUniqueNames)(uniqueMember={0}))
flowable.idm.ldap.query.all-groups=(objectClass=groupOfUniqueNames)
flowable.idm.ldap.query.group-by-id=(&(objectClass=groupOfUniqueNames)(uniqueId={0}))
flowable.idm.ldap.attribute.user-id=uid
flowable.idm.ldap.attribute.first-name=cn
flowable.idm.ldap.attribute.last-name=sn
flowable.idm.ldap.attribute.email=mail
flowable.idm.ldap.attribute.group-id=cn
flowable.idm.ldap.attribute.group-name=cn
flowable.idm.ldap.cache.group-size=10000
flowable.idm.ldap.cache.group-expiration=180000
Flowable Identity Management
To launch Flowable's Identity Management application:
java -jar flowable-idm.war
Then navigate to: http://localhost:8080/flowable-idm
Flowable Modeler
To launch Flowable's Modeler application:
java -jar flowable-modeler.war
Then navigate to: http://localhost:8888/flowable-modeler
Flowable Task
To launch Flowable's Task application:
java -jar flowable-task.war
Then navigate to: http://localhost:9999/flowable-task
Flowable Admin
To launch Flowable's Admin application:
java -jar flowable-admin.war
Then navigate to: http://localhost:9988/flowable-admin
Database Driver
The Flowable UI application wars include the H2 database driver. If you want to use a different database then you need to update each war file, for example:
unzip flowable-idm.war
mv postgresql-42.2.14.jar WEB-INF/lib
jar uf0 flowable-idm.war WEB-INF/lib/postgresql-42.2.14.jar
unzip flowable-task.war
mv postgresql-42.2.14.jar WEB-INF/lib
jar uf0 flowable-task.war WEB-INF/lib/postgresql-42.2.14.jar
Source Code:
- GitHub: The REST API for the Serendipity Customer Engagement Platform
- GitHub: Serendipity the open source Customer Engagement Platform
References:
- Flowable blog: Building your own Flowable Spring Boot Application
- Flowable docs: Spring Boot
- GitHub: Spring Security - OAuth 2.0 Resource Server Sample