Fetch API と Django のクロスオリジンで気をつける事

個人的に開発する中で、localhost:8080 でホストしている javascript ファイルから localhost:8000 でホストしてる Django に対し HTTP リクエストをかけたところ、CORS (Cross Origin Resource Sharing) 関連のエラーが色々出ました。

色々調べながら解決しましたが、自分なりに分かりやすく整理したかったのでこの記事にまとめました。

Javascript からの HTTP リクエスト には fetch API を使っています。

  1. CORS (Cross Origin Resource Sharing) とは
  2. Step 1: GET
  3. Step 2: request に Cookie を含める
  4. Step 3: POST や DELETE

CORS (Cross Origin Resource Sharing) とは

下記こちらからの抜粋です。

アプリケーションが読み込まれたのと同じオリジンに対してのみリソースのリクエストを行うことができ、それ以外のオリジンからの場合は正しい CORS ヘッダーを含んでいることが必要です。

オリジン間リソース共有の仕様は、ウェブブラウザーから情報を読み取ることを許可されているオリジンをサーバーが記述することができる、新たな HTTP ヘッダーを追加することで作用します。

Step 1: GET

まずは一番単純な GET から。Cookie も送らない想定です。

何もクロスオリジン対応をしていないと恐らく「No 'Access-Control-Allow-Origin' header is present on the requested resource.」という様なエラーが発生します。

クロスオリジンの request に対してサーバー側から Access-Control-Allow-Origin ヘッダーを適切に返す必要があります。

Fetch API での対応

Fetch API は普通に fetch() で request URL を叩けば大丈夫です。

let fetchResponse = await fetch(
    'http://localhost:8000/api/something'
)

メインはサーバー側での対応です。

Django での対応

django-cors-headers を pip install し、settings.py を下記のように追記していきます。

pip install django-cors-headers
# settings.py

INSTALLED_APPS = [
   ...
   'corsheaders',
]

MIDDLEWARE = [
  ...
  'corsheaders.middleware.CorsMiddleware',
]

CORS_ALLOWED_ORIGINS = ['http://localhost:8080']

これでクロスオリジンの request を送れる様になります。

次に、request に cookie を含める設定を行います。

クロスオリジンでの fetch API は「デフォルトでは cookies を送信しない」仕様になっています。

これを解決するには fetch API の credentials を 'include' に設定し、サーバー側は Access-Control-Allow-Credentials ヘッダーを true で返す必要があります。

Fetch API での対応

fetch API のオプションに credentials: 'include' を含めます。

let fetchResponse = await fetch(
    'http://localhost:8000/api/something',
    {credentials: 'include'} // 追記分
)

Django での対応

settings.py に「CORS_ALLOW_CREDENTIALS = True」を追記します。

# settings.py
CORS_ALLOW_CREDENTIALS = True

これで Fetch API で Cookie が送れる様になり、Django 側もそれを受けられる様になりました。

Django でログイン済みのはずが AnonymousUser となる件

ちなみにログインユーザーを対象にした処理を Django 側で行う場合、上記の設定をしていないと、例えブラウザの cookie と Django の django_session テーブルで sessionid が一致していても 500 Internal Server Error や 403 Forbidden が発生すると思います。

理由は、クライアント側からの sessionid を request.COOKIES として受け取れていないため、request.user が非ログインユーザーを意味する AnonymousUser となるためです。

Step 3: POST や DELETE

さらに、クロスオリジンで POST や DELETE など unsafe な request を送る場合、クライアント側の fetch() に X-CSRFToken ヘッダーを設定し、Django 側では settings.py の CSRF_TRUSTED_ORIGINS のリストに request を許可するオリジンを記述する必要がります。

Fetch API での対応

let fetchResponse = await fetch(
    'http://localhost:8000/api/something',
    {credentials: 'include'},
    // ここから追記分
    method: 'POST',
    headers: {
        'X-CSRFToken': csrftoken, // Cookie から取得
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        'someKey1': someData1,
        'someKey2': someData2
    }
)

GET メソッドは fetch のデフォルト値でしたが、POST や他のメソッドを使用する場合は method を指定する必要があります。

また、headers に X-CSRFToken として cookie から取得した csrftoken をアサインし、Content-Type も指定する必要があります。

Django での対応

settings.py の CSRF_TRUSTED_ORIGINS のリストに request を許可するオリジンを記述します。

# settings.py
CSRF_TRUSTED_ORIGINS = ['http://localhost:8080']

Javascript から Django REST Framework への POST/DELETE で 415 Unsupported Media Type

問題

Django REST Framework を使って API を作成中、Javascript から POST または DELETE リクエストを送ろうとした結果「POST(リクエスト先 URL) 415 (Unsupported Media Type)」のエラー表示。

*DELETE の場合も同じエラーが出ます。

原因

リクエストヘッダーに Content-Type がなかったのが原因。

解決方法

元々↓だったヘッダー部分に…

fetch('http://example.com/exampleapi',
    {
        method: 'POST',
        headers:{
            "X-CSRFToken": getCookie('csrftoken')
        body: JSON.stringify({ 'username': 'example'})
    }
)

↓の様に Content-Type を追加したら解決しました。

fetch('http://example.com/exampleapi',
    {
        method: 'POST',
        headers:{
            "X-CSRFToken": getCookie('csrftoken'),
            'Content-Type': 'application/json'} // 追記分
        body: JSON.stringify({ 'username': 'example'})
    }
)

DELETE の場合も同じ方法で解決しました。

Javascript から Django REST Framework への POST/DELETE で 403 Forbidden

問題

Django REST Framework を使って API を作成中、Javascript から POST または DELETE リクエストを送ろうとした結果「POST(リクエスト先 URL) 403 (Forbidden)」のエラー表示。

*DELETE の場合も同じエラーが出ます。

原因

CRSF (Cross Site Request Forgery) に対するセキュリティが働いて、リクエストが拒否されている様子。

解決法

ブラウザのクッキーから CSRF Token を取って、POST リクエストのヘッダーに含めたら解決しました。

元々↓だったところに…

const fetchOptions = {
    method: 'POST'
}
fetch('http://example.com/exampleapi',fetchOptions)

クッキーから csfrtoken を抽出して headers に含めて POST リクエストをする様にしました。

function getCookie(name) {
    let cookieValue = null;
    if (document.cookie && document.cookie !== '') {
        const cookies = document.cookie.split(';');
        for (let i = 0; i < cookies.length; i++) {
                const cookie = cookies[i].trim();
                if (cookie.substring(0, name.length + 1) === (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}

const fetchOptions = {
    method: 'POST',
    headers:{"X-CSRFToken": getCookie('csrftoken')}
}
fetch('http://example.com/exampleapi',fetchOptions)

これで一応 POST リクエストが通る様になりました。(HTTP Status 200 が返ってきました。)

DELETE の場合も同じ方法で解決しました。

参考にした記事

Mac に React.js をインストールする方法

  1. Homebrew で node をインストール
  2. ディレクトリを作成して create-react-app をインストール
  3. React プロジェクトの作成
  4. React プロジェクトの実行

1. Homebrew で node をインストール

% brew update
% brew install node
% node --version
v16.10.0

2. ディレクトリを作成して create-react-app をインストール

% mkdir React
% cd React
% 

コマンド「npm install -g create-react-app」を実行します。

% npm install -g create-react-app
npm WARN deprecated tar@2.2.2: This version of tar is no longer supported, and will not receive security updates. Please upgrade asap.

added 67 packages, and audited 68 packages in 4s

4 packages are looking for funding
  run `npm fund` for details

2 high severity vulnerabilities

Some issues need review, and may require choosing
a different dependency.

Run `npm audit` for details.

いくつか警告が出ている様ですがとりあえず無視して進みます。

3. React プロジェクトの作成

例として「awesome-project」という React プロジェクトを作成するとします。

コマンド「create-react-app awesome-project」を実行します。

% create-react-app awesome-project

Creating a new React app in /Users/ユーザー名/React/awesome-project.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts with cra-template...


added 1934 packages, and audited 1935 packages in 2m

150 packages are looking for funding
  run `npm fund` for details

10 moderate severity vulnerabilities

To address all issues, run:
  npm audit fix

Run `npm audit` for details.

Initialized a git repository.

Installing template dependencies using npm...

added 56 packages, and audited 1991 packages in 7s

151 packages are looking for funding
  run `npm fund` for details

10 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.
Removing template package using npm...


removed 1 package, and audited 1990 packages in 3s

151 packages are looking for funding
  run `npm fund` for details

10 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

Created git commit.

Success! Created awesome-project at /Users/ユーザー名/React/awesome-project
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd awesome-project
  npm start

Happy hacking!
% 

4. React プロジェクトの実行

% cd awesome-project
% npm start

「awesome-project」フォルダの中に入り、コマンド「npm start」を実行します。

ターミナルの表示が下記に変わり、ブラウザが起動します。

Compiled successfully!

You can now view awesome-project in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://192.168.3.3:3000

Note that the development build is not optimized.
To create a production build, use npm run build.

ブラウザで上記の表示が出れば無事起動できています。

Control+C でサーバーを停止します。