1. 概要

このチュートリアルでは、人間とボットを区別するために、登録プロセスに Google reCAPTCHA を追加して、Springセキュリティ登録シリーズを継続します。

2. GoogleのreCAPTCHAを統合する

GoogleのreCAPTCHAWebサービスを統合するには、まずサイトをサービスに登録し、ライブラリをページに追加してから、ユーザーのキャプチャ応答をWebサービスで確認する必要があります。

https://www.google.com/recaptcha/adminで当サイトを登録しましょう。 登録プロセスにより、Webサービスにアクセスするためのサイトキーおよびシークレットキーが生成されます。

2.1. APIキーペアの保存

キーはapplication.properties:に保存されます

google.recaptcha.key.site=6LfaHiITAAAA...
google.recaptcha.key.secret=6LfaHiITAAAA...

そして、 @ConfigurationProperties:で注釈が付けられたBeanを使用して、それらをSpringに公開します。

@Component
@ConfigurationProperties(prefix = "google.recaptcha.key")
public class CaptchaSettings {

    private String site;
    private String secret;

    // standard getters and setters
}

2.2. ウィジェットの表示

シリーズのチュートリアルに基づいて、registerration.htmlを変更してGoogleのライブラリを追加します。

登録フォーム内に、属性data-sitekeysite-keyが含まれていることを期待するreCAPTCHAウィジェットを追加します。

ウィジェットは、送信時にリクエストパラメータg-recaptcha-responseを追加します

<!DOCTYPE html>
<html>
<head>

...

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

    ...

    <form action="/" method="POST" enctype="utf8">
        ...

        <div class="g-recaptcha col-sm-5"
          th:attr="data-sitekey=${@captchaSettings.getSite()}"></div>
        <span id="captchaError" class="alert alert-danger col-sm-4"
          style="display:none"></span>

3. サーバー側の検証

新しいリクエストパラメータは、サイトキーと、ユーザーがチャレンジを正常に完了したことを示す一意の文字列をエンコードします。

ただし、自分自身を識別できないため、ユーザーが送信したものが正当であるとは信頼できません。 キャプチャ応答をWebサービスAPIで検証するために、サーバー側の要求が行われます。

エンドポイントはURLでHTTPリクエストを受け入れます https://www.google.com/recaptcha/api/siteverify 、クエリパラメータを使用秘密 応答 、 と remoteip。 スキーマを持つjson応答を返します。

{
    "success": true|false,
    "challenge_ts": timestamp,
    "hostname": string,
    "error-codes": [ ... ]
}

3.1. ユーザーの応答を取得する

reCAPTCHAチャレンジに対するユーザーの応答は、 HttpServletRequestを使用してリクエストパラメーターg-recaptcha-responseから取得され、CaptchaServiceで検証されます。 応答の処理中にスローされた例外は、残りの登録ロジックを中止します。

public class RegistrationController {

    @Autowired
    private ICaptchaService captchaService;

    ...

    @RequestMapping(value = "/user/registration", method = RequestMethod.POST)
    @ResponseBody
    public GenericResponse registerUserAccount(@Valid UserDto accountDto, HttpServletRequest request) {
        String response = request.getParameter("g-recaptcha-response");
        captchaService.processResponse(response);

        // Rest of implementation
    }

    ...
}

3.2. 検証サービス

得られたキャプチャ応答は、最初にサニタイズする必要があります。 単純な正規表現が使用されます。

応答が正当であると思われる場合は、 secret-key captcha response 、およびクライアントのIPアドレスを使用してWebサービスにリクエストを送信します。

public class CaptchaService implements ICaptchaService {

    @Autowired
    private CaptchaSettings captchaSettings;

    @Autowired
    private RestOperations restTemplate;

    private static Pattern RESPONSE_PATTERN = Pattern.compile("[A-Za-z0-9_-]+");

    @Override
    public void processResponse(String response) {
        if(!responseSanityCheck(response)) {
            throw new InvalidReCaptchaException("Response contains invalid characters");
        }

        URI verifyUri = URI.create(String.format(
          "https://www.google.com/recaptcha/api/siteverify?secret=%s&response=%s&remoteip=%s",
          getReCaptchaSecret(), response, getClientIP()));

        GoogleResponse googleResponse = restTemplate.getForObject(verifyUri, GoogleResponse.class);

        if(!googleResponse.isSuccess()) {
            throw new ReCaptchaInvalidException("reCaptcha was not successfully validated");
        }
    }

    private boolean responseSanityCheck(String response) {
        return StringUtils.hasLength(response) && RESPONSE_PATTERN.matcher(response).matches();
    }
}

3.3. 検証の客観化

Jackson アノテーションで装飾されたJavaBeanは、検証応答をカプセル化します。

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonPropertyOrder({
    "success",
    "challenge_ts",
    "hostname",
    "error-codes"
})
public class GoogleResponse {

    @JsonProperty("success")
    private boolean success;
    
    @JsonProperty("challenge_ts")
    private String challengeTs;
    
    @JsonProperty("hostname")
    private String hostname;
    
    @JsonProperty("error-codes")
    private ErrorCode[] errorCodes;

    @JsonIgnore
    public boolean hasClientError() {
        ErrorCode[] errors = getErrorCodes();
        if(errors == null) {
            return false;
        }
        for(ErrorCode error : errors) {
            switch(error) {
                case InvalidResponse:
                case MissingResponse:
                    return true;
            }
        }
        return false;
    }

    static enum ErrorCode {
        MissingSecret,     InvalidSecret,
        MissingResponse,   InvalidResponse;

        private static Map<String, ErrorCode> errorsMap = new HashMap<String, ErrorCode>(4);

        static {
            errorsMap.put("missing-input-secret",   MissingSecret);
            errorsMap.put("invalid-input-secret",   InvalidSecret);
            errorsMap.put("missing-input-response", MissingResponse);
            errorsMap.put("invalid-input-response", InvalidResponse);
        }

        @JsonCreator
        public static ErrorCode forValue(String value) {
            return errorsMap.get(value.toLowerCase());
        }
    }
    
    // standard getters and setters
}

暗黙的に示されているように、 success プロパティの真理値は、ユーザーが検証されたことを意味します。 それ以外の場合、errorCodesプロパティに理由が入力されます。

hostname は、ユーザーをreCAPTCHAにリダイレクトしたサーバーを指します。 多くのドメインを管理していて、それらすべてが同じキーペアを共有するようにしたい場合は、hostnameプロパティを自分で確認することを選択できます。

3.4. 検証の失敗

検証が失敗した場合、例外がスローされます。 reCAPTCHAライブラリは、新しいチャレンジを作成するようにクライアントに指示する必要があります。

これは、クライアントの登録エラーハンドラで、ライブラリのgrecaptchaウィジェットでresetを呼び出すことによって行います。

register(event){
    event.preventDefault();

    var formData= $('form').serialize();
    $.post(serverContext + "user/registration", formData, function(data){
        if(data.message == "success") {
            // success handler
        }
    })
    .fail(function(data) {
        grecaptcha.reset();
        ...
        
        if(data.responseJSON.error == "InvalidReCaptcha"){ 
            $("#captchaError").show().html(data.responseJSON.message);
        }
        ...
    }
}

4. サーバーリソースの保護

悪意のあるクライアントは、ブラウザのサンドボックスのルールに従う必要はありません。 したがって、セキュリティの考え方は、公開されているリソースと、それらがどのように悪用される可能性があるかを確認する必要があります。

4.1. キャッシュを試みます

reCAPTCHAを統合することにより、要求が行われるたびに、サーバーが要求を検証するためのソケットを作成することを理解することが重要です。

真のDoS緩和には、より階層化されたアプローチが必要ですが、クライアントを4つの失敗したキャプチャ応答に制限する基本キャッシュを実装できます。

public class ReCaptchaAttemptService {
    private int MAX_ATTEMPT = 4;
    private LoadingCache<String, Integer> attemptsCache;

    public ReCaptchaAttemptService() {
        super();
        attemptsCache = CacheBuilder.newBuilder()
          .expireAfterWrite(4, TimeUnit.HOURS).build(new CacheLoader<String, Integer>() {
            @Override
            public Integer load(String key) {
                return 0;
            }
        });
    }

    public void reCaptchaSucceeded(String key) {
        attemptsCache.invalidate(key);
    }

    public void reCaptchaFailed(String key) {
        int attempts = attemptsCache.getUnchecked(key);
        attempts++;
        attemptsCache.put(key, attempts);
    }

    public boolean isBlocked(String key) {
        return attemptsCache.getUnchecked(key) >= MAX_ATTEMPT;
    }
}

4.2. 検証サービスのリファクタリング

クライアントが試行制限を超えた場合に中止することにより、キャッシュが最初に組み込まれます。 それ以外の場合、失敗した GoogleResponse を処理するときに、クライアントの応答にエラーを含む試行を記録します。 検証が成功すると、試行キャッシュがクリアされます。

public class CaptchaService implements ICaptchaService {

    @Autowired
    private ReCaptchaAttemptService reCaptchaAttemptService;

    ...

    @Override
    public void processResponse(String response) {

        ...

        if(reCaptchaAttemptService.isBlocked(getClientIP())) {
            throw new InvalidReCaptchaException("Client exceeded maximum number of failed attempts");
        }

        ...

        GoogleResponse googleResponse = ...

        if(!googleResponse.isSuccess()) {
            if(googleResponse.hasClientError()) {
                reCaptchaAttemptService.reCaptchaFailed(getClientIP());
            }
            throw new ReCaptchaInvalidException("reCaptcha was not successfully validated");
        }
        reCaptchaAttemptService.reCaptchaSucceeded(getClientIP());
    }
}

5. GoogleのreCAPTCHAv3を統合する

GoogleのreCAPTCHAv3は、ユーザーの操作を必要としないため、以前のバージョンとは異なります。 送信する各リクエストのスコアを提供するだけで、Webアプリケーションに対して実行する最終的なアクションを決定できます。

繰り返しになりますが、GoogleのreCAPTCHA 3を統合するには、まずサイトをサービスに登録し、ライブラリをページに追加してから、Webサービスでトークンの応答を確認する必要があります。

それでは、 https://www.google.com/recaptcha/admin/create でサイトを登録し、reCAPTCHA v3を選択した後、新しいシークレットキーとサイトキーを取得します。

5.1. application.propertiesとCaptchaSettingsの更新

登録後、application.propertiesを新しいキーと選択したスコアしきい値で更新する必要があります。

google.recaptcha.key.site=6LefKOAUAAAAAE...
google.recaptcha.key.secret=6LefKOAUAAAA...
google.recaptcha.key.threshold=0.5

0.5 に設定されたしきい値はデフォルト値であり、Google管理コンソールで実際のしきい値を分析することで時間の経過とともに調整できることに注意してください。

次に、CaptchaSettingsクラスを更新しましょう。

@Component
@ConfigurationProperties(prefix = "google.recaptcha.key")
public class CaptchaSettings {
    // ... other properties
    private float threshold;
    
    // standard getters and setters
}

5.2. フロントエンド統合

次に、 registerration.html を変更して、サイトキーにGoogleのライブラリを含めます。

登録フォーム内に、grecaptcha.execute関数への呼び出しから受信した応答トークンを格納する非表示フィールドを追加します。

<!DOCTYPE html>
<html>
<head>

...

<script th:src='|https://www.google.com/recaptcha/api.js?render=${@captchaService.getReCaptchaSite()}'></script>
</head>
<body>

    ...

    <form action="/" method="POST" enctype="utf8">
        ...

        <input type="hidden" id="response" name="response" value="" />
        ...
    </form>
   
   ...

<script th:inline="javascript">
   ...
   var siteKey = /*[[${@captchaService.getReCaptchaSite()}]]*/;
   grecaptcha.execute(siteKey, {action: /*[[${T(com.baeldung.captcha.CaptchaService).REGISTER_ACTION}]]*/}).then(function(response) {
	$('#response').val(response);    
    var formData= $('form').serialize();

5.3. サーバー側の検証

WebサービスAPIで応答トークンを検証するには、reCAPTCHAサーバー側検証で見られるのと同じサーバー側要求を行う必要があります。

応答JSONオブジェクトには、次の2つの追加プロパティが含まれます。

{
    ...
    "score": number,
    "action": string
}

スコアはユーザーの操作に基づいており、0(ボットである可能性が非常に高い)から1.0(人間である可能性が非常に高い)の間の値です。

アクションは、同じWebページで多くのreCAPTCHAリクエストを実行できるようにするために、Googleが導入した新しい概念です。

reCAPTCHA v3を実行するたびに、アクションを指定する必要があります。 また、応答のactionプロパティの値が予期された名前に対応していることを確認する必要があります。

5.4. 応答トークンを取得する

reCAPTCHA v3応答トークンは、HttpServletRequestを使用してresponse要求パラメーターから取得され、CaptchaServiceで検証されます。 メカニズムは、reCAPTCHAで上記に見られるものと同じです。

public class RegistrationController {

    @Autowired
    private ICaptchaService captchaService;

    ...

    @RequestMapping(value = "/user/registration", method = RequestMethod.POST)
    @ResponseBody
    public GenericResponse registerUserAccount(@Valid UserDto accountDto, HttpServletRequest request) {
        String response = request.getParameter("response");
        captchaService.processResponse(response, CaptchaService.REGISTER_ACTION);

        // rest of implementation
    }

    ...
}

5.5. v3を使用した検証サービスのリファクタリング

リファクタリングされたCaptchaService検証サービスクラスには、前のバージョンのprocessResponseメソッドに類似したprocessResponse メソッドが含まれていますが、アクション[ X219X]およびGoogleResponsescoreパラメーター:

public class CaptchaService implements ICaptchaService {

    public static final String REGISTER_ACTION = "register";
    ...

    @Override
    public void processResponse(String response, String action) {
        ...
      
        GoogleResponse googleResponse = restTemplate.getForObject(verifyUri, GoogleResponse.class);        
        if(!googleResponse.isSuccess() || !googleResponse.getAction().equals(action) 
            || googleResponse.getScore() < captchaSettings.getThreshold()) {
            ...
            throw new ReCaptchaInvalidException("reCaptcha was not successfully validated");
        }
        reCaptchaAttemptService.reCaptchaSucceeded(getClientIP());
    }
}

検証が失敗した場合は例外をスローしますが、v3では、JavaScriptクライアントで呼び出すresetメソッドがないことに注意してください。

サーバーリソースを保護するために、上記と同じ実装を引き続き使用します。

5.6. GoogleResponseクラスの更新

新しいプロパティscoreおよびactionGoogleResponseJavaBeanに追加する必要があります。

@JsonPropertyOrder({
    "success",
    "score", 
    "action",
    "challenge_ts",
    "hostname",
    "error-codes"
})
public class GoogleResponse {
    // ... other properties
    @JsonProperty("score")
    private float score;
    @JsonProperty("action")
    private String action;
    
    // standard getters and setters
}

6. 結論

この記事では、GoogleのreCAPTCHAライブラリを登録ページに統合し、サーバー側のリクエストでキャプチャ応答を検証するサービスを実装しました。

その後、GoogleのreCAPTCHA v3ライブラリを使用して登録ページをアップグレードし、ユーザーが何もする必要がなくなったため、登録フォームがスリムになることを確認しました。

このチュートリアルの完全な実装は、GitHubから入手できます。