🔏

【Rails】PAY.JP の3Dセキュア認証に対応する実装方法をコードで解説

2025/01/14に公開

ラブグラフでCTOをしております横江( @yokoe24 )です!
あけましておめでとうございます!!🌅

1. EMV 3-D Secure が必須に!

2023年3月に経済産業省が発表した「クレジットカード・セキュリティガイドライン【4.0版】が改訂されました」に

原則、全てのEC加盟店は、2025年3月末までにEMV3-Dセキュアの導入を求める

と記載のある通り、
3Dセキュア認証が2025年3月末までに実質的に義務化される方針となりました。

ちなみにこの 「EMV3-Dセキュア」EMV とはなにかというと、
クレジットカード大手の Europay, Mastercard, Visa の3社が立ち上げた業界標準のようです。(Europay は2002年に Mastercard と合併)
Wikipedia に詳しく書かれていました: https://en.wikipedia.org/wiki/EMV

この業界標準を管理する EMVCo, LLC. のサイトに、
EMV® 3-D Secure に関するホワイトペーパーなど、詳しい技術情報が置いてあります。
https://www.emvco.com/emv-technologies/3-d-secure/

さて、この政府方針に則った PAY.JP の対応は、
2024/11 にライブラリが更新 され、
2025/02 にエンドポイントが正式対応する という、
なかなか期限に対してギリギリながらも出揃ってまいりました。

この記事では、Rails アプリケーションで PAY.JP を使っていて、
これから3Dセキュア認証の対応をしないといけない人向けに、実装方法をまとめていきます。

2. 参考になる情報

実装にあたっては、特に以下の情報が役に立ちました!
読者の方も実装にあたって迷うところがありましたら、ご参考になさってください。

3. 実装にあたっての選択肢

実装にあたっては、以下の点を考慮する必要があります。

3-1. 「サブウィンドウ型」か「リダイレクト型」か

PAY.JPにおける3Dセキュア』でも示されている通り、
「サブウィンドウ型」か「リダイレクト型」かの2通りがあります。

その中でも書かれていますが、「サブウィンドウ型」は ポップアップブロックの影響を受ける可能性が高い ので、
通常は「リダイレクト型」がいいのではないかと考えます。以下に示す実装も、「リダイレクト型」での実装をおこなっています。

3-2. 「アテンプト」を許容するかどうか

これらのドキュメントに書かれていますが、
3Dセキュアに非対応のカード で3Dセキュア認証に進んだ場合、
PAY.JPのAPIレスポンスに "three_d_secure_status": "attempted" が含まれます。

この場合のカードを認めるかどうかは、実装にあたって、ビジネスサイドと考えておく必要があります。

アテンプトのカードを認める場合は、上記のドキュメントに

カード保有者への損害補填はカード会社が行う。
ただし、不正決済自体は防止できていないため、あまりに多く発生する場合には加盟店に損害の補填を求められたり、追加の不正対策を要求される場合がある。

と書かれているように、もしかすると不正利用について事業者が補償を払わないといけない可能性があります。
「3Dセキュア非対応のカードは、もう世の中にそんなにないだろう……」と考えて一律で弾くのも手ですが、一部のユーザーを失ってしまう恐れもあります。どちらを選んでも一定のリスクがありますね。

4. いざ実装!

では、ここまでの前置きを理解した上で実装していきましょう!

以降、環境変数の名前として PAYJP_PUBLIC_KEY, PAYJP_SECRET_KEY を使ったり、
PaymentsController というコントローラー名が現れたりしますが、
ご自身の環境に応じて置き換えて考えてください。

4-1. フロントエンドの更新

現在は erb ファイルに以下のようにスクリプトが書かれていると思います。

<script
  type="text/javascript"
  src="https://checkout.pay.jp"
  class="payjp-button"
  data-key="<%= ENV.fetch("PAYJP_PUBLIC_KEY") %>"
  data-submit-text="決済する"
  data-text="カード情報を入力する"
  data-token-name="payjp_token"
></script>

これを、3行足してこのように変更します!

<script
  type="text/javascript"
  src="https://checkout.pay.jp"
  class="payjp-button"
  data-key="<%= ENV.fetch("PAYJP_PUBLIC_KEY") %>"
  data-submit-text="決済する"
  data-text="カード情報を入力する"
  data-token-name="payjp_token"
  data-payjp-three-d-secure="true"
  data-payjp-three-d-secure-workflow="redirect"
  data-payjp-extra-attribute-email="<%= current_user.email %>"
></script>

注意: 2025/01/31 までは、 src="https://checkout.pay.jp"src="https://checkout.pay.jp/prerelease" に直す必要があります!
ただし、2025/05以降は /prerelease のURLが無効になりますのでお気をつけください!
(プレリリース機能について: https://pay.jp/info/2024/10/29/140000

data-payjp-three-d-secure="true"
data-payjp-three-d-secure-workflow="redirect"
data-payjp-extra-attribute-email="<%= current_user.email %>"

この3行について、特に語りたいのが
data-payjp-extra-attribute-email="<%= current_user.email %>" の部分です。

3Dセキュア認証にあたっては、ユーザーにEメールか電話番号の情報を入力してもらう必要があります。
どちらを入力してもらうかユーザーに選んでもらう場合には

  • data-payjp-extra-attribute-email
  • data-payjp-extra-attribute-phone

の両方を記載することもできます。

しかし、これらの属性にはデフォルト値を設定できること、
そしてサービス側でメールアドレスはすでに得ている場合が多いことを考えると、
ユーザーに選んでもらうのではなく、Eメールの入力欄だけにして、こちらで値を設定してあげるやり方にするほうが、
ユーザー側の負担が今までと変わらないために良いのではないかと思います。

4-2. サーバーサイドの更新

フロントエンドの変更は意外とあっさりでしたね!
続いて、サーバーサイドです。

今までは Controller 側で以下のようにして、都度課金だったり定期課金だったりをスタートさせていたかと思います。

class PaymentsController < ApplicationController
  # 決済画面
  def new
  end

  # 決済の実行
  def create
    payjp_token_id = params[:payjp_token]
    customer = Payjp::Customer.create(card: payjp_token_id)
  
    # customer に対しての決済処理...
  rescue Payjp::CardError => e
    # error code に応じたなんらかのエラー処理 https://pay.jp/docs/api/#error
    redirect_to new_payments_path, alert: "なんらかのエラーメッセージ"
  rescue Payjp::PayjpError => e
    # なんらかのエラー処理
    redirect_to new_payments_path, alert: "なんらかのエラーメッセージ"
  end
end

これを変更し、最終的には以下のようにしました。

class PaymentsController < ApplicationController
  # 決済画面
  def new
  end

  # 決済の実行開始
  def create
    payjp_token_id = params[:payjp_token]

    # セッションにトークンを一時保存
    session[:payjp_tds_token] = payjp_token_id

    # 戻り先URL (back_url) を設定
    back_url = callback_payments_url
    # JWS(JSON Web Signatures)形式のデータに変換
    jws_back_url = JWT.encode({ url: back_url }, ENV.fetch("PAYJP_SECRET_KEY"), "HS256")

    # リファレンス: https://pay.jp/docs/api/#3-dsecure
    endpoint = "https://api.pay.jp/v1/tds/#{payjp_token_id}/start"
    params = {
      publickey: ENV.fetch("PAYJP_PUBLIC_KEY"),
      back_url: jws_back_url,
    }

    # 3Dセキュア認証画面にリダイレクト
    redirect_to "#{endpoint}?#{params.to_query}"
  end

  # 3Dセキュア認証後、ここに飛んでくる
  def callback
    # このURLに直接アクセスするなど、なんらかの理由でセッションがない場合の処理
    return redirect_to new_payments_path, alert: "恐れ入りますが、もう一度やり直してください" if session[:payjp_tds_token].nil?

    # セッションからデータを取り出し削除
    payjp_token_id = session[:payjp_tds_token]
    session.delete(:payjp_tds_token)

    # Token ID から token オブジェクトを取得
    retrieved_token = Payjp::Token.retrieve(payjp_token_id)

    # 3Dセキュア完了エンドポイントにアクセス。3Dセキュア認証の失敗時はここで Payjp::CardError が発生
    retrieved_token.tds_finish

    # 3Dセキュア非対応カードは受け付けない
    if retrieved_token.card.three_d_secure_status == "attempted"
      return redirect_to new_payments_path, alert: "このカードはお取り扱いできません"
    end

    customer = Payjp::Customer.create(card: retrieved_token.id)
  
    # customer に対しての決済処理...
  rescue Payjp::CardError => e
    # error code に応じたなんらかのエラー処理 https://pay.jp/docs/api/#error
    redirect_to new_payments_path, alert: "なんらかのエラーメッセージ"
  rescue Payjp::PayjpError => e
    # なんらかのエラー処理
    redirect_to new_payments_path, alert: "なんらかのエラーメッセージ"
  end
end

なかなか複雑です!

できるだけコードにコメントを加えていますので、Rails に慣れている方なら大丈夫かと思いますが、
これを参考に実装する際、以下の点にご注意ください。

  • payjp-ruby のバージョンは v0.0.16 以上 である必要があります
  • ruby-jwt が必要ですので、今まで使用していないのなら Gemfile に書き足す必要があります
  • callback アクションメソッドを足していますので、 config/routes.rb にルーティングを足す必要があります
  • PaymentsController というコントローラー名にしているので、 back_url = callback_payments_url などとなっていますが、コントローラー名に合わせて適切に変更する必要があります
  • 環境変数の名前は PAYJP_PUBLIC_KEY, PAYJP_SECRET_KEY としていますが、必要に応じて変更してください

また、 if retrieved_token.card.three_d_secure_status == "attempted" のところについて補足しますと、
変数 retrieved_token は以下のようなJSONオブジェクトになっています。

######### retrieved_token #########
#<Payjp::Token:0x1de5c id=tok_1234567890123456789012345678> JSON: {
  "id": "tok_1234567890123456789012345678",
  "card": {"id":"car_1234567890123456789012345678","address_city":null,"address_line1":null,"address_line2":null,"address_state":null,"address_zip":null,"address_zip_check":"unchecked","brand":"Visa","country":null,"created":1735288270,"customer":null,"cvc_check":"passed","email":"test@example.com","exp_month":12,"exp_year":2034,"fingerprint":"12345678901234567890123456789012","last4":"4242","livemode":false,"metadata":{},"name":"***","object":"card","phone":null,"three_d_secure_status":"attempted"},
  "created": 1735288270,
  "livemode": false,
  "object": "token",
  "used": false
}

retrieved_token.card.three_d_secure_status の値は、
3Dセキュアの認証が正常に完了した際は "verified" が入っていますし、
3Dセキュア非対応カードが入力された場合には "attempted" の文字列が入っています。

if retrieved_token.card.three_d_secure_status == "attempted"
  return redirect_to new_payments_path, alert: "このカードはお取り扱いできません"
end

3Dセキュア非対応カードを受け付けるかどうかは、
このような分岐を事業者側が設けるかどうかに判断が委ねられている
わけですね。

5. その他の留意事項

5-1. すでに登録されているクレジットカード

上の例では新規の決済について扱いましたが、
定期課金や、カードが登録済みの状態での都度決済について気を付けなければいけません

すでに登録済みのカードに関しては3Dセキュア認証を通っていないでしょうから、
ユーザーさんにはどこかのタイミングでカードの更新をお願いしたり、
一部のカードの無効化処理をおこなう必要が出てくるかもしれません。

そのようなときに備えて、
PAY.JP の card オブジェクト ID を保存しているテーブルでは、
カードが3Dセキュア認証済みなのかを保存するカラムを追加しておくといいでしょう。

いちおうPAY.JPのAPIで、カードが3Dセキュア認証済みなのかは取得できますが、
データベースに保存しておくほうがなにかと取り回しがきくので、
余力があれば、3Dセキュア認証の対応を実装する際には、各カードの認証ステータスを保存する処理の実装もオススメいたします。

5-2. 対応箇所は意外と多い

今回の対応が必要な箇所は、決済がおこなわれる場所だけでなく、
クレジットカードの変更・追加画面などにも及びます。

checkout.pay.jp の文字列でファイル内を検索して、
対象箇所を全て洗い出しておきましょう!

6. おわりに

「rails payjp 3d secure」 とググっても情報が出なくて、
「あ〜、これ自分でがんばらないといけないやつだ〜」とがんばって実装しました。
いろいろ苦しみましたが、記事にしてみるとやることは明白ですね!

「2025年3月末までに急いで実装しなくちゃ!」と焦っているエンジニアさんの一助になれましたら幸いです!!

ラブグラフのエンジニアブログ

Discussion