mirror of
https://github.com/jlengrand/quarkus-workshop.git
synced 2026-03-10 08:41:21 +00:00
461 lines
17 KiB
Plaintext
461 lines
17 KiB
Plaintext
= Securing Quarkus APIs
|
|
:experimental:
|
|
|
|
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,window=_blank], 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,window=_blank], https://keyloak.org[Keycloak,window=_blank] and https://en.wikipedia.org/wiki/OAuth[OAuth,window=_blank] 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.realm-name=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/admin")
|
|
@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/admin` 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**.
|
|
|
|
image:createexec.png[create,600]
|
|
|
|
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
|
|
|
|
[NOTE]
|
|
====
|
|
In this exercise we are **short-circuiting typical web authentication flows** to illustrate the ease of protecting APIs with Quarkus. In a typical web authentication, users are redirected (via their browser) to a login page, after which a negotiation is performed to retrieve _access tokens_ used on behalf of the user to access protected resources. Here we are doing this manually with `curl`.
|
|
====
|
|
|
|
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={% raw %}'{{ .spec.host }}'{% endraw %})/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 -s -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={% raw %}'{{ .spec.host }}'{% endraw %})/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={% raw %}'{{ .spec.host }}'{% endraw %})/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!
|
|
|
|
[WARNING]
|
|
====
|
|
Access Tokens have a defined lifespan that's typically short (e.g. 5 minutes), so if you wait too long, the token will expire and you'll get denied access. In this case, just re-fetch a new token using the same `curl` command used the first time. Full-fledged applications can take advantage of things like https://oauth.net/2/grant-types/refresh-token/[_Refresh Tokens_,window=_blank] to do this automatically to ensure a good user experience even for slow users.
|
|
====
|
|
|
|
=== Test Admin
|
|
|
|
Obtain an Admin token:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
export ADMIN_TOKEN=$(\
|
|
curl -s -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' \
|
|
) && echo $ADMIN_TOKEN
|
|
----
|
|
|
|
And try again with your new token:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i http://$(oc get route people -o=go-template --template={% raw %}'{{ .spec.host }}'{% endraw %})/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
|
|
|
|
Frequently, resource servers only perform authorization decisions based on role-based access control (RBAC), where the roles granted to the user trying to access protected resources are checked against the roles mapped to these same resources. While roles are very useful and used by applications, they also have a few limitations:
|
|
|
|
* Resources and roles are tightly coupled and changes to roles (such as adding, removing, or changing an access context) can impact multiple resources
|
|
* Changes to your security requirements can imply deep changes to application code to reflect these changes
|
|
* Depending on your application size, role management might become difficult and error-prone
|
|
|
|
Keycloak's _Authorization Services_ provides fine-grained authorization policies that decouples the authorization policy from your code, so when your policies change, your code doesn't have to. In this exercise we'll use Keycloak's Authorization Services to protect our Quarkus APIs.
|
|
|
|
== Enable Quarkus Extension
|
|
|
|
First, you'll need to enable the Keycloak extension:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
mvn quarkus:add-extension -Dextensions="oidc, keycloak-authorization, resteasy-jsonb"
|
|
----
|
|
|
|
=== Configuring Keycloak
|
|
|
|
Next, add these to your `application.properties` for Keycloak:
|
|
|
|
[source,none,role="copypaste"]
|
|
----
|
|
# OIDC config
|
|
quarkus.oidc.auth-server-url={{ KEYCLOAK_URL }}/auth/realms/quarkus
|
|
quarkus.oidc.client-id=backend-service
|
|
quarkus.oidc.credentials.secret=secret
|
|
|
|
# Enable Policy Enforcement
|
|
quarkus.keycloak.policy-enforcer.enable=true
|
|
quarkus.keycloak.policy-enforcer.paths.ready.name=Readiness
|
|
quarkus.keycloak.policy-enforcer.paths.ready.path=/health/ready
|
|
quarkus.keycloak.policy-enforcer.paths.ready.enforcement-mode=DISABLED
|
|
quarkus.keycloak.policy-enforcer.paths.live.name=Liveness
|
|
quarkus.keycloak.policy-enforcer.paths.live.path=/health/live
|
|
quarkus.keycloak.policy-enforcer.paths.live.enforcement-mode=DISABLED
|
|
----
|
|
|
|
This configures the extension with the necessary configuration ( https://www.keycloak.org/docs/latest/securing_apps/index.html#_java_adapter_config[read more,window=_blank] about what these do).
|
|
|
|
[NOTE]
|
|
====
|
|
We explicitly disable authorization checks for the `/health/*` endpoints so that the container platform can access them. To support secured health checks, https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/[different health check mechanisms] like TCP or `exec` methods can be used.
|
|
====
|
|
|
|
=== 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.inject.Inject;
|
|
import javax.ws.rs.GET;
|
|
import javax.ws.rs.Path;
|
|
import javax.ws.rs.Produces;
|
|
import javax.ws.rs.core.MediaType;
|
|
|
|
import io.quarkus.security.identity.SecurityIdentity;
|
|
|
|
@Path("/secured") // <1>
|
|
public class KeycloakResource {
|
|
|
|
@Inject
|
|
SecurityIdentity identity; // <2>
|
|
|
|
|
|
@GET
|
|
@Path("/confidential") // <1>
|
|
@Produces(MediaType.TEXT_PLAIN)
|
|
public String confidential() {
|
|
return ("confidential access for: " + identity.getPrincipal().getName() +
|
|
" with attributes:" + identity.getAttributes());
|
|
}
|
|
}
|
|
|
|
----
|
|
<1> Note that we do not use any `@RolesAllowed` or any other instrumentation on the endpoint to specify access policy. It looks like an ordinary endpoint. Keycloak (the server) is the one enforcing access here, not Quarkus directly.
|
|
<2> The `SecurityIdentity` is a generic object produced by the Keycloak extension that you can use to obtain information about the security principals and attributes embedded in the request.
|
|
|
|
=== Rebuild and redeploy app
|
|
|
|
First, re-build the app using the command palette and selecting **Create Executable JAR**.
|
|
|
|
image:createexec.png[create,600]
|
|
|
|
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.
|
|
|
|
[NOTE]
|
|
====
|
|
Keycloak caches the resource paths that it is protecting, so that every access doesn't cause a roundtrip back to the server to check whether the user is authorized to access the resource. The lifespan of these cached entries can be controlled through https://www.keycloak.org/docs/latest/authorization_services/index.html#_enforcer_filter[Policy Enforcer Configuration,window=_blank].
|
|
====
|
|
|
|
First make sure even `admin` can't access the endpoint:
|
|
|
|
Refresh the admin token (it may have expired):
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
export ADMIN_TOKEN=$(\
|
|
curl -s -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' \
|
|
) && echo $ADMIN_TOKEN
|
|
----
|
|
|
|
And then try to access with it:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i -X GET \
|
|
http://$(oc get route people -o=go-template --template={% raw %}'{{ .spec.host }}'{% endraw %})/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 -s -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' \
|
|
) && echo $JDOE_TOKEN
|
|
----
|
|
|
|
And access the confidential endpoint with your new token:
|
|
|
|
[source,sh,role="copypaste"]
|
|
----
|
|
curl -i -X GET \
|
|
http://$(oc get route people -o=go-template --template={% raw %}'{{ .spec.host }}'{% endraw %})/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.
|
|
|