GMOペイメントゲートウェイを利用し、NewsPicksのクレジットカード決済を本人認証できるようにした話

はじめに

皆様こんにちは、ソーシャル経済メディア「NewsPicks」(Platform Engineeringチーム)エンジニアの北見です。

NewsPicksでは様々な有料コンテンツを提供しておりますが、その際のクレジットカード支払いでは本人認証対応を行なっております。

既に導入済みの企業様も多いとは思いますが、「そもそもどのような仕組みで実現されているのか?」を具体解説している記事はあまり見かけませんでした。

そのため本記事が、本人認証のイメージがまだ湧かない開発者の方へ理解の手助けになれば嬉しいです。

(なお、NewsPicksの課金基盤では GMOペイメントゲートウェイ社 (以下、GMOPG)のライブラリを利用させて頂いるので、それを前提した説明になります。あらかじめご了承ください。)

クレジットカード決済の本人認証とは?

クレジットカード決済を行う際、不正利用を防止するために、本人認証を追加することでセキュリティを高める仕組みです。

決済処理を実行時、即時に決済するのではなく、「3DS サーバ」と呼ばれる本人認証処理を行うサーバを経由して、その結果をもとに決済を実行します。

(クレジットカード・セキュリティガイドラインでは2025年3月までの対応を必須としており、NewsPicksでもその期日前に対応を完了しています。)

本人認証対応前の決済フロー

本人認証対応前の決済処理は非常にシンプルです。

  1. 決済手続きの取引を登録・実行するAPIを実行する
  2. 成功なら、決済情報をNewsPicks内のデータに反映し、決済完了画面に遷移する

これだけで完了しますが、この場合、カード情報が流出してしまった場合の不正利用のリスクが高い状態です。

本人認証対応後の決済フロー

一気に難しくなりましたが、やっていることは「ユーザの決済入力後にすぐに決済を完了せず、callbackを受ける形で決済を完了させる」というだけです。

  1. GMOPGに本人認証付きの取引を登録する。この時、認証完了後に呼び出すCallbackUrlを指定する
  2. 1.で発番された取引IDと共に、NewsPicks側で決済用のデータを保存する(定期購読の新規申し込みなのか、NewsPicks for Kidsなのか等)
  3. 1.で返却されたリダイレクトURLへユーザをリダイレクトさせる
  4. 3.でリダイレクトすると、ユーザは本人認証画面へ遷移する
  5. 本人認証完了後、指定しておいたNewsPicksへのCallbackUrlが実行される(この時、1.で発番された取引IDがコールバックのbodyに含まれる)
  6. 5.の取引IDをもとに2.でNewsPicks内に保存しておいた決済用の情報を取り出し、3DS2.0の決済を完了させる
  7. 決済情報をNewsPicks内のデータに反映し、決済完了画面に遷移する

画面遷移を挟んでしまうので、callbackで自社の決済処理に戻った際、どのような決済処理だったかを復元する必要があります。

そこで最初に取引を登録した際、取引IDをkey値とした redis に保存しておく運用にしています。

今後の課題:「OpenAPIタイプへの移行」

これは弊社のシステム対応が追いついていないのが問題なのですが、GMOPGが推奨するOpenAPI方式に移行できておりません。

GMOPGのAPIを実行する方法として、大別して「プロトコル/モジュールタイプ」と「OpenAPIタイプ」があります。

参考

以前からの技術資産があるということで、NewsPicksではまだモジュールタイプを利用していますが、これからは「OpenAPIタイプ」が推奨・メンテナンスされるようなので、将来切り替えていきたいと考えていますし、これから新規導入をする企業様は初めからOpenAPIタイプを使うのが良いと思います。

モジュールタイプ利用による弊害

モジュールタイプを利用する上で、一点つまづいたポイントがありました。

弊社の技術スタックにはServer Side Kotlinを採用しています。この場合、GMOPGの提供するjarファイルをリポジトリに組み込み、SDKを呼び出せば本人認証ありの取引を登録できるのですが、本人認証に対応した取引を登録する際、販売店の名前を base64 エンコードした値を設定する処理でエラーになっていました。

実は昔のJavaで削除されたAPIがGMOPGの提供するモジュールで利用されており、NewsPicksの課金システムと互換性がなく、エンコード処理実行時にエラーとなってしまいました。

回避策として、「本人認証の部分だけ生のAPIを実行する」という形をとりました。

/**
 * GMOモジュールタイプAPIにアクセスするためのクライアントのコード例
 */
@Component
class GMOModuleClient(
    val gmoConfig: GmoConfig,
) {
    fun entryTranUserAuth(
        shopId: String,
        shopPass: String,
        orderId: String,
        jobCd: String,
        amount: Int,
        tdFlag: String,
        tds2Type: String,
        tdTenantName: String,
    ): EntryTranUserAuthResponse {
        val body = LinkedMultiValueMap<String, String>().apply {
            add("ShopID", shopId)
            add("ShopPass", shopPass)
            add("OrderID", orderId)
            add("JobCd", jobCd)
            add("Amount", amount.toString())
            add("TdFlag", tdFlag)
            add("Tds2Type", tds2Type)
            add("TdTenantName", tdTenantName) // ←の処理を自前で設定できるようにする必要があった
        }

        try {
            // GMO PGのモジュールタイプAPIはapplication/x-www-form-urlencoded形式でリクエストを送信/受信
            val request = RequestEntity.post(URI("${gmoConfig.moduleUrl}"))
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .accept(MediaType.APPLICATION_FORM_URLENCODED)
                .body(body)
            // このクライアントクラスではSpringFramework付属のRestTemplateを使ってリクエストの送信を行っている
            return RestTemplate().exchange(request, String::class.java)
                .body!!
                .parseEntryTranUserAuthResponse()
        } catch (e: Exception) {
            throw GMOClientIOException("Entry Tran Request to GMO failed", e)
        }
    }

    // GMO PGから返却されるレスポンスをパース
    fun String.parseEntryTranUserAuthResponse(): EntryTranUserAuthResponse {
        val map = split("&")
            .map { it.split("=") }
            .associate { it[0] to it[1] }
        
        // エラー発生時には、エラーコードとその詳細が「ErrCode=aaa&ErrInfo=bbb」の形式で返される
        if (map.contains("ErrCode")) {
            val code = map["ErrCode"]?.split("|")
            val info = map["ErrInfo"]?.split("|")
            if (code == null || info == null) {
                throw GMOClientIOException("Invalid format response from GMO: $this", null)
            }
            val errors = code.zip(info) { c, i -> GMOError(c, i) }
            return EntryTranUserAuthErrorResponse(errors)
        }

        // 成功時には、取引IDと取引パスワードが「AccessID=xxx&AccessPass=yyy」の形式で返される
        val accessId = map["AccessID"]
        val accessPass = map["AccessPass"]
        if (accessId == null || accessPass == null) {
            throw GMOClientIOException("Invalid format response from GMO: $this", null)
        }
        return EntryTranUserAuthSuccessResponse(
            accessId = AccessId(accessId),
            accessPass = AccessPass(accessPass)
        )
    }
}

このクラスを利用して base64 エンコードのエラーを回避しています。

が、上記のような自前実装はしないに越したことはないので、これから新たにGMOPGを導入して決済システムを作る場合は、ドキュメントも洗練されている「OpenAPIタイプ」を使うべきです。

学び:決済系のAPIの命名は手続き的の方がわかりやすい

いわゆる古典的な API を設計する場合は、REST 形式で命名するのが良いとされているでしょう。

  • [POST] /post/
  • [PUT] /post/1/

しかし、今回のような決済系の API は本人認証の開始・完了という手続き的な側面が強く、それを API の命名にも反映した方がわかりやすいと感じました。

例:

  • [POST] /payment/credit-card/initiate (初期化を完了し、リダイレクトURLを返す)
  • [POST] /payment/credit-card/finalize (コールバックを受け取り、決済処理を完了させ、自社の会員を有料会員化する)

自社で API を新たに立てる際、命名の参考にして見て下さい。

工夫:定期購読画面はABテストを利用した段階的なリリース

特に定期購読の新規購読画面は影響範囲が広かったため、万一不具合が出てしまった場合も考え、ABテストを利用した段階的なリリースを行っています。

(本人認証に対応した決済処理を行う対象ユーザを0%10%50%&100%にする形でリリースしています。)

ABテストに関する過去記事は以下をご覧ください。

tech.uzabase.com

tech.uzabase.com

おわりに

NewsPicksは決済導線が多く、テスト含め対応が大変でしたが、チームメンバーに色々と助けられて対応完了できました。

特に最初の実装パターンを見出すまでが苦労しましたが、1つを作りきってしまえば、あとはほぼそれを参照する形で進められたので、終盤の作業はだいぶスムーズになったと感じています。

外部サービスとのやりとりをする部分なので、テストや検証で苦労する部分もありましたが、自社の決済システムをセキュアなものにできて、やりがいも得るものも大きいプロジェクトでした。

決済ライブラリとして GMOPG 以外の選択肢もあるかと思いますが、クレジットカードの本人認証のシステムの動きは概ね変わらないと思いますので、本人認証の全体像の理解に役立てたのであれば幸いです。

Page top