«1. Обзор

В этом руководстве мы продолжим серию Spring Security Registration, добавив Google reCAPTCHA в процесс регистрации, чтобы отличать людей от ботов.

2. Интеграция reCAPTCHA от Google

Чтобы интегрировать веб-сервис reCAPTCHA от Google, нам сначала нужно зарегистрировать наш сайт в сервисе, добавить их библиотеку на нашу страницу, а затем проверить ответ пользователя на капчу с помощью веб-сервиса.

Давайте зарегистрируем наш сайт по адресу https://www.google.com/recaptcha/admin. В процессе регистрации генерируется ключ сайта и секретный ключ для доступа к веб-сервису.

2.1. Хранение пары ключей API

Мы храним ключи в application.properties:

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

И предоставляем их Spring с помощью bean-компонента с аннотацией @ConfigurationProperties:

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

    private String site;
    private String secret;

    // standard getters and setters
}

2.2. Отображение виджета

Опираясь на руководство из серии, мы теперь изменим Registration.html, включив в него библиотеку Google.

Внутри нашей регистрационной формы мы добавляем виджет reCAPTCHA, который ожидает, что атрибут data-sitekey будет содержать ключ сайта.

Виджет добавит параметр запроса 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. Проверка на стороне сервера

Новый параметр запроса кодирует ключ нашего сайта и уникальную строку, идентифицирующую успешное завершение пользователем вызова.

Однако, поскольку мы не можем различить это сами, мы не можем доверять тому, что отправил пользователь, является законным. На стороне сервера выполняется запрос на проверку ответа капчи с помощью API веб-сервиса.

Конечная точка принимает HTTP-запрос по URL-адресу https://www.google.com/recaptcha/api/siteverify с параметрами запроса secret, response и remoteip. Он возвращает ответ json со схемой:

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

3.1. Получение ответа пользователя

Ответ пользователя на запрос reCAPTCHA извлекается из параметра запроса g-recaptcha-response с помощью HttpServletRequest и проверяется с помощью нашего сервиса 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. Служба проверки

Полученный ответ на капчу должен быть предварительно обработан. Используется простое регулярное выражение.

Если ответ выглядит законным, мы делаем запрос к веб-сервису с секретным ключом, ответом капчи и IP-адресом клиента:

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. Объективизация проверки

Java-бин, украшенный аннотациями Джексона, инкапсулирует ответ проверки:

@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
}

Как подразумевается, значение истинности в свойстве успеха означает, что пользователь прошел проверку. В противном случае свойство errorCodes будет заполнено причиной.

Имя хоста относится к серверу, который перенаправил пользователя на reCAPTCHA. Если вы управляете несколькими доменами и хотите, чтобы все они использовали одну и ту же пару ключей, вы можете самостоятельно проверить свойство hostname.

3.4. Ошибка проверки

В случае ошибки проверки создается исключение. Библиотека reCAPTCHA должна указать клиенту создать новую задачу.

Мы делаем это в обработчике ошибок регистрации клиента, вызывая сброс в виджете grecaptcha библиотеки:

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 reCAPTCHA v3

Google reCAPTCHA v3 отличается от предыдущих версий тем, что не требует взаимодействия с пользователем. Он просто дает оценку для каждого отправляемого нами запроса и позволяет нам решить, какие окончательные действия предпринять для нашего веб-приложения.

Опять же, чтобы интегрировать Google reCAPTCHA 3, нам сначала нужно зарегистрировать наш сайт в службе, добавить их библиотеку на нашу страницу, а затем проверить ответ токена с помощью веб-службы.

Итак, давайте зарегистрируем наш сайт на https://www.google.com/recaptcha/admin/create и, выбрав reCAPTCHA v3, получим новый секрет и ключи сайта.

5.1. Обновление application.properties и CaptchaSettings

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

После регистрации нам нужно обновить application.properties новыми ключами и выбранным нами пороговым значением оценки:

Важно отметить, что пороговое значение, установленное на 0,5, является значением по умолчанию. и может быть настроено с течением времени путем анализа реальных пороговых значений в консоли администратора Google.

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

Далее обновим наш класс CaptchaSettings:

5.2. Front-End Integration

Теперь мы изменим Registration.html, чтобы включить библиотеку Google с ключом нашего сайта.

<!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();

Внутри нашей регистрационной формы мы добавляем скрытое поле, в котором будет храниться токен ответа, полученный от вызова функции grecaptcha.execute:

5.3. Проверка на стороне сервера

Нам нужно будет сделать тот же запрос на стороне сервера, что и в проверке на стороне сервера reCAPTCHA, чтобы проверить токен ответа с помощью API веб-службы.

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

Объект ответа JSON будет содержать два дополнительных свойства:

Оценка основана на взаимодействиях пользователя и имеет значение от 0 (скорее всего, бот) до 1,0 (скорее всего, человек).

Действие — это новая концепция, которую представил Google, чтобы мы могли выполнять множество запросов reCAPTCHA на одной и той же веб-странице.

Необходимо указывать действие каждый раз, когда мы выполняем reCAPTCHA v3. И мы должны убедиться, что значение свойства действия в ответе соответствует ожидаемому имени.

5.4. Получение токена ответа

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
    }

    ...
}

Токен ответа reCAPTCHA v3 извлекается из параметра запроса ответа с помощью HttpServletRequest и проверяется с помощью нашего CaptchaService. Механизм идентичен показанному выше в reCAPTCHA:

5.5. Рефакторинг службы валидации в версии 3

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());
    }
}

Рефакторинговый класс службы валидации CaptchaService содержит метод processResponse, аналогичный методу processResponse предыдущей версии, но он заботится о проверке параметров действия и оценки GoogleResponse:

~~ ~ В случае неудачной проверки мы создадим исключение, но обратите внимание, что в версии 3 нет метода сброса, который можно было бы вызвать в клиенте JavaScript.

У нас по-прежнему будет та же реализация, что и выше, для защиты ресурсов сервера.

5.6. Обновление класса GoogleResponse

@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
}

Нам нужно добавить новую оценку свойств и действие в Java-бин GoogleResponse:

6. Заключение

В этой статье мы интегрировали библиотеку Google reCAPTCHA на нашу страницу регистрации и внедрили сервис для проверки ответа капчи с запросом на стороне сервера.

Позже мы обновили страницу регистрации с помощью библиотеки Google reCAPTCHA v3 и увидели, что форма регистрации стала компактнее, поскольку пользователю больше не нужно предпринимать никаких действий.