Перейти к основному содержимому

Регистрация в Spring — Интеграция reCAPTCHA

· 9 мин. чтения

1. Обзор

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

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

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

Давайте зарегистрируем наш сайт по адресу 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 будет содержать site-key .

Виджет добавит параметр запроса 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, мы можем реализовать элементарный кеш, который ограничивает клиента четырьмя неудачными ответами на капчу:

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****

После регистрации нам нужно обновить 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. Интерфейсная интеграция

Теперь мы изменим Registration.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.foreach.captcha.CaptchaService).REGISTER_ACTION}]]*/}).then(function(response) {
$('#response').val(response);
var formData= $('form').serialize();

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

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

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

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

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

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

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

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

Токен ответа reCAPTCHA v3 извлекается из параметра запроса ответа с помощью HttpServletRequest и проверяется с помощью нашего 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 предыдущей версии, но он тщательно проверяет параметры действия и оценки GoogleResponse :

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

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

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

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

Нам нужно добавить новую оценку свойств и действие в Java- бин 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
}

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

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

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

Полная реализация этого руководства доступна на GitHub .