Enregistrement avec Spring - Intégrez reCAPTCHA

Enregistrement avec Spring - Integrate reCAPTCHA

1. Vue d'ensemble

Dans cet article, nous allons continuer la sérieSpring Security Registration en ajoutantGooglereCAPTCHA au processus d'enregistrement afin de différencier l'humain des bots.

2. Intégrer le reCAPTCHA de Google

Pour intégrer le service Web reCAPTCHA de Google, nous devons d'abord enregistrer notre site auprès du service, ajouter leur bibliothèque à notre page, puis vérifier la réponse captcha de l'utilisateur avec le service Web.

Enregistrons notre site àhttps://www.google.com/recaptcha/admin. Le processus d'enregistrement génère unsite-key et unsecret-key pour accéder au service Web.

2.1. Stockage de la paire de clés API

Nous stockons les clés dans lesapplication.properties:

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

Et exposez-les à Spring en utilisant un bean annoté avec@ConfigurationProperties:

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

    private String site;
    private String secret;

    // standard getters and setters
}

2.2. Affichage du widget

En nous appuyant sur le tutoriel de la série, nous allons maintenant modifier lesregistration.html pour inclure la bibliothèque de Google.

Dans notre formulaire d'inscription, nous ajoutons le widget reCAPTCHA qui s'attend à ce que l'attributdata-sitekey contienne lessite-key.

Le widget ajouterathe request parameter g-recaptcha-response when submitted:





...





    ...

    
...

3. Validation côté serveur

Le nouveau paramètre de requête encode la clé de notre site et une chaîne unique identifiant la réussite du défi par l'utilisateur.

Cependant, puisque nous ne pouvons pas le discerner nous-mêmes, nous ne pouvons pas croire que ce que l'utilisateur a soumis est légitime. Une requête côté serveur est effectuée pour valider lescaptcha response avec l'API du service Web.

Le point de terminaison accepte une requête HTTP sur l'URLhttps://www.google.com/recaptcha/api/siteverify, avec les paramètres de requêtesecret,response etremoteip..Il renvoie une réponse json ayant le schéma:

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

3.1. Récupérer la réponse de l'utilisateur

La réponse de l’utilisateur au défi reCAPTCHA est extraite du paramètre de requêteg-recaptcha-response à l’aide deHttpServletRequest et validée avec nosCaptchaService. Toute exception levée lors du traitement de la réponse annulera le reste de la logique d'inscription:

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. Service de validation

La réponse captcha obtenue doit être nettoyée en premier. Une expression régulière simple est utilisée.

Si la réponse semble légitime, nous adressons une requête au service Web avec lessecret-key, lescaptcha response et lesIP address du client:

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. Objectiver la validation

Un bean Java décoré d'annotationsJackson encapsule la réponse de validation:

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

Comme implicite, une valeur de vérité dans la propriétésuccess signifie que l'utilisateur a été validé. Sinon, la propriétéerrorCodes remplira avec la raison.

Lehostname fait référence au serveur qui a redirigé l'utilisateur vers le reCAPTCHA. Si vous gérez de nombreux domaines et souhaitez qu'ils partagent tous la même paire de clés, vous pouvez choisir de vérifier vous-même la propriétéhostname.

3.4. Échec de la validation

En cas d'échec de la validation, une exception est levée. La bibliothèque reCAPTCHA doit demander au client de créer un nouveau défi.

Nous le faisons dans le gestionnaire d'erreurs d'enregistrement du client, en invoquant reset sur le widgetgrecaptcha de la bibliothèque:

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. Protéger les ressources du serveur

Les clients malveillants n'ont pas besoin d'obéir aux règles du sandbox du navigateur. Notre mentalité en matière de sécurité devrait donc être axée sur les ressources exposées et la manière dont elles pourraient être maltraitées.

4.1. Cache des tentatives

Il est important de comprendre qu'en intégrant reCAPTCHA, chaque demande effectuée entraînera le serveur à créer un socket pour valider la demande.

Bien qu'une approche plus en couches soit nécessaire pour une véritable atténuation de la DoS; Nous pouvons implémenter un cache élémentaire qui limite un client à 4 réponses captcha ayant échoué:

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. Refactoriser le service de validation

Le cache est d'abord incorporé en abandonnant si le client a dépassé la limite de tentatives. Sinon, lors du traitement d'unGoogleResponse infructueux, nous enregistrons les tentatives contenant une erreur avec la réponse du client. La validation réussie efface le cache des tentatives:

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. Conclusion

Dans cet article, nous avons intégré la bibliothèque reCAPTCHA de Google dans notre page d'inscription et mis en œuvre un service pour vérifier la réponse captcha avec une requête côté serveur.

L'implémentation complète de ce tutoriel est disponible dans legithub project - il s'agit d'un projet basé sur Maven, il devrait donc être facile à importer et à exécuter tel quel.