QuarkusのアプリにJWT RBACを追加する

はじめに

Quarkusのアプリケーションを作ったことが無かったので、エコーサーバを作りたいと思います。ただ、エコーサーバ作るだけだと面白くないので、JWT認証と合わせてやってみようかと思います。
以下のようなことを目標にします。

  • Quarkusのプロジェクトの作成方法を知る
  • Quarkusでエコーサーバがかけるようになる(ユニットテスト含む)
  • Quarkusアプリケーションに対してJWT RBACをかけることができるようになる

基本的にはドキュメントのここの内容をなぞる感じでやろうと思います。

環境

$ uname -srvmpio
Linux 5.4.0-42-generic #46-Ubuntu SMP Fri Jul 10 00:24:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.1 LTS
Release:    20.04
Codename:   focal

$ java --version
openjdk 15-ea 2020-09-15
OpenJDK Runtime Environment (build 15-ea+25-1229)
OpenJDK 64-Bit Server VM (build 15-ea+25-1229, mixed mode, sharing)

$ mvn -v
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15-ea, vendor: Oracle Corporation, runtime: /home/someone/.sdkman/candidates/java/15.ea.25-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-42-generic", arch: "amd64", family: "unix"

プロジェクトの作成

作成方法は、Mavenアーキタイプを使う方法やIntelliJなどのIDEから作成する方法などいろいろあるようですがStart coding with code.quarkus.ioを使いたいと思います。 設定は以下のような感じ

f:id:yuya_hirooka:20200801120530p:plain

依存はSmallRye JWTだけを追加しました。
JAX-RS(RESTEasy)はデフォルトで入るみたいです。

ざっくりとできたものを眺めてみる

$ cd jwt-sample/
$ tree
.
├── README.md
├── jwt-sample.iml
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── docker
    │   │   ├── Dockerfile.fast-jar
    │   │   ├── Dockerfile.jvm
    │   │   └── Dockerfile.native
    │   ├── java
    │   │   └── dev
    │   │       └── hirooka
    │   │           └── ExampleResource.java
    │   └── resources
    │       ├── META-INF
    │       │   └── resources
    │       │       └── index.html
    │       └── application.properties
    └── test
        └── java
            └── dev
                └── hirooka
                    ├── ExampleResourceTest.java
                    └── NativeExampleResourceIT.java

初期状態では大きく以下のようなものが作成されていました。

  • helloの文字列を返すリソースクラスとそのテスト
  • Dockerfile群
  • pom.xml

DockerfileはJVM用とNativeImage用の2つのタイプで用意されているようです。
pomの依存とプラグインは以下のような感じにました。

 <dependencies>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.rest-assured</groupId>
      <artifactId>rest-assured</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-smallrye-jwt</artifactId>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-maven-plugin</artifactId>
        <version>${quarkus-plugin.version}</version>
        <executions>
          <execution>
            <goals>
              <goal>build</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>${compiler-plugin.version}</version>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>${surefire-plugin.version}</version>
        <configuration>
          <systemPropertyVariables>
            <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
            <maven.home>${maven.home}</maven.home>
          </systemPropertyVariables>
        </configuration>
      </plugin>
    </plugins>

RESTEasyとテスト系、そしてquarkus-smallrye-jwtの依存が初期状態で入ってました。
また、quarkusの起動とかに使われると思しきプラグインとテストの実行プラグインmaven-surefire-pluginが入ってます。

動かしてみるしてみる

$ ./mvnw quarkus:dev

以下のコマンドでdevモードでの起動が可能なようです。

$ ./mvnw quarkus:dev
[INFO] Scanning for projects...
[INFO] 
[INFO] -----------------------< dev.hirooka:jwt-sample >-----------------------
[INFO] Building jwt-sample 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 
[INFO] --- quarkus-maven-plugin:1.6.1.Final:dev (default-cli) @ jwt-sample ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Copying 2 resources
[INFO] Nothing to compile - all classes are up to date
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
Listening for transport dt_socket at address: 5005
__  ____  __  _____   ___  __ ____  ______ 
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ 
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \   
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/   
2020-08-01 12:59:05,700 INFO  [io.quarkus] (Quarkus Main Thread) jwt-sample 1.0.0-SNAPSHOT on JVM (powered by Quarkus 1.6.1.Final) started in 0.983s. Listening on: http://0.0.0.0:8080
2020-08-01 12:59:05,711 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2020-08-01 12:59:05,711 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, mutiny, resteasy, security, smallrye-context-propagation, smallrye-jwt, vertx, vertx-web]

8080ポートで起動したようなので、curlを叩いてみます。

$ curl localhost:8080/hello
hello

初期状態で作成されているExampleResourceがhelloの文字列を返してきますね。

エコーサーバを書いてみる

パスパラメータで受け取った値をそのまま返すだけのエコーサーバを作成します 以下のようなリソースクラスを作成します。

EchoResource.java

@Path("/echo")
public class EchoResource {
    @GET
    @Path("{value}")
    @Produces(MediaType.TEXT_PLAIN)
    public String  echo(@PathParam("value") String value){
       return value;
    }
}

ここは簡単なJAX-RSなので、Quarkus固有のところは無いかなと思います。
devモードだとアプリの再起動はいらなさそうなのでcurlを叩いてみます。

$ curl localhost:8080/echo/ping
ping

期待通り動いていますね。

リソースクラスのテストを書く

せっかくなんで、テストを追加しておきます。

import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.is;

@QuarkusTest
class EchoResourceTest {
    @Test
    void testEchoResource() {
        given().pathParam("value", "hello")
                .when().get("/echo/{value}")
                .then()
                .statusCode(200)
                .body(is("hello"));
    }
}

@QuarkusTestはJunit5のQuarkusの拡張を追加するためのアノテーションです。
その拡張であるQuarkusTestExtensionでは、Quarkusアプリの起動とかテストに必要な諸々をやってくれてます。

また、テストはRestAssuredを使って書くようです。

JWT RBACをかける

アプリケーションとテストを書いたので、JWT認証をかけていきます。
今回、公開、非公開鍵の作成は行わず、公式サイトのチュートリアルの方で作成されているものを利用します。

まずは作成したリソースクラスを以下のように修正します。

@Path("/echo")
@RequestScoped
public class EchoResource {

    private final JsonWebToken jwt;
    static final Logger logger = Logger.getLogger(EchoResource.class);

    public EchoResource(JsonWebToken jwt) {
        this.jwt = jwt;
    }

    @GET
    @Path("{value}")
    @RolesAllowed({"Echoer", "Subscriber"})
    @Produces(MediaType.TEXT_PLAIN)
    public String echo(
            @PathParam("value") String value
    ) {
        String issuer = jwt.getIssuer();
        if (issuer != null) {
            logger.info(String.format("issuer name is %s", issuer));
        }
        return value;
    }
}

まず、@RequestScopedの部分ですがQuarkusではDIのライフサイクルがデフォルトで@ApplicationScopedになります。しかし、JWTはリクエストスコープである必要があるため、望まない挙動が起こることがあるため、@RequestScopedをつけるようにしています。

つぎに、@RolesAllowed({"Echoer"})の部分ですが、JSR 250のエンドポイントを保護するための一般的なアノテーションです。この場合呼び出し側がEchoerというロールを持つ場合に置いてアクセスが可能となります。

JsonWebTokenインターフェースはjava.security.Principalを継承しており、認証済みのトークン情報へのアクセスを提供しています。メソッドの部分では、このインターフェースを通して、クレイムをログ出力する処理を追加しています。

次に、JWT RBACに必要な設定をいくつか記述します。$srcRoot/src/main/resources/application.propertiesに以下の設定を追加します。

mp.jwt.verify.publickey.location=META-INF/resources/publicKey.pem
mp.jwt.verify.issuer=https://quarkus.io/using-jwt-rbac
# quarkus.smallrye-jwt.enabled=true

quarkus.smallrye-jwt.enabled=trueでJWTの有効無効を操作できますが、デフォルトで有効となっているので、記述する必要はありません。 その他の設定も名前から推測できるかもしれませんが、 mp.jwt.verify.publickey.locationにはJWT発行者の公開鍵を登録し、mp.jwt.verify.issuerの発行者のドメインを登録しておきます。
$srcRoot/src/main/resources/META-INF/resources/publicKey.pemを作成しておきます。

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEq
Fyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwR
TYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5e
UF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9
AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYn
sIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9x
nQIDAQAB
-----END PUBLIC KEY-----

再度書きますと、この鍵は公式サイトのチュートリアルで作成されているものをそのまま利用しています。

ここまでで、アプリケーション側の準備が整ったので、一旦cURLlを叩いてみます。

$ curl -v localhost:8080/echo/hello
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /echo/hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< www-authenticate: Bearer {token}
< Content-Length: 0
< 
* Connection #0 to host localhost left intact

401が帰っているので、きちんと認証が追加されているみたいですね。
それではこのメソッドにアクセスできるようにcurlにJWTを渡してみましょう。
利用するJsonClaimsは以下の通りとなります。

{
  "iss": "https://hirooka.dev/jwt-rbac",
  "jti": "a-123",
  "sub": "jdoe-using-jwt-rbac",
  "upn": "henoheno@hirooka.dev",
  "preferred_username": "henoheno",
  "aud": "using-jwt-rbac",
  "birthdate": "1993-05-27",
  "groups": [
    "Echoer"
  ]
}

簡単に説明を表にまとめると以下の通りです。

クレイム名 説明
iss issトークンの発行者を示しmp.jwt.verify.issuerの値と一致している必要があります
upn MicroProfile JWT RBAC specで定義されているクレイムで、Principalによって利用されます
groups JWTに付与されるトップレベルでのロールの一覧を定義します。

こいつを、以下の秘密鍵でJWTを作ります。

-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWK8UjyoHgPTLa
PLQJ8SoXLLjpHSjtLxMqmzHnFscqhTVVaDpCRCb6e3Ii/WniQTWw8RA7vf4djz4H
OzvlfBFNgvUGZHXDwnmGaNVaNzpHYFMEYBhE8VGGiveSkzqeLZI+Y02G6sQAfDtN
qqzM/l5QX8X34oQFaTBW1r49nftvCpITiwJvWyhkWtXP9RP8sXi1im5Vi3dhupOh
nelk5n0BfajUYIbfHA6ORzjHRbt7NtBl0L2J+0/FUdHyKs6KMlFGNw8O0Dq88qnM
uXoLJiewhg9332W3DFMeOveel+//cvDnRsCRtPgd4sXFPHh+UShkso7+DRsChXa6
oGGQD3GdAgMBAAECggEAAjfTSZwMHwvIXIDZB+yP+pemg4ryt84iMlbofclQV8hv
6TsI4UGwcbKxFOM5VSYxbNOisb80qasb929gixsyBjsQ8284bhPJR7r0q8h1C+jY
URA6S4pk8d/LmFakXwG9Tz6YPo3pJziuh48lzkFTk0xW2Dp4SLwtAptZY/+ZXyJ6
96QXDrZKSSM99Jh9s7a0ST66WoxSS0UC51ak+Keb0KJ1jz4bIJ2C3r4rYlSu4hHB
Y73GfkWORtQuyUDa9yDOem0/z0nr6pp+pBSXPLHADsqvZiIhxD/O0Xk5I6/zVHB3
zuoQqLERk0WvA8FXz2o8AYwcQRY2g30eX9kU4uDQAQKBgQDmf7KGImUGitsEPepF
KH5yLWYWqghHx6wfV+fdbBxoqn9WlwcQ7JbynIiVx8MX8/1lLCCe8v41ypu/eLtP
iY1ev2IKdrUStvYRSsFigRkuPHUo1ajsGHQd+ucTDf58mn7kRLW1JGMeGxo/t32B
m96Af6AiPWPEJuVfgGV0iwg+HQKBgQCmyPzL9M2rhYZn1AozRUguvlpmJHU2DpqS
34Q+7x2Ghf7MgBUhqE0t3FAOxEC7IYBwHmeYOvFR8ZkVRKNF4gbnF9RtLdz0DMEG
5qsMnvJUSQbNB1yVjUCnDAtElqiFRlQ/k0LgYkjKDY7LfciZl9uJRl0OSYeX/qG2
tRW09tOpgQKBgBSGkpM3RN/MRayfBtmZvYjVWh3yjkI2GbHA1jj1g6IebLB9SnfL
WbXJErCj1U+wvoPf5hfBc7m+jRgD3Eo86YXibQyZfY5pFIh9q7Ll5CQl5hj4zc4Y
b16sFR+xQ1Q9Pcd+BuBWmSz5JOE/qcF869dthgkGhnfVLt/OQzqZluZRAoGAXQ09
nT0TkmKIvlza5Af/YbTqEpq8mlBDhTYXPlWCD4+qvMWpBII1rSSBtftgcgca9XLB
MXmRMbqtQeRtg4u7dishZVh1MeP7vbHsNLppUQT9Ol6lFPsd2xUpJDc6BkFat62d
Xjr3iWNPC9E9nhPPdCNBv7reX7q81obpeXFMXgECgYEAmk2Qlus3OV0tfoNRqNpe
Mb0teduf2+h3xaI1XDIzPVtZF35ELY/RkAHlmWRT4PCdR0zXDidE67L6XdJyecSt
FdOUH8z5qUraVVebRFvJqf/oGsXc4+ex1ZKUTbY0wqY1y9E39yvB3MaTmZFuuqk8
f3cg+fr8aou7pr9SHhJlZCU=
-----END PRIVATE KEY-----

詳細な、JWTの作成方法はここをご覧ください。 作成されたトークンは以下の通りになります。
(トークンの有効期限がきれていると思うので、自分で作成してください)

eyJraWQiOiIvcHJpdmF0ZUtleS5wZW0iLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2hpcm9va2EuZGV2L2p3dC1yYmFjIiwianRpIjoiYS0xMjMiLCJzdWIiOiJqZG9lLXVzaW5nLWp3dC1yYmFjIiwidXBuIjoiaGVub2hlbm9AaGlyb29rYS5kZXYiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJoZW5vaGVubyIsImF1ZCI6InVzaW5nLWp3dC1yYmFjIiwiYmlydGhkYXRlIjoiMTk5My0wNS0yNyIsImdyb3VwcyI6WyJFY2hvZXIiXSwiaWF0IjoxNTk2Mzc1NDEwLCJhdXRoX3RpbWUiOjE1OTYzNzU0MTAsImV4cCI6MTU5NjM3NTcxMH0.aGBodEwgJIJMbe36F9HRqeZtA2ZGTxbOvfyax7iqbbHpAO4RajtbkQOiPG6BXIDQUAV6lhnR6-SjHhWu1i9B1oHkUMQUKEA70C9wMeUQCk1Va_NWkjsMbUMv-n5inkC2RLzBraNg91MI7d9HqawzC4oOQl1MB2oSDsaQIKQs_oy8lMmBvGYFi9TkiZxrbkpeiyZi5eFGCxr5m7yonEYkPz8c6-C5FfjInyaCU8v-OTHKQRJN0QSQckkUsJuZdfc1k-tTYmUfyTqJpkFpukCq6Lfob6qELpmYB3Zc5UQ8I-__OcFFT7DOjRt7HpjL8xdh1RHIz4pEjJY8J6NGj6XTtw

最後に作成したトークンをつかってエンドポイントにアクセスしてみます。

$ curl -v -H "Authorization: Bearer eyJraWQiOiIvcHJpdmF0ZUtleS5wZW0iLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2hpcm9va2EuZGV2L2p3dC1yYmFjIiwianRpIjoiYS0xMjMiLCJzdWIiOiJqZG9lLXVzaW5nLWp3dC1yYmFjIiwidXBuIjoiaGVub2hlbm9AaGlyb29rYS5kZXYiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJoZW5vaGVubyIsImF1ZCI6InVzaW5nLWp3dC1yYmFjIiwiYmlydGhkYXRlIjoiMTk5My0wNS0yNyIsImdyb3VwcyI6WyJFY2hvZXIiXSwiaWF0IjoxNTk2Mzc1NDEwLCJhdXRoX3RpbWUiOjE1OTYzNzU0MTAsImV4cCI6MTU5NjM3NTcxMH0.aGBodEwgJIJMbe36F9HRqeZtA2ZGTxbOvfyax7iqbbHpAO4RajtbkQOiPG6BXIDQUAV6lhnR6-SjHhWu1i9B1oHkUMQUKEA70C9wMeUQCk1Va_NWkjsMbUMv-n5inkC2RLzBraNg91MI7d9HqawzC4oOQl1MB2oSDsaQIKQs_oy8lMmBvGYFi9TkiZxrbkpeiyZi5eFGCxr5m7yonEYkPz8c6-C5FfjInyaCU8v-OTHKQRJN0QSQckkUsJuZdfc1k-tTYmUfyTqJpkFpukCq6Lfob6qELpmYB3Zc5UQ8I-__OcFFT7DOjRt7HpjL8xdh1RHIz4pEjJY8J6NGj6XTtw" localhost:8080/echo/hello
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /echo/hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> Authorization: Bearer eyJraWQiOiIvcHJpdmF0ZUtleS5wZW0iLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2hpcm9va2EuZGV2L2p3dC1yYmFjIiwianRpIjoiYS0xMjMiLCJzdWIiOiJqZG9lLXVzaW5nLWp3dC1yYmFjIiwidXBuIjoiaGVub2hlbm9AaGlyb29rYS5kZXYiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJoZW5vaGVubyIsImF1ZCI6InVzaW5nLWp3dC1yYmFjIiwiYmlydGhkYXRlIjoiMTk5My0wNS0yNyIsImdyb3VwcyI6WyJFY2hvZXIiXSwiaWF0IjoxNTk2Mzc1NDEwLCJhdXRoX3RpbWUiOjE1OTYzNzU0MTAsImV4cCI6MTU5NjM3NTcxMH0.aGBodEwgJIJMbe36F9HRqeZtA2ZGTxbOvfyax7iqbbHpAO4RajtbkQOiPG6BXIDQUAV6lhnR6-SjHhWu1i9B1oHkUMQUKEA70C9wMeUQCk1Va_NWkjsMbUMv-n5inkC2RLzBraNg91MI7d9HqawzC4oOQl1MB2oSDsaQIKQs_oy8lMmBvGYFi9TkiZxrbkpeiyZi5eFGCxr5m7yonEYkPz8c6-C5FfjInyaCU8v-OTHKQRJN0QSQckkUsJuZdfc1k-tTYmUfyTqJpkFpukCq6Lfob6qELpmYB3Zc5UQ8I-__OcFFT7DOjRt7HpjL8xdh1RHIz4pEjJY8J6NGj6XTtw
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Length: 5
< Content-Type: text/plain;charset=UTF-8
< 
* Connection #0 to host localhost left intact
hello

今度はきちんとアクセスができていることを確認できました。 また、ログには以下のように出力されておりクレイムへのアクセスもできていることがわかります。

2020-08-02 22:37:09,599 INFO  [dev.hir.EchoResource] (executor-thread-199) issuer name is https://hirooka.dev/jwt-rbac