Skip to content

Latest commit

 

History

History
506 lines (359 loc) · 20.2 KB

README.md

File metadata and controls

506 lines (359 loc) · 20.2 KB

Integrating Open Policy Agent (OPA) with Spring Security Reactive and JSON Web Tokens (JWT)

Author License Linux & MacOS

Build & Test Release

Copyright & Licensing

The code is copyright (c) 2021 AlertAvert.com. All rights reserved
The code is released under the Apache 2.0 License, see LICENSE for details.

Motivation

Spring Security assumes a fairly simplistic Role-Based access control (RBAC) where the service authenticates the user (via some credentials, typically username/password) and returns a UserDetails object which also lists the Authorities that the Principal has been granted.

While it is also possible to integrate Spring Security with JSON Web Tokens (JWT) this is also rather cumbersome, and lacks flexibility.

Finally, integrating the app with an Open Policy Agent server for the relatively new Spring Reactive (WebFlux) model is far from straightforward.

Ultimately, however, Spring Security "collapses" authentication and authorization into a single process, based on the UserDetails abstraction, which sometimes does not allow sufficient flexibility.

This library aims at simplifying the ability for an application/service to:

  • clearly separating authentication from authorization;
  • easily adopt JWTs (API Tokens) as a means of authentication;
  • simplify integration with OPA for authorization;
  • keeping the authorization logic (embedded in Rego policies) separate from the business logic (carried out by the application).

It also provides a blueprint to inject OPA authorization in a Spring Reactive (WebFlux) application.

Usage

(aka: Guide for the impatient)

See either this repository releases page or Maven Central for the most recently available release:

ext {
    jwtOpaVersion = '0.9.0'
}

Configure your project dependencies to include all necessary Spring libraries (JWT-OPA requires at a minimum starter-security) as they are not included in the published artifact, the library itself, and other supporting libraries:

dependencies {
    // Spring Framework and Security Dependencies, via Boot Starter Kits.
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

    // JWT and Encryption dependencies, needed at runtime.
    implementation 'com.auth0:java-jwt:3.10.3'
    implementation 'org.bouncycastle:bcprov-jdk15on:1.70'

    // JWT-OPA Integration, this library.
    // See: https://search.maven.org/artifact/com.alertavert/jwt-opa
    implementation "com.alertavert:jwt-opa:${jwtOpaVersion}"

    // All other dependencies for your project.
    // For example Spring WebFlux and Spring Data MongoDB:
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // etc...
}

For more details, take a look into the webapp-example demo project, including how to configure and run tests.

Architecture

Architecture

To acquire an API Token the client needs to access one of the "authenticated" endpoints (as defined in the routes.authenticated list property - see the RoutesConfiguration class) and obtain a valid JWT from the JwtTokenProvider; an example of how to do this (using a simple Spring Data repository, backed by MongoDB) is in the /login controller in the example app (LoginController): the SecurityConfiguration class is what one would implement in any Spring Application with Spring Security enabled:

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {
  @Bean
  public ReactiveUserDetailsService userDetailsService(ReactiveUsersRepository repository) {
    return username -> {
      return repository.findByUsername(username)
          .map(User::toUserDetails);
    };
  }
}

Obviously, instead of accessing a local database, the application could use a WebClient to access a remote service to retrieve any details (including an encoded password).

Once the user has been authenticated, we can generate a JWT API Token, and return it to the client:

@GetMapping
Mono<JwtController.ApiToken> login(
    @RequestHeader("Authorization") String credentials
) {
  return usernameFromHeader(credentials)
      .flatMap(repository::findByUsername) // See Note.
      .map(u -> {
        String token = provider.createToken(u.getUsername(), u.roles());
        return new JwtController.ApiToken(u.getUsername(), u.roles(), token);
      })
      .doOnSuccess(apiToken ->
          log.debug("User {} authenticated, API Token generated: {}",
              apiToken.getUsername(), apiToken.getApiToken()));
}

NoteAs you may notice, we are duplicating the roundtrip to the DB for the User data; this may (or may not) be a performance issue, especially on performance-sensitive APIs: an obvious solution would be to use either a co-located cache, or even an in-memory one, with a relatively short TTL.

Authorization via Open Policy Agent server

More interestingly, once the client has an API Token, it can be used to authorize any other request: this is done by configuring the OpaReactiveAuthorizationManager as a ReactiveAuthorizationManager (this is "chained" via the JwtReactiveAuthorizationManager) which takes care of validating the API Token.

All of this is done transparently by the jwt-opa library, without having to change anything in the actual application.

@Override
public Mono<AuthorizationDecision> check(
    Mono<Authentication> authentication,
    ServerHttpRequest request
) {

  return authentication.map(auth -> {
        return makeRequestBody(auth.getCredentials().toString(), request);
      })
      .flatMap(body -> client.post()
          .accept(MediaType.APPLICATION_JSON)
          .contentType(MediaType.APPLICATION_JSON)
          .bodyValue(body)
          .exchange())
      .flatMap(response -> response.bodyToMono(Map.class))
      .map(res -> {
        Object result = res.get("result");
        if (StringUtils.isEmpty(result)) {
          return Mono.error(unauthorized());
        }
        return result.toString();
      })
      .map(o -> Boolean.parseBoolean(o.toString()))
      .map(AuthorizationDecision::new);
}

Simplified code excerpt, please see the OpaReactiveAuthorizationManager class for the full code

the client is a Spring WebClient configured to connect to the OPA Server as configured via the OpaServerConfiguration configuration, which reads the following properties from application.yaml:

opa:
  server: "localhost:8181"
  policy: kapsules
  rule: allow

This will eventually send a TokenBasedAuthorizationRequestBody (encoded as JSON) to the following endpoint:

http(s)://localhost:8181/v1/data/kapsules/allow

Depending on what the allow rule maps to, this will eventually grant/deny access to the requested endpoint (given the HTTP Method and, optionally, the request's body content).

There is a relationship between the policy endpoint and the Rego package in your policy: they must match, with dots in the package replaced by slashes in the policy path:

# Rego:
package com.alertavert.policies

grant {
  # the policy
}

# application.yaml
opa:
  policy: com/alertavert/policies
  rule: grant

See OPA Policies for more details, and the OPA Documentation for more on Rego and the OPA server API.

Signing Secrets

Overview

In order to ensure validity of its contents, a JWT needs to be cryptographically signed and the signature added to its body; see the JWT Handbook for more details.

jwt-opa offers currently two signature methods for JWTs:

  • a passphrase (secret), using symmetric encryption which needs to be used for both signing and authenticating the JWT; and

  • asymmetric Private/Public keypair (using Elliptic Cryptography) where the private key is used to sign and the public key can be used to validate the JWT.

The advantage of the latter is that the Public key can be distributed, and any service (including others completely unrelated to jwt-opa) can validate the API Token.

This is being used, for example, by Copilot IQ to use jwt-opa (integrated within its Spring Boot API server) to provide API Token for its Lambda Go functions, where they ask jwt-opa to generate trusted API Token, but then authentication can be carried out indipedently by the Lambdas, without ever needing to incur the cost of an additional call to the API server.

This also points to the advantage of using OPA as an authorization service, which can serve several disparate other services, completely abstracting away the authorization logic.

Secrets Configuration

Key configuration is done via Spring Boot externalized configuration (e.g., in application.yaml) via the keys object; this in turn has the following fields:

keys:
  algorithm: EC
  location: keypair
  name: /var/local/keys/ec-key

Possible values for algorithm are:

  • PASSPHRASE: plaintext secret
  • EC: Elliptic Curve cryptography key pair

Depending on the value of location the name property has a different meaning:

  • only available for PASSPHRASE

    • env
      env var name which contains the signing secret
    • file
      the path to file whose contents are the plaintext secret this is NOT secure and should only be used for dev/testing
  • only available for EC

    • keypair
      the relative or absolute path to the keypair, without extension, to which .pem and .pub will be added
  • either of EC or PASSPHRASE:

    • awssecret
      name of AWS SecretsManager secret
    • vaultpath
      path in HashiCorp Vault (not implemented yet)

In the above, file paths can be absolute or relative (in production use, we recommend full absolute paths to avoid hard-to-debug issues - at any rate, the error message should be sufficient to locate the source of the issue).

When using aswsecret, a PASSPHRASE is simply read from SecretsManager/Vault as plaintext, while for an EC KeyPair it is stored as a JSON-formatted secret, with two keys: priv and pub (see AWS SecretsManager support).

Generating a KeyPair

Use the keygen.sh script, specifying the name of the keys and, optionally, a folder where to save the keys (if the folder doesn't exist it will be created):

$ ./keygen.sh  ec-key private

See this for more details.

Make sure the keys are in a private folder (not under source control) and then point the relevant application configuration (application.yaml) to them:

keys:
  algorithm: ec
  location: keypair
  name: "private/ec-key"

You can use either an absolute path, or the relative path to the current directory from where you are launching the Web server, and make sure to includ the keys' filename, but not the extension(s) (.pem and .pub) as the KeypairFileReader will add them automatically.

AWS SecretsManager support

This is the recommended secure way to store and access signing secrets

We support storing signing secrets (both plaintext passphrase or a private/public key pair) in AWS SecretsManager by simply configuring access to AWS:

aws:
  region: us-west-2
  profile: my-profile

the profile must match one of those configured in the ~/.aws/credentials file:

# my-profile
[my-profile]
aws_access_key_id = AJIA2....XT
aws_secret_access_key = 22Y8...YM

we also support direct acces to SM via IAM Roles when jwt-opa is embedded in a service running on AWS (e.g., as a pod in Amazon Kubernetes) via a Token file whose name is stored in the AWS_TOKEN_FILE env var (see the documentation for AWS SDK's WebIdentityTokenFileCredentialsProvider) -- in this case you should not specify a aws.profile or the client will fail to authenticate.

We also support connecting to a running instance of LocalStack via the endpoint_url configuration:

aws:
  region: us-west-2
  profile: default
  endpoint: http://localhost:4566

Run LocalStack via docker with something like (this is a compose.yaml fragment, YMMV):

  19   │   localstack:
  20   │     container_name: "awslocal"
  21   │     image: "localstack/localstack:1.3"
  22   │     hostname: awslocal
  23   │     environment:
  24   │       - AWS_REGION=us-west-2
  25   │       - EDGE_PORT=4566
  26   │       - SERVICES=sqs
  27   │     ports:
  28   │       - '4566:4566'
  29   │     volumes:
  30   │       - "${TMPDIR:-/tmp}/localstack:/var/lib/localstack"
  31   │       - "/var/run/docker.sock:/var/run/docker.sock"
  32   │     networks:
  33   │       - sm-net

Prior to running the webapp, upload the secret with:

     export AWS_REGION=us-west-2
     export AWS_ENDPOINT=http://localhost:4566
     aws --endpoint-url $AWS_ENDPOINT secretsmanager create-secret --name demo-secret \
         --secret-string "astrong-secret-dce44st"

To upload a keypair to AWS SM, the easiest way is to use the aws-upload-keys script, after having set the AWS_PROFILE env var and generated the keys:

export AWS_PROFILE=my-profile
export AWS_REGION=us-east-1
./keygen.sh dev-keys testdata
./aws-upload-keys.sh testdata/dev-keys dev-keypair

these can then be made available to the application via the following application.yaml configuration:

aws:
  region: us-east-1
  profile: my-profile

keys:
  algorithm: EC
  location: awssecret
  name: dev-keypair

Key Format
While not relevant for library users, the KeyPair is stored in SM as a JSON object, with two pub and priv fields, which are the contents of the keys (base-64 encoded binary) without delimiters:

└─( aws --output json secretsmanager list-secrets \
    | jq -r ".SecretList[].Name" | grep dev

└─( echo -e $(aws --output json secretsmanager get-secret-value \
    --secret-id dev-keypair | jq -r .SecretString)

{ "priv": "AMB....Pi/88", "pub": "MF....v+A==" }

Hashicorp Vault support

This is not implemented yet, see Issue #49.

Running the Server

Supporting Services

The sample app (webapp-example) uses the following services:

  • Mongo (users DB);
  • OPA Policy Server

Use the following to run the servers locally:

./run-example.sh

You can also optionally pass in a value for the Spring Boot profile to use (and relative configuration to use, if defined):

./run-example.sh debug,dev

2023-01-07 15:07:37.015  INFO : Starting JwtDemoApplication using Java 17 on gondor with PID 363820
2023-01-07 15:07:37.017  INFO : The following profiles are active: debug,dev
...

The service will continue running after you stop the server via Ctrl-C (as you may want to re-run it via ./gradlew bootRun): to stop the opa and mongo containers too, simply use:

docker compose down

from the same directory as the compose.yaml is stored, or point to it via the -f option.

TODO: a Helm chart to run all services on a Kubernetes cluster.

Web Server (Demo app)

This is a very simple Spring Boot application, to demonstrate how to integrate the jwt-opa library; there is still some work to refine it, but by and large, it gives a good sense of what is required to integrate a Spring Boot app with an OPA server:

  1. implement a SecurityConfiguration @Configuration class;
  2. implement a mechanism to retrieve UserDetails given a username; and
  3. implement something similar to the LoginController to serve API Tokens to authenticated users.

In future releases of the jwt-opa library we may also provide "default" implementations of some or all of the above, if this can be done without limiting too much client's options; or maybe they could be provided in a jwt-opa-starter extension library.

TODO: there are stil a few rough edges in the demo app and its APIs.

Trying out the demo

NOTE

As this is a toy demo, we happily store the password in a source-controlled configuration file: you should easily realize that this is an extremely dumb thing to do, please don't do it.

The admin password is stored in application.yaml:

db:
  server: localhost
  port: 27017
  name: opa-demo-db

  # Obviously, well, DON'T DO THIS for a real server.
  admin:
    username: admin
    password: 8535b9c4-a

Note

The system user does not get re-created, if it already exists: if you change (and then forget) the password, you will need to manually delete it from Mongo directly:

docker exec -it mongo mongo
> show dbs;
...
opa-demo-db  0.000GB
> use opa-demo-db
> db.users.find()
{ "_id" : ObjectId("5ff8173b20953c451f10a384"), "username" : "admin", ...
> db.users.remove(ObjectId("5ff81..."))

and then restart the server to recreate the admin user. Alternatively, just stop & restart the Mongo container (but all data will be lost).

To access the /login endpoint, you will need to use Basic authentication:

$ http :8080/login --auth admin:342dfa7b-4

this will generate a new API Token, that can then be used in subsequent HTTP API calls, with the Authorization header:

http :8080/users Authorization:"Bearer ... JWT goes here ..."

OPA Policies

They are stored in src/main/rego and can be uploaded to the OPA policy server via a curl POST (see REST API in Useful Links); examples of policy evaulations are in src/test/policies_tests as JSON files; they can be executed against the policy server using the /data endpoint:

POST http://localhost:8181/v1/data/com/alertavert/userauth/allow

{
  "input" : {
    "api_token" : "eyJ0eX****e9ZuZA",
    "resource" : {
      "method" : "GET",
      "path" : "/users",
    }
  }
}

The actual format of the request POSTed to OPA can be seen in the Debug logs of the server:

2023-01-07 15:21:29.335 DEBUG : POST Authorization request:
{
  "input" : {
    "api_token" : "eyJ0eX****e9ZuZA",
    "resource" : {
      "method" : "GET",
      "path" : "/users",
      "headers" : {
        "User-Agent" : "PostmanRuntime/7.30.0",
        "Host" : "localhost:8081",
        "Accept-Encoding" : "gzip, deflate, br"
      }
    }
  }
}
2023-01-07 15:21:29.458 DEBUG : OPA Server returned: {result=true}
2023-01-07 15:21:29.458 DEBUG : JWT Auth Web Filter :: GET /users
2023-01-07 15:21:29.458 DEBUG : Authenticating token eyJ0eX...
2023-01-07 15:21:29.462 DEBUG : API Token valid: sub = `admin`, authorities = [SYSTEM]
2023-01-07 15:21:29.462 DEBUG : Validated API Token for Principal: `admin`
2023-01-07 15:21:29.462 DEBUG : Auth success, principal = `JwtPrincipal(sub=admin)`

Useful links

  1. REGO Playground
  2. OPA Server REST API for Data
  3. OPA Documentation
  4. OPA - How does it work
  5. OPA Policy Reference
  6. Docker Hub - OPA image