CLOVER🍀

That was when it all began.

Java JWTでJWT

これは、なにをしたくて書いたもの?

  • 最近、ちょっとJWTについて知らないといけないなぁと思うできごとがありまして
  • 裏の仕組みとしてJWTを使っているのもいいのですが、もう少しJWT自体に向き合ってみようと
  • なにかしらJWTを扱えるライブラリを使って試しつつ、感覚を掴んでみる

という、とにかくJWTを扱ってみようという話。

Java JWT

JWTを扱うのには、Java JWTというライブラリを選んでみました。

GitHub - jwtk/jjwt: Java JWT: JSON Web Token for Java and Android

jwt.ioにも載っているライブラリで、GitHubのスター数も多めかなと。

JSON Web Tokens - jwt.io

そもそも、JWT自体は?

仕様を読もう。

JSON Web Token (JWT)

JWTによるJSONに対する電子署名と、そのユースケース | DevelopersIO

このあたりを読んでいて、JWT(JSON Web Token)、JWS(JSON Web Signature)、JWE(JSON Web Encryption)の
関係がちょっとわかる感じですねぇ。

JSON Web Token (JWT)

JSON Web Token (JWT)

クレームのセットをJSONオブジェクトとして文字列表現にしてJWSやJWEにエンコードすることで, クレームに対するデジタル署名やMACと暗号化の両方が可能になる.

JSON Web Signature (JWS)

JSON Web Signature (JWS)

デジタル署名もしくはMAC化されたメッセージを表現するデータ構造. JWSは以下の3つの値より構成される: JWS ヘッダ, JWS ペイロード, JWS 署名

仕様書のJWTの例を見ていくと、JSONであるJWTヘッダ、JWTクレーム・セットからJWSが作られていくさまが
わかりますね。

JWTの例

Java JWTの「Signed JWTs」を見ると、その様子がコードで書かれているので、こちらの方が感覚をつかみやすいかも
しれません。

Signed JWTs

JWSを構成する文字列は、Base64で簡単にデコードでき、JWTヘッダ、JWTクレーム・セット、署名となり、内容自体は誰でも読める、
という感じですね(改ざん防止は署名でできる)。

Java JWTを使ってみる

というわけで、ここまで前置きにしてJava JWTでちょっと遊んでみましょう。

環境

今回の環境は、こちら。

$ java -version
openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-1ubuntu0.18.04.1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)


$ mvn -version
Apache Maven 3.6.0 (97c98ec64a1fdfee7767ce5ffb20918da4f719f3; 2018-10-25T03:41:47+09:00)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 1.8.0_181, vendor: Oracle Corporation, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-39-generic", arch: "amd64", family: "unix"
準備

Maven依存関係は、こちら。

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.10.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.10.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.10.5</version>
            <scope>runtime</scope>
        </dependency>

jjwt-apiとjjwt-implはとりあえず必要なのと、JSONを扱うためのライブラリが必要です。Java JWTはJacksonとJSON-Javaの
シリアライザー/デシリアライザーを提供しており、このどちらかを使うことになります。

なお、jjwt-api以外は、いずれもスコープはruntimeでOKです。

あとは、テストコードで確認するので、テストライブラリを追加。

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.3.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.3.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.11.1</version>
            <scope>test</scope>
        </dependency>
テストコードの雛形

テストコードの雛形は、こちら。
src/test/java/org/littlewings/jjwt/JjwtTest.java

package org.littlewings.jjwt;

import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.crypto.spec.SecretKeySpec;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class JjwtTest {

    // ここに、テストを書く!!
}

ここに、テストを足していきたいと思います。

簡単なJava JWTのサンプル

とりあえず、書いてみたのはこんな感じ。

    @Test
    public void gettingStarted() {
        String secretKey = UUID.randomUUID().toString();
        byte[] secretKeyAsBytes = secretKey.getBytes(StandardCharsets.UTF_8);

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .signWith(Keys.hmacShaKeyFor(secretKeyAsBytes))
                        .compact();

        System.out.println("JWT = " + jwsString);

        Jws<Claims> parsedJws =
                Jwts.parser()
                        .setSigningKey(Keys.hmacShaKeyFor(secretKeyAsBytes))
                        .parseClaimsJws(jwsString);

        System.out.println("parsed JWT = " + parsedJws);

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getHeader().getAlgorithm()).isEqualTo("HS256");
    }

秘密鍵はランダムUUIDで適当に生成、JWTクレーム・セットはsubjectのみです。

JWTを作っているのは、この部分です。

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .signWith(Keys.hmacShaKeyFor(secretKeyAsBytes))
                        .compact();

アルゴリズムは、鍵の長さに合わせて選択してもらいました。

選択肢は、このバージョンだと「HmacSHA512」、「HmacSHA384」、「HmacSHA256」から選ばれます。

生成されたJWTは、こんな感じになります。

JWT = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLno6_ph47jgqvjg4TjgqoifQ.wHj5Yg1P9lh4MXQuf5ViXqgHeA9thpZ0vsvfIGAeZ80

パースしてみましょう。

        Jws<Claims> parsedJws =
                Jwts.parser()
                        .setSigningKey(Keys.hmacShaKeyFor(secretKeyAsBytes))
                        .parseClaimsJws(jwsString);

JW"S"としてパースします。

System.out.printlnしてみると、こんな感じになっています。

parsed JWT = header={alg=HS256},body={sub=磯野カツオ},signature=wHj5Yg1P9lh4MXQuf5ViXqgHeA9thpZ0vsvfIGAeZ80

中身を確認。

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getHeader().getAlgorithm()).isEqualTo("HS256");

今回の秘密鍵では、「HmacSHA256」アルゴリズムが選択されたようです(JWT上は「HS512」)。

Java JWTのサンプルでは、秘密鍵を自動生成するサンプルになっているのですが、こちらはまあいいかなと。
※アルゴリズムは明示してある

Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

String jws = Jwts.builder().setSubject("Joe").signWith(key).compact();
アルゴリズムを明示する

先ほどはアルゴリズムをライブラリ側に選んでもらっていましたが、今度は明示してみます。

    @Test
    public void specifiedAlgorithm() {
        String secretKey = UUID.randomUUID().toString();
        byte[] secretKeyAsBytes = secretKey.getBytes(StandardCharsets.UTF_8);

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

        System.out.println("JWT = " + jwsString);

        Jws<Claims> parsedJws =
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString);

        System.out.println("parsed JWT = " + parsedJws);

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getHeader().getAlgorithm()).isEqualTo("HS256");
    }

SecretKeySpecを使って、指定しました。

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

これは、先ほどのライブラリ側で選んでもらっている場合に、内部的に使われている方法と同じなのですがね…。

JWTと、パースした結果はこちらです。

JWT = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLno6_ph47jgqvjg4TjgqoifQ.zr8C4O7IlDpHrnr-8gcEUtlFTMihFL0GAGppGklGhx4
parsed JWT = header={alg=HS256},body={sub=磯野カツオ},signature=zr8C4O7IlDpHrnr-8gcEUtlFTMihFL0GAGppGklGhx4
JWTに有効期限を設定する

続いて、JWTに有効期限を設定してみましょう。

    @Test
    public void expired() {
        String secretKey = UUID.randomUUID().toString();
        byte[] secretKeyAsBytes = secretKey.getBytes(StandardCharsets.UTF_8);

        long timeout = 1 * 1000; // 1 sec

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .setExpiration(new Date(System.currentTimeMillis() + timeout))
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

        System.out.println("JWT = " + jwsString);

        Jws<Claims> parsedJws =
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString);

        System.out.println("parsed JWT = " + parsedJws);

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getHeader().getAlgorithm()).isEqualTo("HS256");

        try {
            TimeUnit.SECONDS.sleep(2L);
        } catch (InterruptedException e) {
            // ignore
        }

        assertThatThrownBy(() ->
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString)
        )
                .isInstanceOf(ExpiredJwtException.class)
                .hasMessageContaining("JWT expired at");
    }

有効期限は、Dateで指定します。今回は、有効期限を1秒にしてみます。

        long timeout = 1 * 1000; // 1 sec

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .setExpiration(new Date(System.currentTimeMillis() + timeout))
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

こうすると、JWTを作成した直後はパースできますが

        Jws<Claims> parsedJws =
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString);

        System.out.println("parsed JWT = " + parsedJws);

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getHeader().getAlgorithm()).isEqualTo("HS256");

少し待つと、有効期限切れで例外がスローされます。

        try {
            TimeUnit.SECONDS.sleep(2L);
        } catch (InterruptedException e) {
            // ignore
        }

        assertThatThrownBy(() ->
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString)
        )
                .isInstanceOf(ExpiredJwtException.class)
                .hasMessageContaining("JWT expired at");

生成されたJWTと、パース結果はこちら。

JWT = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLno6_ph47jgqvjg4TjgqoiLCJleHAiOjE1NDIyOTQ0NzJ9.jaio2KTNJ-OQdMJ-qe9mhOv6LFrvA8PKEz6rvW10Tx0
parsed JWT = header={alg=HS256},body={sub=磯野カツオ, exp=1542294472},signature=jaio2KTNJ-OQdMJ-qe9mhOv6LFrvA8PKEz6rvW10Tx0
署名をちょっといじってみる

JWT(というかJWS)を構成している、最後の要素である署名を少しいじって、検証でエラーになることを確認してみます。

    @Test
    public void invalidKey() {
        String secretKey = UUID.randomUUID().toString();
        byte[] secretKeyAsBytes = secretKey.getBytes(StandardCharsets.UTF_8);

        String jwsString =
                Jwts.builder()
                        .setSubject("磯野カツオ")
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

        System.out.println("JWT = " + jwsString);

        assertThatThrownBy(() ->
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString.substring(0, jwsString.length() - 1))
        )
                .isInstanceOf(SignatureException.class)
                .hasMessage("JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.");
    }

JWTを1文字削ったので、署名が合わなくなり例外がスローされました。

JWTクレーム・セットを設定する

最後に、JWTクレーム・セットを設定してみます。

以下のように、Claimsを使うことでMapのように(というか、Mapの実装になっているのですが)、任意の項目を設定
することができます。

    @Test
    public void setClaims() {
        String secretKey = UUID.randomUUID().toString();
        byte[] secretKeyAsBytes = secretKey.getBytes(StandardCharsets.UTF_8);

        Claims claims = Jwts.claims();
        claims.put(Claims.SUBJECT, "磯野カツオ");
        claims.put(Claims.ISSUER, "foo");
        claims.put("mydata", "Hello!!");

        String jwsString =
                Jwts.builder()
                        .setClaims(claims)
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

        System.out.println("JWT = " + jwsString);

        Jws<Claims> parsedJws =
                Jwts.parser()
                        .setSigningKey(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .parseClaimsJws(jwsString);

        System.out.println("parsed JWT = " + parsedJws);

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getBody().getIssuer()).isEqualTo("foo");
        assertThat(parsedJws.getBody().get("mydata")).isEqualTo("Hello!!");

        assertThat(parsedJws.getBody().toString()).isEqualTo("{sub=磯野カツオ, iss=foo, mydata=Hello!!}");

        assertThat(parsedJws.getHeader().getAlgorithm()).isEqualTo("HS256");
    }

こんな感じですね。

        Claims claims = Jwts.claims();
        claims.put(Claims.SUBJECT, "磯野カツオ");
        claims.put(Claims.ISSUER, "foo");
        claims.put("mydata", "Hello!!");

        String jwsString =
                Jwts.builder()
                        .setClaims(claims)
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

もともと標準的に決まっているもの(Standard Claims)については、以下のようにメソッドが用意されていたりします。

        String jwsString =
                Jwts.builder()
                        .setSubject("...")
                        .setIssuer("...")
                        .signWith(new SecretKeySpec(secretKeyAsBytes, "HmacSHA512"))
                        .compact();

Claimsに対しても、キーが定義されたりしてましたね。

        Claims claims = Jwts.claims();
        claims.put(Claims.SUBJECT, "磯野カツオ");
        claims.put(Claims.ISSUER, "foo");
        claims.put("mydata", "Hello!!");

getterもあるわけですよ。

        assertThat(parsedJws.getBody().getSubject()).isEqualTo("磯野カツオ");
        assertThat(parsedJws.getBody().getIssuer()).isEqualTo("foo");

Mapのキーを、Builderに対して直接設定することもできるので、そのあたりはドキュメントを参照するとよいでしょう。

Claims

同様のことが、JWTヘッダーにも言えます。

Header Parameters

このサンプルで、生成されたJWTとパース結果は、こちらです。

JWT = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLno6_ph47jgqvjg4TjgqoiLCJpc3MiOiJmb28iLCJteWRhdGEiOiJIZWxsbyEhIn0.r2Ew-mOe5G05x_1_Y6qM7FeTEpteev_Rc2YKNjAOOZM
parsed JWT = header={alg=HS256},body={sub=磯野カツオ, iss=foo, mydata=Hello!!},signature=r2Ew-mOe5G05x_1_Y6qM7FeTEpteev_Rc2YKNjAOOZM

なんとなく、雰囲気はつかめたので、これで良しとしましょう。