Django REST frameworkのBasic認証を検証するためのdocker-composeを使った環境づくり

# TL;DR
* DRF(Django REST framework)とBasic認証問題が絡んだ時に起きた私の期待と異なる動作を再現するための環境構築の話
* 一応、上から読んでいくことで最終的に目的の環境を作れるようには書いているつもり
* なお未解決


# DRFとBasic認証

Basic認証がかかっているとDRFの挙動が私が期待したものとは違う動作になる、という問題に遭遇しました。
こういうときに私はまっすぐ問題解決に進むのではなく、よし！再現できる環境をdocker-composeで作ってみよう！という方向に考えが行きがちです。これはその環境構築の記録です。なお、問題は現在も未解決です。しかし環境を構築して再現できたのでそこそこの満足感はあります。

## docker-composeの登場人物紹介

docker-composeを使い、複数のdockerコンテナをいい感じにcomposeして動かします。以下のような構成です。

* dbコンテナ・・・`mysql:5.7`を使用。別にsqliteでも良かった気がしますが気分で選びました。
* proxyコンテナ・・・`nginx:1.16.0`を使用。静的ファイルはこいつが返し、appへのリクエストはリバースプロキシします。
* appコンテナ・・・`python:3.7`を使用。Djangoの本体。uwsgiで動かします。
* わたし・・・しがないエンジニア。最近また視力が落ちてきた気がする。これ以降登場しない。

`docker-compose.yml`を載せるのがわかりやすそうなので以下に示します。

```docker-compose.yml
version: '3'
services:
    db:
        image: mysql:5.7
        environment:
            - MYSQL_USER=user
            - MYSQL_PASSWORD=secret
            - MYSQL_ROOT_PASSWORD=password
            - MYSQL_DATABASE=database
    app:
        build: .
        command: /srv/run.sh
        tty: true
        volumes:
            - .:/srv
            - /etc/passwd:/etc/passwd:ro
            - /etc/group:/etc/group:ro
        ports:
            - "3031:3031"
        user: "${UID}:${GID}"
        depends_on:
            - db
    proxy:
        image: nginx:1.16.0
        volumes:
            - ./nginx/nginx.conf:/etc/nginx/nginx.conf
            - .:/srv
        links:
            - app
        ports:
            - "0.0.0.0:80:80"

volumes:
    .:
```

## 各コンテナの解説

dbコンテナは環境変数を指定して実行しているだけなので、これは飛ばしてappコンテナの話をします。
まずrequirements.txtも示します。今回使うパッケージは以下です。

```requirements.txt
Django==2.2.1
djangorestframework==3.9.4
mysqlclient==1.4.2.post1
uWSGI==2.0.18
```

Dockerfileはこんな感じです。コンテナにrequirements.txtをコピーして`pip install`します。

```Dockerfile
FROM python:3.7
ENV PYTHONUNBUFFERED 1
WORKDIR /srv
COPY requirements.txt /srv/
RUN pip install --no-cache-dir -r requirements.txt
```
ちなみにdocker-composeではディレクトリごとマウントして動かしているのにrequirements.txtだけコピーしているのかというと、ビルド時点ではマウントされないのでこうする必要があるということに作っていて気づきました。
`PYTHONUNBUFFERED`は標準入出力のバッファを無効化するオプションらしいですが、まああまり深く考えずに無効化してます。環境変数に空ではない文字列を入れればいいみたいです。
Djangoのコードを置く場所は`/srv/`にしてあります。一応、FHS(Filesystem Hierarchy Standard)を意識してのことですが本当にこれであってるのか？という不安は尽きないです。が、まあいいです。

### buildとDjangoのプロジェクトの作成

Dockerfileもあることだし、ビルドしてみよう。

```bash
UID=$UID GID=$GID docker-compose build
```

「`UID=$UID GID=$GID`ってなんだよ？」と思われるかもしれないですが、理由は後述します。兎にも角にもこのコマンドでずらずらとビルドされて、DjantoとかDRFとかが入ったイメージが作られます。

では作ったイメージで走り出します。Djangoのプロジェクトを作るコマンドを実行します。

```bash
UID=$UID GID=$GID docker-compose run --rm app django-admin.py startproject app .
```
appディレクトリとmange.pyファイルができたと思います。Djangoの雛形ですね。ところで、ここで`UID=$UID GID=$GID`をつけた意味が出てきます。というもの、これを無くしてdocker-compose.ymlの`user: "${UID}:${GID}"`の行も消した上でコマンドを実行して貰えれば分かるのですが、ファイルもディレクトリもrootで作られてしまい、root権限がないと編集できなくなってしまいます。

コンテナを動かしているホストのユーザでも編集したいと思うのが人情ってもんですよね。なので変数を介してUIDとGIDを渡してますが、コンテナの中にはそんなユーザはいませんので`/etc/passwd`と`/etc/group`を以下のようにマウントしてます。

```yaml

        volumes:
            - .:/srv
            - /etc/passwd:/etc/passwd:ro
            - /etc/group:/etc/group:ro
 ```

これによりコンテナ内で作ったファイルの編集が可能になります。
しかし`UID=$UID GID=$GID`を毎度毎度つけなきゃいけないというところは私も気に入らない点でして、何とかしたいのですがいい方法が思い浮かばずこれで妥協している状態です。

## mysqlを使うように設定

dbコンテナを用意しているのでちゃんと設定します。`app/settings.py`の`DATABASES`を書き換えます。

```app/settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'database',
        'USER': 'user',
        'PASSWORD': 'secret',
        'HOST': 'db',
        'PORT': '3306',
        'OPTIONS': {
            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
        },
        'ATOMIC_REQUESTS': True,
    }
}
```

## uwsgiで動かしてみる

プロジェクトも作ったことだし、この辺でDjangoのツラを拝んでやりたくなったのでそうしてみます。しかしuwsgiで動かしたいのでいくつか必要なものがあります。例えば`uwsgi.ini`とか。

```uwsgi.ini
[uwsgi]
wsgi-file = /srv/app/wsgi.py
master = true
processes = 1
http = :3031
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1
```

正直、各オプションについて詳しく知っているというわけではないので不要なものが含まれていたりする可能性もありますが、ひとまず動くの良しとします。
さて、appコンテナのcommandには`/srv/run.sh`を指定しています。なのでここに必要なコマンドを書いて行きます。

```run.sh
#!/bin/bash
set -eux
uwsgi --ini /srv/uwsgi.ini
```
用意したiniファイルを読んでuwsgiが動きます。

それから、`app/settings.py`の`ALLOWD_HOSTS`を書き換えて、このDjangoをどこからでもみられるようにします。

```app/settings.py
ALLOWED_HOSTS = ['*']
```
フルオープンというのはどうかと思わないでもないですが検証目的なので良いことにします。

これで準備はできたので動かしてみます。

```bash
UID=$UID GID=$GID docker-compose up -d app
```

実行できたら http://127.0.0.1:3031/ でDjangoのツラを拝めるはずです。

## staticディレクトリの設定

http://127.0.0.1:3031/admin/login/?next=/admin/ でDjango administrationの画面が見れると思いますが恐らくレイアウトが崩れているはずです。必要な画像やcssが見つけられなくてこうなっています。見つけられるようにしましょう。正直、Djangoの静的ファイルの扱いはなかなか一筋縄ではいかなくて理解しきれてない部分もありますが、まずは`app/settings.py`に一行追加しましょう。

```app/settings.py
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
```

`STATIC_ROOT`はcollectstaticが静的ファイルを集めるディレクトリを定義します。そして collectstatic とは、静的ファイルを良い感じに一か所に集めてくれるコマンドということです。
https://docs.djangoproject.com/en/2.2/ref/contrib/staticfiles/#collectstatic
説明が余り十分ではないですがこの辺にしておきます。

どうやって一か所に集めるのがいいのかと考えましたが、`run.sh`で集めるのが良さそうという結論に至ったので、こうします。

```run.sh
#!/bin/bash
set -eux
python manage.py collectstatic --noinput
uwsgi --ini /srv/uwsgi.ini
```

最後に集めた静的ファイルを見れるように`app/urls.py`を書き換えます。

```app/urls.py
"""app URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/2.2/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
```

これで綺麗に見れるようになったはずです。
![django-administration.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24415/7442eceb-b78c-1a05-b708-45021b64e581.png)

変わらない場合は、`UID=$UID GID=$GID docker-compose down`でコンテナを消して、もう一度`UID=$UID GID=$GID docker-compose up -d app`したりすれば行けると思います(何を変更するといつ反映されるのか、どういうときにコンテナ作り直す必要があるのか正確に理解できてない)。

## DRFでAPIを作成

この辺りから検証っぽくなってきます。DRFでPOSTリクエストを受け取って、何もせずにレスポンスを返すAPIを作ってみようと思います。
次のコマンドでposttestアプリ(5秒で考えたような適当な名前)を作ります。

```bash
UID=$UID GID=$GID docker-compose run --rm app python manage.py startapp posttest
```

作成したposttestアプリとrest_frameworkを`app/settings.py`の`INSTALLED_APPS`に追加します。

```app/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'posttest',
]
```

さて、viewを作成しますが本当に何もせずにレスポンスを返すだけのAPIです。

```posttest/views.py
from django.http.response import JsonResponse
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView


class ApiPosttestView(APIView):
    permission_classes = (
        AllowAny,
    )

    def post(self, _request):
        return JsonResponse(
            {'message': 'message'},
        )
```
`permission_classes`は`AllowAny`なので誰でも見れるAPIです。このAPIを呼び出せるように`urls.py`を必要に応じて作ったり追記したりします。

```app/urls.py
"""app URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/2.2/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('posttest.urls')),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
```

```posttest/urls.py
from django.urls import path
from posttest import views


app_name = 'posttest'

urlpatterns = [
    path(
        'api/posttest/',
        views.ApiPosttestView.as_view(),
        name='api_posttest',
    ),
]
```

ここまで出来たら`down`して`up`し直して http://127.0.0.1:3031/api/posttest/ を開くとDRFの画面が見れると思います。

![drf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24415/bf2818f8-b793-f8dd-7399-909601a02426.png)

GETは許可してない、という意味のエラーメッセージが出てますね。POSTすると一応、メッセージが返ってきますが、あまり面白くはないですね。

## nginxでリバースプロキシ

さて今まではuwsgiで動いているDjangoを直接見ていましたがnginxを立てたいと思います。
`docker-compose.yml`を見てもらえれば`./nginx/nginx.conf`の設定ファイルで上書きするようにマウントしてます。この設定ファイルの内容は以下です。

```nginx/nginx.conf
user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    upstream uwsgi {
        server app:3031;
    }

    server {
        listen 80;
        charset utf-8;

        location / {
            include uwsgi_params;
            uwsgi_pass unix:/srv/app.sock;
        }

        location /static {
            alias /srv/static;
        }
    }
}

```
この中で重要なところはここですね。

```nginx/nginx.conf
    upstream uwsgi {
        server app:3031;
    }

    server {
        listen 80;
        charset utf-8;

        location / {
            include uwsgi_params;
            uwsgi_pass unix:/srv/app.sock;
        }

        location /static {
            alias /srv/static;
        }
    }

```

`/static/`へのリクエストは静的ファイルなのでnginxが返します。そうでなければuwsgiにリバースプロキシします。これに合わせて`uwsgi.ini`も変えます。

```uwsgi.ini
[uwsgi]
wsgi-file = /srv/app/wsgi.py
master = true
processes = 1
socket = /srv/app.sock
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1
```
変えたところは`socket = /srv/app.sock`です。今までは動作確認目的でHTTPサーバとして動かしていましたが、nginxを立てた以上それは不要になりますし、UNIXドメインソケットで繋げるのがこういった場合の常套手段であろうと考えてこうしました。

さて動かしてみましょう。

```bash
UID=$UID GID=$GID docker-compose up -d
```
今までは末尾に`app`を付けて`proxy`コンテナが起動しないようにしていましたがもうその必要もないのですべて動かします。
http://127.0.0.1/api/posttest/ これで見れるようになったはずです。相変わらず`"detail": "Method \"GET\" not allowed."`のエラーが出てるはずです。

### 静的ファイルの配信方法

静的ファイルはnginxが配信するので、`app/urls.py`の記述は不要になります。

```diff
 from django.contrib import admin
 from django.urls import path
 from django.urls import include
-from django.conf import settings
-from django.conf.urls.static import static

 urlpatterns = [
     path('admin/', admin.site.urls),
     path('', include('posttest.urls')),
-] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+]
```
gitでcommitしているのでしたらrevertしてなかったことにしましょう。gitのように人生の過去の出来事もなかったことに出来たらいいですよね。

しかし、`STATIC_ROOT`の定義や`run.sh`の`python manage.py collectstatic --noinput`は必要です。proxyコンテナはappコンテナと同じボリュームをマウントし、受けた静的ファイルのリクエストはcollectstaticしたものをnginxが返します。

### Basic認証をかける

前置きがずいぶん長くなりましたがいよいよ本題です。Basic認証をかけてみたときのDRFの挙動を見てみます。
`htpasswd`を使うので無い場合はインストールします(centosならhttpd-tools)。

```bash
sudo yum install -y httpd-tools
```

それではBasic認証用のファイルを`nginx/.htpasswd`に作ってみましょう。ユーザ名はuserでパスワードはpasswordです。

```bash
htpasswd -c nginx/.htpasswd user
New password:
Re-type new password:
Adding password for user user
```

このファイルをマウントするように`docker-compose.yml`に`- ./nginx/.htpasswd:/etc/nginx/.htpasswd`を書き足します。

```docker-compose.yml
    proxy:
        image: nginx:1.16.0
        volumes:
            - ./nginx/nginx.conf:/etc/nginx/nginx.conf
            - ./nginx/.htpasswd:/etc/nginx/.htpasswd
            - .:/srv
        links:
            - app
        ports:
            - "0.0.0.0:80:80"

```

Basic認証が効くようにnginx.confに
`auth_basic "Restricted";` `auth_basic_user_file /etc/nginx/.htpasswd;`
を追記します。

```nginx/nginx.conf

    server {
        listen 80;
        charset utf-8;

        auth_basic "Restricted";
        auth_basic_user_file /etc/nginx/.htpasswd;

        location / {
            include uwsgi_params;
            uwsgi_pass unix:/srv/app.sock;
        }

        location /static {
            alias /srv/static;
        }
    }
```

さて、それでは`down`して`up`してみましょう。どうなるでしょうか。

![programmingerror.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24415/e95f23be-de7b-a2a0-15cb-1a5c12918e51.png)

ProgrammingErrorが発生しました。エラーメッセージを読むとDBにテーブルが無い、と言っています。ユーザ認証に使うテーブルです。今までDBは使ってなかったはずなのにBasic認証をかけると急に使いだすのは不思議です。まあ、でも無いと言って怒るのであれば作りましょう。

```bash
UID=$UID GID=$GID docker-compose run --rm app python manage.py migrate
```

これで必要なテーブルが出来ました。もう一度見てみます。

今度はエラー画面こそ表示されないものの今までとは違うエラーメッセージが表示されたと思います。

```json
{
    "detail": "Invalid username/password."
}
```

Basic認証を通ってるはずなのに、`"Invalid username/password."`で弾かれるという現象を再現できました。

# まとめ

これで無事に、私の期待とは異なる動作をDRFがすることを再現できました。ちなみに現在も未解決です。
https://www.django-rest-framework.org/api-guide/authentication/#basicauthentication
このあたりを読み込めば解決策が見つかりそうな気が・・・引き続き調査を進めようと思います。