この記事は Django Advent Calendar 2017 20日目の記事です。

先日、満を持してバージョン2.0がリリースされました。
ただ、今回は新しい機能の紹介ではなく、1.xでも実装可能な「API認証」について書きます。

アジェンダ

  • この記事を書こうと思ったきっかけ
  • API認証の実装
  • まとめ

この記事を書こうと思ったきっかけ

以前、こちらにも書いたのですが、私は自分がつくったシステムのログイン~ログアウト処理に、これまた自分で作ったGo(Gin)のAPIを利用しています。
※最近はソーシャルログインなど外部サービスとの連携が楽にできるのですが、自分の勉強のためにこのようにしています。

で、以前ASP.NETでつくったレシピ管理ツールを今年Djangoに移行しまして、そこでもAPI認証をすることにしました。
CakePHP3やLaravelでは比較的簡単にできたのですが、Djangoは少し癖があったので、備忘も兼ねて書き残しておこうと思ったのがきっかけです。

API認証の実装

手順は大きく4つです。

  1. 認証用ユーザモデルの作成
  2. 認証ミドルウェアの作成
  3. 認証バックエンドの作成
  4. 設定ファイルの変更

通常、Djangoではマイグレーション時にauth_userテーブルが生成され、そこでユーザ情報を管理します。
グループやバーミッションなど細かい設定もできるのですが、今回はそれらは使用しません。

カスタムユーザモデルの作成

AbstratUserを継承した、独自ユーザーモデルを作成します。

# models.py
""" Models """
from django.contrib.auth.models import AbstractUser


class ApiUser(AbstractUser):
    """ API認証ユーザ """
    REQUIRED_FIELDS = ['account', 'name', 'email']

    is_staff = True
    is_superuser = False

    def get_access_token(self):
        """ アクセストークン取得 """
        return self.access_token if hasattr(self, 'access_token') else None

    def get_full_name(self):
        return self.fullname

    @property
    def is_authenticated(self):
        return True if self.get_username() else False

    def save(self, *args, **kwargs):
        # DB保存はしないのでここの処理は実装しない
        pass

    def from_json(self, data):
        """ JSONの値をオブジェクトに設定 """
        self.pk = data.get('id')
        self.access_token = data.get('accessToken')
        self.username = data.get('account')
        self.fullname = data.get('name')
        self.email = data.get('mailAddress')
        self.is_superuser = data.get('admin') # `admin`フラグがTrueなら管理者とする
        return self

    def to_json(self):
        """ オブジェクトの値をJSONで取得 """
        return {
            'id': self.pk,
            'accessToken': self.access_token,
            'account': self.username,
            'name': self.fullname,
            'mailAddress': self.email,
            'admin': self.is_superuser,
        }

    class Meta(AbstractUser.Meta):
        swappable = 'AUTH_USER_MODEL'
        verbose_name = 'ログインユーザ'
        verbose_name_plural = 'ログインユーザ'
        abstract = False
        managed = False

        # 管理画面の認証も行う場合は必要
        app_label = 'core'
        db_table = 'auth_user'

セッションにsigned_cookieを利用しているためJSON変換処理を用意しています。

カスタム認証ミドルウェアの作成

django.contrib.auth.middleware.AuthenticationMiddlewareを参考に、以下のようなカスタムミドルウェアを作成します。

# middlewares.py
""" Middleware """
import json
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from app.models import ApiUser

class WebApiAuthenticationMiddleware(MiddlewareMixin):
    """ API認証ミドルウェア """

    def process_request(self, request):
        """
        リクエスト時の処理
        @param request
        """
        assert hasattr(request, 'session'), (
            "The Django authentication middleware requires session middleware "
            "to be installed. Edit your MIDDLEWARE%s setting to insert "
            "'django.contrib.sessions.middleware.SessionMiddleware' before "
            "'app.middlewares.WebApiAuthenticationMiddleware'."
        ) % ("_CLASSES" if settings.MIDDLEWARE is None else "")
        request.user = SimpleLazyObject(lambda: self.get_user(request))

    def get_user(self, request):
        """
        セッションからログインユーザを取得する
        @param request
        """
        from django.contrib.auth.models import AnonymousUser
        user_data = request.session.get('user')
        if not user_data:
            return AnonymousUser()

        # セッションから取得したデータをJSONに復元する
        return ApiUser().from_json(json.loads(user_data))

認証バックエンドの作成

Django標準の認証バックエンドとしてModelBackend(データベースを利用)やRemoteUserBackend(ヘッダのユーザー名を利用)が用意されていますが、 今回はAPI認証のため独自のバックエンドファイルを用意します。

# backends.py
""" Backend """
import json
from logging import getLogger
import requests
from django.conf import settings
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from app.models import ApiUser


class WebApiBackend:
    """ WebAPIを利用した認証クラス """
    logger = getLogger(__name__)

    def authenticate(self, request, username=None, password=None, **kwargs):
        """
        認証処理
        :param request
        :param username
        :param password
        :param kwargs
        :return ApiUser or None
        """
        # ユーザIDとパスワードでAPI認証
        auth_response = requests.post(
            'http://api.example.com/auth', json={'account': username, 'password': password})

        # アクセストークン取得エラー
        if not auth_response.ok:
            self.logger.error(auth_response.json())
            return None

        # 取得したアクセストークンからユーザ情報を取得
        token = auth_response.json().get('accessToken')
        user_response = requests.get(
            'http://api.example.com/users', headers={'Authorization': 'Bearer %s' % token})

        # ユーザ情報取得エラー
        if not user_response.ok:
            self.logger.error(user_response.json())
            return None

        user_json = user_response.json()
        user_json['accessToken'] = token

        user = ApiUser().from_json(user_json)

        # 管理画面の認証も行う場合はここでデータベース側の登録・更新処理が必要

        # シリアライズしたJSONデータをセッションに格納
        request.session['user'] = json.dumps(user.to_json())
        return user


@receiver(user_logged_out)
def deauthenticate(request, **kwargs):
    """
    認証解除処理(ログアウト時のイベントフック関数)
    :param request
    :param kwargs
    """
    user = getattr(request, 'user', None)

    # APIの認証解除
    if isinstance(user, ApiUser):
        url = 'http://api.example.com/deauth'
        response = requests.delete(
            url, headers={'Authorization': 'Bearer %s' % user.get_access_token()})

        if not response.ok:
            logger = getLogger(__name__)
            logger.error(response.json())

設定ファイルの変更

最後に、settings.pyを修正します。

# settings.py
…
MIDDLEWARE = [
    ...
    'app.middlewares.WebApiAuthenticationMiddleware', # `AuthenticationMiddleware`の代わりに追加
]

AUTHENTICATION_BACKENDS = [
    'app.backends.WebApiBackend', # 今回作成したバックエンドを追加
    # 記載順に実行され、解決できない場合は後続が実行されるため、
    # 他の認証バックエンドも利用する場合は後続に定義
]

AUTH_USER_MODEL = 'app.ApiUser' # 設定

以上で実装完了です。

まとめ

Djangoには標準で管理画面がついてきますが、こちらはDBありきの実装なので、認証・認可をAPIで乗り切ろうと思うとかなり大変です。
標準の管理画面を利用する場合は嫌でもデータベースを使うことになるので、その場合は素直にテーブル管理しましょう。

一応、上記コード上にもコメントで記載していますが、結局のところ認証ユーザとauth_userテーブルを一旦同期させることになりそうです。

主な手順

  • ApiUserauth_userとして利用
  • API認証時、auth_userにレコードがなければ追加
  • リレーションの維持にはその値を利用

この実装を通して、Djangoが認証をどのように行っているかを学べたのは良い経験でした。
まだまだ知らない機能が多いので、これからも機能追加しつつ楽しんで開発出来たらと思います。

一部記事用に修正しましたが、今回利用したソースコードの元はこちらにありますので、良ければご覧ください。