脱・とりあえず動く[CORS編]


はじめに

「とりあえずなんとかする方法は知っているけど、なんでそれでいいのかわからない」という疑問を解消していきます。
今回はCORS編です。
長くなるので、時間がない方は先に結論を見てください。

問題となるエラーについて

APIサーバーとWebサーバーをそれぞれ別ポートで立てている時、以下のようなエラーが出る場合があります。

これは、開発環境でAngular(localhost:4200)からDjango(localhost:8000)へAPIリクエストを投げた時に起きるエラーです。
「APIサーバー(localhost:8000)へのCross-Originなリダイレクトは、Cross-Origin Resource Sharing policyによって拒否されました」と怒られます。
このエラーの意味するところから理解し、対策・解決方法を探っていきます。

Cross-Origin

オリジン

オリジンとは、「プロトコル」+「ホスト」+「ポート番号」の組み合わせのことです。
上記の例でいうと

サーバー プロトコル ホスト ポート番号
Angular http localhost 4200
Django http localhost 8000

ポート番号が異なるので、別のオリジンとなります。

Cross-Origin

では、なぜ別のオリジン間(Cross-Origin)の通信が問題になるのでしょうか。
CSRF(Cross Site Request forgeries)が考えられるからです。

CSRFは以下の手順で実行されます。

  1. 不正なアクセスを行うスクリプトを仕込んだページを用意する
  2. ユーザーにアクセスさせ、攻撃用ページを踏ませる
  3. アクセスしたユーザーから不正なアクセスが行われる

不正なアクセス自体はユーザーから送信されるので攻撃者は発覚しない、という手法です。

この攻撃方法は、あるドメインから読み込まれたページから、別のドメインへのアクセスが許可されている脆弱性をついています。
そのため、通常はオリジン間のHTTPリクエストは制限されます。
今回のエラーも、localhost:4200から読み込まれたページで実行される、localhost:8000へのHTTPリクエストが制限に引っかかったため生じました。

DjangoのCSRF対策

次に、DjangoのCSRF対策について確認します。
公式によると

  • settings.pyのMIDDLEWAREに 'django.middleware.csrf.CsrfViewMiddleware'が入っていると有効になる(デフォルトで有効)
  • POST, PUT, DELETEのような変更を伴うリクエストに対してチェックを行う
  • レスポンス時、クッキーに「csrftoken」というキーでトークンを発行する
  • リクエストに「X-CSRFToken」というヘッダーに発行したトークンが入っているかをチェック
    • 入っていたらアクセス許可
    • 入っていない、またはトークンが違う場合、403(Forbidden)エラーを返す

となっています。

CORS

次に、対策・解決方法を考えていきます。
オリジン間の制限を変更することで、別オリジン間のHTTPリクエストを許可すれば良いはずです。
この仕組みをCORS(Cross-Origin Resource Sharing)と言います。
設定可能なヘッダーについては公式ぺージを参考にしてください。

対策

基本的にデータを取得される側(APIサーバー)でアクセス許可設定を行います。
エラーの文面をもう一度見ると、
「localhost:4200はAccess-Control-Allow-Originで許可されていません」と怒られています。
なので、レスポンスに「Access-Control-Allow-Origin」というヘッダーを追加します。
後ほど別の方法は紹介しますが、テストのためにDjangoのViewを以下のように変更します。

hoge.view.py
# なんらかのレスポンスを返すView関数
def hoge(request):
    # ~~~なんらかのデータを取得してJsonResponseで返す~~~~
    response = JsonResponse(data)
    response['Access-Control-Allow-Origin'] = 'localhost:4200'
    return response

これでレスポンスが正常に返ってきます。
レスポンスヘッダーは以下のようになっています。

ただし、POST、DELETEなどのメソッドだとうまくいかないケースがあります。
エラーとしては以下のようになります。

見知らぬ単語「Preflight」が出てきました。次はこのエラーに取り掛かりましょう。

Preflight

特定のメソッドや、特定のヘッダーがリクエストに入っていると、実際のリクエストを投げる前に、別ドメインの送信相手に安全確認をとる仕様となっています。
この事前リクエストをPreflightリクエストと言います。
別ドメインからのPreflightリクエストに対し、APIサーバーがどのようなリクエストならアクセスを許可するかを返し、そのレスポンスが返ってきてから実際のリクエストを投げます。

上記のエラーは、
1. Preflightリクエストに対するレスポンスが返ってきていない
2. 実際のリクエストのContent-Typeヘッダーが許可されていない
ことからエラーになっているようです。

先ほど確認したDjangoのCSRF対策と合わせて考えると、

  1. Preflightメソッドに対してレスポンスを返す
  2. クッキーの「csrftoken」をヘッダーに追加する

ことで解決するはずです。

これもまた別の方法がありますが、テストのためにViewを以下のように変更します。

hoge.view.py
# View関数
def hoge(request):
    # Preflightに対するレスポンスを返す(1)
    if request.method == 'OPTIONS':
        response = HttpResponse()
        response['Access-Control-Allow-Origin'] = 'http://localhost:4200'
        response['Access-Control-Allow-Credentials'] = 'true'
        response['Access-Control-Allow-Headers'] = "Content-Type, Accept, X-CSRFToken"
        response['Access-Control-Allow-Methods'] = "POST, OPTIONS"
        return response
    else:
        # ~~~なんらかの処理をしてJsonResponseで返す~~~~
        response = JsonResponse(data)
        response['Access-Control-Allow-Origin'] = 'localhost:4200' 
        response['Access-Control-Allow-Credentials'] = 'true'
        return response

また、Angular側で、ヘッダーにトークンを追加します。

http-intercepter.service.ts
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpXsrfTokenExtractor } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class HttpXsrfInterceptor implements HttpInterceptor {

  constructor(private tokenExtractor: HttpXsrfTokenExtractor) {
  }
  // httpリクエストに対してヘッダーを追加する(2)
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const headerName = 'X-CSRFToken';
    const token = this.tokenExtractor.getToken() as string;
    if (token !== null && !req.headers.has(headerName)) {
      req = req.clone({ headers: req.headers.set(headerName, token) });
    }
    return next.handle(req);
  }
}

また、app.module.tsも変更します。

app.module.ts
@NgModule({

  imports: [
    // 追加。トークンが入っているクッキー名とヘッダー名を指定(2)
    HttpClientXsrfModule.withOptions({cookieName: 'csrftoken', headerName: 'X-CSRFToken'})
  ],
  providers: [
    // サービスの登録
    { provide: HTTP_INTERCEPTORS,useClass: HttpXsrfInterceptor,  multi: true },
  ],
}

また、HttpClientのpostメソッドのoptionsに{withCredentials: true}を追加します。
これでPOSTリクエストも成功します。

django-cors-headers

以上で別オリジンからのリクエストを正常に処理できるようになりました。
ただ、Djangoには便利なライブラリがあります。django-cors-headersです。
今までは確認のためにviewで返すリクエストに直接ヘッダーを追加していましたが、django-cors-headersを使用し、設定をすれば勝手にヘッダーを追加してくれます。

利用法

インストール
pip install django-cors-headers

設定追加

settings.py
# 追加分のみ
INSTALLED_APPS = [
    'corsheaders'
]

# 上から順に実行されるので、CommonMiddleWareより上に挿入
MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',
 ]

# 許可するオリジン
CORS_ORIGIN_WHITELIST = [
    'localhost:4200',
]
# レスポンスを公開する
CORS_ALLOW_CREDENTIALS = True

これだけです。
そのほかにも設定があるので、状況によって追加・変更を行います。

結論

  • django-cors-headersを使う
    • またはレスポンスにヘッダーを追加する
  • Angularで
    • クッキーに入っている「csrftoken」を「X-CSRFToken」ヘッダーに追加
    • HttpClientのメソッドオプションの「withCredentials」をtrueにする
    • HttpClientXsrfModule.withOptionsを設定する

以上で解決するはずです。
自分はAngular側の設定で時間を溶かしてしまいました...。
何かあればコメント・指摘等お願いします。

参考ページ

https://developer.mozilla.org/ja/docs/Web/HTTP/HTTP_access_control
https://www.trendmicro.com/ja_jp/security-intelligence/research-reports/threat-solution/csrf.html
https://stackoverflow.com/questions/46040922/angular4-httpclient-csrf-does-not-send-x-xsrf-token
https://github.com/ottoyiu/django-cors-headers