JSON Web Token(JWT)によるJava認証のスーパーチャージ
Javaアプリケーションで安全な認証を構築する準備ができていますか? トークン(具体的にはJSON Webトークン)を使用するメリットがわからない、またはそれらをどのように展開する必要があるのか? このチュートリアルでは、これらの質問などに回答できることをうれしく思います。
JSON Web Token(JWTs)とJJWT library(StormpathのCTOであるLes Hazlewoodによって作成され、community of contributorsによって維持されている)に飛び込む前に、いくつかの基本を説明しましょう。
1. 認証と トークン認証
ユーザーIDを認証するためにアプリケーションが使用するプロトコルのセット。 従来、アプリケーションはセッションCookieを介してIDを保持していました。 このパラダイムは、セッションIDのサーバー側ストレージに依存しているため、開発者は、一意でサーバー固有のセッションストレージを作成するか、完全に独立したセッションストレージレイヤーとして実装する必要があります。
トークン認証は、サーバー側のセッションIDでは解決できなかった問題を解決するために開発されました。 従来の認証と同様に、ユーザーは検証可能な資格情報を提示しますが、セッションIDではなくトークンのセットが発行されるようになりました。 初期資格情報は、標準のユーザー名/パスワードのペア、APIキー、または別のサービスからのトークンです。 (StormpathのAPIキー認証機能は、この例です。)
1.1. なぜトークン?
非常に簡単に言えば、セッションIDの代わりにトークンを使用すると、サーバーの負荷が軽減され、権限管理が合理化され、分散またはクラウドベースのインフラストラクチャをサポートするためのより優れたツールが提供されます。 JWTの場合、これは主にこれらのタイプのトークンのステートレスな性質によって実現されます(詳細は以下を参照)。
トークンは、クロスサイトリクエストフォージェリ(CSRF)保護スキーム、OAuth 2.0インタラクション、セッションID、および(Cookie内の)認証表現など、さまざまなアプリケーションを提供します。 ほとんどの場合、標準はトークンの特定の形式を指定していません。 HTMLフォームの典型的なSpring Security CSRF tokenの例を次に示します。
適切なCSRFトークンを使用せずにそのフォームを投稿しようとすると、エラーレスポンスが表示されます。これは、トークンの有用性です。 上記の例は、「ダム」トークンです。 これは、トークン自体から収集される固有の意味がないことを意味します。 これは、JWTが大きな違いを生む場所でもあります。
参考文献:
Spring Security OAuthでJWTを使用する
Spring Security OAuthで対称署名と非対称署名の両方でJSON Webトークンを使用するためのガイド。
Spring REST API OAuth2 Angular
Spring REST API用にOAuth2を設定する方法と、Angularクライアントからそれを使用する方法を学びます。
Spring REST APIのOAuth2 – AngularJSで更新トークンを処理する
更新トークンをAngularJSクライアントアプリに保存する方法、期限切れのアクセストークンを更新する方法、およびZuulプロキシを活用する方法を学びました。
2. JWTには何が含まれていますか?
JWT(「jots」と発音)は、さまざまなアプリケーションでトークンとして使用できる、URLセーフで、エンコードされ、暗号で署名された(暗号化されていることもある)文字列です。 CSRFトークンとして使用されているJWTの例を次に示します。
この場合、トークンは前の例よりもはるかに長いことがわかります。 前に見たように、トークンなしでフォームが送信されると、エラー応答が返されます。
それでは、なぜJWTなのでしょうか?
上記のトークンは暗号で署名されているため、検証することができ、改ざんされていないことを証明できます。 また、JWTはさまざまな追加情報でエンコードされます。
JWTの構造を見て、このすべての長所をどのように絞り出すかをよりよく理解しましょう。 ピリオド(.
)で区切られた3つの異なるセクションがあることに気付いたかもしれません。
ヘッダ |
eyJhbGciOiJIUzI1NiJ9 |
ペイロード |
eyJqdGkiOiJlNjc4ZjIzMzQ3ZTM0MTBkYjdlNjg3Njc4MjNiMmQ3MCIsImlhdC I6MTQ2NjYzMzMxNywibmJmIjoxNDY2NjMzMzE3LCJleHAiOjE0 |
署名 |
rgx_o8VQGuDa2AqCHSgVOD5G68Ld_YYM7N7THmvLIKc |
各セクションはbase64のURLエンコードされています。 これにより、URLで安全に使用できるようになります(これについては後で説明します)。 各セクションを個別に詳しく見ていきましょう。
2.1. ヘッダー
base64でヘッダーをデコードすると、次のJSON文字列が取得されます。
{"alg":"HS256"}
2.2. ペイロード
ペイロードをデコードすると、次のJSON文字列が得られます(わかりやすくするためにフォーマットされています)。
{
"jti": "e678f23347e3410db7e68767823b2d70",
"iat": 1466633317,
"nbf": 1466633317,
"exp": 1466636917
}
ご覧のように、ペイロード内には、値を持つキーがいくつかあります。 これらのキーは「クレーム」と呼ばれ、JWT specificationには、これらのうち7つが「登録済み」クレームとして指定されています。 彼らです:
iss |
発行者 |
sub |
件名 |
aud |
聴衆 |
exp |
有効期限 |
nbf |
以前ではない |
iat |
で発行された |
jti |
JWT ID |
JWTを構築するとき、任意のカスタムクレームを入力できます。 上記のリストは、使用されるキーと予想されるタイプの両方で予約されているクレームを単純に表しています。 CSRFには、JWT ID、「Issued At」時間、「Not Before」時間、およびExpiration時間があります。 有効期限は、発行された時刻からちょうど1分経過しています。
2.3. 署名
最後に、ヘッダーとペイロードを一緒に取得することにより、署名セクションが作成されます(。 間に)、指定されたアルゴリズム(この場合はSHA-256を使用したHMAC)と既知のシークレットを通過させます。 シークレットはalwaysバイト配列であり、使用するアルゴリズムにとって意味のある長さである必要があることに注意してください。 以下では、バイト配列に変換される(読みやすさのために)ランダムなbase64エンコード文字列を使用します。
擬似コードでは次のようになります。
computeHMACSHA256(
header + "." + payload,
base64DecodeToByteArray("4pE8z3PBoHjnV1AhvGk+e8h2p+ShZpOnpr8cwHmMh1w=")
)
秘密を知っている限り、自分で署名を生成し、その結果をJWTの署名セクションと比較して、改ざんされていないことを確認できます。 技術的には、暗号で署名されたJWTはJWSと呼ばれます。 JWTは暗号化することもでき、JWEと呼ばれます。 (実際には、JWTという用語はJEWおよびJESを表すために使用されます。)
これにより、JWTをCSRFトークンとして使用する利点に戻ります。 署名を検証でき、JWTでエンコードされた情報を使用してその有効性を確認できます。 したがって、JWTの文字列表現は、サーバー側に保存されているものと一致する必要があるだけでなく、expクレームを調べるだけで、期限切れにならないようにすることができます。 これにより、サーバーは追加の状態を維持しなくなります。
さて、ここでは多くのことをカバーしました。 いくつかのコードに飛び込みましょう!
3. JJWTチュートリアルを設定する
JJWT(https://github.com/jwtk/jjwt)は、エンドツーエンドのJSONWebトークンの作成と検証を提供するJavaライブラリです。 いつまでも無料でオープンソース(Apacheライセンス、バージョン2.0)であり、そのほとんどの複雑さを隠すビルダー中心のインターフェースで設計されました。
JJWTを使用する主な操作には、JWTの構築と解析が含まれます。 次にこれらの操作を見てから、JJWTのいくつかの拡張機能について説明します。最後に、Spring Security、SpringBootアプリケーションでCSRFトークンとしてJWTが動作していることを確認します。
次のセクションで示すコードは、hereにあります。 注:プロジェクトは、公開するAPIと簡単にやり取りできるため、最初からSpringBootを使用しています。
プロジェクトをビルドするには、次を実行します。
git clone https://github.com/eugenp/tutorials.git
cd tutorials/jjwt
mvn clean install
Spring Bootの素晴らしい点の1つは、アプリケーションを簡単に起動できることです。 JJWT Funアプリケーションを実行するには、次の手順を実行します。
java -jar target/*.jar
このサンプルアプリケーションには10個のエンドポイントが公開されています(httpieを使用してアプリケーションと対話します。 hereで見つけることができます。)
http localhost:8080
Available commands (assumes httpie - https://github.com/jkbrzt/httpie):
http http://localhost:8080/
This usage message
http http://localhost:8080/static-builder
build JWT from hardcoded claims
http POST http://localhost:8080/dynamic-builder-general claim-1=value-1 ... [claim-n=value-n]
build JWT from passed in claims (using general claims map)
http POST http://localhost:8080/dynamic-builder-specific claim-1=value-1 ... [claim-n=value-n]
build JWT from passed in claims (using specific claims methods)
http POST http://localhost:8080/dynamic-builder-compress claim-1=value-1 ... [claim-n=value-n]
build DEFLATE compressed JWT from passed in claims
http http://localhost:8080/parser?jwt=
Parse passed in JWT
http http://localhost:8080/parser-enforce?jwt=
Parse passed in JWT enforcing the 'iss' registered claim and the 'hasMotorcycle' custom claim
http http://localhost:8080/get-secrets
Show the signing keys currently in use.
http http://localhost:8080/refresh-secrets
Generate new signing keys and show them.
http POST http://localhost:8080/set-secrets
HS256=base64-encoded-value HS384=base64-encoded-value HS512=base64-encoded-value
Explicitly set secrets to use in the application.
以下のセクションでは、これらの各エンドポイントと、ハンドラーに含まれるJJWTコードを調べます。
4. JJWTを使用したJWTの構築
JJWTのfluent interfaceのため、JWTの作成は基本的に3つのステップのプロセスです。
-
発行者、件名、有効期限、IDなど、トークンの内部クレームの定義。
-
JWTの暗号署名(JWSにする)。
-
JWT Compact Serializationルールに従って、JWTをURLセーフな文字列に圧縮します。
最終的なJWTは、指定された署名アルゴリズムで署名され、提供されたキーを使用して、3つの部分からなるbase64エンコードされた文字列になります。 この時点で、トークンを別のパーティと共有する準備ができました。
実行中のJJWTの例を次に示します。
String jws = Jwts.builder()
.setIssuer("Stormpath")
.setSubject("msilverman")
.claim("name", "Micah Silverman")
.claim("scope", "admins")
// Fri Jun 24 2016 15:33:42 GMT-0400 (EDT)
.setIssuedAt(Date.from(Instant.ofEpochSecond(1466796822L)))
// Sat Jun 24 2116 15:33:42 GMT-0400 (EDT)
.setExpiration(Date.from(Instant.ofEpochSecond(4622470422L)))
.signWith(
SignatureAlgorithm.HS256,
TextCodec.BASE64.decode("Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=")
)
.compact();
これは、コードプロジェクトのStaticJWTController.fixedBuilderメソッドにあるコードと非常によく似ています。
この時点で、JWTと署名に関連するいくつかのアンチパターンについて説明する価値があります。 これまでにJWTの例を見たことがあれば、次の署名アンチパターンシナリオのいずれかに遭遇した可能性があります。
-
.signWith( SignatureAlgorithm.HS256, "secret".getBytes("UTF-8") )
-
.signWith( SignatureAlgorithm.HS256, "Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=".getBytes("UTF-8") )
-
.signWith( SignatureAlgorithm.HS512, TextCodec.BASE64.decode("Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=") )
HSタイプの署名アルゴリズムはいずれもバイト配列を取ります。 人間が文字列を読み取ってバイト配列に変換するのは便利です。
上記のアンチパターン1はこれを示しています。 シークレットは非常に短いために弱くなり、ネイティブ形式のバイト配列ではないため、これは問題があります。 したがって、読みやすくするために、バイト配列をbase64でエンコードできます。
ただし、上記のアンチパターン2は、base64でエンコードされた文字列を取得し、それを直接バイト配列に変換します。 行うべきことは、base64文字列をデコードして元のバイト配列に戻すことです。
上記の番号3はこれを示しています。 では、なぜこれもアンチパターンなのでしょうか? この場合、それは微妙な理由です。 署名アルゴリズムはHS512であることに注意してください。 バイト配列はHS512がサポートできる最大長ではないため、そのアルゴリズムで可能なものよりも秘密が弱くなります。
サンプルコードには、適切な強度のシークレットが特定のアルゴリズムに使用されることを保証するSecretServiceというクラスが含まれています。 アプリケーションの起動時に、HSアルゴリズムごとに新しいシークレットのセットが作成されます。 シークレットを更新するだけでなく、シークレットを明示的に設定するエンドポイントがあります。
上記のようにプロジェクトを実行している場合は、以下を実行して、以下のJWTの例がプロジェクトからの応答に一致するようにします。
http POST localhost:8080/set-secrets \
HS256="Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=" \
HS384="VW96zL+tYlrJLNCQ0j6QPTp+d1q75n/Wa8LVvpWyG8pPZOP6AA5X7XOIlI90sDwx" \
HS512="cd+Pr1js+w2qfT2BoCD+tPcYp9LbjpmhSMEJqUob1mcxZ7+Wmik4AYdjX+DlDjmE4yporzQ9tm7v3z/j+QbdYg=="
これで、/static-builderエンドポイントに到達できます。
http http://localhost:8080/static-builder
これにより、次のようなJWTが生成されます。
eyJhbGciOiJIUzI1NiJ9.
eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.
kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ
今、ヒット:
http http://localhost:8080/parser?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ
応答には、JWTを作成したときに含めたすべてのクレームが含まれます。
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
...
{
"jws": {
"body": {
"exp": 4622470422,
"iat": 1466796822,
"iss": "Stormpath",
"name": "Micah Silverman",
"scope": "admins",
"sub": "msilverman"
},
"header": {
"alg": "HS256"
},
"signature": "kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ"
},
"status": "SUCCESS"
}
これは解析操作であり、次のセクションで説明します。
それでは、クレームをパラメーターとして受け取り、カスタムJWTを構築するエンドポイントに到達しましょう。
http -v POST localhost:8080/dynamic-builder-general iss=Stormpath sub=msilverman hasMotorcycle:=true
Note:hasMotorcycleの主張と他の主張の間には微妙な違いがあります。 httpieは、JSONパラメーターがデフォルトで文字列であると想定しています。 httpieを使用して生のJSONを送信するには、=ではなく:=フォームを使用します。 それがないと、“hasMotorcycle”: “true”が送信されますが、これは私たちが望んでいることではありません。
出力は次のとおりです。
POST /dynamic-builder-general HTTP/1.1
Accept: application/json
...
{
"hasMotorcycle": true,
"iss": "Stormpath",
"sub": "msilverman"
}
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
...
{
"jwt":
"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwiaGFzTW90b3JjeWNsZSI6dHJ1ZX0.OnyDs-zoL3-rw1GaSl_KzZzHK9GoiNocu-YwZ_nQNZU",
"status": "SUCCESS"
}
このエンドポイントをサポートするコードを見てみましょう。
@RequestMapping(value = "/dynamic-builder-general", method = POST)
public JwtResponse dynamicBuilderGeneric(@RequestBody Map claims)
throws UnsupportedEncodingException {
String jws = Jwts.builder()
.setClaims(claims)
.signWith(
SignatureAlgorithm.HS256,
secretService.getHS256SecretBytes()
)
.compact();
return new JwtResponse(jws);
}
2行目では、着信JSONがJava Map
このコードは簡潔ですが、渡されるクレームが有効であることを保証するために、より具体的なものが必要です。 マップに示されているクレームが有効であることがすでにわかっている場合は、.setClaims(Map<String, Object> claims)メソッドを使用すると便利です。 これは、Javaの型安全性がJJWTライブラリに入ってくるところです。
JWT仕様で定義されている登録済みクレームごとに、仕様が正しいタイプをとる対応するJavaメソッドがJJWTにあります。
この例で別のエンドポイントに到達して、何が起こるかを見てみましょう。
http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub:=5 hasMotorcycle:=true
「sub」クレームには整数5を渡したことに注意してください。 出力は次のとおりです。
POST /dynamic-builder-specific HTTP/1.1
Accept: application/json
...
{
"hasMotorcycle": true,
"iss": "Stormpath",
"sub": 5
}
HTTP/1.1 400 Bad Request
Connection: close
Content-Type: application/json;charset=UTF-8
...
{
"exceptionType": "java.lang.ClassCastException",
"message": "java.lang.Integer cannot be cast to java.lang.String",
"status": "ERROR"
}
コードが登録済みクレームのタイプを強制しているため、エラー応答が返されます。 この場合、subは文字列である必要があります。 このエンドポイントをサポートするコードは次のとおりです。
@RequestMapping(value = "/dynamic-builder-specific", method = POST)
public JwtResponse dynamicBuilderSpecific(@RequestBody Map claims)
throws UnsupportedEncodingException {
JwtBuilder builder = Jwts.builder();
claims.forEach((key, value) -> {
switch (key) {
case "iss":
builder.setIssuer((String) value);
break;
case "sub":
builder.setSubject((String) value);
break;
case "aud":
builder.setAudience((String) value);
break;
case "exp":
builder.setExpiration(Date.from(
Instant.ofEpochSecond(Long.parseLong(value.toString()))
));
break;
case "nbf":
builder.setNotBefore(Date.from(
Instant.ofEpochSecond(Long.parseLong(value.toString()))
));
break;
case "iat":
builder.setIssuedAt(Date.from(
Instant.ofEpochSecond(Long.parseLong(value.toString()))
));
break;
case "jti":
builder.setId((String) value);
break;
default:
builder.claim(key, value);
}
});
builder.signWith(SignatureAlgorithm.HS256, secretService.getHS256SecretBytes());
return new JwtResponse(builder.compact());
}
前と同じように、このメソッドはパラメーターとしてクレームのMap<String, Object>を受け入れます。 ただし、今回は、型を適用する各登録済みクレームに対して特定のメソッドを呼び出しています。
これに対する1つの改良点は、エラーメッセージをより具体的にすることです。 今のところ、クレームの1つが正しいタイプではないことがわかっています。 どの主張が誤りだったのか、それがどうあるべきかはわかりません。 より具体的なエラーメッセージを表示する方法は次のとおりです。 また、現在のコードのバグも扱います。
private void ensureType(String registeredClaim, Object value, Class expectedType) {
boolean isCorrectType =
expectedType.isInstance(value) ||
expectedType == Long.class && value instanceof Integer;
if (!isCorrectType) {
String msg = "Expected type: " + expectedType.getCanonicalName() +
" for registered claim: '" + registeredClaim + "', but got value: " +
value + " of type: " + value.getClass().getCanonicalName();
throw new JwtException(msg);
}
}
行3は、渡された値が期待されるタイプであることを確認します。 そうでない場合は、特定のエラーとともにJwtExceptionがスローされます。 以前に行ったのと同じ電話をかけて、これが実際に行われている様子を見てみましょう。
http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub:=5 hasMotorcycle:=true
POST /dynamic-builder-specific HTTP/1.1
Accept: application/json
...
User-Agent: HTTPie/0.9.3
{
"hasMotorcycle": true,
"iss": "Stormpath",
"sub": 5
}
HTTP/1.1 400 Bad Request
Connection: close
Content-Type: application/json;charset=UTF-8
...
{
"exceptionType": "io.jsonwebtoken.JwtException",
"message":
"Expected type: java.lang.String for registered claim: 'sub', but got value: 5 of type: java.lang.Integer",
"status": "ERROR"
}
これで、subクレームがエラーの1つであることを示す非常に具体的なエラーメッセージが表示されます。
コード内のそのバグに戻りましょう。 この問題は、JJWTライブラリとは関係ありません。 問題は、Spring Bootに組み込まれているJSONからJavaへのオブジェクトマッパーが、私たち自身にとってはあまりにも賢いことです。
Javaオブジェクトを受け入れるメソッドがある場合、JSONマッパーは2,147,483,647以下の渡された数値をJavaIntegerに自動的に変換します。 同様に、2,147,483,647より大きい渡された数値をJavaLongに自動的に変換します。 JWTのiat、nbf、およびexpクレームの場合、マップされたオブジェクトが整数であるかLongであるかにかかわらず、ensureTypeテストに合格する必要があります。 そのため、渡された値が正しいタイプであるかどうかを判断するための追加の句があります。
boolean isCorrectType =
expectedType.isInstance(value) ||
expectedType == Long.class && value instanceof Integer;
Longを期待しているが、値がIntegerのインスタンスである場合でも、それは正しいタイプであると言えます。 この検証で何が起こっているかを理解したら、これをdynamicBuilderSpecificメソッドに統合できます。
@RequestMapping(value = "/dynamic-builder-specific", method = POST)
public JwtResponse dynamicBuilderSpecific(@RequestBody Map claims)
throws UnsupportedEncodingException {
JwtBuilder builder = Jwts.builder();
claims.forEach((key, value) -> {
switch (key) {
case "iss":
ensureType(key, value, String.class);
builder.setIssuer((String) value);
break;
case "sub":
ensureType(key, value, String.class);
builder.setSubject((String) value);
break;
case "aud":
ensureType(key, value, String.class);
builder.setAudience((String) value);
break;
case "exp":
ensureType(key, value, Long.class);
builder.setExpiration(Date.from(
Instant.ofEpochSecond(Long.parseLong(value.toString()))
));
break;
case "nbf":
ensureType(key, value, Long.class);
builder.setNotBefore(Date.from(
Instant.ofEpochSecond(Long.parseLong(value.toString()))
));
break;
case "iat":
ensureType(key, value, Long.class);
builder.setIssuedAt(Date.from(
Instant.ofEpochSecond(Long.parseLong(value.toString()))
));
break;
case "jti":
ensureType(key, value, String.class);
builder.setId((String) value);
break;
default:
builder.claim(key, value);
}
});
builder.signWith(SignatureAlgorithm.HS256, secretService.getHS256SecretBytes());
return new JwtResponse(builder.compact());
}
Note:このセクションのすべてのサンプルコードでは、JWTはSHA-256アルゴリズムを使用してHMACで署名されています。 これは、例を単純にするためです。 JJWTライブラリは、独自のコードで利用できる12種類の署名アルゴリズムをサポートしています。
5. JJWTを使用したJWTの解析
先ほど、コード例にJWTを解析するためのエンドポイントがあることを確認しました。 このエンドポイントを打つ:
http http://localhost:8080/parser?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ
この応答を生成します:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
...
{
"claims": {
"body": {
"exp": 4622470422,
"iat": 1466796822,
"iss": "Stormpath",
"name": "Micah Silverman",
"scope": "admins",
"sub": "msilverman"
},
"header": {
"alg": "HS256"
},
"signature": "kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ"
},
"status": "SUCCESS"
}
StaticJWTControllerクラスのparserメソッドは次のようになります。
@RequestMapping(value = "/parser", method = GET)
public JwtResponse parser(@RequestParam String jwt) throws UnsupportedEncodingException {
Jws jws = Jwts.parser()
.setSigningKeyResolver(secretService.getSigningKeyResolver())
.parseClaimsJws(jwt);
return new JwtResponse(jws);
}
行4は、着信文字列が署名付きJWT(JWS)であることを示しています。 そして、JWTの解析に署名するために使用されたのと同じ秘密を使用しています。 5行目は、JWTからのクレームを解析します。 内部的には、署名を検証しており、署名が無効な場合は例外をスローします。
この場合、キー自体ではなくSigningKeyResolverを渡していることに注意してください。 これは、JJWTの最も強力な側面の1つです。 JWTのヘッダーは、署名に使用されるアルゴリズムを示します。 ただし、信頼する前にJWTを検証する必要があります。 キャッチ22のようです。 SecretService.getSigningKeyResolverメソッドを見てみましょう。
private SigningKeyResolver signingKeyResolver = new SigningKeyResolverAdapter() {
@Override
public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) {
return TextCodec.BASE64.decode(secrets.get(header.getAlgorithm()));
}
};
JwsHeaderへのアクセスを使用して、アルゴリズムを検査し、JWTの署名に使用されたシークレットの適切なバイト配列を返すことができます。 これで、JJWTは、このバイト配列をキーとして使用してJWTが改ざんされていないことを確認します。
渡されたJWT(署名の一部)の最後の文字を削除すると、これが応答になります。
HTTP/1.1 400 Bad Request
Connection: close
Content-Type: application/json;charset=UTF-8
Date: Mon, 27 Jun 2016 13:19:08 GMT
Server: Apache-Coyote/1.1
Transfer-Encoding: chunked
{
"exceptionType": "io.jsonwebtoken.SignatureException",
"message":
"JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.",
"status": "ERROR"
}
6. 実際のJWT:Spring SecurityCSRFトークン
この投稿の焦点はSpring Securityではありませんが、JJWTライブラリの実際の使用方法を示すためにここで少し掘り下げていきます。
Cross Site Request Forgeryはセキュリティの脆弱性であり、悪意のあるWebサイトがだまして、信頼を確立したWebサイトにリクエストを送信させます。 これに対する一般的な解決策の1つは、synchronizer token patternを実装することです。 この方法は、トークンをWebフォームに挿入し、アプリケーションサーバーはリポジトリに対して着信トークンをチェックし、正しいことを確認します。 トークンが欠落しているか無効である場合、サーバーはエラーで応答します。
Spring Securityには、シンクロナイザートークンパターンが組み込まれています。 さらに良いことに、Spring Boot and Thymeleaf templatesを使用している場合は、シンクロナイザートークンが自動的に挿入されます。
デフォルトでは、Spring Securityが使用するトークンは「ダム」トークンです。 それは単なる一連の文字と数字です。 このアプローチは問題なく、機能します。 このセクションでは、JWTをトークンとして使用して基本機能を強化します。 提出されたトークンが予想されたものであることを検証することに加えて、JWTを検証して、トークンが改ざんされていないことをさらに検証し、期限切れにならないようにします。
まず、Java構成を使用してSpring Securityを構成します。 デフォルトでは、すべてのパスに認証が必要で、すべてのPOSTエンドポイントにはCSRFトークンが必要です。 これまでに作成したものが引き続き機能するように、少しリラックスします。
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private String[] ignoreCsrfAntMatchers = {
"/dynamic-builder-compress",
"/dynamic-builder-general",
"/dynamic-builder-specific",
"/set-secrets"
};
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.ignoringAntMatchers(ignoreCsrfAntMatchers)
.and().authorizeRequests()
.antMatchers("/**")
.permitAll();
}
}
ここでは2つのことを行っています。 まず、CSRFトークンはREST APIエンドポイントに投稿するときに必要なnotであると言っています(15行目)。 第二に、すべてのパスに対して認証されていないアクセスを許可する必要があると言っています(17行目から18行目)。
SpringSecurityが期待どおりに機能していることを確認しましょう。 アプリを起動し、ブラウザで次のURLにアクセスします。
http://localhost:8080/jwt-csrf-form
このビューのThymeleafテンプレートは次のとおりです。
これは、送信時に同じエンドポイントにPOSTする非常に基本的なフォームです。 フォームにはCSRFトークンへの明示的な参照がないことに注意してください。 ソースを表示すると、次のようなものが表示されます。
これは、Spring Securityが機能していること、およびThymeleafテンプレートがCSRFトークンを自動的に挿入していることを知るために必要なすべての確認です。
値をJWTにするには、カスタムCsrfTokenRepositoryを有効にします。 SpringSecurityの構成がどのように変更されるかを次に示します。
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
CsrfTokenRepository jwtCsrfTokenRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.csrfTokenRepository(jwtCsrfTokenRepository)
.ignoringAntMatchers(ignoreCsrfAntMatchers)
.and().authorizeRequests()
.antMatchers("/**")
.permitAll();
}
}
これを接続するには、カスタムトークンリポジトリを返すBeanを公開する構成が必要です。 構成は次のとおりです。
@Configuration
public class CSRFConfig {
@Autowired
SecretService secretService;
@Bean
@ConditionalOnMissingBean
public CsrfTokenRepository jwtCsrfTokenRepository() {
return new JWTCsrfTokenRepository(secretService.getHS256SecretBytes());
}
}
そして、これが私たちのカスタムリポジトリ(重要な部分)です:
public class JWTCsrfTokenRepository implements CsrfTokenRepository {
private static final Logger log = LoggerFactory.getLogger(JWTCsrfTokenRepository.class);
private byte[] secret;
public JWTCsrfTokenRepository(byte[] secret) {
this.secret = secret;
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
String id = UUID.randomUUID().toString().replace("-", "");
Date now = new Date();
Date exp = new Date(System.currentTimeMillis() + (1000*30)); // 30 seconds
String token;
try {
token = Jwts.builder()
.setId(id)
.setIssuedAt(now)
.setNotBefore(now)
.setExpiration(exp)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
} catch (UnsupportedEncodingException e) {
log.error("Unable to create CSRf JWT: {}", e.getMessage(), e);
token = id;
}
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token);
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
...
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
...
}
}
generateTokenメソッドは、作成後30秒で有効期限が切れるJWTを作成します。 この配管が整ったら、アプリケーションを再度起動して、/jwt-csrf-formのソースを確認できます。
これで、非表示フィールドは次のようになります。
フザ! これで、CSRFトークンはJWTです。 それほど難しくはありませんでした。
ただし、これはパズルの半分にすぎません。 デフォルトでは、Spring SecurityはCSRFトークンを保存し、Webフォームで送信されたトークンが保存されたトークンと一致することを確認するだけです。 機能を拡張してJWTを検証し、有効期限が切れていないことを確認したいと思います。 そのために、フィルターを追加します。 SpringSecurityの構成は次のようになります。
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterAfter(new JwtCsrfValidatorFilter(), CsrfFilter.class)
.csrf()
.csrfTokenRepository(jwtCsrfTokenRepository)
.ignoringAntMatchers(ignoreCsrfAntMatchers)
.and().authorizeRequests()
.antMatchers("/**")
.permitAll();
}
...
}
9行目で、フィルターを追加し、デフォルトのCsrfFilterの後にフィルターチェーンに配置しています。 したがって、フィルターがヒットするまでに、JWTトークン(全体として)は、Spring Securityによって保存された正しい値であることがすでに確認されています。
これがJwtCsrfValidatorFilterです(Spring Security構成の内部クラスであるためプライベートです):
private class JwtCsrfValidatorFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// NOTE: A real implementation should have a nonce cache so the token cannot be reused
CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
if (
// only care if it's a POST
"POST".equals(request.getMethod()) &&
// ignore if the request path is in our list
Arrays.binarySearch(ignoreCsrfAntMatchers, request.getServletPath()) < 0 &&
// make sure we have a token
token != null
) {
// CsrfFilter already made sure the token matched.
// Here, we'll make sure it's not expired
try {
Jwts.parser()
.setSigningKey(secret.getBytes("UTF-8"))
.parseClaimsJws(token.getToken());
} catch (JwtException e) {
// most likely an ExpiredJwtException, but this will handle any
request.setAttribute("exception", e);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
RequestDispatcher dispatcher = request.getRequestDispatcher("expired-jwt");
dispatcher.forward(request, response);
}
}
filterChain.doFilter(request, response);
}
}
23行目をご覧ください。 以前と同様にJWTを解析しています。 この場合、例外がスローされると、要求はexpired-jwtテンプレートに転送されます。 JWTが検証されると、処理は通常どおり続行されます。
これにより、デフォルトのSpring Security CSRFトークンの動作をJWTトークンリポジトリとバリデーターでオーバーライドする際のループが閉じられます。
アプリを起動し、/jwt-csrf-formを参照し、30秒強待ってからボタンをクリックすると、次のようなメッセージが表示されます。
7. JJWT拡張機能
仕様を超えた機能のいくつかについて一言で、JJWTの旅を締めくくります。
7.1. クレームを強制する
解析プロセスの一部として、JJWTを使用すると、必要なクレームとそれらのクレームが持つべき値を指定できます。 これは、JWTに有効であると見なすために存在しなければならない特定の情報がある場合に非常に便利です。 クレームを手動で検証するための多くの分岐ロジックを回避します。 サンプルプロジェクトの/parser-enforceエンドポイントを提供するメソッドは次のとおりです。
@RequestMapping(value = "/parser-enforce", method = GET)
public JwtResponse parserEnforce(@RequestParam String jwt)
throws UnsupportedEncodingException {
Jws jws = Jwts.parser()
.requireIssuer("Stormpath")
.require("hasMotorcycle", true)
.setSigningKeyResolver(secretService.getSigningKeyResolver())
.parseClaimsJws(jwt);
return new JwtResponse(jws);
}
5行目と6行目は、登録されたクレームとカスタムクレームの構文を示しています。 この例では、ISSクレームが存在しないか、値がStormpathでない場合、JWTは無効と見なされます。 また、カスタムhasMotorcycleクレームが存在しないか、値がtrueでない場合も無効になります。
まず、ハッピーパスに従うJWTを作成しましょう。
http -v POST localhost:8080/dynamic-builder-specific \
iss=Stormpath hasMotorcycle:=true sub=msilverman
POST /dynamic-builder-specific HTTP/1.1
Accept: application/json
...
{
"hasMotorcycle": true,
"iss": "Stormpath",
"sub": "msilverman"
}
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
...
{
"jwt":
"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIn0.qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0",
"status": "SUCCESS"
}
それでは、そのJWTを検証しましょう。
http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIn0.qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0
GET /parser-enforce?jwt=http
-v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIn0.qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0 HTTP/1.1
Accept: */*
...
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
...
{
"jws": {
"body": {
"hasMotorcycle": true,
"iss": "Stormpath",
"sub": "msilverman"
},
"header": {
"alg": "HS256"
},
"signature": "qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0"
},
"status": "SUCCESS"
}
ここまでは順調ですね。 さて、今回は、hasMotorcycleを除外しましょう。
http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub=msilverman
今回は、JWTを検証しようとすると:
http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIn0.YMONlFM1tNgttUYukDRsi9gKIocxdGAOLaJBymaQAWc
我々が得る:
GET /parser-enforce?jwt=http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIn0.YMONlFM1tNgttUYukDRsi9gKIocxdGAOLaJBymaQAWc HTTP/1.1
Accept: */*
...
HTTP/1.1 400 Bad Request
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: close
Content-Type: application/json;charset=UTF-8
...
{
"exceptionType": "io.jsonwebtoken.MissingClaimException",
"message":
"Expected hasMotorcycle claim to be: true, but was not present in the JWT claims.",
"status": "ERROR"
}
これは、hasMotorcycleのクレームが予期されていたが、欠落していたことを示しています。
もう1つの例を見てみましょう。
http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath hasMotorcycle:=false sub=msilverman
今回は、必要なクレームが存在しますが、値が間違っています。 次の出力を見てみましょう。
http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjpmYWxzZSwic3ViIjoibXNpbHZlcm1hbiJ9.8LBq2f0eINB34AzhVEgsln_KDo-IyeM8kc-dTzSCr0c
GET /parser-enforce?jwt=http
-v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjpmYWxzZSwic3ViIjoibXNpbHZlcm1hbiJ9.8LBq2f0eINB34AzhVEgsln_KDo-IyeM8kc-dTzSCr0c HTTP/1.1
Accept: */*
...
HTTP/1.1 400 Bad Request
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: close
Content-Type: application/json;charset=UTF-8
...
{
"exceptionType": "io.jsonwebtoken.IncorrectClaimException",
"message": "Expected hasMotorcycle claim to be: true, but was: false.",
"status": "ERROR"
}
これは、hasMotorcycleクレームが存在したが、予期しない値があったことを示しています。
MissingClaimExceptionとIncorrectClaimExceptionは、JWTでクレームを適用するときの友達であり、JJWTライブラリだけが持つ機能です。
7.2. JWT圧縮
JWTについて多くのクレームがある場合、JWTは大きくなる可能性があります。非常に大きくなるため、一部のブラウザーではGET URLに収まらない場合があります。
大きなJWTを作りましょう:
http -v POST localhost:8080/dynamic-builder-specific \
iss=Stormpath hasMotorcycle:=true sub=msilverman the=quick brown=fox jumped=over lazy=dog \
somewhere=over rainbow=way up=high and=the dreams=you dreamed=of
生成するJWTは次のとおりです。
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIiwidGhlIjoicXVpY2siLCJicm93biI6ImZveCIsImp1bXBlZCI6Im92ZXIiLCJsYXp5IjoiZG9nIiwic29tZXdoZXJlIjoib3ZlciIsInJhaW5ib3ciOiJ3YXkiLCJ1cCI6ImhpZ2giLCJhbmQiOiJ0aGUiLCJkcmVhbXMiOiJ5b3UiLCJkcmVhbWVkIjoib2YifQ.AHNJxSTiDw_bWNXcuh-LtPLvSjJqwDvOOUcmkk7CyZA
あの吸盤は大きい! それでは、同じ主張でわずかに異なるエンドポイントに到達しましょう。
http -v POST localhost:8080/dynamic-builder-compress \
iss=Stormpath hasMotorcycle:=true sub=msilverman the=quick brown=fox jumped=over lazy=dog \
somewhere=over rainbow=way up=high and=the dreams=you dreamed=of
今回は、以下を取得します。
eyJhbGciOiJIUzI1NiIsImNhbGciOiJERUYifQ.eNpEzkESwjAIBdC7sO4JegdXnoC2tIk2oZLEGB3v7s84jjse_AFe5FOikc5ZLRycHQ3kOJ0Untu8C43ZigyUyoRYSH6_iwWOyGWHKd2Kn6_QZFojvOoDupRwyAIq4vDOzwYtugFJg1QnJv-5sY-TVjQqN7gcKJ3f-j8c-6J-baDFhEN_uGn58XtnpfcHAAD__w.3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE
62文字短く! JWTの生成に使用されるメソッドのコードは次のとおりです。
@RequestMapping(value = "/dynamic-builder-compress", method = POST)
public JwtResponse dynamicBuildercompress(@RequestBody Map claims)
throws UnsupportedEncodingException {
String jws = Jwts.builder()
.setClaims(claims)
.compressWith(CompressionCodecs.DEFLATE)
.signWith(
SignatureAlgorithm.HS256,
secretService.getHS256SecretBytes()
)
.compact();
return new JwtResponse(jws);
}
6行目に、使用する圧縮アルゴリズムを指定していることに注意してください。 それだけです。
圧縮されたJWTの解析はどうですか? JJWTライブラリは自動的に圧縮を検出し、同じアルゴリズムを使用して解凍します。
GET /parser?jwt=eyJhbGciOiJIUzI1NiIsImNhbGciOiJERUYifQ.eNpEzkESwjAIBdC7sO4JegdXnoC2tIk2oZLEGB3v7s84jjse_AFe5FOikc5ZLRycHQ3kOJ0Untu8C43ZigyUyoRYSH6_iwWOyGWHKd2Kn6_QZFojvOoDupRwyAIq4vDOzwYtugFJg1QnJv-5sY-TVjQqN7gcKJ3f-j8c-6J-baDFhEN_uGn58XtnpfcHAAD__w.3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE HTTP/1.1
Accept: */*
...
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: application/json;charset=UTF-8
...
{
"claims": {
"body": {
"and": "the",
"brown": "fox",
"dreamed": "of",
"dreams": "you",
"hasMotorcycle": true,
"iss": "Stormpath",
"jumped": "over",
"lazy": "dog",
"rainbow": "way",
"somewhere": "over",
"sub": "msilverman",
"the": "quick",
"up": "high"
},
"header": {
"alg": "HS256",
"calg": "DEF"
},
"signature": "3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE"
},
"status": "SUCCESS"
}
ヘッダーのcalgクレームに注意してください。 これは自動的にJWTにエンコードされ、解凍に使用するアルゴリズムに関するパーサーにヒントを提供します。
Note
|
JWE仕様は圧縮をサポートしています。 JJWTライブラリの今後のリリースでは、JWEと圧縮されたJWEをサポートします。 指定されていない場合でも、他のタイプのJWTでの圧縮を引き続きサポートします。 |
8. Java開発者向けのトークンツール
この記事の中心はSpring BootまたはSpring Securityではありませんでしたが、これらの2つのテクノロジーを使用することで、この記事で説明したすべての機能を簡単に実証できました。 サーバーを起動して、これまでに説明したさまざまなエンドポイントで遊んでみることができるはずです。 ちょうどヒット:
http http://localhost:8080
Stormpathは、Javaコミュニティに多くのオープンソース開発ツールを提供することにも興奮しています。 これらが含まれます:
8.1. JJWT(私たちが話していること)
JJWTは使いやすいtool for developers to create and verify JWTs in Javaです。 Stormpathがサポートする多くのライブラリと同様に、JJWTは完全に無料でオープンソースであるため(Apacheライセンス、バージョン2.0)、誰もがそれがどのように機能するかを見ることができます。 問題を報告したり、改善を提案したり、コードを送信したりすることをためらわないでください!
8.2. jsonwebtoken.io and java.jsonwebtoken.io
jsonwebtoken.ioは、JWTのデコードを簡単にするために作成した開発者ツールです。 既存のJWTを適切なフィールドに貼り付けて、ヘッダー、ペイロード、および署名をデコードします。 jsonwebtoken.io is powered by nJWT, the cleanest free and open source (Apache License, Version 2.0) JWT library for Node.js developers. このWebサイトでは、さまざまな言語用に生成されたコードを確認することもできます。 ウェブサイト自体はオープンソースであり、hereで見つけることができます。
java.jsonwebtoken.ioは、特にJJWTライブラリ用です。 右上のボックスでヘッダーとペイロードを変更し、左上のボックスでJJWTによって生成されたJWTを確認し、下のボックスでビルダーとパーサーのJavaコードのサンプルを確認できます。 ウェブサイト自体はオープンソースであり、hereで見つけることができます。
8.3. JWTインスペクター
ブロックの新しい子供であるJWT Inspectorは、開発者がブラウザ内で直接JWTを検査およびデバッグできるようにするオープンソースのChrome拡張機能です。 JWTインスペクターは、サイト上のJWT(Cookie、ローカル/セッションストレージ、ヘッダー内)を検出し、ナビゲーションバーとDevToolsパネルから簡単にアクセスできるようにします。
9. JWT This Down!
JWTは、通常のトークンにインテリジェンスを追加します。 暗号的に署名および検証し、有効期限を組み込み、他の情報をJWTにエンコードする機能により、真にステートレスなセッション管理の舞台が整います。 これは、アプリケーションをスケーリングする機能に大きな影響を与えます。
Stormpathでは、OAuth2トークン、CSRFトークン、およびマイクロサービス間のアサーションなどにJWTを使用しています。
JWTの使用を開始すると、過去のダムトークンに戻ることはできません。 何か質問がある? Twitterの@afitnerdで私を襲ってください。