arcanum_jp’s blog

おっさんの日記

SpringBootで認証を学ぶ、PASETO

こちらの続きです。
arcanum.hatenablog.com

クライアントーサーバ間の認証をするためのトークンって、JWTってのが今の流行りなんだ!フフン、俺最先端!とばかりにググってると結構、JWTを使うべきか?なんてJWTの危険性についての記事が目につき、なんでだろ〜と調べていると今はPASETOなんてものもあるらしい。なんですとーー!(の割には2018年からでPASETOの記事が引っかからないのであんまり普及していないのかなとか思ったりしますが・・・)

PASETOは以下のサイトが公式みたい。検索しているとPASETOではなくPASTと表記する人もいるみたいなので検索するときは注意ですね。にしてもgithubは2018年からあんまり動いていませんね・・・気になります。一通り今の仕様での開発が終わったって事でしょうか?各言語の実装を見るにチマチマとイシューは発行されているようで、動いてはいるようですが。
paseto.io


PASETOはPlatform-Agnostic Security Tokensの略で、プラットフォームに依存しないセキュリティトークンらしいです。すみません、グーグル翻訳に突っ込んだだけですがいい具合に感じられるので書いてみました。PASETO自体はJWTの危険性を回避しつつ、ステートレスなトークンを実現するための仕様にとどまっており実装は各言語に任せられているようです。また、PASETOのトークン形式は以下になっています

version + "." + purpose + "." + Payload + "." + Footer(ある場合)

これらがPayload、Footer部分がBASE64エンコードされます。

例としてこんな感じ。これはPASETOのgithubから引っ張ってきたのですがPayloadしかない例ですね。

v2.local.QAxIpVe-ECVNI1z4xQbm_qQYomyT3h8FtV8bxkz8pBJWkT8f7HtlOpbroPDEZUKop_vaglyp76CzYy375cHmKCW8e1CCkV0Lflu4GTDyXMqQdpZMM1E6OaoQW27gaRSvWBrR3IgbFIa0AkuUFw.UGFyYWdvbiBJbml0aWF0aXZlIEVudGVycHJpc2Vz


versionは2019年5月現在、V1とV2があり、V1はそれまでの互換性のためにあるため、利用はV2が推奨されています。公式サイトの各言語の実装状況を見ると、V1が実装されていない言語がありますが、V2以降に実装されたのだと思います。

purposeはlocalとpublicの2種類あり、localは共有キーによる暗号化で、Payload自身が暗号化されます。publicは公開鍵、秘密鍵による暗号化でPayloadは暗号化されません。単にサーバーとクライアント間をトークンで認証したい(ただし内容にIDなどの情報を含む)で使いたい場合はlocal、クライアント、サーバーともトークンの内容を知る必要がある場合はpublicでしょうか。よくわかりません。JWTと同様に改ざんに強いようですね。改ざんされたトークンはトークンの検証時に弾かれる仕組みは同じです。


利用シーケンスですが、以下のページのWhat Should We Use PASETO For?の節によると、自サイトに閉じる、APIを公開するようなサービスでトークンとして使う分にはlocalで十分なようですね。
github.com


色々な言語向けに実装を作っている方がいて、Javaは2つあるみたいですが今回はMavenでの使い方があるので、atholbro/pasetoを使います。では先日作ったこのリポジトリからPASETOを使うよう変更していきたいと思います。githubはこちら。この内容を元にJava側の使い方とRFCの行ったり来たりを繰り返していました。

github.com



pom.xmlの変更部分

<!-- JWT関連をコメントアウト。
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
</dependency>
 -->
<dependency>
    <groupId>net.aholbrook.paseto</groupId>
    <artifactId>meta</artifactId>
   <version>0.3.0</version>
</dependency>

<!-- Javaのバージョンなどにより以下も必要な場合があります。 -->
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
</dependency>

あと、pom.xmlにこれも追加しておきます

<repositories>
    <repository>
        <id>lazysodium</id>
        <url>https://dl.bintray.com/terl/lazysodium-maven</url>
    </repository>
</repositories>

JWTを使っている部分でエラーが出ます。以下のファイルがコンパ入りエラーになります。ID/PASS認証とトークン認証の部分なので狙い通りです。

JWTAuthenticationFilter
JWTAuthorizationFilter

クラス名もPASETOに合うようにちょいと変更しながら修正していきます。

JWTAuthenticationFilter --> PASETOAuthenticationFilter
JWTAuthorizationFilter --> PASETOAuthorizationFilter

v2.local , Footerを使わないケース

atholbro/pasetoにある説明の通り直していきます。

前回使ったソースを変更していくため、変更部分はコメントにしています。
JWTAuthenticationFilter.java

/*
String token = Jwts.builder()
    .setSubject(user.getUsername())
    .claim("role", user.getAuthorities())
    .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
    .signWith(SignatureAlgorithm.HS512, SECRET.getBytes())
    .compact();
*/
byte[] key = Hex.decode("707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f");
TokenService<Token> tokenService = PasetoBuilders.V2.localService(() -> key, Token.class).build();
Token pasetoToken = new Token();
pasetoToken.setIssuer("arcanum.jp");
pasetoToken.setSubject(user.getUsername());
pasetoToken.setTokenId("uniqu id at JWT");
pasetoToken.setExpiration(OffsetDateTime.now().plusHours(8));	// 8 hours
String token = tokenService.encode(pasetoToken);
    	

こちらも前回のソースに変更を加える形で行なっています。ロールはとりあえずROLE_USERを入れています。
JWTAuthorizationFilter.java

/*
JwtParser parser = Jwts.parser().setSigningKey(SECRET.getBytes());
Claims claims = parser.parseClaimsJws(token).getBody();
        	
String user = claims.getSubject();
        	
List grants = (List) claims.get("role");
String[] arrayRole = new String[grants.size()];
for (int i = 0 ; i < grants.size(); i++) {
    LinkedHashMap grant = (LinkedHashMap) grants.get(i);
    String rolestr = (String) grant.get("authority");
    arrayRole[i] = rolestr;
}
List<GrantedAuthority> roles = AuthorityUtils.createAuthorityList(arrayRole);
*/
        	
byte[] key = Hex.decode("707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f");
TokenService<Token> tokenService = PasetoBuilders.V2.localService(() -> key, Token.class).build();
Token pToken = tokenService.decode(token);
String user = pToken.getSubject();
        	
List<GrantedAuthority> auth = AuthorityUtils.createAuthorityList("ROLE_USER"); // とりあえず

暗号化キーをバイト化してビルダーに突っ込むとトークンサービスが取得できるようです。取得できたトークンサービスに任意の値を入れたトークンを突っ込むと文字列化されたトークンが取得できます。非常にシンプルですね。トークン(この場合クレーム)にはPASETOのサイトで公開してるRFCによると以下の情報を格納できます。

iss Issuer 発行者
sub Subject ユーザーを識別する情報、ユーザーIDなど
aud Audience クライアント側の情報*1
exp Expiration 有効期限
nbf Not Before 有効開始日時
iat Issured At 発行日
jti Token ID JWTの一意な識別子*2
kid Key-ID クレームのオプショナルな情報  *3

*1 この情報を元に受け取ったクライアントは自分向けのトークンかどうか検証を行う
*2 トークンで一意な値。例えばユーザーが複数回ログインした場合は1:多の関係になります。
*3 実際にはクレーム部ではなくフッターに入る(下記参照)


ビルダーはversion, purposeに合わせて4種類あるので目的にあったものでサービスを取得します。

PasetoBuilders.V1.localService( )
PasetoBuilders.V1.publicService( )
PasetoBuilders.V2.localService( )
PasetoBuilders.V2.publicService( )

さてcurlなどで実行してみるとトークンが取得できます。

curl -v -X POST -d "{ \"loginId\" : \"nyasba\", \"pass\" : \"password\"}" -H "accept: application/json" -H "Content-Type: application/json" "http://localhost:8080/user/login" | jq .
v2.local.M9kI2YfLSnbSF1ir3HvLxYTtHOvM9pjZf_XG3MaCJmYK6jOVwwZM0cLLKNaQA97dkm23vTrOQFtfvFi2jOYvXONxgBwLMUbi8mOsb2vItH6Zf18vd0go5SwqF7OEpuDu1Bi8xYJwJjNALtwXyChGeGRniht0x9PyL2i_1OXYIHgzyqF3mzqL5KDeA6YXuqvDPFWMdmJiJD1OW38XRnUQfuUvXT9P-xQ


このトークンのクレーム(local.より後)についてBASE64デコードしてみます。デコードにはこちらのサイトを使いました。
tool-taro.com

もちろんデコードしても見えません。暗号化されているのですから。JWTと異なりクレームの内容は暗号化されているので安心ですね。
f:id:arcanum_jp:20190530084659p:plain


このトークンを元に今度はアクセスしてみます

curl -X GET -H "Authorization: Bearer v2.local.M9kI2YfLSnbSF1ir3HvLxYTtHOvM9pjZf_XG3MaCJmYK6jOVwwZM0cLLKNaQA97dkm23vTrOQFtfvFi2jOYvXONxgBwLMUbi8mOsb2vItH6Zf18vd0go5SwqF7OEpuDu1Bi8xYJwJjNALtwXyChGeGRniht0x9PyL2i_1OXYIHgzyqF3mzqL5KDeA6YXuqvDPFWMdmJiJD1OW38XRnUQfuUvXT9P-xQ" "http://localhost:8080/private"

コンソールに以下が出たのでトークンの検証が無事行われたと言う事でしょう。先ほどの修正にあるようにROLE_USERは後から入れたのでnyasubaが表示された事でクレームにある情報からユーザーのIDが取得できたという事になります。

this is private for nyasba auth: [ROLE_USER]

TokenService#decode( )でクライアントから送られたトークンの検証を行います。ここで例外など起こらずにTokenオブジェクトが取得できた場合はトークン自体の検証は行われたと言う事になります。便利ですね。また、トークンが期限切れを起こしている場合はTokenService#decode( )の時点でExpiredTokenExceptionが送出されます。

v2.local , Footerを使うケース

フッターを使うケースです。フッターにはシステムで任意の値がJsonで入れられる模様ですね。ただし、フッターは暗号化されないため、出来上がったトークンのフッター部分をBASE64デコードすると丸見えになるので注意が必要です。ライブラリではKeyIdクラスが用意されていて、RFCにあるkidのみサポートしています。なのでフッターを使う場合はTokenService#encode( )を以下のように変更すれば良いです。

KeyId kid = new KeyId();
kid.setKeyId("my key id");

//String token = tokenService.encode(pasetoToken);
String token = tokenService.encode(pasetoToken, kid);

これで以下のトークンが取得できます。

v2.local.iHsfvYhVJhDL8CKBB_X1kNa_4_iNLRMGgdjT5w85rTqj_ElGy-V5TAJHRg09baCQdtNSv1NA1IaeNnbGdLbIo7-ZV8sRdwAOiYFBPuEHLVAP7ty1wlwo8gxn0AAsGwm-klEr2-lNReHQWYm9nOZH-kzi5dIzvw_IIA_-tBjNz5_sCcIy_LCxIuEzjxx86qgNZZxvg9kHFVkjVsuf_RcWRqY-GAxZDMs.eyJraWQiOiJteSBrZXkgaWQifQ


トークンの最後に”eyJraWQiOiJteSBrZXkgaWQifQ”が追加されましたね。フッター部です。これをBASE64デコードすると以下になります。たしかにフッター部は暗号化されていませんん。また追加した情報がBASE64デコードする事で見る事ができました。

f:id:arcanum_jp:20190530085932p:plain

Java上ではフッターがある場合はtokenService.decode( )ではなく、tokenService.decodeWithFooter( )を使います。tokenService.decode( )と同様、このメソッドでクレームとフッターの検証が行われ、完了した場合のみTokenWithFooterオブジェクトが取得できるようです。

TokenWithFooter<Token, KeyId> tokenWithFooter = tokenService.decodeWithFooter(token, KeyId.class);
Token pToken = tokenWithFooter.getToken();
KeyId footer = tokenWithFooter.getFooter();


トークンの検証ができていない状態でもフッターの内容は取得できます。以下のようにするようです。ただし、ここでの注意点はtokenService.decode( )/ tokenService.decodeWithFooter( )をする前に tokenService.getFooter( )をしてもフッター情報は取得できますが、その情報はトークンの検証が済んでない状態という事に注意してください。

KeyId kid = tokenService.getFooter(token, KeyId.class);

フッター部は何に必要なのでしょうか?どうせなら全部暗号化されているクレームに含めた方がよいと感じるのですが。トークンの検証が行われない限り、トークンのクレームにあるユーザーに対する内容は取得できません、しかしUIの都合上、認証エラー時に例えばユーザー名を付加したメッセージを出したい場合などがあります。その場合にフッター部は暗号化されていないため、この情報を使えると言う事です。


ここで疑問が起きます。フッターは自由な情報を設定できるそうですがKeyID意外の情報はどのようにして格納するのでしょうか?格納したい情報のクラスを作成し、エンコードする際に指定します。この場合、クラスのインスタンス変数はpublicにするか、Bean仕様にします。


オリジナルなフッター情報(CustomFooter.java
Stringはもとより、int, List, String[ ], Map は入るようです。

public class CustomFooter {	
    private String[] auth;	
    private String name;	
    private int age;
    // getter/ setter
}

こんな風に入れます

//KeyId kid = new KeyId();
//kid.setKeyId("my key id");
//String token = tokenService.encode(pasetoToken, kid);

CustomFooter footer = new CustomFooter();
footer.setAuth(new String[] {"ROLE_USER", "ROLE_ADMIN"});
footer.setAge(17);
footer.setName("zunko");
String token = tokenService.encode(pasetoToken, footer);


出来上がったトークンを見てみましょう

v2.local.7-s3cd5GLbXvEUmng4k7DCpG37K4A-c6w3Jrs-jyqJlAO6NDjFNNZHAAjqb9ZwHvkP8G63WdRun7rnj8-Vgzhh1AEk87cwHek3_up03QWgKJM6BlvQ46RpRnGA3Bue9w8MKfiv47eOYHItK8dSBu4gt9kP6sxESeIfSevjbIsd31Il8OjX6D0obVYib6APrD0tJlTyc3wwm_90MdNqXj6jQcFSSpmKI.eyJhdXRoIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwibmFtZSI6Inp1bmtvIiwiYWdlIjoxN30

このうちのフッターは以下のようにデコードできました
f:id:arcanum_jp:20190530195826p:plain


カスタムフッターの時も同様にカスタムクラスのClassを指定すれば良いです。

TokenWithFooter<CustomToken, CustomFooter> tokenWithFooter = tokenService.decodeWithFooter(token, CustomFooter.class);
Token pToken = tokenWithFooter.getToken();
CustomFooter footer = tokenWithFooter.getFooter();


このトークンを元に/privateにアクセスするとコンソール上に以下が表示されましたのでカスタムなフッターが利用できた模様です。

this is private for nyasba auth: [ROLE_USER, ROLE_ADMIN]

v2.public , Footerを使わないケース

さて次にv2.publicの使い方です。TokenServiceオブジェクトの生成方法が変わりますが、公開鍵と非公開鍵が必要になります。暗号化方式はED25519です。サンプルの公開鍵、非公開鍵はgithub内のテストにありますのでそれを使ってみます。


SecurityConstants.java に以下を追加

// get from RFCTestVectors
public static byte[] RFC_TEST_SK = Hex.decode("b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2");  // 非公開鍵
public static byte[] RFC_TEST_PK = Hex.decode("1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2");   //  公開鍵

public static KeyProvider PROVIDER = new KeyProvider() {
    @Override
    public byte[] getSecretKey() {
        return RFC_TEST_SK;
    }
    @Override
    public byte[] getPublicKey() {
        return RFC_TEST_PK;
    }
};


TokenServiceは以下です。(ラムダ使ってません。別にラムダでもいいですが)デコードする方も同様に修正します。

//byte[] key = Hex.decode("707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f");
//TokenService<Token> tokenService = PasetoBuilders.V2.localService(() -> key, Token.class)
//	    .withDefaultValidityPeriod(Duration.ofDays(15))
//	    .build();
Builder<Token> builder = PasetoBuilders.V2.publicService(SecurityConstants.PROVIDER, Token.class);
TokenService<Token> tokenService = builder.build();
||< 

これでトークンを生成してみます。以下のようになります。確かにv2.publicから始まってます。

>||
v2.public.eyJpc3MiOiJhcmNhbnVtLmpwIiwic3ViIjoibnlhc2JhIiwiZXhwIjoiMjAxOS0wNS0zMVQwNDo0OToxMCswOTowMCIsImp0aSI6InVuaXF1IGlkIGF0IEpXVCJ9_8r7ttGRjJfFm7sy4Fje_vq3kkPJTDCKIVOLWvcsqJNdOA5bjqX9lzoHuugfu2BiW9JmZjmppsE0WghORKMgAw.eyJhdXRoIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwibmFtZSI6Inp1bmtvIiwiYWdlIjoxN30

これをクレームとフッターに分けてBASE64デコードしてみます

クレーム
f:id:arcanum_jp:20190530205108p:plain

フッター
f:id:arcanum_jp:20190530205208p:plain

たしかにクレーム、フッタとも暗号化されておらず、BASE64デコードでJSONで内容がみれます。クレームの最後についたのは認証情報かな?

クレームにカスタムな情報を入れる


さて、RFCではクレームには先で書いたaudなどの予約語はあるが任意の情報を入れる事ができるとあります。でもどうやって入れるのでしょうか?フッターと同様、カスタムのトークンクラスを作成します。カスタムトークンは先ほどのカスタムフッターと同様Bean仕様にするようです。また予約語の件があるためTokenクラスを継承するのが良いようですね。その際、Tokenクラスがequals( ), hashCode( ), toString( )をオーバーライドしているため、同じようにオーバーライドする必要があります。(以下では略してますが動きます)

public class CustomToken extends Token {
    private String message;
    public String getMessage() {
        return message;
    }
    public Token setMessage(String msg) {
        message = msg;
        return this;
    }
}

エンコード時にはTokenを指定した場所をCustomTokenを指定するようにします。サイトではToken.classを任意の(この場合CustomToken)Classインスタンスを指定すれば良いと書いてますが、実際にはジェネリクスでTokenを指定した場所全ても書き換える必要があります(まぁあたりまえですが)

PASETOAuthenticationFilter.java

//Builder<Token> builder = PasetoBuilders.V2.publicService(SecurityConstants.PROVIDER, Token.class);
//TokenService<Token> tokenService = builder.build();
Builder<CustomToken> builder = PasetoBuilders.V2.publicService(SecurityConstants.PROVIDER, CustomToken.class);
TokenService<CustomToken> tokenService = builder.build();
    	
//Token pasetoToken = new Token();
CustomToken pasetoToken = new CustomToken();
pasetoToken.setMessage("hello paseto world!!");

PASETOAuthorizationFilter.java

//Builder<Token> builder = PasetoBuilders.V2.publicService(SecurityConstants.PROVIDER, Token.class);
//TokenService<Token> tokenService = builder.build();
//Token pToken = tokenService.decode(token);
Builder<CustomToken> builder = PasetoBuilders.V2.publicService(SecurityConstants.PROVIDER, CustomToken.class);
TokenService<CustomToken> tokenService = builder.build();
//Token pToken = tokenService.decode(token);
CustomToken pToken = tokenService.decode(token);
String message = pToken.getMessage();
String user = pToken.getSubject();


実際に以下のようなトークンになります。

 v2.public.eyJpc3MiOiJhcmNhbnVtLmpwIiwic3ViIjoibnlhc2JhIiwiZXhwIjoiMjAxOS0wNS0zMVQwNTo0NDowNSswOTowMCIsImp0aSI6InVuaXF1IGlkIGF0IEpXVCIsIm1lc3NhZ2UiOiJoZWxsbyBwYXNldG8gd29ybGQhISJ9-uIsrADYrmnMwbNc2pwRu_racCki2ySmIytkWg04Tg4xhQrI_NjMr84YF4zZvPXlIBBMmiNKsIYVgl6n_wymBw.eyJhdXRoIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwibmFtZSI6Inp1bmtvIiwiYWdlIjoxN30

BASE64デコードすると以下になります。任意の情報(この場合message)が入っているのがわかります。
f:id:arcanum_jp:20190530214540p:plain


今回作成したものは以下のリポジトリにあります。
(色々といじっているままなのでこのままでは動きません)
github.com

PASETOは使えるのか?

JWTを検索すればするほど、JWTは危険云々っていうブログやら記事やらが出てきて、目についたPASETOにカッとなって飛びついてみましたが、正直PASETOで良いのでしょうか?という疑問は残ります。使い方に関して、正直PASETOの公式サイト、Javaであれば今回しらべたライブラリぐらいしか情報源としてはありません。stackoverflowにすら使い方の情報が殆ど出てこなかった。情報量が少なすぎて逆に使うのを躊躇してしまいますね。