Develop Micronaut based GROOVY CRUDQ web application in BTP cloud foundry : Part 1

In this 2 part blog series, I will demonstrate on how you can create a full fledged youb application using Micronaut framework using groovy programming language. Micronaut is a cloud native JVM framework targeted specifically at creating microservices for the cloud. Micronaut has extremely fast startup time, leaves low memory footprint and very easily integrates with several third party technologies.

I will cover the following aspects in the 2 part blog series:

  • Part 1 – Understand how to implement the authentication and authorization of this web app.
  • Part 2 – Understand how you can integrate with HANA cloud database for persistence and how can you do local testing.
  1. To create a full fledged web application that can perform CRUDQ operation on a table in HANA cloud database.
  2. To implement authorization and authentication.
  3. To provide a template for initial setup for developers.

you need to ensure that the following components are already installed in the system:

  1. JVM runtime.
  2. Groovy runtime.
  3. Micronaut runtime.

Navigate to our favorite folder and create a new Micronaut project.

mn create-app --build=gradle --jdk=8 --lang=groovy --test=junit --features=security-session,data-jdbc,views-thymeleaf,h2 com.sap.sflight

The following files will be created.

Open the build.gradle file and add the following dependencies marked with comments:

  • com.sap.cloud.db.jdbc:ngdbc:2.13.9 (HANA DB driver)
  • io.pivotal.cfenv:java-cfenv-boot:2.4.0 (To access the VCAP variable of the app. This will be needed to read the public key using which the JWT token will be verified.
  • com.sap.cloud.security:sapjwt:1.5.27.5 (To verify the JWT token using the public key certificate extracted using the above dependency)
  • com.sap.cloud.security:java-security:2.13.0 (To extract the user details and the roles from the JWT token).
dependencies { implementation("io.micronaut:micronaut-http-client") implementation("io.micronaut.data:micronaut-data-jdbc:3.4.2") implementation("io.micronaut.groovy:micronaut-runtime-groovy") implementation("io.micronaut.security:micronaut-security-session") implementation("io.micronaut.sql:micronaut-jdbc-hikari") implementation("io.micronaut.views:micronaut-views-thymeleaf") compileOnly("io.micronaut:micronaut-http-validation") compileOnly("io.micronaut.data:micronaut-data-processor:3.4.2") compileOnly("io.micronaut.security:micronaut-security-annotations") runtimeOnly("ch.qos.logback:logback-classic") runtimeOnly("com.h2database:h2") implementation("io.micronaut.rxjava3:micronaut-rxjava3") implementation("io.micronaut.rxjava3:micronaut-rxjava3-http-client") implementation("io.micronaut:micronaut-validation") implementation('com.sap.cloud.db.jdbc:ngdbc:2.13.9') //sap jdbc driver. implementation('io.pivotal.cfenv:java-cfenv-boot:2.4.0') //to access the VCAP variables. implementation('com.sap.cloud.security:java-security:2.13.0') //to read the claims of the token. implementation('com.sap.cloud.security:sapjwt:1.5.27.5') //to validate the JWT token.
}

Please read above, this JWT token will be forwarded by the Approuter. The responsibility to verify the token and read the user details lies with the app itself.

you can now start the development.

Let us look at sequence diagram to understand that how the authentication happens. Here are the steps.

  1. User opens the app router URL to launch the application.
  2. App router provides SAP login page for authentication.
  3. App router forwards the authentication credentials to XSUAA for validation.
  4. XSUAA validates the credentials using the underlying IDP.
  5. Post validation, XSUAA creates a JWT signed using its private key and forwards to Approuter.
  6. Approuter verifies the JWT using the XSUAA public key. This is available in the VCAP variables of XSUAA service binding.
  7. Approuter forwards the JWT to the youb application default route.
  8. youb application does a POST to login controller (this is a standard controller) for validation.
  9. Standard login controller invokes the custom authentication module.
  10. Within the custom authentication module, the JWT is verified and the user details and the roles are extracted and the user session is created. JWT verification occurs using the public key available in the VCAP variables of the XSUAA service binding.
  11. The user is redirected to the /home controller of the app.

Authenticatin%20flow

Authentication flow

Entity Class

Lets create an entity class.

package com.sap import io.micronaut.data.annotation.MappedEntity
import jakarta.persistence.Column
import jakarta.persistence.Id @MappedEntity('SFLIGHT')
class SFlightEntity { @Id @Column(name = 'ID') Integer flightId @Column(name = 'FLIGHTNAME') String flightName @Column(name = 'FLIGHTFROM') String flightFrom @Column(name = 'FLIGHTTO') String flightTo @Column(name = 'FLIGHTDATE') Date flightDate
}

Entity Service Repo

And the corresponding service class which contains the operations on this entity. you will be using the standard CRUDQ API which should be sufficient.

package com.sap import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository @JdbcRepository(dialect = Dialect.ORACLE)
interface SFlightRepository extends CrudRepository<SFlightEntity, Integer> { //no need to define additional methods. The methods of standard API CrudRepository is sufficient for requirement.
}

And finally now you need to 2 controllers.

  • The first is the intermediate controller which will receive the JWT token from the approuter. The job of this controller is to immediately invoke the /LOGIN controller with this JWT token so that the app level validation can happen.
  • The actual /HOME controller of the youb app.

Home Controller

This is the home controller. If the database does not have any records existing then some random records are inserted. Note for reading all the entries of the table, no role is required. But for accessing the DELETE, UPDATE, POST operations, the user needs to have the ADMIN role. The authenticated user must have this role assigned to in the BTP role assignment.

package com.sap import io.micronaut.core.annotation.Nullable
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.annotation.Post
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import io.micronaut.views.View
import jakarta.inject.Inject import java.security.Principal @Controller('/home')
class SFlightController { SFlightRepository sFlightRepository @Inject SFlightController(SFlightRepository sFlightRepository) { this.sFlightRepository = sFlightRepository //bootstrap some data in the sflight table, if the table is not having any records. if (this.sFlightRepository.findAll().isEmpty()) { this.sFlightRepository.save(new SFlightEntity(flightId: 1, flightName: 'Air France', flightFrom: 'Paris', flightTo: 'Amsterdam', flightDate: new Date())) this.sFlightRepository.save(new SFlightEntity(flightId: 2, flightFrom: 'New Delhi', flightName: 'Air India', flightTo: 'Frankfurt', flightDate: new Date())) this.sFlightRepository.save(new SFlightEntity(flightId: 3, flightName: 'Delta', flightFrom: 'Los Angeles', flightTo: 'Heathrow', flightDate: new Date())) } } @Get('/') @Secured([SecurityRule.IS_ANONYMOUS, 'ADMIN']) @View('home') //everyone can read the list of the flight. Map<String, Object> getAll(@Nullable Principal principal) { return ['flightList': this.sFlightRepository.findAll(), 'user': principal.getName()] } @Post(value = '/post', consumes = MediaType.APPLICATION_FORM_URLENCODED) @Secured('ADMIN') @View('action') //only admin can perform the operations. Map create(@Body SFlightEntity sFlightEntity) { try { this.sFlightRepository.save(sFlightEntity) return ['message': 'Entry created', 'sFlight': sFlightEntity] } catch (Exception exception) { return ['message': exception.getMessage(), 'sFlight': sFlightEntity] } } @Post(value = '/put/{id}', consumes = MediaType.APPLICATION_FORM_URLENCODED) @Secured('ADMIN') @View('action') //only admin can perform the operations. Map update(@Body SFlightEntity sFlightEntity, @PathVariable('id') String id) { try { this.sFlightRepository.update(sFlightEntity) return ['message': "${id} Entry updated", 'sFlight': sFlightEntity] } catch (Exception exception) { return ['message': exception.getMessage(), 'sFlight': sFlightEntity] } } @Post(value = '/delete/{id}', consumes = MediaType.APPLICATION_FORM_URLENCODED) @Secured('ADMIN') @View('action') //only admin can perform the operations. Map delete(@Body SFlightEntity sFlightEntity, @PathVariable('id') String id) { try { this.sFlightRepository.deleteById(sFlightEntity.getFlightId()) return ['message': "${id} Entry deleted", 'sFlight': sFlightEntity] } catch (Exception exception) { return ['message': exception.getMessage(), 'sFlight': sFlightEntity] } } @Get('/operation/{+name}') @Secured('ADMIN') @View('action') //only admin can perform the operations. Map<String, Object> operation(@Nullable @PathVariable('name') String name) { String operationName = (name.split('/')[0] as String).toUpperCase() //operation name Integer id SFlightEntity sFlightEntity switch (operationName) { case 'POST': return ['sFlight': new SFlightEntity(), 'operation': name] case 'PUT': case 'DELETE': id = name.split('/')[1] as Integer sFlightEntity = this.sFlightRepository.findById(id).get() return ['sFlight': sFlightEntity, 'operation': name] } }
}

If you want to use PUT, DELETE then you need to use javascript in client side. For the sake of simplicity that is omitted.

Landing Controller

And the LANDING controller. This controller receives the JWT token from APPROUTER. The job of this controller is to immediately call the standard LOGIN controller.

package com.sap import io.micronaut.context.ApplicationContext
import io.micronaut.context.env.PropertySource
import io.micronaut.http.HttpRequest
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.server.util.HttpHostResolver
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import io.micronaut.views.View
import io.pivotal.cfenv.core.CfEnv
import jakarta.inject.Inject @Controller('/')
class LandingController { HttpHostResolver hostResolver ApplicationContext applicationContext @Inject LandingController(HttpHostResolver hostResolver, ApplicationContext applicationContext) { this.hostResolver = hostResolver } @Get('/') @Secured(SecurityRule.IS_ANONYMOUS) @View('landing') Map landingZone(HttpRequest httpRequest) { String uri if (this.hostResolver.resolve(httpRequest).toUpperCase() == 'HTTP://LOCALHOST:8080') { return ['token': 'DUMMY', 'uri': this.hostResolver.resolve(httpRequest)] } else { uri = "https://" + new CfEnv().getApp().getApplicationUris()[0] //actual URL when deployed in the cloud. return ['token': httpRequest.getHeaders().getAuthorization().get(), 'uri': uri] } }
}

The corresponding LANDING view is very simple.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <meta charset="UTF-8"> <title>App</title> <link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16x16.png">
</head>
<body onload="document.forms[0].submit()" style="background:blue; color:white;">
<p>Please wait...</p>
<form method="post" th:attr="action=${uri}+'/login'"> <input type="hidden" name="username" id="username" th:value="${token}"/> <input type="hidden" name="password" id="password" th:value="${token}"/>
</form>
</body>
</html>

Authenticator

The authenticator class actually does the validation of the JWT token and extracts the user details from it.

package com.sap import com.fasterxml.jackson.databind.ObjectMapper
import com.sap.cloud.security.jwt.JwtValidation
import com.sap.cloud.security.token.Token
import com.sap.cloud.security.token.TokenClaims
import io.micronaut.core.annotation.Nullable
import io.micronaut.http.HttpRequest
import io.micronaut.http.server.util.HttpHostResolver
import io.micronaut.security.authentication.AuthenticationProvider
import io.micronaut.security.authentication.AuthenticationRequest
import io.micronaut.security.authentication.AuthenticationResponse
import io.pivotal.cfenv.core.CfEnv
import io.reactivex.rxjava3.annotations.NonNull
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.FlowableEmitter
import io.reactivex.rxjava3.core.FlowableOnSubscribe
import io.reactivex.rxjava3.schedulers.Schedulers
import jakarta.inject.Inject
import jakarta.inject.Singleton
import org.reactivestreams.Publisher @Singleton
class Authenticator implements AuthenticationProvider { HttpHostResolver hostResolver @Inject LandingController(HttpHostResolver hostResolver) { this.hostResolver = hostResolver } Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest, AuthenticationRequest<?, ?> authenticationRequest) { return Flowable<AuthenticationResponse>.create(new FlowableOnSubscribe<AuthenticationResponse>() { @Override void subscribe(@NonNull FlowableEmitter<AuthenticationResponse> emitter) throws Throwable { try { //for the local testing take the dummy token and for the actual server use the JWT. if (hostResolver.resolve(httpRequest).toUpperCase() == 'HTTP://LOCALHOST:8080') { emitter.onNext(AuthenticationResponse.success(authenticationRequest.getIdentity() as String, ['ADMIN'])) emitter.onComplete() } else { //this means that the app is actually deployed in BTP. String token = authenticationRequest.getIdentity() Token tokenRead = Token.create(token) //read the token. Map role = new ObjectMapper().readValue(tokenRead.getClaimAsJsonObject('xs.system.attributes') .asJsonString(), Map.class) //read the roles. String email = tokenRead.getClaimAsString(TokenClaims.EMAIL) //read the email. String key = new CfEnv().findServiceByLabel('xsuaa') .getCredentials() .getMap() .get('verificationkey') //Get the public key certificate. key = key.replaceAll('-----BEGIN PUBLIC KEY-----', '') .replaceAll('-----END PUBLIC KEY-----', '') .replaceAll('\\n', '') String validationResult = new JwtValidation().checkJwToken(token.substring(7), key) emitter.onNext(AuthenticationResponse.success(email, role.get('xs.rolecollections') as Collection<String>)) emitter.onComplete() } } catch (Exception exception) { emitter.onNext(AuthenticationResponse.failure(exception.getMessage())) emitter.onComplete() } } }, BackpressureStrategy.ERROR).subscribeOn(Schedulers.io()) }
}

You can visit my previous blog on how you can deploy the app to BTP. Link here. Also SAP blog. Link here.

First, deploy XSUAA service. Content of xs-security.json.

{ "xsappname": "sflight-15ee76b5trial", "tenant-mode": "shared", "scopes": [ { "name": "$XSAPPNAME.Admin", "description": "admin" } ], "role-templates": [ { "name": "ADMIN", "description": "SFLIGHT admin role", "scope-references" : [ "$XSAPPNAME.Admin" ] } ]
}

command:

cf create-service xsuaa application my-xsuaa -c xs-security.json

Second, deploy the SFLIGHT app. Content of manifest.yml.

---
applications: - name: sflight timeout: 600 memory: 1G instances: 1 path: sflight-0.1-all.jar buildpack: java_buildpack

command:

cf push sflight -f "manifest.yml"

Third, deploy APPROUTER service. Content of manifest.yml.

---
applications:
- name: approuter routes: - route: approuter-15ee76b5trial.cfapps.us10.hana.ondemand.com path: approuter memory: 128M buildpacks: - nodejs_buildpack env: TENANT_HOST_PATTERN: 'approuter-(.*).cfapps.us10.hana.ondemand.com' destinations: '[{"name":"app-destination", "url" :"https://sflight.cfapps.us10.hana.ondemand.com", "forwardAuthToken": true}]' services: - my-xsuaa

command:

cf push

Deploy both. After deployment XSUAA service instance should be bound to both the APPROUTER and SFLIGHT applications.

Thats it. Launch the app router URL and you will be authenticatated with the app. Here is the app landing page after authentication.

I have created a full fledged web app loaded with CRUDQ features complaint with SAP authentication module and deployed in BTP. For now I am using the in memory H2 database for persistence. In the next part of the blog I will use SAP HANA cloud.

This application can also serve as a boilerplate for setting up any new app. I will provide the github link in Part 2.