ModelSerializerを継承したシリアライザーからユーザーを登録、更新する際にパスワードをハッシュ化させる

頻繁に使うことになりそうなので備忘録として残しておく。

#1.要約
ModelSerializerクラスが持つ登録時に呼び出すcreateメソッドと更新時に呼び出すupdateメソッドをオーバーライドする必要がある。
また、passwordに対してsettings.AUTH_PASSWORD_VALIDATORSで指定したバリデーターを使うようにすることも忘れてはいけない。

#2.環境及び問題
Python 3.9.0
Django 3.1.7
Django REST Framework 3.12.4

最初にカスタムユーザーモデルを定義した。
長いがemailとpasswordを使って認証を行うカスタムユーザーモデルを定義しただけ。
例によってパスワードはハッシュ化して保存される。

```python:project/users/models.py
from django.db import models
from django.contrib.auth.models import PermissionsMixin, AbstractBaseUser, BaseUserManager

class UserManager(BaseUserManager):

  use_in_migrations = True

  # create_user()とcreate_superuser()の共通処理
  def _create_user(self, email, password, **extra_fields):
    if not email:
      raise ValueError('a user must have an email address')
    email = self.normalize_email(email)
    user = self.model(email=email, **extra_fields)
    user.set_password(password)
    user.save(using=self.db)
    return user

  def create_user(self, email, password=None, **extra_fields):
    extra_fields.setdefault('is_staff', False)
    extra_fields.setdefault('is_superuser', False)
    return self._create_user(email, password, **extra_fields)


  def create_superuser(self, email, password, **extra_fields):
    extra_fields.setdefault('is_staff', True)
    extra_fields.setdefault('is_superuser', True)
    if extra_fields.get('is_staff') is not True:
      raise ValueError('a superuser must have is_staff=True')
    if extra_fields.get('is_superuser') is not True:
      raise ValueError('a superuser must have is_superuser=True')
    return self._create_user(email, password, **extra_fields)


class User(PermissionsMixin, AbstractBaseUser):
  email = models.EmailField(unique=True)
  is_staff = models.BooleanField(default=False)
  is_active = models.BooleanField(default=True)

  objects = UserManager()

  USERNAME_FIELD = 'email'
  EMAIL_FIELD = 'email'
  REQUIRED_FIELDS = []

  class Meta:
    verbose_name = 'user'
    verbose_name_plural = 'users'
```

次にシリアライザーを定義する。

```python:project/api/v1/serializers.py
from rest_framework import serializers, fields
from users.models import User
.
.
.
class UserSerializer(serializers.ModelSerializer):
  
  class Meta:
    # 対象のクラス
    model = User
    # 利用するモデルのフィールド
    fields = ['id', 'email', 'password']
    id = serializers.IntegerField(read_only=False)
```

この状態でコンソール上で下記のようなJSONから新しいユーザーを作ってみることにする

```json
{
  "email": "foo@bar.com",
  "password": "password@0123"
}
```


```
$ python manage.py shell

>>> from rest_framework.parsers import JSONParser
>>> from io import BytesIO
>>> from api.v1.serializers import UserSerializer
>>> from users.models import User
>>> data = JSONParser().parse(BytesIO('{"email": "foo@bar.com", "password": "password@0123"}'.encode()))
>>> serializer = UserSerializer(data=data)
>>> serializer.is_valid()
True
>>> new_user = serializer.save()
```

一見問題なさそうだが、実は既に問題が発生している。

```
>>> new_user.password
'password@0123'
```

なんとパスワードがハッシュ化されていない。
DBを直接確認しても生のパスワードが格納されてしまっていた。
また、UPDATEでも同じことが起きる

```
>>> data = {"email": "bar@baz.com", "password": "password@4567"}
>>> serializer = UserSerializer(instance=new_user, data=data)
>>> serializer.is_valid()
True
>>> updated_user = serializer.save()
>>> updated_user.password
'password@4567'
```

こちらもやはりDBには生のパスワードが格納されていた。

# 3.解決した方法
ModelSerializerには登録時に呼び出されるcreateメソッド、更新時に呼び出されるupdateメソッドが存在するのでこれらをオーバーライドし、passwordをハッシュ化する処理を盛り込む。
また、passwordへのバリデーション時にsettings.AUTH_PASSWORD_VALIDATORSで指定したバリデータを使うようvalidate_password()を定義する。

```python:project/api/v1/serializers.py
from django.contrib.auth import password_validation
from rest_framework import serializers, fields

from users.models import User
from destinations.models import Destination

class UserSerializer(serializers.ModelSerializer):
  
  class Meta:
    # 対象のクラス
    model = User
    # 利用するモデルのフィールド
    fields = ['id', 'email', 'password']
    id = serializers.IntegerField(read_only=False)

  def validate_password(self, password):
    """入力JSONで指定されたpasswordに対してsettings.AUTH_PASSWORD_VALIDATORSで指定したバリデーションを実行"""
    if password_validation.validate_password(password) is False:
      raise serializers.ValidationError(f'The password {password} is not valid')
    return password
    
  # ユーザー作成時にパスワードを暗号化する
  def create(self, validated_data):
    # 後で使うので入力された生のパスワードを取得しておく
    unhashed_password = validated_data.pop('password', None)
    # パスワードを削除した入力データからUser型のインスタンスを生成
    new_user = self.Meta.model(**validated_data)
    # パスワードをハッシュ化してセットし、DBに保存
    if unhashed_password is not None:
      new_user.set_password(unhashed_password)
    new_user.save()
    return new_user
  
  # ユーザー更新時にパスワードを暗号化する
  def update(self, pre_update_user, validated_data):
    # 更新されるユーザーのフィールドを入力データの値に書き換えていく
    for field_name, value in validated_data.items():
      # passwordを更新する際は入力データの値をset_password()の引数に渡してハッシュ化
      if field_name == 'password':
        pre_update_user.set_password(value)
      # password以外のフィールドを更新する際は入力データでそのまま上書きでOK
      else:
        setattr(pre_update_user, field_name, value)
    pre_update_user.save()
    return pre_update_user
```

コンソールを再起動し、正しく動作するのを確認する。
なお、先ほど作成したパスワードがハッシュ化されていないデータは削除してある。

```
$ python manage.py shell

>>> from rest_framework.parsers import JSONParser
>>> from io import BytesIO
>>> from api.v1.serializers import UserSerializer
>>> data = JSONParser().parse(BytesIO('{"email": "foo@bar.com", "password": "password@0123"}'.encode()))
>>> serializer = UserSerializer(data=data)
>>> serializer.is_valid()
True
>>> new_user = serializer.save()
>>> new_user.password
'pbkdf2_sha256$216000$7oDDeRQFlgBh$NJ4wIO5QNEgtLCrqa/z00j+JevrVCYV+lZ/7SAoT6ig='
>>> data = {"email": "bar@baz.com", "password": "password@4567"}
>>> serializer = UserSerializer(instance=new_user, data=data)
>>> serializer.is_valid()
True
>>> updated_user = serializer.save()
>>> updated_user.password
'pbkdf2_sha256$216000$OE3a7dgg8gJc$WAYxiSNKAoIH7aK0Af/vISj3DpGQauaofTIkg+AKpVg='
```

登録時、更新時共にパスワードがハッシュ化されているのが確認できた。

# 参考
https://stackoverflow.com/questions/27586095/why-isnt-my-django-user-models-password-hashed

# 更新
1. 2021/04/08<br>入力JSONのパスワードにバリデーションがかかってなかったのを修正。