Overview
In this tutorial, we will take a look on how to secure an API gateway.
This tutorial is based on the code we implemented in Getting Started with Docker tutorial. If you didn't complete the tutorial, we highly encourage you to do so.
Prerequisites
- JDK 8
- Docker (not required, you can use Stack distribution as well)
curl
To keep things simple, we'll start from the code already implemented in the tutorial mentioned before, which is available here.
Let's quickly recap on the things we'll reuse here.
We've already implemented the HelloWorldHandlerFactory
which simply prints the configured message to the HTTP response. All the details are available here in section API implementation: creating new Handler.
In this tutorial we'll make sure that this handler is accessible only for authenticated users.
JWT
A JWT (JSON Web Token) is a well-known solution for API authorization. The JWT allows verifying the owner of some JSON data. The JSON data stored in a JWT is serialized, not encrypted. So it can be seen by anyone (remember to always use HTTPS).
However, it is cryptographically signed by the authentication provider. So no one can modify it, it guarantees that can be trusted.
The JWT Authentication flow looks like this:
- A user logs in with his/her credentials.
- The user receives an encrypted JWT in a cookie tagged with the HttpOnly flag (it tells that only the server can access this particular cookie).
- From now, all requests within the same domain contain JWT token and secured API can verify them (those endpoints share the same signing key).
Please note that, for simplicity, we specify JWT in HTTP headers. Usually, there is some proxy front server (e.g. Apache or Nginx) that rewrites an authentication cookie to HTTP headers.
Since we'll be focusing only on step 3, we'll mimic the result of the other two by generating the token ourselves.
Configure JWT auth in OpenAPI
As always, we'll use the Design First approach, therefore we'll modify our Open API 3 configuration file.
You might have already noticed how we use swagger.io and OpenAPI references alternately, depending on what we find more readable for the situation. It's good to understand the difference between them:
OpenAPI is the specification and Swagger is a set of tools for implementing the specification.
For more information see this article.
Let's add a new endpoint in the paths:
section and a new securityScheme
in the components
section:
knotx/conf/openapi.yaml
paths:
/api/secure/jwt: // endpoint with JWT authentication
get:
operationId: hello-world-operation-jwt
security:
- helloJwtAuth: [] // here we specify which security scheme we'll be using (see below)
responses:
'200':
description: Hello World API protected with JWT
'401':
description: Unauthorized access
Notice different responses defined for the secured endpoint. For more info on different responses see this tutorial.
Now let's create security scheme definitions. We'll place it in a separate place in the same file as above in components
section. For now we'll have only one securityScheme
called helloJwtAuth
:
components:
securitySchemes:
helloJwtAuth: // our custom security scheme
type: http
scheme: bearer
bearerFormat: JWT
For more information on bearer
scheme see this documentation.
Now we'll take care of handling our operations and security schemas. Let's modify knotx/conf/routes/operations.conf
:
routingOperations = ${routingOperations} [
{
operationId = hello-world-operation-jwt
handlers = [
{
name = hellohandler
config = {
message = "Hello World From Knot.x with JWT!"
}
}
]
}
]
As we can see, we use our hellohandler
implemented in HelloWorldHandlerFactory
, but with different configured message.
Now let's add security handlers (in the same file):
securityHandlers = [
{
schema = helloJwtAuth
factory = helloJwtAuthFactory
config = {
algorithm = "HS256"
publicKey = "M0NTY3ODkwIiwibmFtZSI6"
symmetric = true
}
}
]
We provided an array of securityHandlers
. It's a collection of objects which map schema
with factory
that must implement AuthHandlerFactory
interface. We can also pass some config here.
We used HS256
with a symmetric
key. A signature is generated by calculating a digest using the HMAC-SHA256 hashing algorithm. The key used in HMAC is, by definition, symmetric. A symmetric key is used for both to encrypt and decrypt JWT. So we need to configure two properties: symmetric
and publicKey
. More info available here.
Be careful not to disclose your private keys anywhere publicly!
It is worth noting that operations are not aware of any security they will be behind of!
Authentication Handler implementation
For the purpose of this tutorial let's add a new module to our project. Let's name it security-module
and give it the following structure.:
modules
└─ security-module
├── build.gradle.kts // gradle build script
└── src
└── main
├── java/io/knotx/examples/security/auth
│ └── JwtAuthHandlerFactory.java // the handler factory
└── resources
└── META-INF/services
└── io.knotx.server.api.security.AuthHandlerFactory // META-INF file used by Knot.x to find the handler
The modules/security-module/build.gradle.kts
takes care of any dependencies we'll need in this tutorial:
dependencies {
"io.knotx:knotx".let { v ->
implementation(platform("$v-dependencies:${project.property("knotx.version")}"))
implementation("$v-fragments-handler-api:${project.property("knotx.version")}")
}
"io.vertx:vertx".let { v ->
implementation("$v-core")
implementation("$v-rx-java2")
implementation("$v-health-check")
implementation("$v-auth-jwt")
}
"org.apache".let { v ->
compile("$v.httpcomponents:httpclient:4.5.3")
compile("$v.commons:commons-lang3:3.9")
}
}
Let's not forget to add the module to the main settings.gradle.kts
!
include("security-module")
// ...
project(":security-module").projectDir = file("modules/security-module")
Now let's take care of the actual authentication handler factory implementation:
JwtAuthHandlerFactory.java
package io.knotx.examples.security.auth;
import io.knotx.server.api.security.AuthHandlerFactory;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.PubSecKeyOptions;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.reactivex.core.Vertx;
import io.vertx.reactivex.ext.auth.jwt.JWTAuth;
import io.vertx.reactivex.ext.web.handler.AuthHandler;
import io.vertx.reactivex.ext.web.handler.JWTAuthHandler;
public class JwtAuthHandlerFactory implements AuthHandlerFactory {
@Override
public String getName() {
return "helloJwtAuthFactory";
}
@Override
public AuthHandler create(Vertx vertx, JsonObject config) {
PubSecKeyOptions pubSecKey = new PubSecKeyOptions(config);
JWTAuthOptions jwtAuthOptions = new JWTAuthOptions().addPubSecKey(pubSecKey);
return JWTAuthHandler.create(JWTAuth.create(vertx, jwtAuthOptions));
}
}
We're creating a configuration based on the config JSON the factory receives from operations.conf
and return a JWTAuthHandler
that does all the authentication for us.
Let's not forget to register our factory for Java ServiceLoader:
io.knotx.server.api.security.AuthHandlerFactory
io.knotx.examples.security.auth.JwtAuthHandlerFactory
Build & Run
In this tutorial we'll be using the Docker distribution, but it will work with Stack distribution as well. Please refer to this tutorial on how to work with the Stack distribution.
First, let's rename the Docker image we're about to create:
gradle.properties
// ...
docker.image.name=knotx-example/secure-api-gateway
// ...
Now let's build the image:
$ gradlew clean build-docker
and run it:
$ docker run -p8092:8092 knotx-example/secure-api-gateway
After a while the Docker container should be up and running.
Let's try accessing our endpoints:
$ curl -X GET http://localhost:8092/api/secure/jwt
Unauthorized
We were not authorized to access this endpoint.
We need an Authorization
header for our request. As mentioned before, we'll mimic a successful login and we'll generate the token ourselves.
There are many ways to generate a token. We'll use jwt.io
.
Open the page, scroll to the Debugger section and generate the token:
- Select
HS256
as the algorithm. - Fill in the key you used in the configuration.
- Copy the generated token.
Now let's try this curl
command with Authorization
header:
$ curl -X GET http://localhost:8092/api/secure/jwt -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.vPWK59pl5GWimz8UVbL3CmrceSfmNvvCgyzwLVV9jT8'
{"message":"Hello World From Knot.x with JWT!"}
Functional tests
It's always a good practice to test our code. Let's implement some tests that will automatically do the same things we did manually above.
The tests we're about to write will be functional tests, that will be executed on a real, running Docker image, just after the healthcheck
(described in this tutorial) executes successfully.
Let's add a new file: functional/src/test/java/com/project/test/functional/JWTAuthITCase.java
class JWTAuthITCase {
@Test
@DisplayName("GIVEN no authorization WHEN call JWT secured API EXPECT Unauthorized")
void givenNoAuthorizationWhenCallBasicAuthApiExpectUnauthorized() {
given()
.port(8092)
.when()
.get("/api/secure/jwt")
.then()
.assertThat()
.statusCode(401);
}
@Test
@DisplayName("GIVEN authorization WHEN call JWT secured API EXPECT Ok")
void givenAuthorizationWhenCallBasicAuthApiExpectOk() {
given()
.port(8092)
.header("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.vPWK59pl5GWimz8UVbL3CmrceSfmNvvCgyzwLVV9jT8")
.when()
.get("/api/secure/jwt")
.then()
.assertThat()
.statusCode(200);
}
}
We've created two simple tests using REST-assured. They do the same actions we did above:
- call secured endpoint without authorization and expect
401 - Unauthorized
response - call the same endpoint with authorization and expect
200 - OK
response
If you try rebuilding the project now (remember to stop any running containers before that) you'll notice that those test will be invoked. Now we're confident that if our future changes will somehow disable or modify our /api/secure/jwt
logic, we'll know at build time!
Summary
In this tutorial we have successfully secured our API Gateway with one of the most common authentication methods.
The only thing left to do here for a complete application is to implement login functionality. After successful login, the token should be stored by the browser's cookie in a manner that the token is appended to every request, but is not available for the front-end logic. Lastly, our proxy (e.g. Apache or Nginx) should rewrite this cookie to a header before passing a request to Knot.x.
You can find full project implementation here.