Registro no Spring - Integrar reCAPTCHA

Registro no Spring - Integrar reCAPTCHA

1. Visão geral

Neste artigo, continuaremos a sérieSpring Security Registration adicionandoGooglereCAPTCHA ao processo de registro para diferenciar humanos de bots.

2. Integração do reCAPTCHA do Google

Para integrar o serviço da web reCAPTCHA do Google, primeiro precisamos registrar nosso site com o serviço, adicionar sua biblioteca à nossa página e, em seguida, verificar a resposta do captcha do usuário com o serviço da web.

Vamos registrar nosso site emhttps://www.google.com/recaptcha/admin. O processo de registro gerasite-keyesecret-key para acessar o serviço da web.

2.1. Armazenamento do par de chaves API

Armazenamos as chaves emapplication.properties:

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

E exponha-os ao Spring usando um bean anotado com@ConfigurationProperties:

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

    private String site;
    private String secret;

    // standard getters and setters
}

2.2. Exibindo o widget

Com base no tutorial da série, agora modificaremosregistration.html para incluir a biblioteca do Google.

Dentro de nosso formulário de registro, adicionamos o widget reCAPTCHA que espera que o atributodata-sitekey contenhasite-key.

O widget irá anexarthe request parameter g-recaptcha-response when submitted:





...





    ...

    
...

3. Validação do lado do servidor

O novo parâmetro de solicitação codifica a chave do nosso site e uma string única que identifica a conclusão bem-sucedida do desafio pelo usuário.

No entanto, como não podemos discernir isso, não podemos confiar no que o usuário enviou é legítimo. Uma solicitação do lado do servidor é feita para validarcaptcha response com a API de serviço da web.

O endpoint aceita uma solicitação HTTP no URLhttps://www.google.com/recaptcha/api/siteverify, com os parâmetros de consultasecret,response eremoteip. Ele retorna uma resposta json com o esquema:

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

3.1. Recuperar a resposta do usuário

A resposta do usuário ao desafio reCAPTCHA é recuperada do parâmetro de solicitaçãog-recaptcha-response usandoHttpServletRequeste validada com nossoCaptchaService. Qualquer exceção lançada durante o processamento da resposta interromperá o restante da lógica de registro:

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. Serviço de Validação

A resposta captcha obtida deve ser higienizada primeiro. Uma expressão regular simples é usada.

Se a resposta parecer legítima, fazemos uma solicitação ao serviço da web comsecret-key,captcha response eIP address do cliente:

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. Objetivando a Validação

Um Java-bean decorado com anotaçõesJackson encapsula a resposta de validação:

@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 errorsMap = new HashMap(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
}

Como implícito, um valor verdadeiro na propriedadesuccess significa que o usuário foi validado. Caso contrário, a propriedadeerrorCodes será preenchida com o motivo.

Ohostname se refere ao servidor que redirecionou o usuário para o reCAPTCHA. Se você gerencia muitos domínios e deseja que todos compartilhem o mesmo par de chaves, pode optar por verificar a propriedadehostname você mesmo.

3.4. Falha de validação

No caso de uma falha na validação, uma exceção é lançada. A biblioteca reCAPTCHA precisa instruir o cliente a criar um novo desafio.

Fazemos isso no gerenciador de erros de registro do cliente, invocando reset no widgetgrecaptcha da biblioteca:

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. Protegendo os recursos do servidor

Clientes maliciosos não precisam obedecer às regras da caixa de proteção do navegador. Portanto, nossa mentalidade de segurança deve estar nos recursos expostos e como eles podem ser abusados.

4.1. Cache de tentativas

É importante entender que, ao integrar o reCAPTCHA, cada solicitação feita fará com que o servidor crie um soquete para validar a solicitação.

Embora uma abordagem mais estratificada seja necessária para uma verdadeira mitigação de DoS; Podemos implementar um cache elementar que restringe um cliente a 4 respostas de captcha com falha:

public class ReCaptchaAttemptService {
    private int MAX_ATTEMPT = 4;
    private LoadingCache attemptsCache;

    public ReCaptchaAttemptService() {
        super();
        attemptsCache = CacheBuilder.newBuilder()
          .expireAfterWrite(4, TimeUnit.HOURS).build(new CacheLoader() {
            @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. Refatorando o serviço de validação

O cache é incorporado primeiro pela interrupção se o cliente excedeu o limite de tentativas. Caso contrário, ao processar umGoogleResponse malsucedido, registramos as tentativas que contêm um erro com a resposta do cliente. A validação bem-sucedida limpa o cache de tentativas:

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. Conclusão

Neste artigo, integramos a biblioteca reCAPTCHA do Google em nossa página de registro e implementamos um serviço para verificar a resposta do captcha com uma solicitação do lado do servidor.

A implementação completa deste tutorial está disponível emgithub project - este é um projeto baseado em Maven, portanto, deve ser fácil de importar e executar como está.