【Django】Auth機能とmiddleware.pyを使ってアクセス制御を行い、許可設定関連は全てDBで行う方法

# 概要
* 以前の記事で、Auth機能とMessageフレームワークを使ってユーザーごとにアクセス権限を設定する手順を紹介しました。
    * [Auth機能とMessageフレームワークを使ってユーザーごとにアクセス権限を設ける方法](https://qiita.com/Ryo-0131/items/1b1c3367e7727e2e9ce3)
* 上記方法の場合、グループ名をハードコーディングする必要があり、ややセキュリティ的に望ましくないのと改修が少し面倒です。そのため、今回はDB上にグループ名とパス名を紐付けてアクセス制御する方法を紹介します。具体的には、`middleware.py`というdjangoミドルウェアを使ってアクセス制御を実施します。
    * 今回のアクセス制御では、許可されていないURLにアクセスしようとするとリダイレクトされ「アクセス権限がありません」と表示される仕様になっています。

## 前提
* ディレクトリ構造は以下の通り。

```
app
├── auth_account
│   ├── models.py
│   ├── views.py
│   ├── urls.py
│   └── etc...
├── app
│   ├── settings.py
│   └── etc...
├── home
│   ├── views.py
│   ├── urls.py
│   ├── etc...
│   └── templates/pages
│       ├── base.html
│       └── home.html
└── sample
│   ├── views.py
│   ├── urls.py
│   └── templates/sample
│       ├── base.html
│       └── sample.html
└── sample2 etc...
```


# 実装手順
* ①Auth機能でGroup作成＆User追加
* ②`models.py`の編集/マイグレート
* ③`settings.py`の編集
* ④テンプレートの編集
* ⑤`middleware.py`の実装

## ①Auth機能でGroup作成＆User追加
* Djangoの管理者アカウントでログインします。
* Admin画面から「Group」を選択すると、以下の画面に遷移されるので、任意の名前でグループを作成します。今回は「writers」とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710373/ed2d6386-a183-7184-ec9b-828355f2531a.png)

* 次に、「Users」を選択して、任意のユーザー「hoge」を作成後、先ほどのグループに追加してあげます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710373/8f3cea73-5ef2-33fe-611b-8eae9a0eaece.png)

* 追加が終わったら、SAVEで保存します。
* これで、「writers」グループにユーザー「hoge」を追加することができました。


## ②`models.py`の編集/マイグレート
* `models.py`に以下を記載します。記載後、マイグレートも忘れず実施してください。

```app/home/models.py
from django.db import models
from django.contrib.auth.models import Group

# 他のモデルは省略

class Path(models.Model):
    path_id = models.AutoField(primary_key=True)

    path = models.CharField(max_length=255, db_column='path', default='', null=True)

    class Meta:
        db_table = 'path'


class PathXGroup(models.Model):
    path_x_group_id = models.AutoField(primary_key=True)

    group_id = models.ForeignKey(Group, on_delete=models.CASCADE, db_column='group_id', default='', null=True)

    path_id = models.ForeignKey('Path', on_delete=models.CASCADE, db_column='path_id', default='', null=True)

    class Meta:
        db_table = 'path_x_group'
```

* `path`カラムのレコードには`/sample1/`や`/sample2/`などのURLを入れます。
* ここで`PathXGroup`というクロステーブルが重要になります。`group_id`は、Adminで作成したGroupを指定し、どのパスにアクセスして良いかをここで指定します。例えば、`writers`は`/sample/`へのアクセスはOKだが`/sample2/`へのアクセスがNGの場合、`/sample/`のみを紐づけます。

## ③`settings.py`の編集
* `settings.py`に以下を追加します。一番下以外はデフォルトで存在していると思います。

```app/app/settings.py
MIDDLEWARE = [
    # 他のミドルウェアは省略
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'auth_account.middleware.AccessControlMiddleware',
]
```

* Djangoにおいて、ミドルウェアはDjangoのリクエストとレスポンスの処理フローに介入し、カスタムの処理を追加するために使用されます。今回で言えば、リクエストが処理される前に、ユーザーの認証状態や所属するグループを確認し、アクセス制御のロジックを適用します。
* Djangoの`MIDDLEWARE`設定は、ミドルウェアクラスのPythonパッケージとクラスの完全修飾名を指定します。したがって、`'auth_account.middleware.AccessControlMiddleware'`は、`auth_account`パッケージ内の`middleware.py`モジュールにある`AccessControlMiddleware`クラスを指定しています。これは⑤で作成します。

## ④テンプレートの編集
* `base.html`テンプレート内には、メッセージフレームワークで表示されるメッセージを画面上部に表示するためのコードを記載します。
* 今回は以下のサイトよりコピーさせていただきました（Bootstrap4で表示）。
    * [Djangoで通知メッセージを利用する（メッセージフレームワーク）](https://dot-blog.jp/news/django-messages-frame-work/)

```django:app/home/templates/pages/base.html
# 略

<body>

{% if messages %}
<ul class="messages_ul">
  {% for message in messages %}
    <li class="alert{% if message.tags %} alert-{{ message.tags }}{% endif %}" role="alert">{{ message }}</li>
  {% endfor %}
</ul>
{% endif %}

# 略

```


## ⑤`middleware.py`の実装
* `auth_account`ディレクトリに`middleware.py`ファイルを作成し、`AccessControlMiddleware`を定義します。

```middleware.py
from django.shortcuts import redirect
from home.models import PathXGroup
from django.urls import reverse
from django.contrib import messages


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

    def __call__(self, request):
        # ユーザーがログインしているかどうかを確認する
        if request.user.is_authenticated:
            allowed_paths = []
            excepted_paths = ['/', '/home/', '/login/', '/logout/']
            # ユーザーの所属するグループを取得する
            user_groups = request.user.groups.all()

            # ユーザーのグループに関連する許可されたパスを取得する
            for group in user_groups:
                allowed_groups = PathXGroup.objects.filter(group_id=group)
                allowed_paths += [item.path_id.path for item in allowed_groups]

            # 静的ファイル（CSSやJS、画像など）には制限をかけない
            if not request.path.startswith('/static/'):
                # '/' または '/home/' または'/admin/の場合は例外処理としてスキップする
                if request.path in excepted_paths or request.path.startswith('/admin/'):
                    return self.get_response(request)
                
                # ユーザーがアクセスしようとしているURLパターンを取得する
                requested_pattern = '/' + request.path.strip('/').split('/')[0] + '/'
                # ユーザーがアクセスしようとしているURLパターンが許可されている場合は許可する
                if any(requested_pattern.startswith(path) for path in allowed_paths):
                    return self.get_response(request)
                
                # リダイレクトが発生した場合の処理
                if request.path != reverse('login') and request.path != '/login/home/':
                    messages.warning(request, 'アクセス権限がありません')
                    return redirect('/')
        
        return self.get_response(request)
```

* これにより、ログインしているユーザーのグループは、クロステーブルで指定されたパス以外にはアクセスできなくなりました。仮にURL直打ちを実施した場合、メッセージで「アクセス権限がありません」が表示されます。
    * メッセージの背景色や文字色の変更はHTML/CSSで可能です
* ただし、`/sample/`へのアクセス制御ができても、`/sample/page`という階層へのページにアクセスができたら意味がありません。そこで、上記の実装方法では変数`requested_pattern`により、`sample`で始まるもの全体（`/`の位置で区切ってURLパターンとして指定されたもの）を制御しています。具体的な処理は以下の通りです(例：`/sample/page`だった場合)。
    * `request.path`から両端のスラッシュを除去するために、`strip('/')`を使用→`sample/page`になる
    * `split('/')`を使って、文字列をスラッシュで分割→`sample`と`page`のリストが生成される
    * `[0]`を使用して、リストの最初の要素である`sample`を取得
    * 取得した`sample`を文字列の先頭と末尾にスラッシュを追加→`/sample/`が生成される
    * 上記の処理により、`/sample/page`であっても`/sample/hoge`であっても、`/sample/`となります

### 所属グループがアクセス可能なパスの取得について
* `user_groups`：Userが所属するグループ
* `allowed_groups`：`PathXGroup`モデルのオブジェクトのコレクション
* `item`：`allowed_groups`クエリセットの各要素（要素名は任意。読みやすいものが良い）
* `item.path_id.path`：パスの値
* `allowed_paths`：パスが追加されるリスト

### スキップ処理させるURLがある場合
* ルートディレクトリやそこにリダイレクトするものは`excepted_paths`で例外としてスキップさせています。`/`もリクエストパターンで当てはめていたら、ルート`/`から始まるもの全てが制御されてしまうからです。
* 静的ファイルをスキップさせています。これは、CSSファイルのURLである`/static/home/style.css`にアクセスしようとしても、許可されたパスに含まれていないとアクセス制御されてしまうからです。アクセスを許可する必要があります。全てのディレクトリのCSSのURLを記載するのでは煩雑なので、`startwith`で静的ファイルへのアクセスにはこのミドルウェアが介入させないようにしています。
* 同様に`admin`もスキップさせています。ここは`admin`権限のあるログインユーザーであれば、どんなグループでもアクセスできるべきだからです。

### リダイレクトループに陥った場合
* 最後のIF文で、リダイレクト処理が発生した場合（許可されていないURLにアクセスしようとした場合）に発生するメッセージを定義しています。このプログラムの場合は、ログイン画面についてはアクセス制御が不要かつ`index`関数で`redirect("login")`としているので、IF分で「このURLでは不要」という条件を指定してあげています。ここを指定しないと無限リダイレクト（`/`→`/login/`→`/`→`/login/`→...）が発生し、エラーになります。
    * リダイレクトループのエラーは「このページは動作していません　localhost でリダイレクトが繰り返し行われました。」と表示されます。
    * `reverse`関数は、DjangoのURL逆引き（URL reverse）機能を提供する関数です。URLパターンの名前を受け取り、それに対応するURLを返します。今回でいえば、`login`はURL`/login/`を指しています。
* `and request.path != '/login/home/'`としているのは、ログイン直後の画面`'/login/home/'`で「アクセス権限がありません」と表示されるのはおかしいから追記しています。プログラムによって初期画面のURLは違うと思うので別途判断ください。
* ちなみに`home/`ではなくて`/home/`としているのは、DEBUGで見たときに以下のようになっていたからです。実装中に困ったことがあった時はまずはDEBUGしてみると良い。
 
```log
2023-05-17 15:55:53,743 | DEBUG | root - /home/
```