Files
quarkus-workshop/docs/security.adoc
jamesfalkner 2e727ba4e1 fixes
2019-07-15 21:32:54 -04:00

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.