Gatlingで複雑な認証システム(SAMLやCSRFトークンなど)の負荷テストを簡単に!!

◆ Live配信スケジュール ◆
サイオステクノロジーでは、Microsoft MVPの武井による「わかりみの深いシリーズ」など、定期的なLive配信を行っています。
⇒ 詳細スケジュールはこちらから
⇒ 見逃してしまった方はYoutubeチャンネルをご覧ください
【4/18開催】VSCode Dev Containersで楽々開発環境構築祭り〜Python/Reactなどなど〜
Visual Studio Codeの拡張機能であるDev Containersを使ってReactとかPythonとかSpring Bootとかの開発環境をラクチンで構築する方法を紹介するイベントです。
https://tech-lab.connpass.com/event/311864/

こんにちは、サイオステクノロジー武井です。今回は、SAMLをベースとした認証システムや、CSRFトークンが必須なWebアプリケーションの負荷テストを簡単にするツール「Gatling」の使い方と実践的なスクリプトをご紹介します。

Gatlingとは?

Webアプリケーションへの負荷テストツールです。同じようなものにJMeterがありますが、Gatlingの最大のメリットは、ScalaベースのDSLによって、シナリオを作成できるということです。

単純なシナリオであれば、わざわざDSLによって定義することはありません。JMeterにも、JMeter自身をプロキシとすることによって、Webブラウザでの操作をそのままシナリオに落とし込める方法が存在します。

ただし、複雑なシナリオの場合にはそのようにはいきません。例えば、不正なアクセスを防ぐ仕組みであるCSRFトークンは、Webアプリケーションへの、ある画面へのアクセスごとに任意のトークンを画面に埋め込みます。アクセスする際は、その画面に埋め込まれたトークンをサーバーに送出、サーバー側でそのトークンの有効性を確認します。

このCSRFトークンを負荷テストのシナリオで再現するためには、画面に埋め込まれたトークンを抽出する必要はあります。例えばCSRFトークンが<input type=”hidden” name=”tokne” value=”XXX”>のように埋め込まれていた場合は、正規表現か何かで、XXXを抽出しなければなりません。

また、SAMLの認証のシナリオにおいても同様です。SAML Responseを送出する方法として、Post Bindingが採用されていた場合、IdP側で認証が成功すると、そのHTTPレスポンスにSAML ResponseをJava ScriptのOnLoadでPostするようなHTMLが返ってきます。この場合も、正規表現か何かで、HTMLからSAML Responseを抽出しなければいけません。

このように、認証のような複雑なシナリオでは、HTTPレスポンスを解析して必要な値を抽出するという複雑な処理が必要になります。このようなときには、やはりDSLでシナリオを記述できるGatlingがラクチンです。JMeterでもできなくはないですが、あのXMLの設定で複雑なシナリオを実現するのはJMeterの独自仕様を理解しなければいけないので骨が折れます。Gatlingであれば、多少Scalaの知識があれば、Scalaのコーディング技法で、シナリオを自由に定義することができます。

では、様々なユースケースごとにGatlingの素晴らしをご紹介していきたいと思います。

インストール

何はともあれ、まずはインストールです。GatlingはScalaで記述しますので、Javaがインストールされていることが前提となります。

以下のURLにアクセスして、「DOWNLOAD NOW! 」をクリックして、Gatling本体をダウンロードします。

https://gatling.io/open-source

Gatlingを任意のディレクトリに解凍して配置すればインストールは環境です。以下の例では、/opt/gatlingというディレクトリに配置する例です。

# mkdir /opt/gatling
# mv gatling-charts-highcharts-bundle-3.3.1.zip /opt/gatling
# unzip gatling-charts-highcharts-bundle-3.3.1.zip
# mv gatling-charts-highcharts-bundle-3.3.1/* /opt/gatling/

ユースケースその1:簡単な負荷テストをする

まずは超簡単な負荷テストを実施してみたいと思います。シナリオは以下とします。

  • https://hoge.example.com/というURLに対して、同時10接続でアクセスする。MethodはGETとする。

 

上記のシナリオは以下のように定義します。

import scala.concurrent.duration._

// 使用するライブラリをインポートする。
import io.gatling.core.Predef._
import io.gatling.https.Predef._
import io.gatling.jdbc.Predef._

class UseCase01 extends Simulation {

    // 送出するHTTPヘッダを定義する。
    val httpsProtocol = https
        .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0")
        .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
        .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3")
        .header("Accept-Encoding","gzip, deflate, br")
        .header("DNT","1")
        .header("Connection","keep-alive")
        .header("Upgrade-Insecure-Requests","1")


    // シナリオの名前を定義する。任意の名称でよい。
    val scn = scenario("RecordedSimulation")
        // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。
        // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。
        .exec(https("SP")
          // 負荷テスト対象のURLを定義する。ここで定義したURLに対して負荷がかかる。
          .get("https://hoge.example.com"))


    // 同時10ユーザーでアクセスする。
    setUp(scn.inject(
      atOnceUsers(10)
    ))

}

 

このシナリオを実行します。シナリオのファイルを/opt/gatling/user-files/simulationsディレクトリに配置します。

# cp UseCase01.scala /opt/gatling/user-files/simulations

 

実行します。

# cd /opt/gatling/bin
# ./gatling.sh -s UseCase01 --run-description ""

 

以下のように結果が出力されます。

================================================================================
---- Global Information --------------------------------------------------------
> request count                                         10 (OK=10     KO=0     )
> min response time                                    198 (OK=198    KO=-     )
> max response time                                    280 (OK=280    KO=-     )
> mean response time                                   241 (OK=241    KO=-     )
> std deviation                                         28 (OK=28     KO=-     )
> response time 50th percentile                        251 (OK=251    KO=-     )
> response time 75th percentile                        255 (OK=255    KO=-     )
> response time 95th percentile                        278 (OK=278    KO=-     )
> response time 99th percentile                        280 (OK=280    KO=-     )
> mean requests/sec                                     10 (OK=10     KO=-     )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms                                            10 (100%)
> 800 ms < t < 1200 ms                                   0 (  0%)
> t > 1200 ms                                            0 (  0%)
> failed                                                 0 (  0%)
================================================================================

 

request countのKOが0であれば、全てのリクエストは成功しています。OKが成功、KOが失敗を表しています。

ユースケースその2:フォームに入力した値をPostするケース

次に、フォームに入力した値をPostするようなケースを考えてみます。ログイン画面でIDとパスワードを入力して送信するというのがよくある例ですね。以下のようなシナリオを考えてみます。

  • ログイン画面はhttps://hoge.example.comというURLとする。
  • ユーザー名のパラメータ名はusername、パスワードのパラメータ名はpasswordとする。
  • https://hoge.example.com/loginというURLに対して、ユーザー名とパスワードをPostする。
  • Content-Typeはapplication/x-www-form-urlencodedとする。
  • 上記のシナリオを同時10接続で実施する。

 

上記のシナリオは以下のように定義します。

import scala.concurrent.duration._

// 使用するライブラリをインポートする。
import io.gatling.core.Predef._
import io.gatling.https.Predef._
import io.gatling.jdbc.Predef._

class UseCase02 extends Simulation {

    // 送出するHTTPヘッダを定義する。
    val httpsProtocol = https
        .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0")
        .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
        .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3")
        .header("Accept-Encoding","gzip, deflate, br")
        .header("DNT","1")
        .header("Connection","keep-alive")
        .header("Upgrade-Insecure-Requests","1")

    // シナリオの名前を定義する。任意の名称でよい。
    val scn = scenario("RecordedSimulation")
        // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。
        // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。
        .exec(https("login")
          // ログイン画面のURLを定義する。
          .post("https://hoge.example.com/"))
        .exec(https("post")
          // ユーザーIDとパスワードをPostするURLを定義する。
          .post("https://hoge.example.com/login")
          // PostするユーザーIDとパスワードの値を定義する。
          .formParam("username", "ntakei")
          .formParam("password", "password"))

    // 同時10ユーザーでアクセスする。
    setUp(scn.inject(
      atOnceUsers(10)
    ))

}

 

実行方法は、ユースケースその1と同じですので割愛します。

ユースケースその3:CSRFトークンを送出するケース

だんだん複雑になってきます。CSRFトークンを送出するケースを考えてみます。以下のようなシナリオを考えてみます。ユースケースその2にCSRFトークンを送出する処理を追加しました。

  • ログイン画面はhttps://hoge.example.comというURLとする。
  • ユーザー名のパラメータ名はusername、パスワードのパラメータ名はpasswordとする。
  • ログイン画面にはCSRFトークンが、<input type=”hidden” name=”tokne” value=”XXX”>のように埋め込んである。
  • https://hoge.example.com/loginというURLに対して、ユーザー名とパスワードをPostする。
  • Content-Typeはapplication/x-www-form-urlencodedとする。
  • 上記のシナリオを同時10接続で実施する。

 

上記のシナリオは以下のように定義します。

import scala.concurrent.duration._

// 使用するライブラリをインポートする。
import io.gatling.core.Predef._
import io.gatling.https.Predef._
import io.gatling.jdbc.Predef._

class UseCase03 extends Simulation {

    // 送出するHTTPヘッダを定義する。
    val httpsProtocol = https
        .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0")
        .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
        .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3")
        .header("Accept-Encoding","gzip, deflate, br")
        .header("DNT","1")
        .header("Connection","keep-alive")
        .header("Upgrade-Insecure-Requests","1")

    // シナリオの名前を定義する。任意の名称でよい。
    val scn = scenario("RecordedSimulation")
        // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。
        // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。
        .exec(https("login")
          // ログイン画面のURLを定義する。
          .post("https://hoge.example.com/")
          // 正規表現でCSRFトークンを取り出し、tokenという変数に格納する。
          .check(regex("""""").saveAs("token")))
        .exec(https("post")
          // ユーザーIDとパスワードをPostするURLを定義する。
          .post("https://hoge.example.com/login")
          // PostするユーザーIDとパスワードの値を定義する。
          .formParam("username", "ntakei")
          .formParam("password", "password")
          // 先程ログイン画面から取り出したCSRFトークンを定義する。
          .formParam("token", "${loginurl}"))

    // 同時10ユーザーでアクセスする。
    setUp(scn.inject(
      atOnceUsers(10)
    ))

}

 

実行方法は、ユースケースその1と同じですので割愛します。

ユースケースその4:SAMLに対応したIdPに負荷をかけるケース

最後はSAMLに対応したIdPに負荷をかけるケースを考えてみます。シナリオは以下のようになります。

  • SAMLに対応したIdP(以降、IdPと呼称)のURLはidp.exmaple.comとする。
  • IdPは正しいユーザー名とパスワードを入力すれば認証は完了とする。
  • SAMLに対応したSP(以降、SPと呼称)のURLはsp.exmaple.comとする。
  • SPの仕様はHTTPレスポンスとして、IdPに入力したユーザー名がそのまま返ってくるものとする。
  • SPにSAML Responseを返す方法は、Post bindingsとする。
  • Post BindigsでSAML Responseを返す際、SAML Responseのパラメータ名はSAMLResponse、Relay Stateのパラメータ名はRelayStateとする。
  • IdPに入力するユーザー名はusers.csvに以下のように定義し、上から順番にIdPにPostするものとする。
    username,password
    test001,password1
    test002,password2
    test003,password3
    

     

上記のシナリオは以下のように定義します。

import scala.concurrent.duration._
import scala.util.parsing.json.JSON

import io.gatling.core.Predef._
import io.gatling.https.Predef._
import io.gatling.jdbc.Predef._

class UseCase04 extends Simulation {

    // IdPへのログインユーザー情報を定義したCSV(users.csv)をfeederに読み込む。
    val feeder = csv("/opt/gatling/user-files/data/users.csv")

    // シナリオの名前を定義する。任意の名称でよい。
    val scn = scenario("RecordedSimulation")
        // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。
        // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。
        // SPのシナリオを定義する。
        .exec(https("SP")
          // SPのURLを定義する。
          .get("https://sp.example.com/sp.php")
          // HTTPリクエスト時に送信するヘッダを定義する。
          .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0")
          .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
          .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3")
          .header("Accept-Encoding","gzip, deflate, br")
          .header("DNT","1")
          .header("Connection","keep-alive")
          .header("Upgrade-Insecure-Requests","1")
        // 念の為、1秒ポーズを入れる。
        .pause(1)
        // CSVのユーザー情報を上から順番に取得する設定をする。
        .feed(feeder.circular)
        // IdPにログインするシナリオを定義する。
        .exec(https("IdP")
          // IdPのログイン情報のPost先のURLを定義する。環境変数から取得したクライアントIDをマッピングする。
          .post("https://idp.example.com/login")
          // HTTPリクエスト時に送信するヘッダを定義する。
          .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0")
          .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
          .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3")
          .header("Accept-Encoding","gzip, deflate, br")
          .header("Content-Type","application/x-www-form-urlencoded")
          .header("Origin","https://idp.example.com")
          .header("DNT","1")
          .header("Connection","keep-alive")
          .header("Upgrade-Insecure-Requests","1")
          // IdPへのログインに必要な情報(ログインID、パスワードなど)を設定する。この情報がPostされる。
          // CSVで定義したユーザー名とパスワードがPostされる。
          .formParam("username", "${username}")
          .formParam("password", "${password}")
          // レスポンスからSAML ResponseとRelayStateを取得してセッションに格納する。
          .check(regex("""name=\"SAMLResponse\" value=\"(.+)\"""").saveAs("samlp"))
          .check(regex("""name=\"RelayState\" value=\"(.+)\"""").saveAs("relaystate")))
        .exec(session => {
           // セッションに格納したSAML Responseなどの情報を取得する。
           val samlp = session("samlp").as[String]
           // RelayState内の:を:(セミコロン)に置換する。
           val relaystate = session("relaystate").as[String].replace(":", ":")

           // Mapから取得した情報をセッションに格納する。後のシナリオで取り出す。
           var session1 = session.set("samlp", samlp)
                                 .set("relaystate", relaystate)
           session1})
        // 念の為、1秒ポーズを入れる。
        .pause(1)
        // SPにSAMLResponseをPostするシナリオを定義する。
        .exec(https("SAMLP2SP")
           // SPにSAMLResponseをPostする先のURLを定義する。
          .post("https://sp.example.com/POST")
          // HTTPリクエスト時に送信するヘッダを定義する。
          .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0")
          .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
          .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3")
          .header("Accept-Encoding","gzip, deflate, br")
          .header("Content-Type","application/x-www-form-urlencoded")
          .header("Origin","https://idp.example.com")
          .header("DNT","1")
          .header("Connection","keep-alive")
          .header("Upgrade-Insecure-Requests","1")
          // 前の画面から取得したSAMLResponseとRelayStateをPostする。
          .formParam("RelayState", "${relaystate}")
          .formParam("SAMLResponse", "${samlp}")
          // SPの画面にはログインしたユーザーIDが表示されるので、そのレスポンスをチェックする。
          .check(bodyString.is("${username}")))

    // 同時10ユーザーでアクセスする。
    setUp(scn.inject(
      atOnceUsers(10)
    ))

}

 

実行方法は、ユースケースその1と同じですので割愛します。

まとめ

いかがでしたでしょうか?ScalaによるDSLでシナリオを定義するGatlingであれば、認証システムへの負荷テストも簡単に実現できますね!!もうGatlingなしでは生きていけません。No Gatling, No Life!!

アバター画像
About 武井 宜行 269 Articles
Microsoft MVP for Azure🌟「最新の技術を楽しくわかりやすく」をモットーにブログtech-lab.sios.jp)で情報を発信🎤得意分野はAzureによるクラウドネイティブな開発(Javaなど)💻「世界一わかりみの深いクラウドネイティブ on Azure」の動画を配信中📹 https://t.co/OMaJYb3pRN
ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

1人がこの投稿は役に立ったと言っています。


ご覧いただきありがとうございます。
ブログの最新情報はSNSでも発信しております。
ぜひTwitterのフォロー&Facebookページにいいねをお願い致します!



>> 雑誌等の執筆依頼を受付しております。
   ご希望の方はお気軽にお問い合わせください!

Be the first to comment

Leave a Reply

Your email address will not be published.


*


質問はこちら 閉じる