django-rest-framework-jwtで発行されたtokenを無効化する


jwtだと発行されてから有効期限が切れるまでtokenを無効化することが基本的にできない。
ただ、有効期限にかかわらず即座にtokenを無効化したいケースも出てくるのではと思い実装してみた。
(そもそも有効期限を十分に短めに設定しておけという話は置いといて)

どうやって実装したか

先に結論を書いておくと下記のように実装してみた。

  1. User(に関連したモデル)に任意の文字列を持たせる
  2. token発行時にその文字列をpayloadに含ませる
  3. 認証時にはpaylodadに含まれる文字列とUser(に関連したモデル)に設定された文字列と比較する
    (一致しなければ認証を失敗させる)

上記の実装をした上で発行済のtokenを無効化したくなったらUser(に関連したモデル)に設定された文字列を変更する。

User(に関連したモデル)に任意の文字列を持たせる

https://docs.djangoproject.com/en/1.10/topics/auth/customizing/#extending-the-existing-user-model
↑あたりを参考にUser(に関連したモデル)に任意の文字列を持たせる。
django.contrib.auth.models.Userを継承したクラスを実装したりする方法もあるけど今回はOneToOneFieldで関連付けたモデルに文字列を持たせる。

from django.contrib.auth.models import User
from django.db import models

class Jwt(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    key = models.CharField(max_length=100)

payloadにuser.jwt.keyを含める

まずpayloadを作成す関数を<myproj>.jwt.pyに実装する。
(デフォルトのrest_framework_jwt.utils.jwt_payload_handlerで取得したpayloadにuser.jwt.keyを含めるように実装。)

<myproj>.jwt.py:

from rest_framework_jwt.utils import jwt_payload_handler as default_jwt_payload_handler


def jwt_payload_handler(user):
    payload = default_jwt_payload_handler(user)
    payload['userkey'] = user.jwt.key
    return payload

で、settings.pyでpayload_handlerとして指定する。

settings.py:

REST_FRAMEWORK = {
    'JWT_PAYLOAD_HANDLER': '<myproj>.jwt.jwt_payload_handler',
}

余談

若干話はそれますがdjangorestframework-jwt 1.10.0ではデフォルトのrest_framework_jwt.utils.jwt_payload_handlerを使用すると'The following fields will be removed in the future: email and user_id. 'という警告が出るようになってます。
https://github.com/GetBlimp/django-rest-framework-jwt/blob/13ca172251f229f1f59165da170e58cb458d6b7f/rest_framework_jwt/utils.py#L36-L40

emailとuser_idは認証には使用されておらずpayloadに含める必要がないのでpayloadから削除してしまうほうが望ましかったりします。
(payloadに含まれる内容はクライアント側で容易に見ることができてしまうため、万が一有効期限が切れた無効なtokenなどが第三者に渡った場合にemailなどのユーザー情報が漏れることになるので)

なのでpayload_handlerを実装する際はemailとuser_idをpayloadから削除してしまう方が良さそうです。

from rest_framework_jwt.utils import jwt_payload_handler as default_jwt_payload_handler


def jwt_payload_handler(user):
    payload = default_jwt_payload_handler(user)

    payload.pop('user_id')
    payload.pop('email')

    payload['userkey'] = user.jwt.key
    return payload

user.jwt.keyとpayloadを比較して認証する

まずはrest_framework_jwt.authentication.JSONWebTokenAuthenticationを拡張して認証クラスを実装する。

<myproj>.jwt.py:

from rest_framework import exceptions
from rest_framework_jwt.authentication import JSONWebTokenAuthentication

class JWTAuthentication(JSONWebTokenAuthentication):
    def authenticate_credentials(self, payload):
        user = super(JSONWebTokenAuthentication, self).authenticate_credentials(payload)

        if user.jwt.key != payload.get('userkey'):
            raise exceptions.AuthenticationFailed

        return user

で、settings.pyで認証クラスとして指定する。

settings.py:

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'jwtproj.jwt.JWTAuthentication',
    ),
    'JWT_PAYLOAD_HANDLER': 'jwtproj.jwt.jwt_payload_handler',
}

payloadって拡張していいのか

JWTに詳しくないのでpayloadを拡張して認証して良いのか不安だったのでいろいろ情報をあさっていたところRFC7519に以下の記述があった。
https://tools.ietf.org/html/rfc7519#section-4

The set of claims that a JWT must contain to be considered valid is context dependent and is outside the scope of this specification.
Specific applications of JWTs will require implementations to understand and process some claims in particular ways.

JWTではcraimsに何が含まれているべきかは仕様の範囲外なので何をcraimsに入れてどう処理するのかは実装次第ってことみたいなのでとりあえずJWTとしては問題なさそう。

バージョン

Django==1.10.6
djangorestframework==3.6.2
djangorestframework-jwt==1.10.0

参考サイト

Django REST framework JWT
JSON Web Token (JWT)
DjangoでJWTを使ったトークン認証を実装する
django-rest-framework-jwtの認証をカスタマイズする方法
JWT(JSON Web Token)を使った認証を試みる