📘

Cloudflare Turnstile をやってみる

2023/03/26に公開

Cloudflare TurnstileはCAPTCHAの代わりになるものです。Cloudflareの調査ですとインターネットユーザー合計でCAPTCHAに費やす時間は1日で500年分にも上るようです。
CloudflareではCAPTCHAの代替手段としてManaged Challengeという仕組みを開発しました。

このManaged Challengeと連携して、簡単に人間(というよりはブラウザ)からのアクセスかボットからのアクセスかを見分ける機能を簡単に実装できるのがTurnstileです。Turnstileは回転する改札口という意味がありビルの入り口などによくあるぐるぐる回る回転扉のことです。

先日アップしたWaiting Room (待合室)とことなりCloudflareのCDNを使っていなくても簡単に実装できるのが特徴です。またDNS Proxyモードで管理されていなくても利用することが可能なのも利点です。

現在(2023年3月26日現在)、Turnstileはベータ版が提供されており100万回呼び出し/月までは無料です。それ以上の呼び出しが必要な場合は、こちらまでお問合せいただくか、050-1791-1110までお電話ください。

ブログ公開後価格がアップデートされました。
Cloudflareのロゴが表示されるバージョンであれば回数関係なく無償となりました。従来あった100万回の制限は消えています。一方ロゴなど画面をカスタマイズしたり、バックグランドモードでの動作では見積もりベースとなります。お問い合わせください!

Managed Challenge と Private Access Tokens
Managed Challenge は Cloudflareが開発したブラウザからのアクセスであるかBotからのアクセスであるかを判別する仕組みです。この仕組みはTurnstileのほかにBot対策サービスなどにも組み込まれていますが、ユーザーからのアクセスリクエストがあった際に、クライアントブラウザに非対話形式(つまりユーザーが気づかない間に)JavaScriptを実行させ様々な情報を入手し、リクエスタがブラウザかどうかを判別します。取得している詳細は公開されていませんが、ここで入手した情報をそれ以外には用いない、ということをCloudflareでは皆さんにお約束しています。それをサービス化しているものがTurnstileです。

90%以上のケースにおいてCAPTCHAの表示を防ぐことが出来るということがわかっています。勿論判別しきれない場合は、CAPTCHAを表示させる、OTPの入力を求める、などのオプションと連携が可能です。
またこれにより、ユーザーがCAPTCHAのパズル解読をあきらめてサイト訪問を取りやめるケースを31%削減することができました。

このManaged Challengeは様々なテクノロジーを組み合わせているのですが例えばGoogleが開発したPicassoという手法がその一例です。アクセスを行ってきたブラウザに簡単な画像の描写を要求します。ブラウザごとに実装の方言が存在しその結果をもってブラウザを判別するなどを行っています。


ピカソが書くような絵を判別に用いるのでPicassoです。

またGoogle,AppleとCloudflareが共同開発を行ったPrivate Access Tokensという仕組みも実装されています。これはiOS16以降に対応しているのですが、Private Access Tokensに対応しているOS/ブラウザからのアクセスの場合CAPTCHAは永久に表示されなくなります。このトークンは1年間の有効期限を持ち非常に高いユーザー体験をもたらします。

さっそくやってみる
それでは早速実装してみましょう。オリジンウェブサーバがまず必要になりますが、Cloudflare公式でWorkersをWebサーバと見立てたデモが公開されていますので、そちらを使います。

  1. Workersの起動
    Workersについては過去何度か記事を上げていますので、こちら等を参考にHello World!までをやってみて下さい。
    Cloudflareから払い出されるデフォルトドメイン(workers.dev)でHello World!が起動しています。

2.Custom Domainsの設定
TurnstileはCloudflareのCDNの利用は不要でご利用いただけます。Cloudflare CDNをリクエストが経由する場合は以下の手順に従ってください。Cloudflare CDNを使わない場合、このステップは飛ばしてください。
ユーザーがオリジンにアクセスした際に、Cloudflare内部ネットワークを通るように設定する必要があります。DNS Proxyモードがそれにあたります。DNS Proxyモードについては、前日別の記事で解説していますので確認してください。
簡単に言うと、CNAMEをWorkersに設定し、名前解決した際のIPアドレスがオリジンではなくCloudflareのIPアドレスが戻るモードのことです。対してDNS OnlyモードはオリジンのIPアドレスが戻ります。Workersには設定方法が2種類あります。
1.Routes (DNS Onlyモードと選択が可能)
2.Custom Domains (DNS Proxyモードのみ)
ドキュメントに明示的な記載はありませんでしたが、Custom Domainsで設定したときのみTurnstileは動作しました。皆さんは別のオリジンを使うケースもあると思いますが、Workersでテストする際は、必ずDNS Proxyモードを使う必要があると覚えておいてください。

WorkersでCustom Domainsを設定するのは簡単です。Cloudflareで既に管理されているDNSに対する適当なサブドメイン用文字列をwrangler.tomlに記載してpublishするだけです。

routes = [
	{ pattern = "turnstile.harunobukameda.labrat.online", custom_domain = true }
]

この例でいえば、harunobukameda.labrat.onlineがすでにCloudflareで管理しているDNS名、turnstile.harunobukameda.labrat.onlineがWorkersのFQDN名であるturnstile.harunobukameda.workers.devに対するCNAMEです。
以下のようにもともとのURLではWorkersへのアクセスが不可能となり、設定したCustom Domainsのみでアクセスが可能となります。

  1. Turnstileの設定
    ではここから本題のTurnstileの設定を行っていきます。

    "Add site"を押します。この際Cloudflare CDNを使わず、つまりドメインを持っていない方はWorkersのテストドメインをそのまま投入してください。(turnstile.harunobukameda.workers.dev)など
    以下のように設定を行いCreateを押します。

    次の画面で表示されるSite Key と Secret Keyをコピーしておきます。
    Site Key:
    保護対象のhtmlに埋め込むキー。
    このキーをTurnstile基盤に投げることで保護が作動します。
    この鍵は外から見えても構いません。
    Secret Key:
    Turnstileの認証結果を受け入れるかどうかの判断に用いられるキー。
    この鍵は重要であり漏洩しないような注意が必要です。
    もう一度この図を見ておくと2つの鍵の役割がわかりやすいと思います。

  2. WorkersとTurnstileの連携実装
    4-1. Wrangler.tomlの以下の部分を修正します。

(前)

main = "src/index.js"

(後)

main = "src/index.mjs"

バージョンによってデフォルトではworkers.jsがセットされているケースもありますが、気にせずsrc/index.mjsに置き換えます。

4-2. index.js→index.mjsにRenameし以下を張り付け
srcフォルダにindex.jsが無ければ新規でindex.mjsを作成します。
SECRET_KEY の値を必ず先ほどコピーした値に置き換えておきます。

import explicitRenderHtml from './explicit.html';
import implicitRenderHtml from './implicit.html';

// This is the demo secret key. In prod, we recommend you store
// your secret key(s) safely.
const SECRET_KEY = '0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

async function handlePost(request) {
    const body = await request.formData();
    // Turnstile injects a token in "cf-turnstile-response".
    const token = body.get('cf-turnstile-response');
    const ip = request.headers.get('CF-Connecting-IP');

    // Validate the token by calling the "/siteverify" API.
    let formData = new FormData();
    formData.append('secret', SECRET_KEY);
    formData.append('response', token);
    formData.append('remoteip', ip);

    const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
        body: formData,
        method: 'POST',
    });

    const outcome = await result.json();
    if (!outcome.success) {
        return new Response('The provided Turnstile token was not valid! \n' + JSON.stringify(outcome));
    }
    // The Turnstile token was successfuly validated. Proceed with your application logic.
    // Validate login, redirect user, etc.
    // For this demo, we just echo the "/siteverify" response:
    return new Response('Turnstile token successfuly validated. \n' + JSON.stringify(outcome));
}

export default {
    async fetch(request) {
        if (request.method === 'POST') {
            return await handlePost(request);
        }

        const url = new URL(request.url);
        let body = implicitRenderHtml;
        if (url.pathname === '/explicit') {
            body = explicitRenderHtml;
        }
        
        return new Response(body, {
            headers: {
                'Content-Type': 'text/html',
            },
        });
    },
};

4-3. srcフォルダに"implicit.html"を作成し以下を張り付け
sitekeyの値を必ず先ほどコピーした値に書き換えておきます。

<!DOCTYPE html>
<html lang="en">
<head>
<title>Turnstile &dash; Dummy Login Demo</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.1/css/bootstrap.min.css" integrity="sha512-siwe/oXMhSjGCwLn+scraPOWrJxHlUgMBMZXdPe2Tnk3I0x3ESCoLz7WZ5NTH6SZrywMY+PB1cjyqJ5jAluCOg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.9.1/font/bootstrap-icons.min.css" integrity="sha512-5PV92qsds/16vyYIJo3T/As4m2d8b6oWYfoqV+vtizRB6KhF1F9kYzWzQmsO6T3z3QG2Xdhrx7FQ+5R1LiQdUA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
html,
body {
  height: 100%;
}

body {
  display: flex;
  align-items: center;
  padding-top: 40px;
  padding-bottom: 40px;
  background-color: #fefefe;
}

.form-signin {
  width: 100%;
  max-width: 330px;
  padding: 15px;
  margin: auto;
}

.form-signin .checkbox {
  font-weight: 400;
}

.form-signin .form-floating:focus-within {
  z-index: 2;
}

.form-signin input[type="text"] {
  margin-bottom: -1px;
  border-bottom-right-radius: 0;
  border-bottom-left-radius: 0;
}

.form-signin input[type="password"] {
  margin-bottom: 10px;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
</style>
</head>
<body>
<main class="form-signin">
  <form method="POST" action="/handler">
    <h2 class="h3 mb-3 fw-normal">Turnstile &dash; Dummy Login Demo</h2>

    <div class="form-floating">
      <input type="text" id="user" class="form-control">
      <label for="user">User name</label>
    </div>
    <div class="form-floating">
      <input type="password" id="pass" class="form-control" autocomplete="off" readonly value="CorrectHorseBatteryStaple">
      <label for="pass">Password (dummy)</label>
    </div>

    <div class="checkbox mb-3">
      <!-- The following line controls and configures the Turnstile widget. -->
      <div class="cf-turnstile" data-sitekey="0x4AAAAAAADfqkZzYGYBOdTE" data-theme="light"></div>
      <!-- end. -->
    </div>
    <button class="w-100 btn btn-lg btn-primary" type="submit">Sign in</button>
    <p class="mt-5 mb-3 text-muted"><a href="https://github.com/cloudflare/turnstile-demo-workers"><i class="bi bi-github"></i> See code</a></p>
    <p class="mt-5 mb-3 text-muted">Go to the <a href="/explicit">explicit render demo</a></p>
  </form>
</main>
</body>
</html>

4-4. srcフォルダに"explicit.html"を作成し以下を張り付け
sitekeyの値を必ず先ほどコピーした値に書き換えておきます。

<!DOCTYPE html>
<html lang="en">
<head>
<title>Turnstile &dash; Dummy Login Demo</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.1/css/bootstrap.min.css" integrity="sha512-siwe/oXMhSjGCwLn+scraPOWrJxHlUgMBMZXdPe2Tnk3I0x3ESCoLz7WZ5NTH6SZrywMY+PB1cjyqJ5jAluCOg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.9.1/font/bootstrap-icons.min.css" integrity="sha512-5PV92qsds/16vyYIJo3T/As4m2d8b6oWYfoqV+vtizRB6KhF1F9kYzWzQmsO6T3z3QG2Xdhrx7FQ+5R1LiQdUA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=_turnstileCb" async defer></script>
<style>
html,
body {
  height: 100%;
}

body {
  display: flex;
  align-items: center;
  padding-top: 40px;
  padding-bottom: 40px;
  background-color: #fefefe;
}

.form-signin {
  width: 100%;
  max-width: 330px;
  padding: 15px;
  margin: auto;
}

.form-signin .checkbox {
  font-weight: 400;
}

.form-signin .form-floating:focus-within {
  z-index: 2;
}

.form-signin input[type="text"] {
  margin-bottom: -1px;
  border-bottom-right-radius: 0;
  border-bottom-left-radius: 0;
}

.form-signin input[type="password"] {
  margin-bottom: 10px;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
</style>
</head>
<body>
<main class="form-signin">
  <form method="POST" action="/handler">
    <h2 class="h3 mb-3 fw-normal">Turnstile &dash; Dummy Login Demo</h2>

    <div class="form-floating">
      <input type="text" id="user" class="form-control">
      <label for="user">User name</label>
    </div>
    <div class="form-floating">
      <input type="password" id="pass" class="form-control" autocomplete="off" readonly value="CorrectHorseBatteryStaple">
      <label for="pass">Password (dummy)</label>
    </div>

    <div class="checkbox mb-3">
      <!-- The Turnstile widget will be injected in the following div. -->
      <div id="myWidget"></div>
      <!-- end. -->
    </div>
    <button class="w-100 btn btn-lg btn-primary" type="submit">Sign in</button>
    <p class="mt-5 mb-3 text-muted"><a href="https://github.com/cloudflare/turnstile-demo-workers"><i class="bi bi-github"></i> See code</a></p>
    <p class="mt-5 mb-3 text-muted">Go to the <a href="/">implicit render demo</a></p>
  </form>
</main>
<script>
// This function is called when the Turnstile script is loaded and ready to be used.
// The function name matches the "onload=..." parameter.
function _turnstileCb() {
    console.debug('_turnstileCb called');

    turnstile.render('#myWidget', {
      sitekey: '1x00000000000000000000AA',
      theme: 'light',
    });
}
</script>
</body>
</html>

implicit と explicitの違いについては後で説明しますのでまずはテストまですすみましょう!

4-5. Wrangler publishを実行しテストアクセス
適当なID/パスワードでログインしてみて下さい。

正しくログインできれば以下のような表示がされます

Cookieは空ですが何某かのトークンがセットされていることがわかります。

ログイン前のTurnstileの画面ではCookieに様々な値がセットされていることがわかります。

implicitモード と explicitモード
Turnstileでは2つのモードが準備されています。
Implicitモードは、認証済みのユーザーがウェブサイトにアクセスするときに使用されます。この場合、Turnstileは、ブラウザに保存されているクッキーなどの情報を使用して、ユーザーが認証済みであるかどうかを判断します。Turnstileは、認証に失敗した場合、ユーザーを認証ページにリダイレクトします。
Explicitモードは、認証されていないユーザーがウェブサイトにアクセスするときに使用されます。この場合、Turnstileは、認証ページにリダイレクトして、ユーザーにログインまたは新しいアカウントの作成を促します。ユーザーがログインまたはアカウントを作成した場合、Turnstileは、クッキーなどの情報を使用して、ユーザーが認証済みであることを確認し、ウェブサイトにリダイレクトします。
つまりExplicitモードでは、常にユーザーは認証を求められます。

  1. Botからのアクセスを試す
    では最後にBotからのアクセスをテストしてみましょう。Chrome拡張プラグインのModHeadderが使い勝手がいいようです。UserAgentを適当なものに書き換えるとのこのように失敗します。(ちなみにCloudflareのBot対策サービスはUAが空だと問答無用で失敗しますので適当な値にしてみて下さい)

Discussion