続・Auth0 java-jwtを使った素のJWT認証 - 公開鍵方式でやってみた

| 17 min read
Author: toshio-ogiwara toshio-ogiwaraの画像

これは、豆蔵デベロッパーサイトアドベントカレンダー2022第25日目で今回が最後の記事になります🙌

Auth0 java-jwtを使った素のJWT認証では理解が比較的容易な共通鍵方式による仕組みを紹介しましたが、今回はその続きとしてRSAの公開鍵方式で同じことをやってみたいと思います。説明は前回の記事をなぞる形で進めて行くため、内容が重複する部分の説明は割愛します。行間が読めないところや「そこもうちょっと説明を」などといったところがある場合は前回の記事を確認していただければと思います。

なお、記事はサンプルアプリの必要な部分の抜粋を記載しています。全量を確認したい、または動作させてみたい方は説明に使用したコードを一式GitHubリポジトリにアップしていますので、そちらを参考にしてください。

JWTの用語について

JWT認証で使われる文字列は正しくはJWS(JSON Web Signature)ですが、記事では一般的な呼び方にならいJWSを単にトークンまたは認証トークンと呼んでいます。

利用するJWTライブラリ

#

共通鍵方式と同じ次のjava-jwtライブラリを使います。

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.2.1</version>
</dependency>

暗号化方式に依らずトークンの生成、検証に必要なものはこのライブラリに含まれています(厳密にはJSONのシリアライズ/デシリアライズを行うjackson-databindは推移的依存により取得されます)

トークンの生成と検証

#

共通鍵方式のときと同様に簡単なコンソールアプリを例にjava-jwtにおける公開鍵方式の使い方をみていきます。サンプルアプリの内容は共通鍵方式と同じとなるため、詳細はこちらを参照としますが、全体のイメージとしては次のようになります。

rsa-gen-verify

なお、秘密鍵と公開鍵はjarに同梱し、そのパスは環境変数から取得するようにします。

秘密鍵と公開鍵の作成

#

RSA暗号による秘密鍵と公開鍵の作成方法はいくつかありますが、ここではopensslコマンドを使って次のように作成します。なお、公開鍵とはなにか?や暗号鍵の生成方法に関する細かい説明はしませんので、必要に応じて別途ネットの情報等を参考にしてください。

  1. 秘密鍵の作成(PKCS#1[1])
    まずは秘密鍵の生成から。
openssl genrsa -out jwt.key.p1 512
512bitの鍵長はキケンですよ!

今回はサンプルのため生成されるトークンが短くなるように敢えて512bitの鍵長を使っていますが、強度が低くプロダクション環境で利用するのはキケンです。現在、安全といわれている鍵長の主流は2048bitとなっています。

  1. 公開鍵の作成
    次に生成した秘密鍵から公開鍵を生成します。
openssl rsa -in jwt.key.p1 -pubout -outform PEM -out jwt.pub.key
  1. 秘密鍵の変換(PKCS#1からPKCS#8へ)
    Javaの標準APIでPKCS#1の鍵フォーマットは直接扱えないため、最後に先ほど生成した秘密鍵を標準APIで扱えるPKCS#8[2]に変換します。
openssl pkcs8 -in jwt.key.p1 -out jwt.key -topk8 -nocrypt

鍵の準備は以上です。記事ではこの2つの鍵を使ってサンプルを説明していきます。

トークンの生成実装(RsaJwtProducer)

#

公開鍵方式でトークンを生成する実装は次のようになります。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.OffsetDateTime;
import java.util.Base64;
import java.util.UUID;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;

public class RsaJwtProducer {
    private String keyPath;
    public RsaJwtProducer(String path) {
        this.keyPath = path;
    }
    public String generateToken() {
        Algorithm alg = Algorithm.RSA256(createPrivateKey());
        String token = JWT.create()
                .withIssuer("RsaJwtProducer")
                .withSubject("ID12345")
                .withExpiresAt(OffsetDateTime.now().plusMinutes(60).toInstant())
                .withIssuedAt(OffsetDateTime.now().toInstant())
                .withJWTId(UUID.randomUUID().toString())
                .withClaim("email", "id123459@exact.io")
                .withArrayClaim("groups", new String[] { "member", "admin" })
                .sign(alg);
        return token;
    }
    private RSAPrivateKey createPrivateKey() {
        try (InputStream is = this.getClass().getResourceAsStream(this.keyPath);
                BufferedReader buff = new BufferedReader(new InputStreamReader(is))) {
            var pem = new StringBuilder();
            String line;
            while ((line = buff.readLine()) != null) {
                pem.append(line);
            }

            String privateKeyPem = pem.toString()
                    .replace("-----BEGIN PRIVATE KEY-----", "")
                    .replaceAll(System.lineSeparator(), "")
                    .replace("-----END PRIVATE KEY-----", "");

                  byte[] encoded = Base64.getDecoder().decode(privateKeyPem);
                  PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded);
                  KeyFactory keyFactory = KeyFactory.getInstance("RSA");
                  return (RSAPrivateKey) keyFactory.generatePrivate(keySpec);
        } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new IllegalStateException(e);
        }
    }
    public static void main(String[] args) {
        String keyPath = System.getenv("KEY_PATH");
        System.out.println(new RsaJwtProducer(keyPath).generateToken());
    }
}

共通鍵方式の実装と比べて分かるとおり、違いは先頭のAlgorithmインスタンスの生成部分だけで他はすべて共通鍵方式と同じになります。この共通鍵方式との差分となるcreatePrivateKeyメソッドの内容は次のとおりになります。

  • 環境変数KEY_PATHで指定されたクラスパス上の秘密鍵ファイルを読み込む
  • ファイルはPEM形式[3]のため、ヘッダー行(-----BEGIN …)とフッター行(----END …)を除去し、BASE64デコードする
  • BASE64デコードしたバイナリデータをPKCS#8の暗号鍵として扱えるようにPKCS8EncodedKeySpecに変換する
  • 変換したものをRSAのKeyFactoryインスタンスに与えてRSAPrivateKeyインスタンスを生成する

createPrivateKeyメソッドで使っているクラスはすべてJava標準APIのjava.*パッケージのものでAuth0に固有なものはなにもありません。RSAPrivateKeyインスタンスの生成手順を細かく説明しましたが、PKCS#8のPEM形式の秘密鍵ファイルからRSAPrivateKeyインスタンスを生成する手順はJava標準APIの手順となるため、java-jwt以外でも同じとなります。このため、RSAPrivateKeyインスタンスさえ生成すれば後は共通鍵方式と同じようにトークンを生成することができます。

RsaJwtProducerの実行

#

それではRsaJwtProducerクラスを実行してみましょう。

秘密鍵はクラスパス直下の/jwt.key[4]に配置しているので、環境変数KEY_PATHにこの値を設定します。また、RsaJwtProducerは-jarオプションで起動可能なExecutable Jar形式でビルドしています。

実行した結果は次のとおりです。

export KEY_PATH=/jwt.key
java -jar target/rsa-jwt-producer.jar

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJJRDEyMzQ1IiwiaXNzIjoiUnNhSnd0UHJvZHVjZXIiLCJncm91cHMiOlsibWVtYmVyIiwiYWRtaW4iXSwiZXhwIjoxNjcxMDk5NTMxLCJpYXQiOjE2NzEwOTU5MzEsImp0aSI6ImI4MmM1NGU2LTA2ODktNGZhYy1iOTQ5LTY5YjlhYWY0MTQ5MiIsImVtYWlsIjoiaWQxMjM0NTlAZXhhY3QuaW8ifQ.r6o8QdjLwQUI2DM5jchHCiHSv4tI4Y7SsMV5lbBo0-BzW2gAcoqeXOI5fFlX0leNTawgHQX8N-PSre_RumNTJQ

実行結果からヘッダーとペイロード、シグニチャが.(ドット)で連結されたトークンを取得することができました。

秘密鍵の管理と置き場は慎重に!

今回はサンプルのためjarの中に秘密鍵を格納していますが、いわずもがなですが秘密鍵は漏洩することのないように厳格に管理する必要があります。鍵情報の管理はそれだけで本が一冊書けるくらい深いテーマのため、ここでは触れませんが、少なくともプロダクション環境で今回のサンプルのように単にjarに同梱しただけというのはやめましょう。

トークンの検証実装(RsaJwtConsumer)

#

今度は秘密鍵で生成した先ほどのトークンを公開鍵を使って検証する方法をみていきます。公開鍵方式でトークンを検証する実装は次のようになります。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.stream.Collectors;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;

public class RsaJwtConsumer {
    private String publicKeyPath;
    public RsaJwtConsumer(String path) {
        this.publicKeyPath = path;
    }
    public DecodedJWT verifyToken(String token) {
        Algorithm alg = Algorithm.RSA256(createPublicKey());
        JWTVerifier verifier = JWT.require(alg)
                .withIssuer("RsaJwtProducer")
                .acceptExpiresAt(5)
                .build();
        try {
            return verifier.verify(token);
        } catch (JWTVerificationException e) {
            System.out.println("JWT verification failed..");
            throw e;
        }
    }
    private RSAPublicKey createPublicKey() {
        try (InputStream is = this.getClass().getResourceAsStream(publicKeyPath);
                BufferedReader buff = new BufferedReader(new InputStreamReader(is))) {
            var pem = new StringBuilder();
            String line;
            while ((line = buff.readLine()) != null) {
                pem.append(line);
            }

            String publicKeyPem = pem.toString()
                    .replace("-----BEGIN PUBLIC KEY-----", "")
                    .replaceAll(System.lineSeparator(), "")
                    .replace("-----END PUBLIC KEY-----", "");

                  byte[] encoded = Base64.getDecoder().decode(publicKeyPem);
                  X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
                  KeyFactory keyFactory = KeyFactory.getInstance("RSA");
                  return (RSAPublicKey) keyFactory.generatePublic(keySpec);
        } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new IllegalStateException(e);
        }
    }
    public static void main(String[] args) {
        String secretkey = System.getenv("PUB_KEY_PATH");

        DecodedJWT jwt = new RsaJwtConsumer(secretkey).verifyToken(args[0]);

        System.out.println("----- DecodedJWT -----");
        System.out.println("alg:" + jwt.getAlgorithm());
        System.out.println("typ:" + jwt.getType());
        System.out.println("issuer:" + jwt.getIssuer());
        System.out.println("subject:" + jwt.getSubject());
        System.out.println("expiresAt:" + jwt.getExpiresAt());
        System.out.println("issuerAt:" + jwt.getIssuedAt());
        System.out.println("JWT-ID:" + jwt.getId());
        System.out.println("email:" + jwt.getClaim("email").asString());
        System.out.println("groups:" + jwt.getClaim("groups")
                    .asList(String.class).stream()
                    .collect(Collectors.joining(",")));
    }
}

トークンの生成と同じように共通鍵方式の実装との差分は公開鍵の生成を行うcreatePublicKeyメソッド部分だけです。このメソッドではJava標準APIのRSAPublicKeyインスタンスを生成していますが、その手順はトークン生成で行ったRSAPrivateKeyインスタンスの生成手順とほぼ同じで、違いはデコードしたバイナリデータをX509EncodedKeySpecインスタンスにする箇所のみです。

RsaJwtConsumerの実行

#

それではRsaJwtConsumerを使ってRsaJwtProducerで取得したトークンを検証し、復元された内容を確認してみましょう。

秘密鍵から生成した公開鍵はクラスパス直下の/jwt.pub.key[5]に配置しているので、環境変数PUB_KEY_PATHにこの値を設定します。また、RsaJwtConsumerも-jarオプションで起動可能なExecutable Jar形式でビルドしています。

この実行した結果は次のとおりです。

export PUB_KEY_PATH=/jwt.pub.key
java -jar target/rsa-jwt-consumer.jar eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJJRDEyMzQ1IiwiaXNzIjoiUnNhSnd0UHJvZHVjZXIiLCJncm91cHMiOlsibWVtYmVyIiwiYWRtaW4iXSwiZXhwIjoxNjcxMDk5NTMxLCJpYXQiOjE2NzEwOTU5MzEsImp0aSI6ImI4MmM1NGU2LTA2ODktNGZhYy1iOTQ5LTY5YjlhYWY0MTQ5MiIsImVtYWlsIjoiaWQxMjM0NTlAZXhhY3QuaW8ifQ.r6o8QdjLwQUI2DM5jchHCiHSv4tI4Y7SsMV5lbBo0-BzW2gAcoqeXOI5fFlX0leNTawgHQX8N-PSre_RumNTJQ

----- DecodedJWT -----
alg:RS256
typ:JWT
issuer:RsaJwtProducer
subject:ID12345
expiresAt:Thu Dec 15 21:18:51 JST 2022
issuerAt:Thu Dec 15 21:18:51 JST 2022
JWT-ID:b82c54e6-0689-4fac-b949-69b9aaf41492
email:id123459@exact.io
groups:member,admin

トークン生成時に設定したクレーム値が復元できていることが確認できます。

共通鍵方式と公開鍵方式の実装から分かるとおり、java-jwtは暗号/復号化の操作をAlgorithmクラスでうまく抽象化しているため、異なる暗号化方式を使う場合でも、Algorithmインスタンスの生成箇所以外はすべて同じように実装できるようになっています。

JWT認証の実装

#

共通鍵方式と同じように今度は公開鍵方式でJWT認証を行うサンプルアプリを実装してみます。サンプルアプリの内容は共通鍵方式のこちらと同じとなるため細かい説明は省略しますが、そのイメージは次のようになります。

java-jwt-auth

サンプルアプリの実装

#

共通鍵方式の実装との違いは認証トークンの生成と検証を行うクラス(赤で色掛けしているクラス)だけとなるため、ここではその実装のみを紹介します。

SimpleIDProviderの構造と実装

#

simpleidprovider-structure

  • AuthTokenProducer
public class AuthTokenProducer {
    private String keyPath;
    public AuthTokenProducer(String path) {
        this.keyPath = path;
    }
    public String generateToken(User user) {
        Algorithm alg = Algorithm.RSA256(createPrivateKey());
        String token = JWT.create()
                .withIssuer("AuthTokenProducer")
                .withSubject(user.id())
                .withExpiresAt(OffsetDateTime.now().plusMinutes(60).toInstant())
                .withIssuedAt(OffsetDateTime.now().toInstant())
                .withJWTId(UUID.randomUUID().toString())
                .withClaim("name", user.name())
                .sign(alg);
        return token;
    }
    private RSAPrivateKey createPrivateKey() {
        try (InputStream is = this.getClass().getResourceAsStream(this.keyPath);
                BufferedReader buff = new BufferedReader(new InputStreamReader(is))) {
            var pem = new StringBuilder();
            String line;
            while ((line = buff.readLine()) != null) {
                pem.append(line);
            }

            String privateKeyPem = pem.toString()
                    .replace("-----BEGIN PRIVATE KEY-----", "")
                    .replaceAll(System.lineSeparator(), "")
                    .replace("-----END PRIVATE KEY-----", "");

                  byte[] encoded = Base64.getDecoder().decode(privateKeyPem);
                  PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded);

                  KeyFactory keyFactory = KeyFactory.getInstance("RSA");
                  return (RSAPrivateKey) keyFactory.generatePrivate(keySpec);

        } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new IllegalStateException(e);
        }
    }
}

AddCalculatorの構造と実装

#

addcalculator-structure

  • AuthTokenVerifier
public class AuthTokenVerifier {
    // JWTVerifierはスレッドセーフのため使いまわしてもOK
    private JWTVerifier verifier;

    public AuthTokenVerifier(String publicKeyPath) {
        Algorithm alg = Algorithm.RSA256(createPublicKey(publicKeyPath));
        this.verifier = JWT.require(alg)
                .withIssuer("AuthTokenProducer")
                .acceptExpiresAt(5)
                .build();
    }
    public DecodedJWT verifyToken(String token) {
        try {
            return verifier.verify(token);
        } catch (JWTVerificationException e) {
            System.out.println("JWT verification failed..");
            throw e;
        }
    }
    private RSAPublicKey createPublicKey(String publicKeyPath) {
        try (InputStream is = this.getClass().getResourceAsStream(publicKeyPath);
                BufferedReader buff = new BufferedReader(new InputStreamReader(is))) {
            var pem = new StringBuilder();
            String line;
            while ((line = buff.readLine()) != null) {
                pem.append(line);
            }

            String publicKeyPem = pem.toString()
                    .replace("-----BEGIN PUBLIC KEY-----", "")
                    .replaceAll(System.lineSeparator(), "")
                    .replace("-----END PUBLIC KEY-----", "");

                  byte[] encoded = Base64.getDecoder().decode(publicKeyPem);
                  X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
                  KeyFactory keyFactory = KeyFactory.getInstance("RSA");
                  return (RSAPublicKey) keyFactory.generatePublic(keySpec);
        } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new IllegalStateException(e);
        }
    }
}

サンプルアプリの実行

#

認証トークンの署名と検証を行う秘密鍵と公開鍵は先ほどのサンプルと同じようにクラスパス直下の/jwt.key/jwt.pub.keyに配置しています。また、SimpleIDProviderAddCalculatorのいずれも-jarオプションで起動可能なExecutable Jar形式でビルドしています。

では、まずSimpleIDProviderに登録されているsoramame/emamarosで、SimpleIDProviderを起動してみます。

export KEY_PATH=/jwt.key
java -jar target/rsa-simple-idprovider.jar soramame emamaros

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzb3JhbWFtZSIsImlzcyI6IkF1dGhUb2tlblByb2R1Y2VyIiwibmFtZSI6IuOBneOCieixhiDlpKrpg44iLCJleHAiOjE2NzExMDI2MzIsImlhdCI6MTY3MTA5OTAzMiwianRpIjoiZjgwMmU5OTItMDU5ZS00ZDVmLWIxYTMtYjRiZjNhMTk1NjQzIn0.CUbzM4lAMoTM2bOexQPMJvyr7HNN3b6lFB7uKN1xQp371ahhZwNHRQG6Xg4IzwS3HxGJlz0HUkieyIAflEd88g

認証が成功しsoramameのユーザ情報に基づいた認証トークンが生成されコンソールに出力されます。

次にこの認証トークンを使ってAddCalculatorを起動しみてみます。第1引数の3と第2引数の4は加算する2つの値となります。

export PUB_KEY_PATH=/jwt.pub.key
java -jar target/rsa-add-calculator.jar 3 4 eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzb3JhbWFtZSIsImlzcyI6IkF1dGhUb2tlblByb2R1Y2VyIiwibmFtZSI6IuOBneOCieixhiDlpKrpg44iLCJleHAiOjE2NzExMDI2MzIsImlhdCI6MTY3MTA5OTAzMiwianRpIjoiZjgwMmU5OTItMDU5ZS00ZDVmLWIxYTMtYjRiZjNhMTk1NjQzIn0.CUbzM4lAMoTM2bOexQPMJvyr7HNN3b6lFB7uKN1xQp371ahhZwNHRQG6Xg4IzwS3HxGJlz0HUkieyIAflEd88g

そら豆 太郎さんからの依頼の計算結果は7です

指定した認証トークンは確かにSimpleIDProviderで生成されたものであるため、認証トークンの検証はOKで足し算の結果が出力されます。

まとめ

#

記事では仕組みを理解するためJWT認証を行う簡単なアプリを作ってみましたが、プロダクション環境でJWT認証を行う場合は、Auth0やFirebase Authenticationなどの認証基盤サービスを使うことをお勧めします。扱っているものが認証という非常に重要な機能のため、JWT認証でウッカリや万が一があった場合のダメージが大きいため自分で作るよりも実績のあるものを使った方が無難です。

また、暗号鍵の管理も含め暗号化には高い専門性が要求されます。ですので、JWT認証べんりー!結構簡単じゃーん!と思ってオレオレJWT認証を実践投入したくなる気持ちも分かりますが、そこはよく考えて専用のサービスやプロダクトを使うことをお勧めします、公開鍵方式を使う場合は特にです。


  1. RSA暗号方式における暗号鍵フォーマットの1つ ↩︎

  2. RFC-5208で規定されている暗号鍵フォーマットの1つ ↩︎

  3. Privacy Enhanced Mailの略。鍵のバイナリデータをBASE64エンコードでテキストにしたものにヘッダー行とフッター行を付けたもの。 ↩︎

  4. ソースツリー上は/src/main/resources/jwt.key ↩︎

  5. ソースツリー上は/src/main/resources/jwt.pub.key ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。