RailsとDeviseのHTTP/HTTPSミックスサイト季節のCORS和えHeroku盛り

こんにちは、普段はフリーランスのプログラマをやってます。
最近「Niteta?」というプロフィール管理サービスをリリースしました。
http://niteta.com/
プロフィール: http://niteta.com/profile/nznak99

自分が好きなものについて語る掲示板的な機能が有ったり、ちょっと他のサービスと毛色が違う感じを目指してます。

さて本題です。
Ruby on RailsでHTTP/HTTPS混在のサイトを作ろうとしたら以外と大変だったので、ここにレシピを残しておきます。

####献立：
1. 下準備（ドメインの仕入れ）
2. おいしい無料SSL証明書
3. Deviseのクッキーサンド
4. CORSクッキー

###1. 下準備（ドメインの仕入れ）

まず、HTTPS/HTTP混在サイトで使うドメインを取得します。
私の場合は深く考えずお名前.comで取得しましたが、Herokuでルートドメインで運用する場合は、あまりいい選択肢では有りません。
昔はHeroku側がDNSのAレコードに登録するためのIPを公開していたのですが、今は非公開となっているため、Aレコードにしかルートドメインが設定できないお名前.comではルートドメイン運用できなくなってしまいました。
ルートドメイン運用したい場合は、ANAMEやApex Nameと呼ばれるレコードを登録できるDNSを使った方が良いです。
私はもうドメイン取得後だったので、別途Gehirn DNSのサービスに登録し、Gehirn側でDNSレコードを設定して、お名前.comのNSにはGehirnのネームサーバを設定するようにしました。
Gehirn DNSではApex AliasというANAME相当のレコードが使えるので、こっちでルートドメインの設定が出来ます。

参考：
http://qiita.com/Oakbow/items/8f76ca074f51fc3bd89c
http://qiita.com/Tkashiro/items/8249455477bbb5333118

###2. おいしい無料SSL証明書

さて、サイトをHTTPS対応するにはSSL証明書が必要です。
さっくり調べてみたところ、海外のサービスですがStartSSLというサービスで無料のSSL証明書が手に入るらしいので、そちらで取得することにしました。

参考：
http://qiita.com/k-shogo/items/870b6d3939dd08da2de4

基本的に上記に記事の通りで上手く行ったのですが、ドメイン認証の際のwebmaster@ドメインのメールアドレスでの受信が上手く出来ず。（MXレコードをGehirn側で設定するのかお名前.com側で設定するのかよく分からず両方設定したせいなのか、単に設定反映が遅いのか・・・）
どうやら、お名前.comでドメイン取得時のメールアドレスでも認証が通るようなので、とりあえずそっちで認証を通すことにしました。
（認証用メールアドレス一覧にお名前.com自体のメールアドレスも出ててたのは謎）

で、無事SSL証明書が手に入ったら、Herokuに登録します。

参考（「HerokuのSSL導入」以降）：
http://qiita.com/GenTamura84/items/7a12ca611705017bcb0e

で上記の記事の通りにやるとSSL add onが追加され、月額20ドルの課金が発生します。
私はここでコマンド一発で課金が発生するHerokuの恐ろしさを目の当たりにしました・・・

まあ、必要なものなのでしょうがないですね。
（独自ドメインじゃなくて、*.herokuapp.comドメインなら始めから証明書が入っているので課金無しでいけそうです、始めはそっちの方が良いのかも）

###3. Deviseのクッキーサンド

で、ここまでやると、HTTPSオンリーのサイトであれば、Railsのconfigを一行追加するだけです。

```config/environments/production.rb
   config.force_ssl = true
```

なんですが、今回はHTTP/HTTPSを混在して使うので、いろいろ面倒な工夫が必要になります。
まず、考慮したことは、HTTPとHTTPSそれぞれで独自のクッキーを使うようにすることです。
なぜかというと、HTTP/HTTPS混在のサイトで同じクッキーを使い回してしまうと、HTTP通信時に中間者攻撃等で漏れてしまったクッキー情報を使って、HTTPSで提供している機能にアクセスできてしまうからです。

参考：
http://railscasts.com/episodes/356-dangers-of-session-hijacking?language=ja&view=asciicast

なので、上記のサイトで記載されているように、HTTPS接続用にSecure Cookieを作る必要が有ります。
認証モジュールとしてDeviseを使う場合はこんな感じになります：

```config/routes.rb
  devise_for :users, :controllers => {
    :sessions => 'users/sessions' # Deviseのコントローラをオーバライド
  }
```

```app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
  def create
    super do |resource|
      # Secure Keyは少なくともユーザごとに異なる値で、かつ推測不可能なものにする。
      cookies.signed[:secure_key] = {secure: true, httponly: true, value: "[Secure Key]"}
    end
  end

  def destroy
    super do |resource|
      # HTTP用のクッキーと同時にSecure Keyを消す
      cookies.delete :secure_key
    end
  end
end
```

Secure KeyのチェックメソッドをApplicationController等に定義しておきます。

```app/controllers/application_controller.rb
class ApplicationController < ActionController::Base

  rescue_from App::InvalidSessionException, with: :handle_invalid_session

  def check_secure_key
    if request.ssl? && current_user
      unless cookies.signed[:secure_key] == "[Secure Key]"
        raise App::InvalidSessionException.new
      end
    end
  end

  def handle_invalid_session(ex)
    # Secure Keyが無かったらログアウト
    sign_out :user 
    flash[:notice] = I18n.t('devise.failure.unauthorized')
    redirect_to new_user_session_path
  end

  ...
end
```

あとはHTTPSのみでアクセスさせたいコントローラにforce_sslと一緒にbefore_filter :check_secure_key を指定しておきます。

```app/controllers/private_controller.rb
class PrivateController < ApplicationController
  force_ssl
  before_filter :check_secure_key

  ...
end
```

ついでに、Deviseのコントローラは全てHTTPS接続にしておきたいので、configで指定しておきます。

```config/environments/production.rb
  config.to_prepare { Devise::SessionsController.force_ssl }
  config.to_prepare { Devise::RegistrationsController.force_ssl }
  config.to_prepare { Devise::PasswordsController.force_ssl }
  config.to_prepare { Devise::ConfirmationsController.force_ssl }
  config.to_prepare { Devise::OmniauthCallbacksController.force_ssl }
  config.to_prepare { Devise::UnlocksController.force_ssl }
```

これで、中間者攻撃に対してセキュアになりました。

最後にサイトのページ内のURLは必ずプロトコルから指定するようにします。

```hoge.html
<a href="/hoge">hoge</a>
# 下のようにプロトコルを付ける
<a href="http://yourdomain.com/hoge">hoge</a>
```

面倒なので、がんばって共通化した方が良さげです。
私はurl_forを独自処理に書き換えて、controllerとactionでマッチングして、httpかhttpsのプロトコルをURLに付与するようにしました

```config/initializer/https_conf.rb
if Rails.env.production?
  module ActionView::Helpers::UrlHelper
    def is_https?(options)
      # TODO: httpsのURLか判定
    end

    def url_for_with_https(options = {})
      options[:protocol] = is_https?(options) ? 'https://' : 'http://' 
      options[:only_path] = false
      url_for_without_https(options)
    end
    alias_method_chain :url_for, :https
  end
end

```

※ 2014/10/03 追記: Rails4 では上記の方法で url_for の置き換えが出来ないようです。

###4. CORSクッキー

ところがまだ終わりでは有りません。
メインディッシュが控えています。
HTTPとHTTPSは使うポート(80, 443)が異なるためSame Originとして扱われません。
そのため、HTTPからHTTPS、またはHTTPSからHTTPのリソースにアクセスするにはサイトにCORS(Cross-Origin Resource Sharing)の対応を入れる必要が有ります。
(ページごとにHTTPかHTTPSか完全に分かれているのであればCORS対応は必要有りませんが、HTTPページから一部の情報だけAjaxでHTTPSのリソースから引っ張ってくる場合にはCORSの設定が必要になります）

RailsでCORSの設定をするには、ブラウザからOPTIONSメソッドのリクエストが着たときに、レスポンスに下記のようなヘッダーを付けて返してあげると良いです：

```
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods:GET, POST, DELETE, PUT, OPTIONS
Access-Control-Allow-Origin: yourdomain.com
Access-Control-Max-Age: 86400
```

ApplicationControllerかなんかで、上記のヘッダーを付ける処理をしてもいいのですが、rack-corsモジュールを使えばもう少し楽になります。

```Gemfile
  gem 'rack-cors', :require => 'rack/cors'
```

```config/application.rb
      config.middleware.insert_before Warden::Manager, Rack::Cors do
        allow do
          origins 'yourdomain.com'
          resource '*',
            :headers => :any,
            :methods => [:get, :post, :delete, :put, :options],
            :credentials => true,
            :max_age => 86400
        end
      end
```

で、ブラウザ側でもCORSに対応したリクエストを投げてもらう必要が有ります。
これはJQuery.ajax呼び出し時にcrossDomain: trueオプションとxhrFields: withCreadential: trueオプションを指定します。
また、get以外のpostやput等でリクエストする際はcsrf tokenを送る必要が有るので、下記のような感じになります。

```js
  $.ajax('https://yourdomain/private.json'), {
    data: {...},
    type: 'post',
    crossDomain: true,
    cache: false,       // mobile SaffariにxhrのPOSTリクエストをキャッシュする問題が有るらしいので念のため
    xhrFields: {
      withCredentials: true
    },
    headers: {
      'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
    },
    dataType: 'json',
    contentType: "application/json",
    success: function(xhr, status) {...}
  });

```

で、ここまでやれば、一通りHTTP/HTTPSが混在した状態でも問題なく動くようになっているはずです。

注意点としては、Chrome, Firefox, Saffari, IE10はCORSに対応しているのですが、IE8、9はMS独自仕様での対応、IE7以前とOperaはCORSに未対応であることです。
IE8, 9 については下記のxdr.jsをjqueryの後にロードすることで、比較的簡単に対応可能なようです。

https://github.com/tlianza/ajaxHooks/blob/master/src/ajax/xdr.js

IE7以前、Operaの場合はJSONPを使うなり、HTTP/HTTPS混在を諦めるなりする必要が有ります。

今回はHTTP/HTTPS混在のサイトの場合の方法でしたが、サブドメイン間でリソースをやり取りする場合も基本的には同じやり方でOKだと思います。

## 最後に

めんどうなので、HTTP/HTTPS混在サイトはあまりお勧めしません。
SPDY等の技術でHTTPS通信が高速化されることを考えると、始めからフルHTTPSサイトっていうのもありかも知れませんね。