mirror of
https://github.com/jlengrand/quarkus-workshop.git
synced 2026-03-10 08:41:21 +00:00
411 lines
14 KiB
Plaintext
411 lines
14 KiB
Plaintext
## Securing Quarkus APIs
|
|
|
|
Bearer Token Authorization is the process of authorizing HTTP requests based on the existence and validity of a bearer token representing a subject and her access context, where the token provides valuable information to determine the subject of the call as well whether or not a HTTP resource can be accessed. This is commonly used in OAuth-based identity and access management systems like https://keycloak.org[Keycloak], a popular open source project. In this exercise we'll show you how to use https://github.com/eclipse/microprofile-jwt-auth/releases/download/1.1.1/microprofile-jwt-auth-spec.pdf[Microprofile JSON Web Token (JWT) RBAC], https://keyloak.org[Keycloak] and https://en.wikipedia.org/wiki/OAuth[OAuth] to secure your Quarkus applications.
|
|
|
|
## Add JWT to Quarkus
|
|
|
|
Like other exercises, we'll need another extension to enable the use of MicroProfile JWT. Install it with:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
mvn quarkus:add-extension -Dextensions="jwt"
|
|
----
|
|
|
|
This will add the necessary entries in your `pom.xml` to bring in JWT support.
|
|
|
|
## Configure Quarkus for MicroProfile JWT
|
|
|
|
Some configuration of the extension is required. Add this to your `application.properties`:
|
|
|
|
[source,properties,role="copypaste"]
|
|
----
|
|
mp.jwt.verify.publickey.location={{KEYCLOAK_URL}}/auth/realms/quarkus/protocol/openid-connect/certs # <1>
|
|
mp.jwt.verify.issuer={{KEYCLOAK_URL}}/auth/realms/quarkus # <2>
|
|
quarkus.smallrye-jwt.auth-mechanism=MP-JWT # <3>
|
|
quarkus.smallrye-jwt.realmName=quarkus
|
|
quarkus.smallrye-jwt.enabled=true
|
|
----
|
|
<1> Sets public key location for JWT authentication. Keycloak exports this for you at the URL.
|
|
<2> Issuer URL. This must match the incoming JWT `iss` _claims_ or else authentication fails.
|
|
<3> Sets authentication mechanism name to `MP-JWT`, the MicroProfile JWT RBAC specification standard name for the token based authentication mechanism.
|
|
|
|
# Create protected endpoints
|
|
|
|
We'll create 2 JWT-protected endpoints. Create a new class `JWTResource` in the `org.acme.people.rest` package with the following code:
|
|
|
|
[source,java,role="copypaste"]
|
|
----
|
|
package org.acme.people.rest;
|
|
|
|
import java.security.Principal;
|
|
import java.util.Optional;
|
|
|
|
import javax.annotation.security.RolesAllowed;
|
|
import javax.enterprise.context.RequestScoped;
|
|
import javax.inject.Inject;
|
|
import javax.json.JsonString;
|
|
import javax.ws.rs.GET;
|
|
import javax.ws.rs.Path;
|
|
import javax.ws.rs.Produces;
|
|
import javax.ws.rs.core.Context;
|
|
import javax.ws.rs.core.MediaType;
|
|
import javax.ws.rs.core.SecurityContext;
|
|
|
|
import org.eclipse.microprofile.jwt.Claim;
|
|
import org.eclipse.microprofile.jwt.Claims;
|
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
|
|
|
@Path("/secured")
|
|
@RequestScoped // <1>
|
|
public class JWTResource {
|
|
|
|
@Inject
|
|
JsonWebToken jwt; // <2>
|
|
|
|
@Inject
|
|
@Claim(standard = Claims.iss)
|
|
Optional<JsonString> issuer; // <3>
|
|
|
|
@GET
|
|
@Path("/me")
|
|
@RolesAllowed("user")
|
|
@Produces(MediaType.TEXT_PLAIN)
|
|
public String me(@Context SecurityContext ctx) { // <4>
|
|
Principal caller = ctx.getUserPrincipal();
|
|
String name = caller == null ? "anonymous" : caller.getName();
|
|
boolean hasJWT = jwt != null;
|
|
return String.format("hello %s, isSecure: %s, authScheme: %s, hasJWT: %s\n", name, ctx.isSecure(), ctx.getAuthenticationScheme(), hasJWT);
|
|
}
|
|
|
|
@GET
|
|
@Path("/me/jwt")
|
|
@RolesAllowed("admin")
|
|
@Produces(MediaType.TEXT_PLAIN)
|
|
public String meJwt(@Context SecurityContext ctx) { // <4>
|
|
Principal caller = ctx.getUserPrincipal();
|
|
String name = caller == null ? "anonymous" : caller.getName();
|
|
boolean hasJWT = jwt != null;
|
|
|
|
final StringBuilder helloReply = new StringBuilder(String.format("hello %s, isSecure: %s, authScheme: %s, hasJWT: %s\n", name, ctx.isSecure(), ctx.getAuthenticationScheme(), hasJWT));
|
|
if (hasJWT && (jwt.getClaimNames() != null)) {
|
|
helloReply.append("Injected issuer: [" + issuer.get() + "]\n"); <5>
|
|
jwt.getClaimNames().forEach(n -> {
|
|
helloReply.append("\nClaim Name: [" + n + "] Claim Value: [" + jwt.getClaim(n) + "]");
|
|
});
|
|
}
|
|
return helloReply.toString();
|
|
}
|
|
}
|
|
----
|
|
<1> Adds a `@RequestScoped` as Quarkus uses a default scoping of `ApplicationScoped` and this will produce undesirable behavior since JWT claims are naturally request scoped.
|
|
<2> `JsonWebToken` provides access to the claims associated with the incoming authenticated JWT token.
|
|
<3> When using JWT Authentication, claims encoded in tokens can be `@Inject` ed into your class for convenient access.
|
|
<4> The `/me` and `/me/jwt` endpoints demonstrate how to access the security context for Quarkus apps secured with JWT. Here we are using a `@RolesAllowed` annotation to make sure that only users granted a specific role can access the endpoint.
|
|
<5> Use of injected JWT Claim to print the all the claims
|
|
|
|
## Rebuild and redeploy app
|
|
|
|
First, re-build the app using the command palette and selecting **Create Executable JAR**. Once that's done, run the following command to re-deploy:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
oc start-build people --from-file target/*-runner.jar --follow
|
|
----
|
|
|
|
## Confirm deployment
|
|
|
|
Run and wait for the app to complete its rollout:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
oc rollout status -w dc/people
|
|
----
|
|
|
|
## Test endpoints
|
|
|
|
The first thing to do to test any endpoint is obtain an access token from your authentication server in order to access the application resources. We've pre-created a few users in Keycloak for you to use:
|
|
|
|
* `alice` is an ordinary user (will have the `user` role) whose password is `alice`
|
|
* `admin` is an Administrator (has the `admin` and `user` role) and their password is `admin`
|
|
* `jdoe` is an ordinary user (has the `user` role) but has also been granted access to `confidential` endpoints in Keycloak, and their password is `jdoe`
|
|
|
|
Try to access the endpoint as an anonymous unauthenticated user:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i http://$(oc get route people -o=go-template --template='{{ .spec.host }}')/secured/me
|
|
----
|
|
|
|
It should fail with:
|
|
|
|
[source,none]
|
|
----
|
|
HTTP/1.1 401 Unauthorized
|
|
Connection: keep-alive
|
|
Content-Type: text/html;charset=UTF-8
|
|
Content-Length: 14
|
|
Date: Tue, 16 Jul 2019 00:40:07 GMT
|
|
|
|
Not authorized
|
|
----
|
|
|
|
Let's try with an authenticated user next.
|
|
|
|
### Test Alice
|
|
|
|
Get a token for user `alice` with this command:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
export ALICE_TOKEN=$(\
|
|
curl -X POST {{KEYCLOAK_URL}}/auth/realms/quarkus/protocol/openid-connect/token \
|
|
--user backend-service:secret \
|
|
-H 'content-type: application/x-www-form-urlencoded' \
|
|
-d 'username=alice&password=alice&grant_type=password' | jq --raw-output '.access_token' \
|
|
)
|
|
echo $ALICE_TOKEN
|
|
----
|
|
This issues a `curl` command to Keycloak (using `backend-service` credentials which is a special user that is allowed acess to the Keycloak REST API), and fetches a token for Alice using their credentials.
|
|
|
|
Try out the JWT-secured API as Alice:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i http://$(oc get route people -o=go-template --template='{{ .spec.host }}')/secured/me \
|
|
-H "Authorization: Bearer $ALICE_TOKEN"
|
|
----
|
|
|
|
You should see:
|
|
|
|
[source,none]
|
|
----
|
|
HTTP/1.1 200 OK
|
|
Connection: keep-alive
|
|
Content-Type: text/plain;charset=UTF-8
|
|
Content-Length: 63
|
|
Date: Tue, 16 Jul 2019 00:40:44 GMT
|
|
|
|
hello alice, isSecure: false, authScheme: MP-JWT, hasJWT: true
|
|
----
|
|
|
|
Now try to access the `/me/admin` endpoint as `alice`:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i http://$(oc get route people -o=go-template --template='{{ .spec.host }}')/secured/me/admin \
|
|
-H "Authorization: Bearer $ALICE_TOKEN"
|
|
----
|
|
|
|
You'll get:
|
|
|
|
[source,none]
|
|
----
|
|
HTTP/1.1 403 Forbidden
|
|
Connection: keep-alive
|
|
Content-Type: text/html;charset=UTF-8
|
|
Content-Length: 34
|
|
Date: Tue, 16 Jul 2019 00:41:37 GMT
|
|
|
|
Access forbidden: role not allowed
|
|
----
|
|
|
|
Alice is not an admin. Let's try with admin!
|
|
|
|
### Test Admin
|
|
|
|
Obtain an Admin token:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
export ADMIN_TOKEN=$(\
|
|
curl -X POST {{KEYCLOAK_URL}}/auth/realms/quarkus/protocol/openid-connect/token \
|
|
--user backend-service:secret \
|
|
-H 'content-type: application/x-www-form-urlencoded' \
|
|
-d 'username=admin&password=admin&grant_type=password' | jq --raw-output '.access_token' \
|
|
)
|
|
----
|
|
|
|
And try again with your new token:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i http://$(oc get route people -o=go-template --template='{{ .spec.host }}')/secured/me/admin \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN"
|
|
----
|
|
|
|
You should see:
|
|
|
|
[source,none]
|
|
----
|
|
HTTP/1.1 200 OK
|
|
Connection: keep-alive
|
|
Content-Type: text/plain;charset=UTF-8
|
|
Content-Length: 2272
|
|
Date: Tue, 16 Jul 2019 00:14:54 GMT
|
|
|
|
hello admin, isSecure: false, authScheme: MP-JWT, hasJWT: true
|
|
Injected issuer: ["{{KEYCLOAK_URL}}/auth/realms/quarkus"]
|
|
|
|
Claim Name: [sub] Claim Value: [af134cab-f41c-4675-b141-205f975db679]
|
|
Claim Name: [groups] Claim Value: [[admin, user]]
|
|
Claim Name: [typ] Claim Value: ["Bearer"]
|
|
Claim Name: [preferred_username] Claim Value: [admin]
|
|
... <more claims>
|
|
----
|
|
|
|
Success! We dump all of the claims from the JWT token for inspection.
|
|
|
|
## Using Keycloak Authentication
|
|
|
|
Keycloak provides similar features as other OAuth/Open ID Connect servers, but can also do implicit authentication without you as a developer needing to explicitly declare protection on endpoints.
|
|
|
|
First, you'll need to enable the Keycloak extension:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
mvn quarkus:add-extension -Dextensions="keycloak"
|
|
----
|
|
|
|
### Configuring Keycloak
|
|
|
|
Next, add these to your `application.properties` for Keycloak:
|
|
|
|
[source,none,role="copypaste"]
|
|
----
|
|
quarkus.keycloak.realm=quarkus
|
|
quarkus.keycloak.auth-server-url={{ KEYCLOAK_URL }}/auth
|
|
quarkus.keycloak.resource=backend-service
|
|
quarkus.keycloak.bearer-only=true
|
|
quarkus.keycloak.credentials.secret=secret
|
|
quarkus.keycloak.policy-enforcer.enable=true
|
|
quarkus.keycloak.policy-enforcer.enforcement-mode=PERMISSIVE
|
|
----
|
|
|
|
This configures the extension with the necessary configuration ( https://www.keycloak.org/docs/latest/securing_apps/index.html#_java_adapter_config[read more] about what these do).
|
|
|
|
### Create Keycloak endpoints
|
|
|
|
Create a new class called `KeycloakResource` in the `org.acme.people.rest` package with the following code:
|
|
|
|
[source,java,role=copypaste]
|
|
----
|
|
package org.acme.people.rest;
|
|
|
|
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.core.MediaType;
|
|
|
|
import org.keycloak.KeycloakSecurityContext;
|
|
|
|
@Path("/secured")
|
|
public class KeycloakResource {
|
|
|
|
@Inject
|
|
KeycloakSecurityContext keycloakSecurityContext; // <1>
|
|
|
|
@GET
|
|
@Path("/confidential") // <2>
|
|
@Produces(MediaType.TEXT_PLAIN)
|
|
public String confidential() {
|
|
return ("confidential access for: " + keycloakSecurityContext.getToken().getPreferredUsername() +
|
|
" from issuer:" + keycloakSecurityContext.getToken().getIssuer());
|
|
}
|
|
}
|
|
----
|
|
<1> The `KeycloakSecurityContext` is an object produced by the Keycloak extension that you can use to obtain information from tokens sent to your application.
|
|
<2> Note that we do not use any `@RolesAllowed` or any other instrumentation on the endpoint. It looks like an ordinary endpoint.
|
|
|
|
[NOTE]
|
|
====
|
|
There are other APIs you can use if you try to auto-complete the method name using Che, e.g. `getBirthDate()` or `getPicture()`. Place the cursor just after `keycloakSecurityContext.getToken().get` and press kbd:[Ctrl+Space] to see them:
|
|
|
|
image::secapis.png[apis, 800]
|
|
====
|
|
|
|
## Rebuild and redeploy app
|
|
|
|
First, re-build the app using the command palette and selecting **Create Executable JAR**. Once that's done, run the following command to re-deploy:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
oc start-build people --from-file target/*-runner.jar --follow
|
|
----
|
|
|
|
## Confirm deployment
|
|
|
|
Run and wait for the app to complete its rollout:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
oc rollout status -w dc/people
|
|
----
|
|
|
|
### Test confidential
|
|
|
|
The `/secured/confidential` endpoint is protected with a policy defined in the Keycloak Server. The policy only grants access to the resource if the user is granted with a `confidential` role. The difference here is that the application is delegating the access decision to Keycloak, so no explicit source code instrumentation is required.
|
|
|
|
First make sure even `admin` can't access the endpoint:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -v -X GET \
|
|
http://$(oc get route people -o=go-template --template='{{ .spec.host }}')/secured/confidential \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN"
|
|
----
|
|
|
|
You should see in the returned HTTP headers:
|
|
|
|
[source,none]
|
|
----
|
|
HTTP/1.1 403 Forbidden
|
|
Connection: keep-alive
|
|
Content-Length: 0
|
|
Date: Tue, 16 Jul 2019 00:59:56 GMT
|
|
----
|
|
|
|
Failed as expected!
|
|
|
|
To access the confidential endpoint, you should obtain an access token for user `jdoe`:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
export JDOE_TOKEN=$(\
|
|
curl -X POST {{KEYCLOAK_URL}}/auth/realms/quarkus/protocol/openid-connect/token \
|
|
--user backend-service:secret \
|
|
-H 'content-type: application/x-www-form-urlencoded' \
|
|
-d 'username=jdoe&password=jdoe&grant_type=password' | jq --raw-output '.access_token' \
|
|
)
|
|
----
|
|
|
|
And access the confidential endpoint with your new token:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -X GET \
|
|
http://$(oc get route people -o=go-template --template='{{ .spec.host }}')/secured/confidential \
|
|
-H "Authorization: Bearer $JDOE_TOKEN"
|
|
----
|
|
|
|
You should see:
|
|
|
|
[source,none]
|
|
----
|
|
HTTP/1.1 200 OK
|
|
Connection: keep-alive
|
|
Content-Type: text/plain;charset=UTF-8
|
|
Content-Length: 142
|
|
Date: Tue, 16 Jul 2019 00:50:49 GMT
|
|
|
|
confidential access for: jdoe from issuer:{{KEYCLOAK_URL}}/auth/realms/quarkus
|
|
----
|
|
|
|
Success! Even though our code did not explicitly protect the `/secured/confidential` endpoint, we can protect arbitrary URLs in Quarkus apps when using Keycloak.
|
|
|
|
## Congratulations!
|
|
|
|
This exercise demonstrated how your Quarkus application can use MicroProfile JWT in conjunction with Keycloak to protect your JAX-RS applications using JWT claims and bearer token authorization.
|
|
|