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.
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.
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.
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).
See OPA Policies for what this maps to, and the OPA Documentation for more details on Rego and the server REST API.
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.
Briefly, an "elliptic cryptography" key pair can be generated with:
-
generate the EC param
openssl ecparam -name prime256v1 -genkey -noout -out ec-key.pem
-
generate EC private key
openssl pkcs8 -topk8 -inform pem -in ec-key.pem -outform pem \ -nocrypt -out ec-key-1.pem
-
generate EC public key
openssl ec -in ec-key-1.pem -pubout -out public.pem
Save both keys in a private
folder (not under source control) and then point the relevant application configuration (application.yaml
) to them:
secrets:
keypair:
private: "private/ec-key-1.pem"
pub: "private/ec-key-pub.pem"
You can use either an absolute path, or the relative path to the current directory from where you are launching the Web server.
The sample app (jwt-vault
) uses the following services:
- Mongo (users DB);
- OPA Policy Server; and
- Hashicorp Vault (key store) --
TODO:
we are currently storing keys on disk
Use the following to run the servers locally:
./run-example.sh
TODO:
a full Kubernetes service/pod spec to run all services.
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:
- implement a
SecurityConfiguration
@Configuration
class; - implement a mechanism to retrieve
UserDetails
given ausername
; and - 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.
After starting the server (./gradlew bootRun
), you will see in the log the generated password
for the admin
user:
INFO Initializing DB with seed user (admin)
INFO Use the generated password: 342dfa7b-4
Note
The system user does not get re-created, if it already exists: if you lose the random 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 ..."
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/kapsules/valid_token
{
"input": {
"user": "myuser",
"role": "USER",
"token": "eyJ0eXAi....iCzY"
}
}