1 changed files with 354 additions and 0 deletions
@ -0,0 +1,354 @@ |
|||||
|
--- |
||||
|
title: "Secure a Quarkus API with Keycloak" |
||||
|
date: 2020-03-17T00:00:00+02:00 |
||||
|
opensource: |
||||
|
- Keycloak |
||||
|
- Quarkus |
||||
|
--- |
||||
|
|
||||
|
[Quarkus](https://quarkus.io/) is a Java stack that is Kubernetes native, lightweight and fast. |
||||
|
Quarkus can be used for any type of backend development, including API-enabled backends. |
||||
|
[Keycloak](https://www.keycloak.org/) is an open source Single Sign On solution that can be used to secure APIs. |
||||
|
|
||||
|
In this article, I'm describing how to secure a Quarkus API with Keycloak using JWT tokens. |
||||
|
|
||||
|
## Preparation |
||||
|
|
||||
|
As a pre-requisite, install [Maven](https://maven.apache.org/), [jq](https://stedolan.github.io/jq/download/) and [jwt-cli](https://github.com/mike-engel/jwt-cli#installation). |
||||
|
|
||||
|
Create a Quarkus project using either [code.quarkus.io](https://code.quarkus.io/) or Maven. Example shown below with Maven. |
||||
|
|
||||
|
```sh |
||||
|
mvn io.quarkus:quarkus-maven-plugin:1.2.0.Final:create \ |
||||
|
-DprojectGroupId=fr.itix.test \ |
||||
|
-DprojectArtifactId=secured-rest \ |
||||
|
-DclassName="fr.itix.test.SecuredResource" |
||||
|
``` |
||||
|
|
||||
|
Enter the created directory. |
||||
|
|
||||
|
```sh |
||||
|
cd secured-rest |
||||
|
``` |
||||
|
|
||||
|
## Install and configure Keycloak |
||||
|
|
||||
|
Download Keycloak and install it locally. |
||||
|
|
||||
|
```sh |
||||
|
curl -L -o keycloak.tgz https://downloads.jboss.org/keycloak/9.0.0/keycloak-9.0.0.tar.gz |
||||
|
tar zxvf keycloak.tgz |
||||
|
mv keycloak-* keycloak |
||||
|
``` |
||||
|
|
||||
|
Create the Keycloak admin user. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/add-user-keycloak.sh -u admin -r master -p secret |
||||
|
``` |
||||
|
|
||||
|
Start the Keycloak server. |
||||
|
|
||||
|
```sh |
||||
|
nohup ./keycloak/bin/standalone.sh -Djboss.socket.binding.port-offset=100 & |
||||
|
``` |
||||
|
|
||||
|
Quickly, the Keycloak admin console should be available at [localhost:8180/auth/admin/](http://localhost:8180/auth/admin/). The login is **admin** and password is **secret**. |
||||
|
|
||||
|
In the rest of this section, we will configure Keycloak with: |
||||
|
|
||||
|
* a realm named **demo** |
||||
|
* a client named **quarkus-app** |
||||
|
* a user named **jdoe** |
||||
|
* two groups named **user** and **admin** |
||||
|
|
||||
|
Authenticate as admin on Keycloak using the **kcadm** CLI. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh config credentials --server http://localhost:8180/auth --realm master --user admin --client admin-cli --password secret |
||||
|
``` |
||||
|
|
||||
|
Create a realm named **demo**. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh create realms -s realm=demo -s enabled=true -o |
||||
|
``` |
||||
|
|
||||
|
Create a client named **quarkus-app**. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh create clients -r demo -s 'clientId=quarkus-app' -s 'standardFlowEnabled=false' -s 'directAccessGrantsEnabled=true' -s 'serviceAccountsEnabled=true' -s 'clientAuthenticatorType=client-secret' -s 'secret=s3cr3t' |
||||
|
``` |
||||
|
|
||||
|
The framework used by Quarkus to implement security is based on [Eclipse MicroProfile - JWT RBAC Security (MP-JWT)](https://www.eclipse.org/community/eclipse_newsletter/2017/september/article2.php) and requires a claim named **groups** to hold the group membership. |
||||
|
|
||||
|
So, we need to create a **custom protocol mapper** to map the user's client role mapping to the **groups** claim. |
||||
|
|
||||
|
```sh |
||||
|
CLIENT_ID="$(./keycloak/bin/kcadm.sh get clients -r demo -q clientId=quarkus-app |jq -r '.[0].id')" |
||||
|
|
||||
|
./keycloak/bin/kcadm.sh create clients/$CLIENT_ID/protocol-mappers/models -r demo -f - <<EOF |
||||
|
{ |
||||
|
"name": "groups", |
||||
|
"protocol":"openid-connect", |
||||
|
"protocolMapper": "oidc-usermodel-client-role-mapper", |
||||
|
"consentRequired": false, |
||||
|
"config": { |
||||
|
"multivalued": "true", |
||||
|
"userinfo.token.claim": "true", |
||||
|
"id.token.claim": "true", |
||||
|
"access.token.claim": "true", |
||||
|
"claim.name": "groups", |
||||
|
"jsonType.label": "String", |
||||
|
"usermodel.clientRoleMapping.clientId": "quarkus-app" |
||||
|
} |
||||
|
} |
||||
|
EOF |
||||
|
``` |
||||
|
|
||||
|
Create two client roles in the **quarkus-app** client. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh create clients/$CLIENT_ID/roles -r demo -s name=user -s 'description=Can access the application' |
||||
|
./keycloak/bin/kcadm.sh create clients/$CLIENT_ID/roles -r demo -s name=admin -s 'description=Can administer the application' |
||||
|
``` |
||||
|
|
||||
|
Create a user named **jdoe** and set its password to something easy to remember. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh create users -r demo -s username=jdoe -s enabled=true -s firstName=John -s lastName=Doe |
||||
|
./keycloak/bin/kcadm.sh set-password -r demo --username jdoe --new-password password123 |
||||
|
``` |
||||
|
|
||||
|
Give this user the client role **user**. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh add-roles -r demo --uusername jdoe --cclientid quarkus-app --rolename user |
||||
|
``` |
||||
|
|
||||
|
Now Keycloak should be ready to secure your Quarkus API! |
||||
|
|
||||
|
## Secure the Quarkus API with Keycloak |
||||
|
|
||||
|
Add the [quarkus-smallrye-jwt](https://quarkus.io/guides/security-jwt) extension to the Quarkus project. |
||||
|
|
||||
|
```sh |
||||
|
./mvnw quarkus:add-extension -Dextension="quarkus-smallrye-jwt" |
||||
|
``` |
||||
|
|
||||
|
Configure the jwt extension to fetch the OpenID Connect Public Keys from Keycloak (**mp.jwt.verify.publickey.location**), enforce a validation of the issuer, preferred_username, and audience fields. |
||||
|
That way, Quarkus takes care of validating the token for us. |
||||
|
|
||||
|
```sh |
||||
|
cat > src/main/resources/application.properties <<EOF |
||||
|
mp.jwt.verify.publickey.location=http://localhost:8180/auth/realms/demo/protocol/openid-connect/certs |
||||
|
mp.jwt.verify.issuer=http://localhost:8180/auth/realms/demo |
||||
|
smallrye.jwt.verify.audience=quarkus-app |
||||
|
smallrye.jwt.require.named-principal=true |
||||
|
EOF |
||||
|
``` |
||||
|
|
||||
|
Replace `src/main/java/fr/itix/test/SecuredResource.java` with: |
||||
|
|
||||
|
```java |
||||
|
package fr.itix.test; |
||||
|
|
||||
|
import java.security.Principal; |
||||
|
|
||||
|
import javax.annotation.security.PermitAll; |
||||
|
import javax.annotation.security.RolesAllowed; |
||||
|
import javax.enterprise.context.RequestScoped; |
||||
|
import javax.inject.Inject; |
||||
|
import javax.ws.rs.GET; |
||||
|
import javax.ws.rs.Path; |
||||
|
import javax.ws.rs.Produces; |
||||
|
import javax.ws.rs.WebApplicationException; |
||||
|
import javax.ws.rs.core.Context; |
||||
|
import javax.ws.rs.core.MediaType; |
||||
|
import javax.ws.rs.core.SecurityContext; |
||||
|
import javax.ws.rs.core.Response.Status; |
||||
|
|
||||
|
import org.eclipse.microprofile.jwt.Claim; |
||||
|
import org.eclipse.microprofile.jwt.Claims; |
||||
|
|
||||
|
@Path("/api") |
||||
|
@RequestScoped |
||||
|
public class SecuredResource { |
||||
|
@Inject @Claim(standard = Claims.preferred_username) |
||||
|
String username; |
||||
|
|
||||
|
@GET |
||||
|
@Path("/helloEverybody") |
||||
|
@Produces(MediaType.TEXT_PLAIN) |
||||
|
@PermitAll |
||||
|
public String helloEverybody() { |
||||
|
return "Hello everybody !"; |
||||
|
} |
||||
|
|
||||
|
@GET |
||||
|
@Path("/hello") |
||||
|
@Produces(MediaType.TEXT_PLAIN) |
||||
|
public String hello(@Context SecurityContext ctx) { |
||||
|
String name = "dear unknown visitor"; |
||||
|
Principal caller = ctx.getUserPrincipal(); |
||||
|
if (caller != null) { |
||||
|
name = caller.getName(); |
||||
|
} |
||||
|
|
||||
|
return "hello " + name; |
||||
|
} |
||||
|
|
||||
|
@GET |
||||
|
@Path("/helloUsers") |
||||
|
@Produces(MediaType.TEXT_PLAIN) |
||||
|
@RolesAllowed({"user"}) |
||||
|
public String helloUsers() { |
||||
|
return "hello user " + username; |
||||
|
} |
||||
|
|
||||
|
@GET |
||||
|
@Path("/helloAdmins") |
||||
|
@Produces(MediaType.TEXT_PLAIN) |
||||
|
@RolesAllowed({"admin"}) |
||||
|
public String helloAdmins() { |
||||
|
return "hello admin " + username; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
This class defines four HTTP endpoints: |
||||
|
|
||||
|
* **/api/helloEverybody** does not require any authentication at all. |
||||
|
* **/api/hello** tries to authenticate the user and falls back gracefully when no JWT is provided. |
||||
|
* **/api/helloUsers** requires authentication and applies RBAC: the user needs to have the **user** role. |
||||
|
* **/api/helloAdmins** requires authentication and applies RBAC: the user needs to have the **admin** role. |
||||
|
|
||||
|
Run the quarkus project in dev mode. |
||||
|
|
||||
|
```sh |
||||
|
./mvnw compile quarkus:dev |
||||
|
``` |
||||
|
|
||||
|
## Query the Quarkus API |
||||
|
|
||||
|
In another terminal, ensure the **/api/helloEverybody** endpoint is working and reachable without security. |
||||
|
|
||||
|
``` |
||||
|
$ curl http://localhost:8080/api/helloEverybody |
||||
|
Hello everybody ! |
||||
|
``` |
||||
|
|
||||
|
You can query the **/api/hello** endpoint without any authentication. |
||||
|
|
||||
|
``` |
||||
|
$ curl http://localhost:8080/api/hello |
||||
|
hello dear unknown visitor |
||||
|
``` |
||||
|
|
||||
|
Now, get a token from Keycloak for user **jdoe**. |
||||
|
|
||||
|
```sh |
||||
|
TOKEN="$(curl http://localhost:8180/auth/realms/demo/protocol/openid-connect/token -d grant_type=password -d client_id=quarkus-app -d client_secret=s3cr3t -d username=jdoe -d password=password123 -s |jq -r .access_token)" |
||||
|
``` |
||||
|
|
||||
|
When you display the issued token, it contains the standard OpenID Connect claims as well as the **groups** claim as instructed by our custom protocol mapper. |
||||
|
|
||||
|
``` |
||||
|
$ jwt decode "$TOKEN" |
||||
|
|
||||
|
Token header |
||||
|
------------ |
||||
|
{ |
||||
|
"typ": "JWT", |
||||
|
"alg": "RS256", |
||||
|
"kid": "dCsrXmzTBFDbrXqd-paTe9rSti43lHSSrjqbdcAN9IM" |
||||
|
} |
||||
|
|
||||
|
Token claims |
||||
|
------------ |
||||
|
{ |
||||
|
"acr": "1", |
||||
|
"aud": "account", |
||||
|
"auth_time": 0, |
||||
|
"azp": "quarkus-app", |
||||
|
"email_verified": false, |
||||
|
"exp": 1584454157, |
||||
|
"family_name": "Doe", |
||||
|
"given_name": "John", |
||||
|
"groups": [ |
||||
|
"user" |
||||
|
], |
||||
|
"iat": 1584453857, |
||||
|
"iss": "http://localhost:8180/auth/realms/demo", |
||||
|
"jti": "49e5a792-8c36-4202-bd37-7d06fa799fa4", |
||||
|
"name": "John Doe", |
||||
|
"nbf": 0, |
||||
|
"preferred_username": "jdoe", |
||||
|
"realm_access": { |
||||
|
"roles": [ |
||||
|
"offline_access", |
||||
|
"uma_authorization" |
||||
|
] |
||||
|
}, |
||||
|
"resource_access": { |
||||
|
"account": { |
||||
|
"roles": [ |
||||
|
"manage-account", |
||||
|
"manage-account-links", |
||||
|
"view-profile" |
||||
|
] |
||||
|
}, |
||||
|
"quarkus-app": { |
||||
|
"roles": [ |
||||
|
"user" |
||||
|
] |
||||
|
} |
||||
|
}, |
||||
|
"scope": "email profile", |
||||
|
"session_state": "659d4c51-36a9-4bdc-ba4b-2ea84f3390cb", |
||||
|
"sub": "0e83418b-0635-435e-9bac-dd3d3b87cba4", |
||||
|
"typ": "Bearer" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
If you provide a JWT token, the API will update its greeting message accordingly. |
||||
|
|
||||
|
``` |
||||
|
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/hello |
||||
|
hello jdoe |
||||
|
``` |
||||
|
|
||||
|
When setting up Keycloak, we added **jdoe** to the **user** group. So, you should be able to query the **/api/helloUsers** endpoint. |
||||
|
|
||||
|
``` |
||||
|
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/helloUsers |
||||
|
hello user jdoe |
||||
|
``` |
||||
|
|
||||
|
But you cannot *yet* query the **/api/helloAdmins** endpoint. |
||||
|
|
||||
|
``` |
||||
|
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/helloAdmins |
||||
|
Forbidden |
||||
|
``` |
||||
|
|
||||
|
Give **jdoe** the client role **admin**. |
||||
|
|
||||
|
```sh |
||||
|
./keycloak/bin/kcadm.sh add-roles -r demo --uusername jdoe --cclientid quarkus-app --rolename admin |
||||
|
``` |
||||
|
|
||||
|
Get a new token from Keycloak for user **jdoe**. |
||||
|
|
||||
|
```sh |
||||
|
TOKEN="$(curl http://localhost:8180/auth/realms/demo/protocol/openid-connect/token -d grant_type=password -d client_id=quarkus-app -d client_secret=s3cr3t -d username=jdoe -d password=password123 -s |jq -r .access_token)" |
||||
|
``` |
||||
|
|
||||
|
You can *now* query the **/api/helloAdmins** endpoint. |
||||
|
|
||||
|
``` |
||||
|
$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/helloAdmins |
||||
|
hello admin jdoe |
||||
|
``` |
||||
|
|
||||
|
And this concludes this article on how to secure a Quarkus API with Keycloak! |
||||
Loading…
Reference in new issue