【今日のひとこと】GoogleとBigCommerceが連携しましたねー!

ストアにログイン中のカスタマーからのリクエストを Shopify App でセキュアに判別する方法

Shopify App のストアフロントの実装をするとき、ストアにログイン中のカスタマーからのリクエストかどうか を Shopify App で判別したいシーンがあると思います。(たとえばロイヤリティプログラムやウィッシュリストなどのログイン中のカスタマーに紐付くような機能を持つアプリケーションなど)

ソーシャルPLUS の Shopify App では、ログインしているカスタマーからのリクエストを判別し、ソーシャルログインプロバイダの ID 連携機能を実現しています。

※ ストアフロントでカスタマーに紐づくソーシャルログインプロバイダの ID 連携状態を取得して表示させている例

 

Shopify App のストアフロントの実装にあたり、ストアにログイン中のカスタマーからのリクエストかどうかを Shopify App が判別する機能 は Shopify から公式に提供されていません。
よって、Shopify App が独自にその機能を実装することになります。

カスタマーがストアにログインしている情報を Shopify App 側で持つためには、ストアへのログイン時に Shopify App にもなんらかの方法で知らせてあげる必要があります。

考えつく方法としては、ストアへのログイン・ログアウトのリンクをフックして Shopify App にログインセッション(Cookie やトークンなど)を持たせたり外したり、といった実装です。

しかし、同じような実装をするアプリがあれば処理が競合してしまい期待した動作をしなかったり、ということが発生すると思います。

また、ストアではログイン状態になっていないが、Shopify App ではログイン状態になっている、といったズレを防ぐのも大変そうです。

※ Shopify App でログインセッションを管理する方法の例

Shopify App でログインセッションを管理せずに、ストアにログイン中のカスタマーからのリクエストかどうかを判別する にはどうすればいいでしょうか。

以下のステップで方法を解説していきます。

  • App proxy を使ってカスタマーを特定する
  • 共通鍵方式でセキュアにする
  • 共通鍵をオンラインストアのコードに置かない

App proxy を使ってカスタマーを特定する

まずは Shopify App でログインセッションを管理しないようにします。

Shopify App でログインセッションを管理しない場合はストアフロントから直接 Shopify App のサーバーにアクセスさせる必要がないため、Shopify の App proxy という機能を使います。

App proxy はストアフロントで使用できる機能で、以下のメリットがあります。

  1. ストアのドメインで Shopify App にアクセスできる
  2. Shopify からのリクエストであると保証される
  3. どのストアからのアクセスかを特定できる

特に 2 3 が重要で大きなメリットです。

メリットがある App proxy ですが、使う上での制限があります。
それが、Cookie を受け取ったり返したりすることができないということです。

Handling proxy requests

Cookies are not supported for the app proxy, since the app is accessed through the shop’s domain. Shopify strips the Cookie header from the request and Set-Cookie from the response.

Shopify 側ではカスタマーがストアにログイン中かどうかの情報を持っていますが、App proxy から Shopify App にそれらの情報が渡されません。

App proxy 経由のリクエストでどのストアからのアクセスかまでは絞り込めているので、カスタマーの情報を操作するためには、カスタマーの ID やメールアドレスなどのカスタマーを特定するための情報を追加で受け取る必要があります。

具体的には以下のフローになります。

  1. オンラインテーマの Liquid コードでログインしているカスタマーの ID をストアフロントで取得できるようにする
  2. ストアフロントの JavaScript から Shopify の App proxy 経由で Shopify App にカスタマーID を含めてリクエスト
  3. Shopify App で受け取ったカスタマーID を元にカスタマーを特定して操作する

テーマの Liquid に以下のように追記することで、ストアフロントで表示したときにカスタマーがログインしているときはカスタマーの ID を取得することができます。

<input type="text" id="customerId" name="customerId" value="{{ customer.id }}">

// 取得 document.getElementById('customerId').value;

しかし、カスタマー ID などを単純に受け取るような実装では、もしカスタマーID が外部に漏れてしまい悪意のあるリクエストをされてしまうと Shopify App ではカスタマー本人かどうかの判別ができず、意図せずデータを返したり変更したり、といった脆弱性がある実装となってしまいます。

このままでは問題なので、次で App proxy からのリクエストでカスタマーを特定する方法をセキュアにします。

共通鍵方式でセキュアにする

上記の問題を避けるために以下の記事を参考にしました。

Securing customer pages with a Shopify app proxy 

概要は以下のとおりです。

  1. Liquid 内で、なりすまし防止のために共通鍵でカスタマー情報をハッシュ化(シグネチャ)
  2. ストアフロントの JavaScript からカスタマー情報とシグネチャを App proxy 経由で Shopify App にリクエスト
  3. Shopify App でカスタマー情報とシグネチャを共通鍵で検証し、なりすましでないことを確認する

ポイントは ① の Liquid 内でなりすまし防止のためのカスタマー情報をハッシュ化する処理です。
Shopify の Liquid は、Liquid 内で使用できる便利な関数 がたくさんあり、それらの機能を使います。

具体的な実装は以下のとおりです。

ストアフロントに表示させるオンラインコードの Liquid に以下のコードを挿入します。

{% assign issued_at = 'now' | date: "%s" %}
<div id="customer_id">
  {{ customer.id }}
</div>
<div id="issued_at">
  {{ issued_at }}
</div>
<div id="signature">
  {{ customer.id | append: issued_at | hmac_sha256: "hmac_sha256 ハッシュ化用共通鍵" }}
</div>

※ コード内の dateappendhmac_sha256Liquid 内で使用できる関数 です。

ストアフロントで customer_idissued_atsignature を取得できるので、JavaScript から App proxy 経由で取得した情報を含めたリクエストを Shopify App に送ります。

※ Shopify App へのリクエスト URL 例

https://example.myshopify.com/apps/proxy/customers/points?customer_id=xxxxx&issued_at=xxxxx&signature=xxxxxxx

Shopify App 側では以下のようにリクエストを検証します。( Ruby での実装例 )

require 'openssl'

def verify(customer_id, issued_at, signature)
  secret_key = 'hmac_sha256 ハッシュ化用共通鍵'
  # issued_at の発行日時が古い場合はリクエストを拒否する、なども可能
  payload = customer_id + issued_at
  hash = OpenSSL::HMAC.hexdigest('sha256', secret_key, payload)

  hash == signature
end

これで App proxy からのリクエストでカスタマーを特定する方法をセキュアにすることができました。

しかし 共通鍵 をストアのテーマ上に置くと、テーマを読み書きするスコープを持つ他のアプリに鍵を盗まれてしまったり、書き換えられたり、といったリスクが気になります。

ストアを自社だけで運用し、Shopify App はプライベートやカスタムのものしかインストールしないクローズドな環境ということであればこういったリスクは気にしなくてもよいと思いますが、もしそういった環境でなければ次の方法で実装することをおすすめします。

共通鍵をオンラインストアのコードに置かない

App proxy にはレスポンスヘッダに application/liquid を指定すると Liquid コードでレスポンスを返せる、という機能があります。

App proxies support Liquid, Shopify’s template language. An app proxy response that contains Liquid will be rendered with store data into HTML like it was part of the store’s theme.

If the HTTP response from the proxy URL has Content-Type: application/liquid set in its headers, then Shopify renders any Liquid code in the request body in the context of the shop using the shop’s theme. Otherwise, the response is returned directly to the client. Also, any 30x redirects are followed.

この機能を使ってさきほどの方法を応用した設計にします。

  1. ストアフロントの JavaScript からカスタマーID を含めてリクエスト
  2. Shopify App が 受け取った カスタマーID で JWT を生成 してレスポンス
  3. App proxy の Liquid 内で、① で受け取った カスタマーID と Liquid 内で取得できる カスタマー ID(カスタマーがログインしているときだけ取得できる) が同じである場合だけ JWT を返す

 

突然 JWT という言葉が出てきましたが、なりすましを防止するための仕組みとしてよく使われるトークンの仕様で、さきほど出てきたシグネチャと同じようなイメージと思っていただければよいかと思います。
ここでは詳細を省きますので詳しくは以下をご覧になってください。

 

この設計のキモは ③ の処理で、① で受け取った カスタマーID と Liquid 内で取得できる カスタマー ID が一致するかどうかをチェックする 処理です。
② の Shopify App がリクエストを受け付けた時点でカスタマーがログインしているかどうかの判別ができないため、③ の App proxy の Liquid 内で Shopify にログインしているかどうかを判別します。
App proxy で Liquid コードで返せる機能があり、Liquid で分岐処理を書ける機能があるおかげで実現できています。
② の時点で先に JWT を作っておく、というのもポイントですね。

② の Shopify App 側で JWT を生成して ③ の Liquid コードを返す処理は以下のように実装します。( Ruby での実装例 )

require 'jwt'

get '/customer_token' do
  # App proxy リクエストの検証処理は省略

  jwt = encode_jwt(params['customer_id'], params['shop'])

  response.headers['Content-Type'] = 'application/liquid'
  <<~EOS
    {% layout none %}
    {% if customer.id == nil or customer.id != #{params['customer_id']} %}
      {"status":401,"message":"unauthorized","data":null}
    {% else %}
      {"status":200,"message":"success","data":{"token":"#{jwt}"}}
    {% endif %}
  EOS
end

def encode_jwt(customer_id, shop)
  current_time = Time.now.to_i

  payload = {
    sub: customer_id,
    iat: current_time,
    exp: current_time + {任意の時間},
    iss: shop,
    typ: 'customer_token'
  }

  JWT.encode payload, hmac_secret_key, 'HS512', { type: 'JWT' }
end

def hmac_secret_key
  'HMAC-HS512 秘密鍵'
end

{% layout none %} を指定しているのは Liquid で余計な HTML タグの情報を返さないようにするためです。

Content-Type に application/liquid を指定すると、クライアント側(JavaScript 側)で受け取る際の Content-Type は html/text となるため、パースする処理には気をつける必要があります。

また、HTTP ステータスコードはかならず同じコードが返るので、クライアント側ではレスポンスボディの status で成功・不成功を判定します。

以下の JavaScript コードをログイン中のストアより実行してレスポンスを確認してみると、カスタマーがログイン中のときは JWT が返り、ログイン中でないときは JWT を返しません

let customerId = document.getElementById('customerId').value;
let url = '/apps/customer_token?customer_id=' + customerId;
let result = await fetch(
  url,
  {
    headers: {
      accept: "application/json, text/plain, */*"
    },
    credentials: 'include'
  }
);
let json = await result.json();

※ トークンを返すレスポンス例( token キーの eyJ0e... という文字列が JWT )

JWT を取得することができたら、あとはこの JWT を使ってリクエストし、サーバ側の検証が通ればカスタマーの情報を返したり更新したりできます。

参考までに ② の検証処理のコード例も記載しておきます。( Ruby での実装例 )

require 'jwt'

get '/proxy/customer_info' do
  # App proxy リクエストの検証処理は省略
  response.headers['Content-Type'] = 'application/json'
  begin
    customer_token = decode_jwt(params['customer_token'])
    customer_id = customer_token['sub']
    <<~JSON
      {"message":"success","data":{"customer_id":"#{customer_id}"}}
    JSON
  rescue StandardError => e
    puts e
    response.status = 404
    <<~JSON
      {"message":"fail","data":{"customer_id":"#{e.message}"}}
    JSON
  end
end

def decode_jwt(token)
  JWT.decode(token, hmac_secret_key, true, { algorithm: 'HS512' })&.first
end

def hmac_secret_key
  'HMAC-HS512 秘密鍵'
end

これで 共通鍵をオンラインストアのコードに置くことなく、ストアにログイン中のカスタマーからのリクエストかどうか を Shopify App で判別してカスタマーの情報を処理する ことができるようになりました。

まとめ

ストアにログイン中のカスタマーからのリクエストかどうか を Shopify App で判別する 方法 が Shopify の公式で用意されていないので、Shopify というプラットフォームの上でセキュアな設計にするのは大変でしたが、知恵をしぼってよい設計にできたのではないかと思います。

今回はこういった方法で実現しましたが、将来的に Shopify の App proxy => Shopify App の時に ログイン中のカスタマー情報が渡されるようになり判別ができるようになる かもしれません。


もしそうなると今回の実装は不要となりますが、本来はそういった機能を Shopify 側で用意していただいたほうが開発者にとって実装の手間が減って大変助かるので、今後に期待したいと思います。

以上、ご参考になれば幸いです。