Django 初回ログイン時にパスワード変更を強制する

## 環境
Windows 11 Home
Python 3.10.2
Django 4.0.2
venv利用あり

## 関連記事
Django 第1回：[Django Custom User Model の作成](https://qiita.com/startours777/items/706d38e712b0c737a16a)
Django 第2回：[Django 初回ログイン時にパスワード変更を強制する](https://qiita.com/startours777/items/28db625a9bb81a36d4ad)　今回
Django 第3回：[Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する](https://qiita.com/startours777/items/d550ca9b67cea4408a44)
Django 第4回：[Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する](https://qiita.com/startours777/items/529e74062e7054e79517)　
Django 第5回：[Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除](https://qiita.com/startours777/items/5b1fc415e047a2044129)

## 背景
Django利用時、パスワードをメール送信するがそのままそれを利用されたくないケースが存在する。
（セキュリティ的に、とか）
そういったケースでユーザの初回ログイン時にパスワードを強制変更させる機能を実装する。

[前回](https://qiita.com/startours777/items/706d38e712b0c737a16a)の記事でカスタムユーザモデルを作成し、パラメータとして`password_changed`を用意しておいた。
今回は更にMiddlewareを開発し、ページアクセス時に`password_changed`の値が`False`(デフォルト値)であれば
パスワード変更ページにリダイレクトさせる。

## 状態
`users/models.py` にカスタムユーザモデルを用いている。
`password_changed`というパラメータが今回のキー。
[前回](https://qiita.com/startours777/items/706d38e712b0c737a16a)の記事参照。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/bdc7543c-d747-342f-0050-05c319be6b4e.png)

## 手順１
`users` 配下に `password_middleware.py` を作成する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/5699a77a-9048-735e-77f2-972aaedd339b.png)

以下のように記載する。

```python:users\password_middleware.py
import re
from django.http import HttpResponseRedirect
from users.models import User # 作成したCustom User Model

# https://office54.net/python/django/middleware-make-myself

class ForcePasswordChangeMiddleware:
    """
    初期パスワードを初回のログオン時に強制的に変更させるMiddleware。
    'password_changed'がFalseであれば、未変更とみなしパスワード変更ページにリダイレクト。
    パスワード変更後は'password_changed'をTrueにする
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)

        if re.match(r'/admin/password_change/done/?', request.path):
            try:
                # refererが'http://xxx.xxx.xxx.xxx:xx/admin/password_change/'かどうかを確認する
                # すなわちpassword変更されたならば、Userステータスを更新する
                # HOSTNAMEの場合は正規表現を変更してください
                referer = request.environ.get('HTTP_REFERER')
                print("referer:",referer)
                if re.match(r'^https?://((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]).*/admin/password_change/?', referer):
                    user = User.objects.get(email=request.user.email)
                    user.password_changed = True
                    user.save()
            except:
                pass
        return response

    def process_view(self, request, view_func, view_arts, view_kwargs):
        # ログアウトは許可する
        if re.match(r'^/admin/logout/?', request.path):
            return

        # 初回ログイン時にパスワード変更を強いる
        if re.match(r'^/admin/?', request.path) and \
                not re.match(r'^/admin/password_change/?', request.path):
                try:
                    # password_changeがFalseであれば、パスワード変更ページにリダイレクト
                    # すなわち初回ログイン時にパスワード変更を強いる
                    password_changed = request.user.password_changed
                    if password_changed == False:
                        return HttpResponseRedirect('/admin/password_change/')
                except:
                    pass
```
（2022/07/09 : http, https どちらでもいいようにre.matchの構文を修正）


## 解説１
```
def __call__(self, request):
...
```
の部分はview関数が実行された後に呼び出される。
パスワード変更完了時＝/admin/password_change/done/ に遷移した際に、HTTP_REFERERが
http://xxx.xxx.xxx.xxx:xx/admin/password_change/'かどうかをチェックしている。
IPアドレスおよびポートがドメイン名である場合などは正規表現を修正する必要がある。
また/admin/の部分も変えることができるため、変更した方はこちらも修正が必要。

```
    def process_view(self, request, view_func, view_arts, view_kwargs):
        # ログアウトは許可する
        if re.match(r'^/admin/logout/?', request.path):
            return

        # 初回ログイン時にパスワード変更を強いる
        if re.match(r'^/admin/?', request.path) and \
...
```
process_viewではビュー関数を呼ぶ前にフックされる。
ページに遷移する前にこの時点で強制的にリダイレクト（パスワード変更）させてしまう。

## 手順２
`settings.py`に今作成したmiddlewareを追加する。

```mysite\settings.py
MIDDLEWARE = [
    'users.password_middleware.ForcePasswordChangeMiddleware', # 追加
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
```
## 解説２
'users.password_middleware.ForcePasswordChangeMiddleware'は
'App名.pyファイル名.class名'を意味している。
できるだけページが読まれる前に処理をしてほしいので、今回は先頭に追加している。

## 動作確認
### 1.現状
`password_changed.py`には値がない＝Falseの状態
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/94e87139-0b5f-b466-1b25-7872c61f842e.png)
### 2.ログアウトする
ログアウトは許可しているので問題なくログアウトできる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/124d0b24-45a8-c689-107c-0e1feb511794.png)
### 3.ログイン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/d2429366-7464-0f88-8acb-cd32ec1b98bf.png)
### 4.ログイン後
いつもの管理画面が見える前に、パスワード変更画面になった。（成功）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/4d5f40b1-2d3d-3edc-57a1-698cadee8118.png)
### 5.パスワード変更後
各ページにアクセスすることができる。
アカウントのパラメータを見てみると、`password_changed.py`にチェック＝`True`の状態に更新されている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/39a91558-1c94-7776-c06b-854e16bf809c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515586/044d92f2-f3ec-ea38-9706-058dda09c962.png)

## 備考
定期的、例えば３か月おきに`password_changed.py`の値を`False`にすることで、
定期的にパスワード変更を促すことができる。定期的な処理については別の機会に紹介したい。

## 参考
[【Django】Middlewareミドルウェアを新たに自分で作成（自作）する方法](https://office54.net/python/django/middleware-make-myself)
[【Django】Middlewareミドルウェアとは（有効化、構造、実行されるタイミング）](https://office54.net/python/django/django-middleware-about)
Django 第1回：[Django Custom User Model の作成](https://qiita.com/startours777/items/706d38e712b0c737a16a)
Django 第2回：[Django 初回ログイン時にパスワード変更を強制する](https://qiita.com/startours777/items/28db625a9bb81a36d4ad)
Django 第3回：[Django 一定期間パスワードを変更していないユーザにパスワード変更を強制する](https://qiita.com/startours777/items/d550ca9b67cea4408a44)
Django 第4回：[Django ランダムかつ有効期限のあるURLを生成し、上位者に承認してもらいアカウントを発行する](https://qiita.com/startours777/items/529e74062e7054e79517)　
Django 第5回：[Django パスワード試行回数ロックとランダムかつ有効期限付きURLでの本人確認による解除](https://qiita.com/startours777/items/5b1fc415e047a2044129)