月: 2018年7月

  • Python の CGI で reCAPTCHA v2 を使う

    ちょっと前までは人間でも読解困難な文字を読ませていた CAPTCHA ですが、Google の reCAPTCHA v2 では人間である可能性が高い場合は「私はロボットではありません」というチェックを入れるだけとなって、かなりユーザへの負担が軽減されました。

    既にクリックすら必要ない reCAPTCHA v3 のベータテストが行われているので、もしかしたらすぐに v3 へ移行してしまうかもしれませんが、現時点ではまだベータ版ということで v2 を選んでいます。

    reCAPTCHA v2 を自分のサイトに導入するには、クライアントに表示されるページとサーバの両方にコードの追加が必要となります(実体は両方ともサーバにあるファイルですね)。

    流れとしては下記の通りです。

    1. reCAPTHA のページで登録を行う
    2. ユーザに表示する form にコードを埋め込む(JavaScript の読み込みと HTML)
    3. form からの値を Google に投げて判別するコードをサーバ側に追加する

    reCAPTCHA の登録

    https://www.google.com/recaptcha/ を開き、”My reCAPTCHA” から登録を行います。余談ですが右下に reCAPTCHA のマークが表示されているので、このページ自体も reCAPTCHA で保護されていますね。

    reCAPTCHA トップ画面
    reCAPTCHA トップ画面

    “Register a new site” から新しくサイトを登録します。自分の区別しやすい名前とドメイン名を入力するだけです。

    reCAPTCHA register

    登録したサイトを開くと設定の仕方が載っているので、これを参考にソースやコードを書き換えます。癖で secret にモザイクかけてしまいましたが、HTML に書かれるから隠す意味はありませんね。

    reCAPTCHA secret

    クライアントに表示される HTML の変更

    2 点だけです。

    head 要素に JavaScript の読み込みを追加

    <script src='https://www.google.com/recaptcha/api.js'></script>

    form 要素に div 要素を追加する

    <div class="g-recaptcha" data-sitekey="XXXXXXXXYOURSECRETXXXXXXXXX"></div>

    これが reCAPTCHA v2 のチェックボックスになります。

    ここまでで次のような表示が現れたら OK です(ID: の入力欄は関係ありません)。

    reCAPTCHA form

    サーバ側 Python コードの修正

    今回は昔ながらの手法で POST された form を読み取る CGI を対象としています。

    使うモジュールは次の通り。

    import cgi
    import urllib, urllib2
    import json

    cgi は form の値を読み取るために、urllib, urllib2 は Google の API にアクセスするために、json は API の返答を読み取るために使います。

    後は通常の form のように ‘g-recaptcha-response’ の値を取得して、’https://www.google.com/recaptcha/api/siteverify’ に対して secret と ‘g-recaptcha-response’ の値を POST します。

    # form から g-recaptcha-response の値を取得
    form = cgi.FieldStorage()
    response = form.getfirst('g-recaptcha-response', '')
    
    # API で値を検証する
    url = 'https://www.google.com/recaptcha/api/siteverify'
    secret = 'XXXXXXXXYOURSECRETXXXXXXXXX'
    params = urllib.urlencode({'secret': secret, 'response': response})
    req = urllib2.Request(url, params)
    res = json.loads(urllib2.urlopen(req).read())
    
    # 検証結果
    if res['success']:
        # ここに reCAPTCHA 成功時の処理を書く
        print('Passed reCAPTCHA')
    else:
        # ここに reCAPTCHA 失敗時の処理を書く
        print('Failed to pass reCAPTCHA')

    response は JSON で帰ってきますが、簡単に使うだけなら ‘success’ の値だけを取得すれば OK です。response の JSON は次のような内容を含んでいます(https://developers.google.com/recaptcha/docs/verify)。

    {
      "success": true|false,
      "challenge_ts": timestamp,  // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
      "hostname": string,         // the hostname of the site where the reCAPTCHA was solved
      "error-codes": [...]        // optional
    }

    これだけなので、bot 対策に悩まされているなら試してみる価値はあります。

  • Python の bcrypt 実装色々

    Python の bcrypt 実装にはいくつかあって、PyPI で検索すると単体で使うもの以外にも Flask 用、Django 用など様々なものがあります。

    単体で使うには現在でもメンテが活発な bcrypt が良いと思いますが、導入する環境によっては libffi に依存する関係で pip install 中にビルドが通りません。環境を整えれば解決する話(apt なら install libffi-dev で OK)なのですが、共用サーバなのもあって他のもので解決できそうであればその方が良いと判断しました。

    ここで注意しなければいけないのが、上に挙げた 3 つの bcrypt 実装は全て import bcrypt して使うことになっています。これはつまり、異なるライブラリなのに import する名前が bcrypt で同じなので、今どのライブラリを使っているのかが判断できなくて予想外の問題を起す可能性があるということです。

    実際にこんな投稿がありました。

    import bcrypt する場合は開発環境と運用環境の違いに十分注意しないと、一見動いているように見えてもこのようにハマる可能性が高いです。

    bcrypt 3.1.4 から py-bcrypt 0.4 へ

    gensalt() の引数の違い

    試しに同じソースを使って、環境を bcrypt から py-bcrypt に変更してみました。

    <type 'exceptions.TypeError'>: gensalt() got an unexpected keyword argument 'rounds' 
     args = ("gensalt() got an unexpected keyword argument 'rounds'",) 
     message = "gensalt() got an unexpected keyword argument 'rounds'"

    gensalt() の引数が違うようです。

    bcrypt の src/bcrypt/__init__.py を読んでみると、rounds と prefix があります。

    def gensalt(rounds=12, prefix=b"2b"):
        if prefix not in (b"2a", b"2b"):
        raise ValueError("Supported prefixes are b'2a' or b'2b'")
    
        if rounds < 4 or rounds > 31:
        raise ValueError("Invalid rounds")
    
        salt = os.urandom(16)
        output = _bcrypt.ffi.new("char[]", 30)
        _bcrypt.lib.encode_base64(output, salt, len(salt))
    
        return (
            b"$" + prefix + b"$" + ("%2.2u" % rounds).encode("ascii") + b"$" +
            _bcrypt.ffi.string(output)
        )

    一方の py-bcrypt の bcrypt/__init__.py にある gensalt() の実装を見てみると、log_rounds だけです。

    def gensalt(log_rounds = 12):
        """Generate a random text salt for use with hashpw(). "log_rounds"
        defines the complexity of the hashing, increasing the cost as
        2**log_rounds."""
        return encode_salt(os.urandom(16), min(max(log_rounds, 4), 31))

    以上から、bcrypt から py-bcrypt に移行するときは prefix をなくして rounds を log_rounds に直せば OK です。逆の場合は 2a を指定したいときだけ prefix = b’2a’ を与えれば良いです。py-bcrypt は 2a 固定になっていました。

    hashpw() と checkpw()

    hashpw() と checkpw() は特に変更することなく、正常に使えているようです。