Django REST Frameworkでユーザ認証周りのAPIを作る


Django REST Frameworkを使って、以下のAPIを作ったのでメモ。

- ログイン
- ユーザ作成
- ユーザ情報取得
- ユーザ情報更新(パスワード更新含む)
- ユーザ削除

「作った」とはいえ、割と雰囲気で作った部分が大きく、これが最適であるか、という問題もありますので、あくまで参考程度に。

## モデルの作成
モデルに関してはデフォルトのユーザモデルを使ってもいいのですが、実際のアプリケーションを作る場合は何かとカスタマイズが必要になってくるので、以下のようなモデルを作成しました

```python:user/models.py
from django.db import models
from django.contrib.auth.models import (
    BaseUserManager, AbstractBaseUser, _user_has_perm
)
from django.core import validators
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone


class AccountManager(BaseUserManager):
    def create_user(self, request_data, **kwargs):
        now = timezone.now()
        if not request_data['email']:
            raise ValueError('Users must have an email address.')

        profile = ""
        if request_data.get('profile'):
            profile = request_data['profile']

        user = self.model(
            username=request_data['username'],
            email=self.normalize_email(request_data['email']),
            is_active=True,
            last_login=now,
            date_joined=now,
            profile=profile
        )

        user.set_password(request_data['password'])
        user.save(using=self._db)
        return user

    def create_superuser(self, username, email, password, **extra_fields):
        request_data = {
            'username': username,
            'email': email,
            'password': password
        }
        user = self.create_user(request_data)
        user.is_active = True
        user.is_staff = True
        user.is_admin = True
        user.save(using=self._db)
        return user


class Account(AbstractBaseUser):
    username    = models.CharField(_('username'), max_length=30, unique=True)
    first_name  = models.CharField(_('first name'), max_length=30, blank=True)
    last_name   = models.CharField(_('last name'), max_length=30, blank=True)
    email       = models.EmailField(verbose_name='email address', max_length=255, unique=True)
    profile     = models.CharField(_('profile'), max_length=255, blank=True)
    is_active   = models.BooleanField(default=True)
    is_staff    = models.BooleanField(default=False)
    is_admin    = models.BooleanField(default=False)
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)

    objects = AccountManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    def user_has_perm(user, perm, obj):
        return _user_has_perm(user, perm, obj)

    def has_perm(self, perm, obj=None):
        return _user_has_perm(self, perm, obj=obj)

    def has_module_perms(self, app_label):
        return self.is_admin

    def get_short_name(self):
        return self.first_name

    @property
    def is_superuser(self):
        return self.is_admin

    class Meta:
        db_table = 'api_user'
        swappable = 'AUTH_USER_MODEL'
```

adminユーザについては通常通り `$ python manage.py createsuperuser`で作成できます。
デフォルトと異なるのは以下の2点。<br>
1)ログインにパスワードを組み合わせるフィールドをusernameからemailに変更
こちらは下記の部分で適用されています。

```
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
```

2)プロフィールを入力するフィールドを追加(profile)

```
profile     = models.CharField(_('profile'), max_length=255, blank=True)
```


以上です。

## ログイン用のAPIを実装
今回はJWTによるトークン認証を実装しました。
django-rest-framework-jwtについてはpipでインストールしていきます。

```
$ pip install djangorestframework-jwt
```

インストールができたら、settings.pyに以下を追記する。

```python:djangoproject/settings.py
JWT_AUTH = {
    'JWT_VERIFY_EXPIRATION': False,
    'JWT_AUTH_HEADER_PREFIX': 'JWT',
}


REST_FRAMEWORK = { 
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),  
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
    ),  
    'NON_FIELD_ERRORS_KEY': 'detail',
    'TEST_REQUEST_DEFAULT_FORMAT': 'json'
}
```

なお、`'JWT_VERIFY_EXPIRATION': False,`にしておくとトークンの期限が永続化されます
<br>
つぎに、url.pyに下記のようにやっていく

```python:djangoproject/urls.py
from django.conf.urls import url

from rest_framework_jwt.views import obtain_jwt_token

urlpatterns = [
    url(r'^login/', obtain_jwt_token),
]
```

これだけでemailとpasswordを用いて/loginに対してPOSTするとトークンを取得することができる

## ユーザ作成のAPIを実装
### urlの追加

まずはAPIのエンドポイントが必要なのでアプリケーションにurls.pyを作成し、

```python:user/urls.py
from django.conf.urls import include, url
from rest_framework import routers
from .views import AuthRegister

urlpatterns = [
    url(r'^register/$', AuthRegister.as_view()),
]
```

プロジェクト側のurls.pyに読み込ませる。

```python:djangoproject/urls.py
from django.conf.urls import include, url
from django.contrib import admin

from rest_framework_jwt.views import obtain_jwt_token
urlpatterns = [
    url(r'^login/', obtain_jwt_token),
    url(r'^api/', include('authentication.urls')),
]

```

/api/register/にPOSTして、ユーザ作成できるようにする

### Viewの作成

```python:user/views.py
from django.contrib.auth import authenticate
from django.db import transaction
from django.http import HttpResponse, Http404
from rest_framework import authentication, permissions, generics
from rest_framework_jwt.settings import api_settings
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.response import Response
from rest_framework import status, viewsets, filters
from rest_framework.views import APIView
from .serializer import AccountSerializer
from .models import Account, AccountManager

# ユーザ作成のView(POST)
class AuthRegister(generics.CreateAPIView):
    permission_classes = (permissions.AllowAny,)
    queryset = Account.objects.all()
    serializer_class = AccountSerializer

    @transaction.atomic
    def post(self, request, format=None):
        serializer = AccountSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
```

genericsを利用することによって、クラスが受け付けることができるHTTPメソッドを限定することができます。
genericsの詳細については[こちら](http://www.django-rest-framework.org/api-guide/generic-views/)を確認してください。
CreateAPIViewは作成専用のエンドポイントに使用されます。

また、当然の話ですが、ユーザ作成については認証されていない状態でもエンドポイントに対してPOSTする必要があるので`permission_classes = (permissions.AllowAny,)`としています。

### Serializerの作成

```py
from django.contrib.auth import update_session_auth_hash
from rest_framework import serializers

from .models import Account, AccountManager


class AccountSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, required=False)

    class Meta:
        model = Account
        fields = ('id', 'username', 'email', 'profile', 'password')

    def create(self, validated_data):
        return Account.objects.create_user(request_data=validated_data)
```

ざっくり言うと、Modelで定義したユーザ作成のメソッドを呼びにいっている感じです。

とりあえず、上記の状態でemail、password、profile(任意)のjsonを/api/register/にPOSTすると、ユーザが作成されます。

## ログインユーザの情報取得
ユーザの情報の取得に関してはアプリケーションにもよりますが、とりあえず今回はログインしているユーザが自分の情報を取得できるだけにしています。
他ユーザの情報も取得したい場合には、また別にviewやエンドポイントを作成する必要がありますので、あしからず。

### urls.pyの編集
アプリケーション側のurls.pyを編集。

```python:user/urls.py
from django.conf.urls import include, url
from rest_framework import routers
from .views import AuthRegister, AuthInfoGetView

urlpatterns = [
    url(r'^register/$', AuthRegister.as_view()),
    url(r'^mypage/$', AuthInfoGetView.as_view()),
]
```
/api/mypageに対してGETを投げた時にユーザ情報を取得するようにします。

### Viewの編集
先ほどのviews.pyに以下を追記します

```python:user/views.py
# ユーザ情報取得のView(GET)
class AuthInfoGetView(generics.RetrieveAPIView):
    permission_classes = (permissions.IsAuthenticated,)
    queryset = Account.objects.all()
    serializer_class = AccountSerializer

    def get(self, request, format=None):
        return Response(data={
            'username': request.user.username,
            'email': request.user.email,
            'profile': request.user.profile,
            },
            status=status.HTTP_200_OK)
```
RetrieveAPIViewはGETメソッド専用のエンドポイントです。
また、自分自身の情報を取得するようにしているので、`permission_classes = (permissions.IsAuthenticated,)`でログインしている状態でなければ、取得できないようにしています。

この状態で、ヘッダーに`{ 'Content-Type': 'application/json', 'Authorization': 'JWT [ログイン時に取得したトークン]' }`を追加した上でGETメソッドを投げると、ログインしているユーザのusername/email/profileを取得することができます。 


## ログインユーザの情報更新(パスワード更新を含む)
情報を更新する際にPUTを使うかPATCHを使うか、という話がありますが、とりあえず今回はPUTを使いましたが、今になって思えばPATCHでよかったな、などと思っています。
とはいえ、作ったのでPUTでの話をやっていきます。

### urls.pyの編集
/auth_updateというエンドポイントを作成し、AuthInfoUpdateViewというビューを呼び出すようにします。

```python:user/urls.py
from django.conf.urls import include, url
from rest_framework import routers
from .views import AuthRegister, AuthInfoGetView, AuthInfoUpdateView

urlpatterns = [
    url(r'^register/$', AuthRegister.as_view()),
    url(r'^mypage/$', AuthInfoGetView.as_view()),
    url(r'^auth_update/$', AuthInfoUpdateView.as_view()),
]
```

### Viewの編集
views.pyに以下を追加する。
/auth_updateにPUTがあった時に呼び出されるビュー、AuthInfoUpdateViewの実装です。
キーとしてはemailを利用しているので、PUTを送る際にはログインしているユーザのemailもJSONに含める必要があります。


```python:user/views.py
# ユーザ情報更新のView(PUT)
class AuthInfoUpdateView(generics.UpdateAPIView):
    permission_classes = (permissions.IsAuthenticated,)
    serializer_class = AccountSerializer
    lookup_field = 'email'
    queryset = Account.objects.all()

    def get_object(self):
        try:
            instance = self.queryset.get(email=self.request.user)
            return instance
        except Account.DoesNotExist:
            raise Http404
```
UpdateAPIViewはその名の通り、更新専用のviewでPUT/PATCHを受け付けます。


### Serializerの編集
serializers.pyのAccountSerializerを下記のように編集していきます。

```python:user/serializers.py
class AccountSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, required=False)

    class Meta:
        model = Account
        fields = ('id', 'username', 'email', 'profile', 'password')

    def create(self, validated_data):
        return Account.objects.create_user(request_data=validated_data)

    def update(self, instance, validated_data):
        if 'password' in validated_data:
            instance.set_password(validated_data['password'])
        else:
            instance = super().update(instance, validated_data)
        instance.save()
        return instance
```
passwordの更新があった場合は、受け取ったパスワードをハッシュ化する必要があるので、set_passwordメソッドを利用します。
ユーザ情報取得と同様、ヘッダーに`{ 'Content-Type': 'application/json', 'Authorization': 'JWT [ログイン時に取得したトークン]' }`を付けてPUTする必要があります。


## ユーザ削除
最後にログインしているユーザの削除を実装していきます。

### urls.pyの編集
/deleteというエンドポイントを作り、AuthInfoDeleteViewを呼び出すようにしています。

```python:user/urls.py
from django.conf.urls import include, url
from rest_framework import routers
from .views import AuthRegister, AuthInfoGetView, AuthInfoUpdateView, AuthInfoDeleteView

urlpatterns = [
    url(r'^register/$', AuthRegister.as_view()),
    url(r'^mypage/$', AuthInfoGetView.as_view()),
    url(r'^auth_update/$', AuthInfoUpdateView.as_view()),
    url(r'^delete/$', AuthInfoDeleteView.as_view()),
]
```

### Viewの編集
AuthInfoDeleteViewの実装です。

```python:user/views.py
# ユーザ削除のView(DELETE)
class AuthInfoDeleteView(generics.DestroyAPIView):
    permission_classes = (permissions.IsAuthenticated,)
    serializer_class = AccountSerializer
    lookup_field = 'email'
    queryset = Account.objects.all()

    def get_object(self):
        try:
            instance = self.queryset.get(email=self.request.user)
            return instance
        except Account.DoesNotExist:
            raise Http404
```
`generics.DestroyAPIView`とすることで、DELETEメソッドのみを受け付けるビューとなります。
ユーザ情報の取得、更新と同様にヘッダーに`{ 'Content-Type': 'application/json', 'Authorization': 'JWT [ログイン時に取得したトークン]' }`を付けてDELETEを送ることで、ログインしているユーザの削除を行うことが出来ます。

以上です。

## 課題とか
実は今の状態ではemailの更新ができません。いや、できることはできまるのですが、クライアント側で持っているトークンが古いemailでログインしたものなので、新しいemailで取得したトークンをクライアント側が持たなければ挙動がおかしくなってしまいます。パスワードと新しいemailで/loginにPOSTすれば新しいemailで新しいトークンを発行できますが、それはナンセンスかな、と。クライアント側に平文でパスワードを持たせる必要がありますし。とりあえず、ここはもう少し詳しく調べてみたいと思っています。

また、パスワード忘れのために、パスワード再発行のAPIも用意する必要があると思いますが、そこは
めんどくさかったので
今回は見送りました。

あと、明らかな問題点はログイントークンを何かしらの方法で取得された場合、簡単にユーザ情報が改ざんされたり削除されてしまうという点です。
とはいえ、トークンが悪意を持って奪われる問題はJWTにはついてまわる話なのかな、と思いますが。
今回はトークンの有効期限を無限にしていますが、有効期限を設けたり、APIにリクエストを送る際にはリフレッシュトークンを発行したりするというのが実際にアプリケーションを公開する場合には必要なのかな、と思っています。

なお、上記のAPIとフロントにAngularを組み合わせたものを以下のリポジトリにおいています。
https://github.com/xKxAxKx/auth_django_and_angular2