Seiichi Yonezawa

JWTの認証について

前回のデプロイに続いて、今回はNode.jsでのユーザ認証について考えてみます。 Railsでユーザ認証が必要であれば、Deviseを利用すれば簡単に追加することができます。 Node.jsにも同様のPassport.jsというライブラリが存在していて、このライブラリを使えばユーザ認証周りは簡単に追加できます。

しかし、前々から気になっていたJWTをユーザ認証に使うことは可能でしょうか。 そこで、この投稿ではJWTの簡単な概要に触れつつ、実装方法も触れていくという構成にしようと思います。

JWTとは

https://medium.com/vandium-software/5-easy-steps-to-understanding-json-web-tokens-jwt-1164c0adfcec

まずJWTを理解するにあたって、上記の記事を参考にしました。 JWTはheader(ヘッダ)、payload(データの本体)、signature(署名)の3つからなり、headerとpayloadはそのままbase64(64進数)形式でエンコードされています。 つまり、このことからもわかるんですがJWTはpayloadの内容を暗号化するものではありません。 人間が読むのは大変ですが、RubyならBase64.decode64(payload)で暗号内容を解読できてしまいます。

そこで、signatureはheaderの暗号方式をもとにpayloadと有効期限を署名しています。 サーバが署名したJWTをクライアントに発行して、リクエストに含まれているJWTが同一であれば認証が通ります。 これも同じ暗号方式がわかれば同じ署名を発行できるかもしれませんが、署名時に秘密文を指定するので仕組みとしてはSSL証明書と似ているかもしれません。

JWTを実装する

https://medium.com/@faizanv/authentication-for-your-react-and-express-application-w-json-web-tokens-923515826e0

続いて上記の記事を参考にJWTを実装していきます。 ここで重要なのは以下の部分です。

const secret = process.env.SECRET
const same = await user.isCorrectPassword(password)

if (same) {
  const payload = { id: user.id }
  const token = jwt.sign(payload, secret, {
    expiresIn: '1h'
  })
  res.json({ token })
}

いくつかアレンジを加えていますが、ログイン時にUserのパスワードが正しければJWT通常のJSONとしてレスポンスを返します。 また、上記の通りpayloadは暗号化されないためここではメールアドレスのかわりにIDにしました。 そしてsecretはRailsのように環境変数から取得し、1時間有効なJWTを発行します。

const secret = process.env.SECRET
const token = req.headers['x-access-token']

jwt.verify(token, secret, function(err, decoded) {
  res.userId = decoded.id
  next()
})

続いてミドルウェアです。 jwt.verifyに先ほど指定したsecretを指定して、ヘッダーのJWTが正しければリクエストの処理が続きます。 もちろんサーバが署名していないJWTをHTTPヘッダーに指定しようとしても401が返されます。

特筆すべき点として、JWTの認証方法は毎回リクエスト時にデータベースのアクセスをする必要がありません。 JWTの有効期限が切れるまでは基本的にstateless(状態に依存しない)であるといえます。 JWTは秘密文を共有すれば容易にスケールアウトすることが可能だというわけです。

ログアウトの処理

これで一通り無事にユーザの認証をすることができるようになりました。 ログインの処理ができたらログアウトの処理も必要ですが、先ほどの投稿ではログアウトの記述が特にありませんでした。 jsonwebtokenのREADMEを見てもsignとかverifyくらいしか説明されていません。

https://stackoverflow.com/questions/21978658/invalidating-json-web-tokens

このStack Overflowの最初の回答を要約すると、JWTベースの認証ではログアウトにいくつかのアプローチがあるようです:

  1. クライアント側からJWTを消去する
  2. 消去したJWTをデータベースに格納する(ブラックリスト)
  3. JWTの有効期限を短くする(リフレッシュトークン)
  4. 秘密文を変更する
  5. タイムスタンプ/バージョンを追加する

抜けがあるかもしれませんが、これらが考えられる回答のようです。 しかし、どれも決定打といえるようなアプローチがないようにも思えます。

まず1に関してですが、これはクライアント側では必須の挙動には変わりありません。 でももし消去したJWTを使えば再び同じJWTで認証できてしまいます。 見た目では変わりありませんが、ログアウトするという意味では不十分です。

2ではログアウトのリクエスト時にセッション切れしたJWTを格納し、次回以降のリクエストで同じJWTが含まれていれば認証に失敗するというものです。 上記の回答ではこの方法が有力にも思えるのですが、この場合データベースを利用しなければなりません。 データベースを利用するならばトークンベースで同じことができるので、JWTを復号化する必要はないように思えます。

3は1と関連していますが、先ほどの実装例ではひとつのJWTを繰り返し利用するのに対して、毎回のリクエストに新しいJWTを返すというものです。 仮にいくら有効期限を短くしたとしても、ログアウトがされたわけではないので期限内なら認証は有効です。 それどころかあまりにも有効期限が短いと、少し放置したら再度ログインを強要されるので利便性もよくありません。

4は秘密文を変更することで、それまでのJWTを物理的に無効にすることができます。 一見素晴らしい方法と思いきや、これを行うと他のユーザや端末で一斉にログインが必要になってしまいます。 個別に秘密文を格納しようとすれば当然データベースも利用しなければなりません。

5の方法は2のように現在有効なJWTを保存しておくというものです。 この方法は最終的に更新した日付や現在有効なバージョンをどのように参照するかと考えたら、やはりデータベースが必要です。

JWTの用途とは

Stop using JWT for sessions

この投稿を要約すると、セッション管理の代替としてJWTを使うのは誤りであるといったところでしょうか。 JWTを利用する場面はマイクロサービス同士が一度限りの認証でやりとりをするのに必要だというわけです。 こちらのパート2も最終的にはセッションに落ち着くようで、ユーザ認証においてはセッションを利用するのが賢明だといえそうです。

まとめ

開発環境でデータベースをすべて削除した後で、ユーザが存在しないのにログインできてしまう場面に出くわしました。 上記の通りサーバーを起動しなおすごとに秘密文を変更すれば解決する問題なのですが不便です。 JWTはパスポートのようなもので、それが有効な限りは権限を行使できるようなものです。 ユーザ認証に関しては昔ながらの方法がもっともよい方法だといえるのかもしれません。

投稿一覧