カラーミーショップ サービス基盤チームのkymmtです。この記事では、サーバサイドレンダリングするシングルページアプリケーションとAPIサーバからなるWebアプリケーションのセッション管理方法について紹介します。
アプリケーションの構成
構成の概要
今回は例としてEC事業部で提供するカラーミーリピートをとりあげます。構成としては、Railsで作られたAPIサーバ1と、Vue.jsで作られたシングルページアプリケーション(SPA)からなります。また、SPAはExpressが動くフロントエンドサーバでサーバサイドレンダリング(SSR)します。APIサーバはSPAかフロントエンドサーバだけが呼び出します。各ロールはサブドメインが異なります。
APIサーバでセッションIDを持つCookieを発行し、Redisを用いてセッション管理します。また、APIサーバへのセッションが有効なリクエストはフロントエンドサーバとブラウザの両方から送信します。
以降、APIサーバのことをAPIと呼びます。
この構成にする理由
RESTishなAPIでは、サーバでセッションを管理せず、クライアントが渡してくるクレデンシャルを使って都度認証するというステートレスな方式が用いられることがあります。この方式は、APIとそれを利用するクライアントが別の開発者によって作られている場合や、microservicesでさまざまなサービスが同一クライアントを認証したい場合によく使われます。ステートレスなAPIはサーバサイドでトークン管理する必要がなく、スケールさせやすいという利点もありますが、クライアントが認証サーバから渡されるJWTなどのクレデンシャルをどう持てば安全なのかについて、よく議論になります2。
一方、特定のSPAのバックエンドとしての役割に徹しているAPIであれば、開発主体は同一であることがほとんどでしょう。このとき、SPAとAPIが一体となって単一のWebアプリケーションを構成するので、APIだけステートレスである必要はなく、ステートフルなAPIとしてセッションCookieを発行し、インメモリデータベースなどでセッション管理するほうが、Cookieのしくみに乗ることでセキュアな実装にしやすくなります。詳しい利点は次のとおりです。
- HttpOnlyなCookieでセッションを扱えるので、スクリプトを通じた窃取に強い
- サーバサイドで利用者ごとにセッションデータを保持するので、有事のときに特定利用者のセッションを簡単に無効化できる
- ウェブアプリケーションフレームワークでセッション機構がサポートされている場合、その機能を使うことでコードの見通しがよくなる
実現方法
ステートフルなAPIを利用したセッション管理を実現するための手順は次のとおりです。
- APIでセッション情報を保存するためのインメモリデータベースを準備する
- 特定の複数ロールの間で使えるCookieを発行する
- SSR時にブラウザから受信したCookieをAPIに転送する
- セッション情報をもとに認証する
この手順について詳しく説明します。なお、これ以降の説明では、APIでRailsを使っているとします。
1. APIでセッション情報を保存するためのインメモリデータベースを準備する
この構成では、セッション情報をなんらかのインメモリデータベースに保存します。今回はRedisを使います。
RailsにはRedisをキャッシュストアとして使うことができる機能が入っています。つまり、Rails.cache
を使うときにRedisにデータが保存されるということです。
# config/environments/production.rb
Rails.application.configure do
# ...
config.cache_store = :redis_cache_store, { url: url, # RedisスキームのURL
# ...
}
# ...
end
さらに、Railsはセッションストアとしてキャッシュストアを設定できるので、Redisをセッションストアとして使えます。
# config/application.rbのRails::Applicationを継承したクラス内に書く
config.session_store :cache_store,
key: key, # Cookieのキー名
expire_after: expire_after, # 有効期限
# その他のオプション…
2. 特定の複数ロールの間で使えるCookieを発行する
例えばカラーミーリピートだと、colorme-repeat.jp配下の複数のサブドメインにアプリケーションをホストしています。具体的にはSPAとAPIは異なるサブドメインとなっています。利用者のセッションを管理するためには、これらのロールのあいだでCookieをやりとりしなければなりません。しかし、デフォルトのCookieは設定されたオリジンと同一でないと送信されません。発行するCookieのDomain属性に親ドメインを指定することで、その親ドメイン配下であれば常にCookieを送信できるようになります。
# config/application.rbのRails::Applicationを継承したクラス内に書く
config.session_store :cache_store,
key: key, # Cookieのキー名
expire_after: expire_after, # 有効期限
domain: 'colorme-repeat.jp', # 親ドメイン
# そのほかのオプション…
また、サブドメインが異なるロールのあいだでXHRを用いて通信する場合、CORSを使う必要もあります。デフォルトではCORSでCookieを送信することができませんが、サーバがレスポンスヘッダでAccess-Control-Allow-Credentials
をtrueとして返し、クライアントがXmlHttpRequest.withCredentials
をtrueに設定することで、XHRでもCookieが送信できるようになります。
Railsなら、サーバサイドはRack::Corsを使うことでAccess-Control-Allow-Credentials
を設定できます。
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
# ...
resource '*',
credentials: true,
# ...
end
end
クライアントは、Axiosであれば以下のようにしてXmlHttpRequest.withCredentials
を有効にできます。
const client = axios.create({
withCredentials: true,
// ...
})
Cookieを発行できるようになったので、サーバサイドでログインしたとみなすときにセッションへ利用者情報を格納します。また、ログアウト時はセッションから利用者情報を削除します。
# 例
def login
reset_session
session[:current_user_id] = @user.id
end
def logout
reset_session
end
3. SSR時にブラウザから受信したCookieをAPIに転送する
外部サイトからの遷移時やリロード時はSSRによってページをレンダリングします。このとき、SSRを実行するフロントエンドサーバからAPIにリクエストを発行するので、フロントエンドサーバがセッションCookieの内容を知らないとAPIからリソースを取得できません。
API呼び出しのリクエストヘッダにCookieとしてセッションIDを付与すれば、フロントエンドサーバからAPIを呼び出すことができます。これを実現するために、ブラウザが送信するCookieをフロントエンドサーバで動いているExpressで受信して、API呼び出し時にそのまま転送します。
詳細なコードは割愛しますが、具体的には次のような流れでCookieを転送し、必要なAPIを呼び出しています。
- ExpressへのリクエストのCookieに含まれるセッションIDをVuexのストアに保存する
- Vueコンポーネントが必要とするデータを取得するためにAPIを呼び出すとき、先ほど保存したセッションIDをCookieヘッダとしてリクエストに付与する
- レンダリングしたHTMLが持つストアの初期状態にセッションIDが漏洩するのを防ぐために、レンダリング前にVuexのストアからセッションIDを削除する
4. セッション情報をもとに認証する
ここまで来れば、通常どおりセッションに保存した利用者情報をもとに利用者を認証することができます。
# 例
def current_user
@current_user ||= User.active.find_by(id: session[:current_user_id])
end
さらに実際は、Cookieの属性(Secure、HttpOnly、SameSiteなど)を適切に設定したり、CSRF対策をすることも重要です。サーバサイドでセッションの有効/無効を管理できるようにする必要もあります。
まとめ
この記事では、サーバサイドレンダリングするシングルページアプリケーションとAPIサーバからなる単一のWebアプリケーションのセッション管理方法について紹介しました。同じような構成のアプリケーションを開発しようとしている方のお役に立てれば幸いです。
-
application/jsonなリクエストだけを受け付け、JSONだけを返します ↩