DRF勉強vol3_JWT認証

## はじめに

DRF(Django REST framework)の勉強のため、以下書籍を参考にサンプル作成します。  
最終的には書籍にあるVue+DRFの作成を目指します。

1. 現場で使えるDjangoの教科書
2. 現場で使えるDjango REST Frameworkの教科書

Vol.2では認証なしでCRUDするAPIを作成しました。
Vol.3では作成しているアプリケーションにJWT認証を導入します。

### 環境

- Intel Mac 13.4.1(c)
- Python 3.11.4
- Django 4.2.3
- DRF 3.14.0

### 認証方式の例

### DRF標準(抜粋)
#### Basic認証

- トークンとして「ユーザー名」と「パスワードをBase64エンコード」した値を利用する。
- トークンはWebブラウザ内に保存され、リクエストする度にリクエストヘッダに書き込まれて自動送信される。
- CSRF対策が必須。
- 機密情報が簡単に復元できるため本番環境での利用には向かない。

#### Cookie認証

- セッション利用する方式。
- セッションIDはCookieに保存されて、リクエストする毎にリクエストヘッダにセッションIDをセットして送信する。
- サーバー側でユーザーのログイン状態をセッションで管理する。
  - ユーザーの状態をサーバー側で管理するためステートレスの概念には反している。
- CSRF対策が必須。

#### トークン認証

- サーバーのDBにユーザーに対して1:1で紐付くトークンを発行しておいて保存しておく。
- SPAの認証方式として最もポピュラーなアプローチとされている。
- クライアントはトークンをWebストレージに格納してもCookieに保存しても良いが、Cookieを利用するならCSRF対策が必要。
- DRFのTokenAuthenticationで生成されるトークンには有効期限がないので注意が必要。

### 外部パッケージを利用する場合

#### JWT認証

- 認証情報を含んだJSON形式のデータをHTTPヘッダーで送信できるようにエンコードしたトークンを利用する。
- トークンには著名が含まれているため、改竄の検知が可能。
- OpenID ConnectのIDトークンの形式としても採用されている。
- クライアントはトークンをWebストレージに格納してもCookieに保存しても良いが、Cookieを利用するならCSRF対策が必要。
- トークンに有効期間を含めることが可能。
- トークンは復号化が可能なため、機微な情報を含めてはだめ。


## 手順

### 認証で利用するパッケージの追加

認証で利用するパッケージを追加します。

```sh
$ poetry add djangorestframework-simplejwt dj-rest-auth
```

<details>
<summary>この時点のpyproject.toml</summary>


```toml
[tool.poetry]
name = "backend"
version = "0.1.0"
description = ""
authors = ["Jozuo <jozuo.dev@gmail.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
django = "^4.2.3"
djangorestframework = "^3.14.0"
djangorestframework-simplejwt = "^5.2.2"
dj-rest-auth = "^4.0.1"


[tool.poetry.group.dev.dependencies]
debugpy = "^1.6.7"
pytest = "^7.4.0"
black = "^23.7.0"
ruff = "^0.0.280"
mypy = "^1.4.1"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
```
</details>

### JWT認証用の設定追加

Djangoの設定ファイル(`config/settings.py`)に設定を追加します。

```diff:config/settings.py
  ---省略---
  INSTALLED_APPS = [
      "django.contrib.admin",
      "django.contrib.auth",
      "django.contrib.contenttypes",
      "django.contrib.sessions",
      "django.contrib.messages",
      "django.contrib.staticfiles",
      # 3rd party package
      "rest_framework",
+     "rest_framework.authtoken",
+     "dj_rest_auth",
      # application
      "apiv1.apps.Apiv1Config",
      "shop.apps.ShopConfig",
  ]  
  ---省略---
+ 
+ # DRF
+ REST_FRAMEWORK = {
+     "DEFAULT_AUTHENTICATION_CLASSES": [
+         "dj_rest_auth.jwt_auth.JWTCookieAuthentication",
+     ]
+ }
+ 
+ # dj-rest-auth
+ REST_AUTH = {
+     "USE_JWT": True,
+ }
+ 
+ # django-rest-framework-simplejwt
+ SIMPLE_JWT = {
+     "AUTH_HEADER_TYPES": ("JWT",),
+     "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),  # アクセストークンの有効期間を60に延長
+ }
```


### DBのマイグレーション

JWT認証用のマイグレーションを行います。

```sh
$ ./manage.py migrate
```

### ルーティングの設定

dj-rest-authのルーティング設定を追加します。

```diff:config/urls.py
  ---省略---
  urlpatterns = [
      path("admin/", admin.site.urls),
+     path("api/v1/auth/", include("dj_rest_auth.urls")),
      path("api/v1/", include("apiv1.urls")),
  ]  
  ---省略---
```

この設定により以下のエンドポイントが追加されます。


| メソッド | URL | 処理 |
|---|-----|---|
| POST | /api/vi/auth/login/ | ログイン処理 |
| GET | /api/v1/auth/user/ | ユーザー情報の取得

### 管理者ユーザーの作成

Djangoの管理者ユーザーを作成します。

```sh
$ ./manage.py createsuperuser
```

### ログイン処理の確認

#### ログイン成功

- リクエスト

    ```sh
    $ curl -s -H "Content-Type: application/json" \
        -X POST http://localhost:8000/api/v1/auth/login/ \
        -d '{"username": "admin", "password":"adminpassword"}' | jq .
    ```

- レスポンス

    ```json
    {
      "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjkwMTkwMjkwLCJpYXQiOjE2OTAxODY2OTAsImp0aSI6IjQ5ZGM3NjE2NDZkNDQ1N2FhNzZkMjBhZTBiMzk5YTM5IiwidXNlcl9pZCI6MX0._s_O5EdvXIAqwwcrbqDrcK81h6vTcMlSUxnGcgqmHp8",
      "refresh": "",
      "user": {
        "pk": 1,
        "username": "admin",
        "email": "admin@example.com",
        "first_name": "",
        "last_name": ""
      }
    }
    ```

<details>
<summary>レスポンス詳細</summary>

```sh
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 08:19:17 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: POST, OPTIONS
X-Frame-Options: DENY
Content-Length: 348
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Set-Cookie:  csrftoken=hUEu1Opuh0y7rc0q4Sz70Q5nwfyKLhyr; expires=Mon, 22 Jul 2024 08:19:17 GMT; Max-Age=31449600; Path=/; SameSite=Lax
Set-Cookie:  sessionid=sb1gx9efefh683gy0timwfmepriibqyw; expires=Mon, 07 Aug 2023 08:19:17 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax

{"access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjkwMTkwMzU3LCJpYXQiOjE2OTAxODY3NTcsImp0aSI6ImRkZWEyMWM5NmJmMjQ3OGY4OTY2ODg4Y2FmM2ExMTFhIiwidXNlcl9pZCI6MX0.C6_iRLgvSCvcfciL09GeOSozQcIuIsiUUB3Fj8u5fpA","refresh":"","user":{"pk":1,"username":"admin","email":"admin@example.com","first_name":"","last_name":""}}%
```
</details>

#### ログイン失敗

- リクエスト

    ```sh
    $ curl -i -H "Content-Type: application/json" \
        -X POST http://localhost:8000/api/v1/auth/login/ \
        -d '{"username": "admin", "password":"hogehoge"}'
    ```

- レスポンス

    ```sh
    HTTP/1.1 400 Bad Request
    Date: Mon, 24 Jul 2023 08:20:43 GMT
    Server: WSGIServer/0.2 CPython/3.11.4
    Content-Type: application/json
    Vary: Accept
    Allow: POST, OPTIONS
    X-Frame-Options: DENY
    Content-Length: 68
    X-Content-Type-Options: nosniff
    Referrer-Policy: same-origin
    Cross-Origin-Opener-Policy: same-origin
    
    {"non_field_errors":["提供された認証情報でログインできません。"]}%
    ```

### ユーザー情報の取得

#### 取得成功

- リクエスト

    ```sh
    $ curl -s -H "Content-Type: application/json" \
        -H "Authorization: JWT ${JWT_TOKEN}" \
        -X GET http://localhost:8000/api/v1/auth/user/  | jq .
    ```

- レスポンス

    ```json
    {
      "pk": 1,
      "username": "admin",
      "email": "admin@example.com",
      "first_name": "",
      "last_name": ""
    }
    ```

<details>
<summary>レスポンス詳細</summary>

```sh
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 08:31:24 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept
Allow: GET, PUT, PATCH, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 86
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"pk":1,"username":"admin","email":"admin@example.com","first_name":"","last_name":""}%
```
</details>

#### 認証情報なし

- リクエスト

    ```sh
    $ curl -i -H "Content-Type: application/json" \
        -X GET http://localhost:8000/api/v1/auth/user/ 
    ```

- レスポンス

    ```sh
    HTTP/1.1 401 Unauthorized
    Date: Mon, 24 Jul 2023 08:25:40 GMT
    Server: WSGIServer/0.2 CPython/3.11.4
    Content-Type: application/json
    WWW-Authenticate: JWT realm="api"
    Vary: Accept
    Allow: GET, PUT, PATCH, HEAD, OPTIONS
    X-Frame-Options: DENY
    Content-Length: 58
    X-Content-Type-Options: nosniff
    Referrer-Policy: same-origin
    Cross-Origin-Opener-Policy: same-origin
    
    {"detail":"認証情報が含まれていません。"}%
    ```

#### 認証情報が不正

- リクエスト

    ```sh
    $ curl -i -H "Content-Type: application/json" \
        -H "Authorization: JWT ${WRONG_JWT_TOKEN}" \
        -X GET http://localhost:8000/api/v1/auth/user/ 
    ```

- レスポンス

    ```sh
    HTTP/1.1 401 Unauthorized
    Date: Mon, 24 Jul 2023 08:34:00 GMT
    Server: WSGIServer/0.2 CPython/3.11.4
    Content-Type: application/json
    WWW-Authenticate: JWT realm="api"
    Vary: Accept
    Allow: GET, PUT, PATCH, HEAD, OPTIONS
    X-Frame-Options: DENY
    Content-Length: 183
    X-Content-Type-Options: nosniff
    Referrer-Policy: same-origin
    Cross-Origin-Opener-Policy: same-origin
    
    {"detail":"Given token not valid for any token type","code":"token_not_valid","messages":[{"token_class":"AccessToken","token_type":"access","message":"Token is invalid or expired"}]}%
    ```

### アプリケーションAPIの認証設定

先に作成したアプリケーションのAPIに認証を設定します。  
参照系はログイン不要、更新系はログイン必須として設定します。

```diff:apiv1/views.py
  from rest_framework import viewsets
+ from rest_framework.permissions import IsAuthenticatedOrReadOnly
  
  from shop.models import Book
  from .serializers import BookSerializer
  
  
  class BookViewSet(viewsets.ModelViewSet):
      queryset = Book.objects.all()
      serializer_class = BookSerializer
+     permission_classes = [IsAuthenticatedOrReadOnly]
```


#### 参照系(一覧)

認証情報なしでも呼び出し可能です。

- リクエスト

    ```sh
    $ curl -s -H "Content-Type: application/json" \
        -X GET http://localhost:8000/api/v1/books/ | jq .
    ```

- レスポンス

    ```json
    [
      {
        "id": "0723d0ee-3903-4b07-8c41-190f0b8bec9e",
        "title": "テストテスト",
        "price": 100
      },
      {
        "id": "d7cc2fa5-7d79-4d50-8220-5c97e305b1c5",
        "title": "テスト2",
        "price": 100
      }
    ]
    ```

<details>
<summary>リクエスト詳細</summary>

```sh
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 08:40:14 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept
Allow: GET, POST, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 167
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

[{"id":"0723d0ee-3903-4b07-8c41-190f0b8bec9e","title":"テストテスト","price":100},{"id":"d7cc2fa5-7d79-4d50-8220-5c97e305b1c5","title":"テスト2","price":100}]%
```
</details>

#### 更新系(登録)

正しい認証情報を設定しないと呼び出しできません。

##### 認証OK

- リクエスト

    ```sh
    $ curl -s -H "Content-Type: application/json" \
        -H "Authorization: JWT ${JWT_TOKEN}" \
        -X POST http://localhost:8000/api/v1/books/ \
        -d '{"title":"テスト4", "price":400}' | jq .
    ```

-  レスポンス

    ```json
    {
      "id": "6ce62a36-521f-4b02-8550-8e1134d8822d",
      "title": "テスト4",
      "price": 400
    }
    ```

<details>
<summary>リクエスト詳細</summary>

```sh
HTTP/1.1 201 Created
Date: Mon, 24 Jul 2023 08:48:55 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept
Allow: GET, POST, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 78
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"id":"6ce62a36-521f-4b02-8550-8e1134d8822d","title":"テスト4","price":400}%
```
</details>

###### 認証情報なし

- リクエスト

    ```sh
    $ curl -i -H "Content-Type: application/json" \
        -X POST http://localhost:8000/api/v1/books/ \
        -d '{"title":"テスト4", "price":400}'
    ```

- レスポンス

    ```sh
    HTTP/1.1 401 Unauthorized
    Date: Mon, 24 Jul 2023 08:44:14 GMT
    Server: WSGIServer/0.2 CPython/3.11.4
    Content-Type: application/json
    WWW-Authenticate: JWT realm="api"
    Vary: Accept
    Allow: GET, POST, HEAD, OPTIONS
    X-Frame-Options: DENY
    Content-Length: 58
    X-Content-Type-Options: nosniff
    Referrer-Policy: same-origin
    Cross-Origin-Opener-Policy: same-origin
    
    {"detail":"認証情報が含まれていません。"}%
    ```

###### 認証情報が不正

- リクエスト

    ```sh
    $ curl -i -H "Content-Type: application/json" \
        -H "Authorization: JWT ${WRONG_JWT_TOKEN}" \
        -X POST http://localhost:8000/api/v1/books/ \
        -d '{"title":"テスト4", "price":400}'
    ```

- レスポンス

    ```sh
    HTTP/1.1 401 Unauthorized
    Date: Mon, 24 Jul 2023 08:47:37 GMT
    Server: WSGIServer/0.2 CPython/3.11.4
    Content-Type: application/json
    WWW-Authenticate: JWT realm="api"
    Vary: Accept
    Allow: GET, POST, HEAD, OPTIONS
    X-Frame-Options: DENY
    Content-Length: 183
    X-Content-Type-Options: nosniff
    Referrer-Policy: same-origin
    Cross-Origin-Opener-Policy: same-origin
    
    {"detail":"Given token not valid for any token type","code":"token_not_valid","messages":[{"token_class":"AccessToken","token_type":"access","message":"Token is invalid or expired"}]}%
    ```