無印吉澤

Site Reliability Engineering(SRE)、ソフトウェア開発、クラウドコンピューティングなどについて、吉澤が調べたり試したことを書いていくブログです。

Ruby on Rails 5 アプリにあとから API 機能(JWT, CORS 対応)を追加する

f:id:muziyoshiz:20170320163237p:plain

はじめに

https://www.admiral-stats.com/ という URL で、Ruby on Rails 5 で作った Admiral Stats というサービスを動かしています。このサービス自体については、過去の記事 を参照ください。

このサービスに最近 API 機能を追加したので、その方法を紹介します。一通り読んでもらえると、API サーバって割と簡単に作れるんだなーというのがわかると思います。

今回追加した API

API を追加した目的

Admiral Stats は、艦これアーケードというゲームのプレイデータを、ユーザ(提督)から時々アップロードしてもらって、それを時系列に可視化するサービスです。

f:id:muziyoshiz:20160828003101p:plain

プレイデータは、SEGA の公式プレイヤーズサイトから、非公式のエクスポータを使ってエクスポートしてもらいます。そのためのエクスポータも、ブックマークレット版、PowerShell 版、Ruby 版と色々配布しています。

今までは Web ブラウザで Admiral Stats にログインして、「インポート」ページからプレイデータをアップロードしてもらう必要がありました。

f:id:muziyoshiz:20170320163724j:plain:w600

自分で使っていてもこれが面倒だったので、この部分を自動化するために API を作ることにしたわけです。

最終的に作った API の仕様

  • GET /api/v1/import/file_types
    • Admiral Stats がインポート可能なファイル種別のリストを返す。
    • エクスポータによっては、現在の Admiral Stats がまだサポートしていないプレイデータもエクスポートする。この API を使うことで、未サポートのデータをアップロードしなくて済むようにする。
  • POST /api/v1/import/:file_type/:timestamp
    • ボディ部に含まれる JSON(プレイデータ)をパースし、データベースに登録する。
    • :file_type/api/v1/import/file_types が返すファイル種別のいずれか。
    • :timestamp%Y%m%d_%H%M%S 形式。日本にしか無いゲームのためのツールなので、問答無用で JST で解釈する。

この API の呼び出しは、後述するトークンで認証します。例えば、curl で呼び出す場合、Authorization ヘッダにトークンを入れて、以下のように呼び出します(このトークンは架空のものなので、実際に叩くと失敗します)。

happyturn% curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNDg5MDY1Nzg1fQ.q-SlF1tPKyCiNBOzdbQ2JLSqSr7d380WbKxZp818xeo" \
> -i -X GET https://www.admiral-stats.com/api/v1/import/file_types
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"289acc0adafeb819ebf2fca179abb2a7"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 553b2151-562e-47f7-b46f-c0a41384ed5a
X-Runtime: 0.044076
Vary: Origin
Transfer-Encoding: chunked

["Personal_basicInfo","TcBook_info","CharacterList_info","Event_info"]

使い方

API トークンの発行

ユーザは、まずは API トークンを発行します。Admiral Stats にログインすると、「API トークンの設定」というメニューが表示されるので、ここで新しいトークンを発行できます。

もしトークンが漏れた場合は、「トークンの再発行」ボタンを押すと新しいトークンが発行されて、古いトークンは使えなくなります。このような失効処理は、JWT の機能では実現できないので、後述するテーブルを使って実現しました。

f:id:muziyoshiz:20170320164221p:plain:w600

エクスポータの設定

次に、このトークンをエクスポータに設定します。この設定はブックマークレット版が一番簡単で、Admiral Stats が生成するブックマークレットに、そのユーザのトークンが自動的に埋め込まれるようになります。

f:id:muziyoshiz:20170320164428p:plain:w600

これをブックマークバーにドラッグアンドドロップすると、以下のようなブックマークレットが登録されます。

javascript:(function(u,t,b){var%20s=document.createElement('script');s.charset='UTF-8';s.id='admiral-stats-exporter';s.setAttribute('data-token',t);s.setAttribute('data-skip-backup',b);s.src=u;document.body.appendChild(s)})('https://www.admiral-stats.com/bookmarklets/exporter.js','eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNDg5MDY1Nzg1fQ.q-SlF1tPKyCiNBOzdbQ2JLSqSr7d380WbKxZp818xeo','true');

ちなみに、PowerShell 版の場合は起動時に表示されるプロンプトでトークンを書く、Ruby 版の場合は設定ファイル config.yaml にトークンを書く、という方法で設定できます。

エクスポータの実行

あとは、従来通りに、ブックマークレットを SEGA 公式サイトで実行すると、エクスポートから自動アップロードまで行われます。

ただ、ブックマークレット版は、非同期の JavaScript しか書けない関係上、「エクスポートから自動アップロードのどこかが失敗したらエラーを出す」ということがうまくいきません(無理矢理書くことはできるのかもしれませんが……)。

その対策として、Admiral Stats に「API ログの確認」というメニューを作り、ここを見てもらえれば自動アップロードが成功したかどうかを確認できるようにしました。ちなみに、PowerShell 版と Ruby 版では、実行に失敗するとエラーメッセージが表示されます。

f:id:muziyoshiz:20170320164603p:plain:w600

以上が、API の簡単な使い方の解説でした。詳細は Admiral Stats の使い方 をご参照ください。

JSON Web Token (JWT) への対応

まずは、API のための認証を簡略化するための、トークン発行・検証機能を実装します。このトークンとして、今回は JWT を採用しました。

JWT とは?

JWT とは、認証トークンとして使うために、JSON 形式のデータを Base64 エンコードして署名を付けた文字列のことです。

JWT に関する情報は https://jwt.io/ にまとまっています。また、IETF の RFC 7519 - JSON Web Token (JWT) として仕様策定されています。ちなみに、この RFC によると、JWT の発音は「ジョット」らしいです。

The suggested pronunciation of JWT is the same as the English word “jot”.

JWT は、3個の Base64 文字列を、ドット(.)で繋いだ文字列です。https://jwt.io/ に表示されているサンプルを、試しに irb でデコードしてみます。

irb(main):001:0> require 'base64'
=> true
irb(main):002:0> Base64.decode64('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
=> "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"
irb(main):003:0> Base64.decode64('eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9')
=> "{\"sub\":\"1234567890\",\"name\":\"John Doe\",\"admin\":true}"
irb(main):004:0> Base64.decode64('TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ')
=> "L\x95@\xF7\x93\xAB3\xB16p\x16\x9B\xDFDL\x1E\xB1\xC3pG\xF1\x8E\x86\x19\x81\xE1N4X{\x1E\x04"

JWT では、特に暗号化されているわけではないのがわかると思います。また、3個目の Base64 文字列は署名なので、JSON にはなりません。署名の検証は、後述する gem で行えます。

また、トークンを検証する際には「有効期限が切れていないか調べる」とか「必要な権限を持っているか調べる」とか、よく行う作業があると思います。

JWT では、よく使う情報を格納するためのキー名が予約されていて、それらは Claim Names と呼ばれます。例えば、トークンの期限切れ時刻を表す “exp” (Expiration Time) という claim があります。この名前に従う必要はないんですが、従っておくと、JWT のライブラリにビルトインされている検証処理を使えて便利です。

JWT を操作するための gem

https://jwt.io/ には、以下の3種類の gem が載っています。このサイトの比較表では、機能の違いは特にありません。

このなかで、jwt は最も star が多く、README がこれを読むだけでも十分 JWT を理解できそうなくらい充実していました。そこで今回は、無難に jwt を選びました。

Rails アプリに組み込む - トークンの発行

Admiral Stats が発行する JWT のペイロードは、以下の形式にしました。(設定ファイルへのコピペが面倒にならないように)トークンをなるべく短くしたかったため、"iat" の claim だけ採用しています。

{ "id": ユーザID, "iat": トークン発行日 }

トークンの鍵は、secret_key_base の値をそのまま使うことにしました。この値は Rails.application.secrets.secret_key_base で参照できるようです。

また、一度発行したトークンをいつでも無効にできるように、トークンの内容を admiral_tokens というテーブルに保存するようにしました。JWT の検証に成功しても、このテーブルに同じトークンが登録されていない場合は「有効期限切れ」と扱うことにしました。

以下は、token_controller.rb 内に実装した、トークンの発行処理です。

    begin
      AdmiralToken.transaction do
        AdmiralToken.where('admiral_id = ?', current_admiral.id).delete_all

        issued_at = Time.now
        token = JWT.encode({ id: current_admiral.id, iat: issued_at.to_i }, Rails.application.secrets.secret_key_base, 'HS256')

        AdmiralToken.create!(
            admiral_id: current_admiral.id,
            token: token,
            issued_at: issued_at
        )
      end
    rescue => e
      logger.error(e)
      @error = "トークンの発行に失敗しました。(原因:#{e.message}"
    end

Rails アプリに組み込む - トークンの検証

検証処理は、application_controller.rb 内の jwt_authenticate メソッドとして実装しました。この実装は An Introduction to Using JWT Authentication in Rails を参考にしました。

  # JWT で認証したユーザ(提督)の情報を返すメソッド
  def jwt_current_admiral
    @jwt_current_admiral ||= Admiral.find(@jwt_admiral_id)
  end

  # Authorization ヘッダに含まれる JWT で認証状態をチェックするためのメソッド
  def jwt_authenticate
    unless jwt_bearer_token
      response.header['WWW-Authenticate'] = 'Bearer realm="Admiral Stats"'
      render json: { errors: [ { message: 'Unauthorized' }]}, status: :unauthorized
      return
    end

    unless jwt_decoded_token
      response.header['WWW-Authenticate'] = 'Bearer realm="Admiral Stats", error="invalid_token"'
      render json: { errors: [ { message: 'Invalid token' } ] }, status: :unauthorized
      return
    end

    unless @jwt_admiral_id
      # 有効期限の検査
      jwt_admiral_id = jwt_decoded_token[0]['id']
      if AdmiralToken.where(admiral_id: jwt_admiral_id, token: jwt_bearer_token).exists?
        @jwt_admiral_id = jwt_admiral_id
      else
        response.header['WWW-Authenticate'] = 'Bearer realm="Admiral Stats", error="invalid_token"'
        render json: { errors: [ { message: 'Expired token' } ] }, status: :unauthorized
      end
    end
  end

  # Authorization ヘッダの Bearer スキームのトークンを返します。
  def jwt_bearer_token
    @jwt_bearer_token ||= if request.headers['Authorization'].present?
                            scheme, token = request.headers['Authorization'].split(' ')
                            (scheme == 'Bearer' ? token : nil)
                          end
  end

  # JWT をデコードした結果を返します。
  def jwt_decoded_token
    begin
      # verify_iat を指定しても、実際には何も起こらない
      # iat を含めない場合も、iat が未来の日付の場合も、エラーは発生しなかった
      @jwt_decoded_token ||= JWT.decode(
          jwt_bearer_token,
          Rails.application.secrets.secret_key_base,
          { :verify_iat => true, :algorithm => 'HS256' }
      )
    rescue JWT::DecodeError, JWT::VerificationError, JWT::InvalidIatError
      # エラーの詳細をクライアントには伝えないため、常に nil を返す
      nil
    end
  end

ちなみに、エラーメッセージは、以下のように出し分けています。

  • Unauthorized: Authorization ヘッダに Bearer トークンが含まれていない
  • Invalid token: JWT の署名検証に失敗した
  • Expired token: JWT の署名検証には成功したが、テーブル上に無かった

このメソッドをAPI のコントローラ(api_import_controller.rb)の before_action に追加すると、トークンでの認証が可能になります。

また、今回は、普通の Rails アプリに、あとから API サーバの機能を追加しているので、API 用のコントローラのみ CSRF 対策を無効化する必要があります。以下は、コントローラのコードの冒頭です。

class ApiImportController < ApplicationController
  include Import

  # API 用のコントローラでは CSRF 対策を無効化する
  skip_before_action :verify_authenticity_token

  before_action :jwt_authenticate

あとは API の機能そのものを実装すれば、API サーバの実装完了です。

Cross-Origin Resource Sharing (CORS) への対応

ただし、この API をブックマークレットから叩こうとすると、Web ブラウザが以下のようなエラーを出して、クロスドメイン接続をブロックしてしまいます。

クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、https://www.admiral-stats.com/api/v1/import/Area_captureInfo/20170308_235926 にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダ ‘Access-Control-Allow-Origin’ が足りない)。

この同一生成元ポリシー(Same-Origin Policy)は、セキュリティのための機能ですが、今回のような場合には困ります。この接続を許可するには、API サーバ側が CORS に対応する必要があります。

CORS とは?

CORS とは、別ドメインの Web サーバが明示的に許可した場合に限り、あるドメインから別ドメインへのクロスドメイン通信を許可するための、HTTP の仕様です。

別ドメインの Web サーバが明示的に許可した場合だけ使える、という意味では、できることは JSONP(JavaScript の読み込みとしてリクエストを送り、コールバック関数でラップした JSON を返す)と大差ありません。しかし、CORS のほうが、サーバ・クライアント双方の実装を大幅に簡略化できます。

CORS のプロトコルを大まかに説明すると、次のようになります。

  1. ページ内で読み込んだ JavaScript が、別ドメインへの XMLHttpRequest を send する
  2. Web ブラウザが、1 の URL に対して、OPTIONS リクエストを送信する
  3. Web サーバが、Access-Control-Allow-Origin ヘッダなどを含み、ボディが空のレスポンスを返す
  4. 3 に含まれるヘッダが、1 のリクエストを許可している(例:Access-Control-Allow-Origin ヘッダにそのサイトのドメインが含まれている)かどうかを検査する
  5. 4 の検査をパスしたら、Web ブラウザが、1 の URL に対して、実際のリクエスト(GET や POST)を送信する
  6. Web サーバが、Access-Control-Allow-Origin ヘッダなどを含む、本来のレスポンスを返す

CORS に対応するための rack-cors gem

Ruby on Rails で CORS に対応するためには、rack-cors という gem を使います。

実は、Rails 5 から導入された rails new NAME --api でアプリケーションを作ると、この rack-cors を使うための以下の設定が、コメントアウトされた状態で書き込まれます(参考)。

  • Gemfile に gem 'rack-cors'
  • config/initializers/cors.rb に以下の設定

しかし、Admiral Stats は、すでに普通の Rails アプリとして作ってしまっているので、これと同じことを手作業で行います。

今回は以下の内容で cors.rb を作成しました。設定と、実際に返される HTTP ヘッダの対応関係については、コード中に書いたコメントの通りです。

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  # Insert CORS request headers for API responses
  allow do
    # Access-Control-Allow-Origin: (Origin に書かれたものをそのまま返す)
    origins '*'

    # /api 以下の URL に対してのみ、CORS 対応
    # Access-Control-Allow-Methods: GET, POST, OPTIONS
    # Access-Control-Allow-Headers: (OPTIONS に対してのみ Access-Control-Request-Headers に書かれたものをそのまま返す)
    # Access-Control-Max-Age: 3600
    resource '/api/*',
             :methods => [:get, :post, :options],
             :headers => :any,
             :max_age => 3600
  end
end

rack-cors の注意点

rack-cors を導入すると、OPTIONS メソッドに対してレスポンスを返すようになります。そのために routes.rb を変更する必要はありません

ただ、テストコードのなかで、この OPTIONS へのレスポンスをテストしようとしたところ、以下のように RoutingError が出て失敗しました。IntegrationTest から、rack-cors が生成するルートは見えないようです。

ActionController::RoutingError: No route matches [OPTIONS] "/api/v1/import/Personal_basicInfo/20170309_000000"
    test/controllers/api_import_controller_test.rb:233:in `block (2 levels) in <class:ApiImportControllerTest>'
    test/controllers/api_import_controller_test.rb:232:in `block in <class:ApiImportControllerTest>'

一方、routes.rb に書かれている GET や POST については、以下のように Origin ヘッダをつければ、正しく Access-Control-Allow-Origin ヘッダなどが返されました。

  test 'data_types Origin ヘッダがある場合' do
    get '/api/v1/import/file_types', headers: { 'Authorization' => "Bearer #{TOKEN}", 'Origin' => 'https://kancolle-arcade.net'}

    assert_response 200
    assert_equal JSON.generate(
        [
            'Personal_basicInfo',
            'TcBook_info',
            'CharacterList_info',
            'Event_info'
        ]), @response.body

    assert_equal 'https://kancolle-arcade.net', @response.headers['Access-Control-Allow-Origin']
    assert_equal 'GET, POST, OPTIONS', @response.headers['Access-Control-Allow-Methods']
    assert_nil @response.headers['Access-Control-Allow-Headers']
    assert_equal '3600', @response.headers['Access-Control-Max-Age']
    assert_equal 'true', @response.headers['Access-Control-Allow-Credentials']
  end

そのため「OPTIONS についても同様のヘッダが返されるはずだ」と考えて、テストを書くのは諦めました。まあ、すべての API に OPTIONS のテストを書くのは、現実的ではないですしね……。

以上で、ブックマークレット版のエクスポータからも、Admiral Stats の API を叩いて、JSON ファイルをアップロードできるようになりました。

まとめ

今回は、普通の Ruby on Rails 5 アプリに、あとから API 機能を追加するために必要だったことをまとめました。API 機能のために、jwt gem を使ってトークンの発行・検証を行い、rack-cors gem を使って CORS 対応(ブックマークレット対応)を行いました。

実際にやってみて、やり方さえわかれば、割と短時間で実装できるもんだな……と思いました。Admiral Stats のソースコードは GitHub で公開しているので、興味のある方は読んでみてください。

github.com