こんにちは。MERY のサーバーサイドエンジニアの @saidie です。
MERY では、女の子が知りたい最新の情報や欲しくなるファッションアイテムを、記事という形式を通して毎日届けています。 そして、ここ一年弱の間、ユーザが欲しいと思ったアイテムをそのまま手軽に MERY 上で買うことのできる、EC のシステム開発を進めてきました。
この記事では、MERY の EC システムを開発するにあたって、既存の MERY 会員のログインセッションと EC において必要とされる新たなセッションに関する設計の裏側と、セッション情報のやり取りに JWT (JSON Web Token) を使う取り組みについてご紹介したいと思います。
MERY の EC セッション
まず、EC システムを開発するにあたって、必要とされるセッションは以下の二つだと考えました。
- カートセッション
- ショッピングカートへの商品の追加/削除から注文まで。センシティブな情報をやり取りしないことと、途中で長く離脱したユーザが戻ってきた時にもカートにアクセスできるよう、有効期限は比較的長めにしたい。
- ログインセッション
- ユーザに関連した情報 (住所や過去の注文など) のやり取り。UX を損なわない範囲で有効期限はできるだけ短くしたい。
これらに加えて、MERY では記事を作成したり、気に入った画像やアイテムなどを LOVE して見返すことのできる MERY 会員のセッションが存在していました。 EC におけるログインセッションは MERY 会員のセッションを拡張することで代替できなくはなかったのですが、
- 既存の MERY のデータと個人情報満載の EC データはインフラ構成的に厳密に分けておきたい
- マイクロサービスの流れに乗って、EC 関連の情報へは API を通してアクセスしたい
devise にはもう近づきたくない
のような理由で、EC セッションは新たに実装する API が管理する形にしました。
セッションの管理と JWT
さて、セッション管理の方法としてよく使われる方法は、以下のようなものだと思います。
- セッション開始時にランダムな文字列 (トークン) を生成して、ユーザやカートといったリソース(の識別子)と対にしてデータベースに格納する
- トークンを UA (User Agent; ブラウザなど) にセキュアな通信路で送信する
- UA はリソースリクエスト時にトークンを一緒に送る
- トークンをデータベースから引いてきて、リソースの識別子を取得する
このようにトークンを安全なデータベースに格納しておくで、UA から送られてきたトークンの正当性を確かめることができます。 また、仮にトークンが漏洩した場合など、データベースから該当のトークンを削除することで無効化することが出来ます。
一方、トークンにデジタル署名など特殊な細工を施すことで、トークンとなんらかの鍵だけでトークンを検証する方法もあります。 これを実現できるデジタル署名を使った方法が比較的最近 RFC になった JSON Web Signature (JWS; RFC7515) で、さらに暗号化も備えたものが JSON Web Encryption (JWE; RFC7516) です。 JWS はざっくり言うと、任意の JSON と署名を URL セーフな文字列にエンコードしてくっつけたもので、Web アプリ開発者にとって扱いやすいものになっています。JWE はさらに JSON の暗号化も行います。 この JWS/JWE で、二者間でやり取りする識別情報的なものを格納したものが JSON Web Token (JWT; RFC7519) であり、OAuth 2.0 の拡張仕様や OpenID Connect などで活用されています。 JWT では JSON オブジェクトのフィールド名がいくつか予約されています。例えば、
iss: JWT の発行者sub: 識別される主体 (ユーザなど)aud: JWT の宛先exp: JWT の有効期限
などがあります。この JWT をセッショントークンとして用いると、以下のようにセッションに紐付いたリソースを特定できます。
- セッション開始時に
subにリソースの識別子を入れた JWT を生成する (サーバの秘密鍵で署名) - JWT を UA にセキュアな通信路で送信する
- UA はリソースリクエスト時に JWT を一緒に送る
- サーバは公開鍵を使って JWT を検証した後、
subを取り出す- この時、
algの検証も必ず行う (参考: JWS 実装時に作りがちな脆弱性パターン - OAuth.jp, JVN#06120222)
- この時、
また、必要に応じて iss, aud, exp などの検証もすると良いです。署名の検証に必要なのは公開鍵だけなので、生成したサーバ以外でも検証できることがシステムのアーキテクチャ次第では一つの利点となります。
ただし、JWT は基本的に手元にないので、無効にしたい場合は何かしらの対策を講じる必要があります。
有効期限を極端に短くする、JWT に ID を付与して無効化した ID 一覧を保存して比較する、JWT の発行日時が特定日時より古いものは無効とする、などが考えられます。
MERY EC の JWT
話を戻して、MERY では EC のセッションにはこの JWT (JWS) を使うことにしました。理由は主に以下のような感じです。
- トークンをデータベース管理しなくてもよい
- ただし、トークンを失効させるためにまた別の仕組みが必要なのでこれに関しては一長一短という感じ
- トークンの検証のために API に問い合わせる必要がない
- API リクエスト削減できて嬉しさがある
- JSON には付加的な情報も含めることが出来るので、何かと便利
モダンでなんかかっこいい気がする!ブログネタになる
付加的な情報の一例として、カートに入っている商品の個数を JWT に埋め込んでいたりします。
商品個数はサイトヘッダのカートアイコンのバッジを出すために使われており、MERY の大部分のページで表示されます。 都度 API に問い合わせずとも JWT の中身を見るだけで取得できるため、API へのリクエスト数を大幅に削減できています。
具体的に Ruby で JWT を扱う場合は、jwt gem などを使うことで、JWT の生成や検証を簡単にすることができます。 ほぼ README のコピペですが、
require 'jwt' private_key = OpenSSL::PKey::EC.new(File.read('/path/to/private_key')) public_key = OpenSSL::PKey::EC.new(File.read('/path/to/public_key')) payload = { exp: Time.now.to_i + 60 * 60 * 24, foo: 100, bar: { buzz: 'Hello, world!' } } # JWT のエンコード token = JWT.encode(payload, private_key, 'ES512') puts token # eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzUxMiJ9.eyJleHAiOjE0NzIxOTIwNDgsImZvbyI6MTAwLCJiYXIiOnsiYnV6eiI6IkhlbGxvLCB3b3JsZCEifX0.AJkWXCSgLLe39MOZp8DjkbhrXZ3M-6lxpK0Ns7yGupHOF1qXP5dtcA6KQKVxdu8Z4-Aq5EhSSKq6uTtuQkkisDWLAduoa9xx06cGKMbwvPC7R4OEdS8O-D8AgYtKMGg7fsCaSLn76xtYw5W1AdsVMWirlT2WvAaawI5U6O1JroPd-MOw # JWT の検証とデコード payload, header = JWT.decode(token, public_key, true, { algorithm: 'ES512' }) puts payload puts header # {"exp"=>1472192048, "foo"=>100, "bar"=>{"buzz"=>"Hello, world!"}} # {"typ"=>"JWT", "alg"=>"ES512"}
のように使うことができます。
まとめ
いろいろとぼかしたり端折ったりした部分もありますが、MERY EC におけるセッション管理は概ねこのような感じで行っています。 とりあえずこれまでのところ、JWT を使うことによる辛さはほとんど感じたことはありません。
データにデジタル署名をしてやり取りするというのは使い古された手法ではありますが、Web で扱いやすい形になっている JWT (というか JWS/JWE) には様々な応用可能性があるんじゃないかな、と思っています。
ペロリでは JWT を使い倒して新たな地平を切り開きたい方、MERY のセッション管理いけてないから作り直してやるぜという方、とにかく何でもいいから女の子にかわいいを届けていきたいという方のご応募をお待ちしています。 www.wantedly.com