AngularJS の $http サービスのXSRF対策について調べた

AngularJS の $http サービスのXSRF対策について調べた

AngularJS の $http サービスにはXSRFCSRF)の対策の機能が実装されています。 (AngularJSでは、cross-site request forgeries の略を XSRF と呼んでいる場面が多くみられるので、ここではXSRFで統一します。)

AngularJS側の動作

XHRが実行された際に $http サービスは、CookieXSRF-TOKEN があれば、 X-XSRF-TOKEN という名前でHTTPヘッダーにセットします。※ この名前は変更することができます。

サーバ側の対応

送られてきたCookieに含まれる XSRF-TOKEN の値と HTTPヘッダーのX-XSRF-TOKEN の値が合っていることを確認します。

自分のドメイン上で実行されるJavaScript以外はCookieを読み込むことが出来ないため、 サーバーはXHRが自分のドメイン上で実行されて送られてきた事を保証することが出来ます。

実装して確認してみる

簡単な実装を行い動作を確認してみます。 テキストボックスに入力した値をXHRでサーバにリクエストし、Tokenの検証が成功したら入力した値を返し、テキストボックスの直下に値を表示させます。 構成としては、sinatraで画面(html+AngularJS)をレンダリングし、そのレンダリングした画面でXHRが実行するようにします。

XHRでメッセージをリクエストし、レスポンスで同じメッセージが返ってくれば成功です。

require 'sinatra'
require 'sinatra/reloader'
require 'sinatra/cookies'
require 'json'
require 'active_support'
require 'active_support/core_ext'

# 画面をレンダリングする
get '/' do
  # Tokenを生成
  token = SecureRandom.base64(32)

  response.set_cookie(
    'XSRF-TOKEN',
    value: token,
    # jsからcookieを読み取る必要があるので httponly を off にする
    httponly: false,
    secure: false
  )
  erb :index
end

# Tokenの検証に成功したら、入力した文字をそのまま返す
post '/add_message' do
  if same_xsrf_token?(cookies['XSRF-TOKEN'])
    { message: request_params['message'] }.to_json
  else
    status 403
    { error: 'CSRF検証に失敗しました' }.to_json
  end
end

private

def request_params
  request_body = request.body.read
  return {} if request_body.blank?
  JSON.parse(request_body)
end

def same_xsrf_token?(token)
  return false if token.blank?

  http_headers = request.env.select { |k, v| k.start_with?('HTTP_') }

  token == http_headers['HTTP_X_XSRF_TOKEN']
end

フロント側

<!DOCTYPE html>
<html ng-app='App'>

<head>
  <meta charset="utf-8">
  <title>angularjs xsrf</title>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.6/angular.min.js"></script>
  <script src="js/controller.js"></script>
</head>

<body ng-controller="AppController">
  <input type="text" ng-model="message">
  <input type="button" value="送信" ng-click="send_message()">
  <h1>{{receive_message}}</h1>
</body>
var app = angular.module('App', []);

app.controller('AppController', ['$scope', '$http', function($scope, $http){
  $scope.send_message = function(){
    $http({
      method: 'POST',
      url: 'add_message',
      data: { message: $scope.message }
    })
    .success(function(data, status, headers, config){
      $scope.receive_message = data.message;
    })
    .error(function(data, status, headers, config){
      $scope.receive_message = data.error;
    });
  }
}]);

動作させてみる

画面で確認

  • 無事リクエストしたメッセージが取得できていることを確認しました。

f:id:takapi86:20171223225437p:plain

  • ちなみに検証に失敗するとこうなります。

f:id:takapi86:20171223225500p:plain

リクエストの内容を確認する

POST /add_message HTTP/1.1
Host: localhost:4567
Connection: keep-alive
Content-Length: 20
Accept: application/json, text/plain, */*
Origin: http://localhost:4567
X-XSRF-TOKEN: VGqbpCECjRRXF6XJFwD4IJA0zxzxdobSr4mnMJieyvI=
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.75 Safari/537.36
Content-Type: application/json;charset=UTF-8
Referer: http://localhost:4567/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: XSRF-TOKEN=VGqbpCECjRRXF6XJFwD4IJA0zxzxdobSr4mnMJieyvI=

Cookie XSRF-TOKEN に保存されているのと同じ値が、 X-XSRF-TOKEN が付与されていることを確認

JWT(+JWS)について調べた (JWTの検証)

前回、こちらでHMAC SHA256で署名したJWT(+JWS)を作成していきましたが、今回はその検証を行っていきます。

takapi86.hatenablog.com

rfc7519 に検証手順があるので、それを参考にしつつ流れを確認しました。

  • JWTをheader、payload、signatureに分割
  • 分割したheader、payload、signatureをBASE64urlでデコード
  • headerから署名アルゴリズムを確認する
  • 確認した署名方法でJWTの検証を行なう

これに加えて有効期限の検証も行っていきます。

とにかくコードを書いていきます。 今回はコードの中で説明をしていく形になりますが、順番的にはこんな感じで検証していくのが良いかと思われます。

# -*- coding: utf-8 -*-

require 'base64'
require 'json'
require 'openssl'

jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6MTU0MDY1MjQwMH0.a2xzydhHuo2UREDRIdcyKmlihl_1gl_BYCxdQEKmgTE'

# JWTをheader、payload、signatureに分割

encoded_header, encoded_payload, encoded_signature = jwt.split('.')

# 分割したheader、payload、signatureをBASE64urlでデコード

decoded_header = Base64.urlsafe_decode64(encoded_header) # パディングがなくてもよしなにデコードしてくれる
decoded_payload =  Base64.urlsafe_decode64(encoded_payload)
decoded_signature = Base64.urlsafe_decode64(encoded_signature)

# headerから署名アルゴリズムを確認する
# 今回は形式はJWTでHS256以外のものは対応しないので、それ以外は処理しないようにします

header = JSON.parse(decoded_header)
payload = JSON.parse(decoded_payload)

unless header['typ'] == 'JWT' && header['alg'] == 'HS256'
  puts 'error: 対象外の形式です。'
  return
end

# 確認した署名方法でJWTの検証を行なう

secret = 'secret'

verification_signature = OpenSSL::HMAC.digest(
  OpenSSL::Digest::SHA256.new,
  secret,
  "#{encoded_header}.#{encoded_payload}"
)

if decoded_signature != verification_signature
  puts 'error: 検証が失敗しました。'
  return
end

# 有効期限を設けていれば、その検証も行なう

unless Time.now.to_i < payload['exp']
  puts 'error: 有効期限が過ぎています。'
  return
end

puts '検証OK'

参考

JWT(+JWS)について調べた

JWT とは

JSONオブジェクトで、安全に2者間で情報をやりとりするための表現方法です。 RFC7519で定義されています。

JWS とは

JSON Web Signatureの略、JWTのエンコード形式、デジタル署名、もしくはMACを行ったメッセージの表現 RFC7515で定義されています。 他には、暗号化するための仕様としてJSON Web Encryption (JWE)なんかがあります。

今回は、使われているケースが多いJWT+JWSの組み合わせのパターンでの実装を調べていきたいと思います。

特徴

  • コンパクトである
    • サイズが小さいので、URL、POSTパラメータ、またはHTTPヘッダー内で送信できます。
  • 自己完結されている
    • ペイロードには、ユーザーに関する必要な情報がすべて含まれているため、(使い方にもよりますが)データベースを複数回クエリする必要はありません。
    • ペイロードとは・・・やりとりしたい情報の本体(ヘッダなどの付加的情報が取り除かれたデータです。)
  • url-safeである
    • URLで使用できる文字列のみで構成されています。

どんなときに使うのか

  • 認証

    • こちらが一般的な使われ方のようです。
    • ユーザーがサービスにログインしJWTを発行してらもらい、その発行されたJWTで他に許可された異なるドメインのリソースを取得する。などの使い方ができます。
  • 情報の交換

    • JWTには、公開鍵/秘密鍵のペアを使用して署名することができるため、送信者は自分が誰であるかを確認できます。さらに、ヘッダーとペイロードを使用して署名が計算されるので、コンテンツが改ざんされていないことを確認することもできます。

OpenID ConnectやOAuthと組み合わせて使うケースもあるようです。

JWTの構造

これらをBase64エンコーディング(urlsafe、パディング無し)に変換し、.区切りで結合します。

こんな形になります。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6MTU0MDY1MjQwMH0.a2xzydhHuo2UREDRIdcyKmlihl_1gl_BYCxdQEKmgTE

ヘッダ

ヘッダには、このトークンのタイプ、使用されているハッシュアルゴリズムなどの情報で構成されます。

この例では、 ハッシュアルゴリズムが、HS256 (HMAC SHA-256アルゴリズムトークンのタイプが、JWT ということを示しています。

{
  "alg": "HS256",
  "typ": "JWT"
}

ペイロード

ペイロードは以下のような形式になります。 ペイロードにはクレームを含んでいます。 (クレームとは、エンティティ(通常はユーザー)と追加のメタデータのことを指します。)

クレームには、以下3種類があります。

  • 予約済み

    • 必須ではありませんが、事前定義された推奨のクレームです。例えば以下のようなものがあります。
      • iss(発行者)
      • exp(有効期限)
      • sub(サブジェクト)
      • aud(オーディエンス)
  • 公開済み

    • JWTを使用する人が自由に定義できます。 しかし、衝突を避けるためには、IANA JSON Webトークレジストリで定義するか、衝突抵抗ネームスペースを含むURIとして定義する必要があります。
  • 個人用

    • これらは、それらの使用に同意する当事者間で情報を共有するために作成されたカスタムクレームです。
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "exp": 1540652400
}

有効期限(exp)は、 NumericDate である必要があるので、ここでは、UNIXTIMEを入れています。 https://tools.ietf.org/html/rfc7519#section-4.1.4

署名

先ほど、紹介したJOSE ヘッダ、JWSペイロード、(Base64urlエンコード済)を、 HMAC SHA-256 アルゴリズムで署名し, [JWS]に指定された形で署名をbase64urlエンコードすると, このエンコード済JWS署名を作り出せます。

RubyでJWTを作ってみる

# -*- coding: utf-8 -*-

require 'base64'
require 'json'
require 'openssl'

header = {
  alg: 'HS256',
  typ: 'JWT'
}

payload = {
  sub: '1234567890',
  name: 'John Doe',
  admin: true,
  exp: 1540652400
}

secret = 'secret'

encoded_header = Base64.urlsafe_encode64(header.to_json).delete('=')
encoded_payload = Base64.urlsafe_encode64(payload.to_json).delete('=')

signature = OpenSSL::HMAC.digest(
  OpenSSL::Digest::SHA256.new,
  secret,
  "#{encoded_header}.#{encoded_payload}"
)
encoded_signature = Base64.urlsafe_encode64(signature).delete('=')

jwt = [
  encoded_header,
  encoded_payload,
  encoded_signature,
].join('.')

puts '[jwt] => ' + jwt

結果

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6MTU0MDY1MjQwMH0.a2xzydhHuo2UREDRIdcyKmlihl_1gl_BYCxdQEKmgTE

値の確認

こちらのjwt.ioというサイトのDebuggerツールで、値の確認ができます。

https://jwt.io/#debugger-io

JWTの検証

こちらは、別の記事でまとめていく予定です。

window.postMessage APIの使い方を調べた

postMessageとは

  • HTML5で拡張された機能
  • 任意のウィンドウへクロスオリジン通信が可能
  • セキュアである
  • 双方向の通信チャンネルが確立されてから通信を行う
    • 悪意あるWebサイトから知らないうちにされないようにしているため

使い方(送信)

win.postMessage("メッセージ", "http://www.example.com");

メッセージを受け取りたい対象に対してpostMessageを呼びます。 コードで示している win は以下を指します。

  • 自分のドキュメントウィンドウ内に生成されたiframe
  • JavaScriptで開いたポップアップウィンドウ
  • 自分のドキュメントウィンドウを含むウィンドウ
  • 自分のドキュメントを開いたウィンドウ

第一引数に渡したいメッセージ(文字列)、第二引数に宛先のオリジンを指定します。

受け取り方(受信)

addEventListenerを使って、messageイベントを登録します。

addEventListener('message', function(event) {
  // 受け取ってからの処理
}, false);

この event には、以下3つのプロパティが用意されています。

  • event.data
    • 実際のメッセージ内容(文字列)
  • event.origin
    • メッセージを送ってきたページの生成元
  • event.source
    • メッセージを送ってきた window オブジェクトの参照

セキュリティ上の注意点

postMessageの第二引数にワイルドカードを指定しない

postMessageの第二引数に '*' を指定すると、どのオリジンに対してもメッセージを送信することができるため、意図して使わない限り指定しないようにしたほうが良さそうです。

受け取ったメッセージの送信元を確認する

addEventListenerは送られてきたすべてのメッセージを受け取ることができるため、信頼できないページからのメッセージも受け取ることができます。 そのため、受け取ったら必ず以下のように信頼できる送信元であるかチェックをしたほうが良いです。

addEventListener('message', function(event) {
  if (event.origin !== 'http://www.example.com/') {
    return;
  }
}, false);

補足

最近は文字列でなくても良い

最新のブラウザではpostMessageの第一引数に、様々なデータオブジェクトを受け付けることができるようになっています。 ただ、IE8,IE9の場合はこの方法は使えないので、互換性を持たせたいのであればJSONを使うほうが良さそうです。 (もう、サポート切れているし意識しなくても良いかな。)

IE8以下は、addEventListener は使えないので attachEvent を使う

詳しくはこちら https://qiita.com/39_isao/items/506e08031adfca9a5933

参考

CORSの動作を確認した

前回、こちらのエントリーにて、XMLHttpRequestが SOP の対象となった場合の動作を確認しました。

takapi86.hatenablog.com

今回は、Cross-Origin Resource Sharing (CORS)の仕様したがって、SOPで制限されている クロスオリジンへの XMLHttpRequest(XHR) を可能にします。

CORSについては以下を参照すると良いでしょう。

リクエストは2パターン

リクエストは2パターンあります。条件によってそれぞれ使い分けされます。

  • シンプルなリクエス
  • プリフライトリクエス

それぞれ動作を確認していきます。

シンプルなリクエス

シンプルなリクエストとは

シンプルなリクエストとは、以下のすべての条件に合うものです。

HTTP アクセス制御 (CORS)から抜粋

  • 許可されたメソッドは以下に限る:

    • GET
    • HEAD
    • POST
  • ユーザエージェントが自動的に設定するヘッダ (Connection や User-Agent など) を除き、手動で設定できるヘッダは以下に限る:

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type
  • Content-Type ヘッダで許可される値は以下に限る:

    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

実際にやってみる

前回の記事で、「異なるオリジンにXHRをしてみる」というセクションで、異なるオリジンにXHRをしSOP の制御対象となることを確認しましたが、このリクエストを許可するようにします。

前回と同じ環境で、異なるオリジンのXHRが許可されるような条件を加え試します。

前回の環境

  • XHR送信側サーバ(Sinatra): http://test.example.com:4567/ (HTML,JSをレンダリングし、そこからXHRを送信します。)
  • XHR受信側サーバ(Sinatra): http://test.example.com:4568/
  • リクエストメソッド: POST
  • レスポンス: Failed to load http://test.example.com:4568/: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://test.example.com:4567' is therefore not allowed access. というエラーがブラウザに表示され、Response が受け取れない
Accept:*/*
Accept-Encoding:gzip, deflate, br
Accept-Language:ja
Connection:keep-alive
Content-Length:0
Content-Type:text/plain
Host:localhost:4567
Origin:http://test.example.com:4567
Referer:http://test.example.com:4567/tools
User-Agent:Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.91 Safari/537.36

加える条件

XHR受信側サーバ(http://test.example.com:4568/) の Response Header に以下を返すようにします。

Access-Control-Allow-Origin: http://test.example.com:4567/

結果

レスポンスを受け取ることができました。

レスポンスヘッダ

Access-Control-Allow-Origin:http://test.example.com:4567
Connection:keep-alive
Content-Length:116
Content-Type:text/html;charset=utf-8
Server:thin
X-Content-Type-Options:nosniff
X-Frame-Options:SAMEORIGIN
X-XSS-Protection:1; mode=block

レスポンスボディ(レスポンスメソッド とUserAgentを返すようにしています。)

POST from [Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.91 Safari/537.36]

Access-Control-Allow-Origin ヘッダ

上記の動きで確認した通り、Access-Control-Allow-Origin は、指定した Origin のアクセスを許可することができます。 リクエストで送られたOrigin ヘッダの内容とこの Access-Control-Allow-Originが同じであればアクセス許可されるという仕組みです。 (Origin ヘッダは、XHRで異なるOriginへ送信するときに自動的に付与されます。) どの Origin でもアクセスを許可させる場合は、Access-Control-Allow-Origin: * という形にもできます。 ただし、これは、後ほど説明する クレデンシャルを含むリクエスト には使うことができません。

プリフライトリクエス

以下のようなリクエストのときに、プリフライトリクエストを行います。

  • GET、HEAD、POST 以外のメソッドを使用した場合。また application/x-www-form-urlencoded、multipart/form-data、または text/plain 以外の Content-Type とともに POST を使用してリクエストを行う場合、例えば application/xml または text/xml を使用して POST で XMLペイロードをサーバーへ送るときは、リクエストでプリフライトを行います。

  • カスタムヘッダをリクエストに設定した場合 (例えば、X-PINGOTHER のようなヘッダを用いるリクエスト)。

実際にやってみる

ひとまず、ダメ元で、先ほどシンプルなリクエストで行ったもののリクエストメソッドをPOSTからPUTに変更しただけの状態で試してみます。

デベロッパーツールのConsoleには、以下のようなエラーが表示されました。

xhr.js:18 OPTIONS http://test.example.com:4568/ net::ERR_ABORTED
send_request @ xhr.js:18
onclick @ tools:22
tools:1 Failed to load http://test.example.com:4568/: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://test.example.com:4567' is therefore not allowed access. The response had HTTP status code 404.
xhr.js:18 XHR failed loading: OPTIONS "http://test.example.com:4568/".

OPTIONSメソッドでアクセスしようとしたが、サーバ側でOPTIONSを受け取る処理を行っていなかったので、404で返ってきているみたいですが、 なぜ、PUTではなく、OPTIONSで送信されたのでしょう。

プリフライトリクエストの流れ

OPTIONSで送信されたのには、以下の理由があります。

HTTP アクセス制御 (CORS)には、こう記述されています。

シンプルなリクエスト (前述) とは異なり、"プリフライト" リクエストは始めに、実際のリクエストを送信しても安全かを確かめるために他ドメインのリソースへ向けて OPTIONS メソッドを使用して HTTP リクエストを送信します。クロスサイトリクエストはユーザーデータに影響を与える可能性があるため、このようにプリフライトを行います。

つまり、流れとしては、

  1. [クライアント]OPTIONSメソッドを送信
  2. [サーバ]許可しているメソッドやカスタムヘッダをResponse Headerに含め返します。
  3. メソッドを許可: Access-Control-Request-Method
  4. カスタムヘッダを許可: Access-Control-Request-Headers (こちらは後ほど詳しく説明します。)

  5. [クライアント]元々送ろうとしていたリクエストを送信する

・・・あとは、通常通り

改めて実際にやってみる

今回は、PUTメソッドを許可するので、サーバ側に以下の設定を加えます。

  • OPTIONSメソッドを受け取るようにする
  • OPTIONSメソッドを受け取ったら、以下のヘッダ・値を返すようにする
    • Access-Control-Allow-Origin: http://test.example.com:4567
    • Access-Control-Allow-Methods: PUT

そして送信!

デベロッパーツールのConsoleには、以下のように、OPTIONSとPUTを送信している様子が見えます。

xhr.js:18 XHR finished loading: OPTIONS "http://test.example.com:4568/".
send_request @ xhr.js:18
onclick @ tools:22
XHR finished loading: PUT "http://test.example.com:4568/".

リクエストヘッダ(OPTIONS)

OPTIONS / HTTP/1.1
Host: localhost:4568
Connection: keep-alive
Access-Control-Request-Method: PUT
Origin: http://test.example.com:4567
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.91 Safari/537.36
Accept: */*
Referer: http://test.example.com:4567/tools
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.8,en;q=0.6

レスポンスヘッダ(OPTIONS)

HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Access-Control-Allow-Origin: http://test.example.com:4567
Access-Control-Allow-Methods: PUT
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Connection: close
Server: thin

リクエストヘッダ(PUT)

PUT / HTTP/1.1
Host: localhost:4568
Connection: keep-alive
Content-Length: 0
Accept: */*
Origin: http://test.example.com:4567
Accept-Language: ja
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.91 Safari/537.36
Content-Type: text/plain
Referer: http://test.example.com:4567/tools
Accept-Encoding: gzip, deflate, br

レスポンスヘッダ(PUT)

HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Access-Control-Allow-Origin: http://test.example.com:4567
Content-Length: 115
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Connection: keep-alive
Server: thin

うまくリクエストを送ることができました。

クレデンシャルを含むリクエス

同一オリジンであれば、クレデンシャルを含むリクエストはデフォルトで許可されますが、 異なるオリジンへクレデンシャルを含むリクエストを送るときは、注意が必要です。

ちなみに、クレデンシャルというのは、Cookieの送信、BASIC認証を指します。

実際にやってみる

ひとまず、そのままではできないことを確認しましょう。

こんな感じで、XHRでCookieを含めて送信するようにします。

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

デベロッパーツールのConsoleには、以下のようなエラーが表示されました。

tools:1 Failed to load http://test.example.com:4568/: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. Origin 'http://test.example.com:4567' is therefore not allowed access. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.
xhr.js:19 XHR failed loading: POST "http://test.example.com:4568/".

なるほど、Access-Control-Allow-Credentials ヘッダを追加というものをつけて上げる必要があると

というわけで、サーバに以下のヘッダを返すようにします。

Access-Control-Allow-Credentials:true

注意点としては、上記を追加する場合は、Access-Control-Allow-Origin:*の指定ができません。 必ず、オリジンを指定する必要があります。

改めて実際にやってみる

では、引き続きやってみましょう。

シンプルなリクエスト・プリフライトリクエストが必要なものどちらも行いましたが、今度は、成功しました。

デベロッパーツールのConsole

xhr.js:19 XHR finished loading: POST "http://test.example.com:4568/".
send_request @ xhr.js:19
onclick @ tools:21
xhr.js:19 XHR finished loading: OPTIONS "http://test.example.com:4568/".
send_request @ xhr.js:19
onclick @ tools:22
XHR finished loading: PUT "http://test.example.com:4568/".

CORSのヘッダについて

CORSのリクエストヘッダ/レスポンスヘッダの詳細については、HTTP アクセス制御 (CORS)に詳しく書いてあるので、ここを読めばバッチリでしょう。

参考

XMLHttpRequestが Same-Origin Policy の対象となった場合の動作を確認した

XMLHttpRequest(以降「XHR」と表記)が Same-Origin Policyの対象となった場合の動作を実際にリクエストを送信し、動作を確認しました。

今回はSame-Origin Policy周りの検証をしていくにあたっての第一弾として、今後はもう少し踏み込んだ内容でやっていく予定です。

Same-Origin Policy(SOP)とは

Same-Origin Policy(以降「SOP」と表記) は、日本語では、同一オリジンポリシー同一生成元ポリシーなどと呼ばれています。 あるオリジン(スキーム、ホスト、ポートの組み合わせ)から読み込まれた文書やスクリプトについて、そのリソースから他のオリジンのリソースにアクセスできないように制限するものです。 逆に、同じオリジンであれば、制限なしにアクセスが行えます。

今回は、XHRのSOPの動作を確認をしていきますが、他には、DOMアクセス、Cookieに関するSOP。プラグインなどでは、Java,FlashSilverlightなどにもそれぞれ個別のSOPが存在します。

詳しくは、MDN web docsのこちらのページが参考になります。

動作を確認する

簡単な検証用webアプリケーションをSinatraで作成しました。

アプリケーションは以下のような機能をもっています。

  • 画面から好きなURI宛にXHRをGet、Postで送信することができる。
  • 画面からXHRの結果(Response BodyとStatus Code)を見ることができる。
    • Response Bodyには、送った Request Method とUserAgentを表示します。
  • ルートパスにGet、Postリクエストを送ることができ、リクエストの内容をコンソールに表示することができる。

画面はこんな感じです。バッと雑に作って確認していきます。

f:id:takapi86:20170916180051p:plain

これを、先ほど説明したアプリケーションをそれぞれポートを変えて2つ起動し、XHRでやり取りできるか、できないかを確認します。

  • http://test.example.com:4567/
  • http://test.example.com:4568/

※ホスト名は検証したものとは異なります。

同一のオリジンにXHRをしてみる

まずは、アプリケーションの機能で、画面から自分宛にXHRを送信します。

http://test.example.com:4567/tool にアクセスすると、 画面から好きなURI宛にXHRをGet、Postで送信することができるツールが開くので、 ここから、http://test.example.com:4567/宛にXHRを送信します。

結果としては、もちろんですが、リクエストの送信・レスポンスの取得が成功しました。

  • デベロッパーツールのConsoleからリクエストが送られていることを確認(Log XMLHttpRequestsを有効にしています。)
XHR finished loading: GET "http://test.example.com:4567/".
XHR finished loading: POST "http://test.exapmle.com:4567/".
  • サーバ側にXHRが届いていることを確認
GET from [Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.79 Safari/537.36]
XXX.XXX.XXX.XXX - - [XX/Sep/2017:XX:XX:XX +0900] "GET / HTTP/1.1" 200 - 0.0038
POST from [Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.79 Safari/537.36]
"Content-Type [text/plain]"
"Body [Request Body]"
XXX.XXX.XXX.XXX - - [XX/Sep/2017:XX:XX:XX +0900] "POST / HTTP/1.1" 200 19 0.0005
  • 検証用のアプリケーションの画面からも確認

f:id:takapi86:20170916181421p:plain

f:id:takapi86:20170916181429p:plain

異なるオリジンにXHRをしてみる

ポートを変えて同じアプリケーションを起動し、そこへXHRを送信してみます。

要求されたリソースに Access-Control-Allow-Origin ヘッダーがないので、アクセスが許可されていない旨のメッセージがブラウザに表示されました。(Get、Post両方とも同じ内容)

Failed to load http://test.example.com:4568/: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://test.example.com:4567' is therefore not allowed access.
  • リクエスト自体は、サーバ側に届いているようです。
GET from [Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.79 Safari/537.36]
XXX.XXX.XXX.XXX - - [XX/Sep/2017:XX:XX:XX +0900] "GET / HTTP/1.1" 200 - 0.0005
POST from [Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.79 Safari/537.36]
"Content-Type [text/plain]"
"Body [Request Body]"
XXX.XXX.XXX.XXX - - [XX/Sep/2017:XX:XX:XX +0900] "POST / HTTP/1.1" 200 19 0.0005
  • 検証用のアプリケーションの画面からも確認

Get、Postどちらも値が取得できませんでした。

f:id:takapi86:20170916181739p:plain

まとめ

異なるオリジンへは、特に対策を行わない限りXHRでResponseの値を取得することができませんでした。 また、リクエストはサーバ側に届きますが、クライアント側でレスポンスは取得できないということがわかりました。

参考

SOPに関しては、以下を参考にしました。

TwitterAPIを使ってOAuth2.0のフロー(Client Credentials)を確認した。

先日、以下のエントリーにて、4つあるOAuth2.0の認可フロー(Grant Type)からAuthorization Codeの流れを確認しました。

takapi86.hatenablog.com

今回は、TwitterAPIにOAuth2.0でツイートの検索ができるよう認可してもらい、実際に検索結果のjsonが返ってくるところまでやっていきたいと思います。

Client Credentialsの流れは動画で確認できます。

www.youtube.com

参考

やること・ゴール

  • 前回同様、Rubyで簡易的な実装で流れを掴んで行きたいと思います。
  • 上記にも書きましたが、TwitterAPIにOAuth2.0でツイートの検索ができるよう認可してもらい、実際に検索結果のjsonが返ってくるところまでやっていきたいと思います。

Client Credentialsについて

Client Credentialsは他の認証フロー3つとは、少し異なります。

  • ユーザ
  • リソースを保持するサービス・認可をするサービス
  • 第三者(リソースを保持するサービスのリソースを使いたい)

上記3人の登場人物がいたとすると、Client Credentials以外の認証フロー(Authorization Code, Implicit, Resource owner password credentials)は、 第三者がリソースへのアクセスの認可をしてもらうためのものです。 それに対して、Client Credentialsは認可を必要としていないリソースへアクセスする場合に使用します。

具体的にどういうこと?というと、 例えば、今回実装するTwitterAPIでは、 ‘Application-only authentication’ という認可のタイプ、以下の操作をすることが許可されます。 (TwitterAPIでは、この認可タイプ以外は、OAuth2.0に対応していないようです。)

  • ツイートの検索
  • ユーザのツイートの取得
  • ユーザー情報を取得
  • 任意のアカウントの友人やフォロワーの取得

など

反対にアクセスできないものは、

  • ツイートの投稿
  • ダイレクトメッセージの送信

など

つまり、ユーザ認可が必要のない公開されている内容のみ使用したい場合に使います。

恐らくアクセス数の制御のためだと思われます。

大まかな流れ

Client Credentials の説明を上記で行った通り、今回TwitterAPIでは、 ‘Application-only authentication’ の認可をもらいます。

流れとしては以下です。公式ページの流れに沿って実装していきます。

Application-only authentication — Twitter Developers

  • consumer_keyとconsumer_secretを取得する
  • consumer_keyとconsumer_secretをBase64エンコードし、bearer_tokenを取得する
  • bearer_tokenを使って、特定のアカウントのツイート一覧を表示する

consumer_keyとconsumer_secretを取得する

Twitter Developersにログインし、 consumer_keyとconsumer_secretをもらいます。 手順は検索すると多くの記事がでてくるので割愛します。

consumer_keyとconsumer_secretをBase64エンコードし、bearer_tokenを取得する

ここは公式ページの Step 1: Encode consumer key and secret, Step 2: Obtain a bearer token に該当します。

以下のコードにあるように、consumer_key, consumer_secretをURIエンコードし、 : で結合後、Base64エンコーディングし、リクエストを送信します。

# -*- coding: utf-8 -*-

require 'uri'
require 'net/http'
require 'openssl'
require 'base64'

consumer_key = URI.encode({ consumer_key })
consumer_secret = URI.encode({ consumer_secret })

credential = Base64.strict_encode64(consumer_key + ':' + consumer_secret)

uri = URI.parse('https://api.twitter.com/oauth2/token')
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true

request_body_params = 'grant_type=client_credentials'
request_header = {
  'Authorization': "Basic #{credential}",
  'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  'Content-Length': request_body_params.length.to_s,
  'Accept-Encoding': 'gzip'
}

request = Net::HTTP::Post.new(uri.request_uri, request_header)
request.body = request_body_params

response = https.request(request)

puts response.code
puts response.body

成功するとステータスコード200で以下のレスポンスが返ってきます。 access_tokenbearer_token です。

{
  "token_type":"bearer",
  "access_token": { ここにアクセストークンが表示されます。 }
}

bearer_tokenを使って、特定のアカウントのツイート一覧を表示する

ここは公式ページの Step 3: Authenticate API requests with the bearer token に該当します。 ひとまず、公式のサンプル通りに実装していきます。

# -*- coding: utf-8 -*-

require 'uri'
require 'net/http'

access_token = { access_token }

uri = URI.parse('https://api.twitter.com/1.1/statuses/user_timeline.json')
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true

request_header = {
  'Authorization': "Bearer #{access_token}",
  'Accept-Encoding': 'gzip'
}
request_params = {
  count: '100',
  screen_name: 'twitterapi'
}

uri.query = URI.encode_www_form(request_params)
request = Net::HTTP::Get.new(uri.request_uri, request_header)

response = https.request(request)

puts response.code
puts response.body

成功するとステータスコード200でそれっぽいレスポンスがjsonで返ってきます。

せっかくなので、自分のツイートの検索してみます。 パラメータの仕様はこちらで確認しました。

GET search/tweets — Twitter Developers

# -*- coding: utf-8 -*-

require 'uri'
require 'net/http'

access_token = { access_token }

uri = URI.parse('https://api.twitter.com/1.1/search/tweets.json')
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true

request_header = {
  'Authorization': "Bearer #{access_token}",
  'Accept-Encoding': 'gzip'
}
request_params = {
  q: '腹痛に耐えながらnginxをビルドし直し自サービスをhttp2化した。',
  lang: 'ja',
  result_type: 'mixed',
  count: '1'
}

uri.query = URI.encode_www_form(request_params)
request = Net::HTTP::Get.new(uri.request_uri, request_header)

response = https.request(request)

puts response.code
puts response.body

結果のjsonはちゃんと見ていませんが screen_name に自分のアカウントが表示されていたので良さそうな気がします。

以上で、一通りの流れを確認しました。 今後も様々な認証・APIに触れて、API実装力を鍛えていきたい次第です。