Security Top

I just announced the new Learn Spring Security course, including the full material focused on the new OAuth2 stack in Spring Security 5:

>> CHECK OUT THE COURSE

1. Overview

In this tutorial, we'll continue the Spring Security Registration series by adding Google reCAPTCHA to the registration process in order to differentiate human from bots.

2. Integrating Google's reCAPTCHA

To integrate Google's reCAPTCHA web-service, we first need to register our site with the service, add their library to our page, and then verify the user's captcha response with the web-service.

Let's register our site at https://www.google.com/recaptcha/admin. The registration process generates a site-key and secret-key for accessing the web-service.

2.1. Storing the API Key-Pair

We store the keys in the application.properties:

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

And expose them to Spring using a bean annotated with @ConfigurationProperties:

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

    private String site;
    private String secret;

    // standard getters and setters
}

2.2. Displaying the Widget

Building upon the tutorial from series, we'll now modify the registration.html to include Google's library.

Inside our registration form, we add the reCAPTCHA widget which expects the attribute data-sitekey to contain the site-key.

The widget will append the request parameter g-recaptcha-response when submitted:

<!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. Server-Side Validation

The new request parameter encodes our site key and a unique string identifying the user's successful completion of the challenge.

However, since we cannot discern that ourselves, we cannot trust what the user has submitted is legitimate. A server-side request is made to validate the captcha response with the web-service API.

The endpoint accepts an HTTP request on the URL https://www.google.com/recaptcha/api/siteverify, with the query parameters secret, response, and remoteip. It returns a json response having the schema:

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

3.1. Retrieve User's Response

The user's response to the reCAPTCHA challenge is retrieved from the request parameter g-recaptcha-response using HttpServletRequest and validated with our CaptchaService. Any exception thrown while processing the response will abort the rest of the registration logic:

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. Validation Service

The captcha response obtained should be sanitized first. A simple regular expression is used.

If the response looks legitimate, we then make a request to the web-service with the secret-key, the captcha response, and the client's IP address:

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. Objectifying the Validation

A Java-bean decorated with Jackson annotations encapsulates the validation response:

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

As implied, a truth value in the success property means the user has been validated. Otherwise the errorCodes property will populate with the reason.

The hostname refers to the server that redirected the user to the reCAPTCHA. If you manage many domains and wish them all to share the same key-pair, you can choose to verify the hostname property yourself.

3.4. Validation Failure

In the event of a validation failure, an exception is thrown. The reCAPTCHA library needs to instruct the client to create a new challenge.

We do so in the client's registration error-handler, by invoking reset on the library's grecaptcha widget:

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. Protecting Server Resources

Malicious clients do not need to obey the rules of the browser sandbox. So our security mindset should be at the resources exposed and how they might be abused.

4.1. Attempts Cache

It is important to understand that by integrating reCAPTCHA, every request made will cause the server to create a socket to validate the request.

While we'd need a more layered approach for a true DoS mitigation, we can implement an elementary cache that restricts a client to 4 failed captcha responses:

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. Refactoring the Validation Service

The cache is incorporated first by aborting if the client has exceeded the attempt limit. Otherwise when processing an unsuccessful GoogleResponse we record the attempts containing an error with the client's response. Successful validation clears the attempts cache:

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. Integrating Google's reCAPTCHA v3

Google's reCAPTCHA v3 differs from the previous versions because it doesn't require any user interaction. It simply gives a score for each request that we send and lets us decide what final actions to take for our web application.

Again, to integrate Google's reCAPTCHA 3, we first need to register our site with the service, add their library to our page, and then verify the token response with the web service.

So, let's register our site at https://www.google.com/recaptcha/admin/create and, after selecting reCAPTCHA v3, we'll obtain the new secret and site keys.

5.1. Updating application.properties and CaptchaSettings

After registering, we need to update application.properties with the new keys and our chosen score threshold value:

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

It's important to note that the threshold set to 0.5 is a default value and can be tuned over time by analyzing the real threshold values in the Google admin console.

Next, let's update our CaptchaSettings class:

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

5.2. Front-End Integration

We'll now modify the registration.html to include Google's library with our site key.

Inside our registration form, we add a hidden field that will store the response token received from the call to the grecaptcha.execute function:

<!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. Server-Side Validation

We'll have to make the same server-side request seen in reCAPTCHA Server-Side Validation to validate the response token with the web service API.

The response JSON object will contain two additional properties:

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

The score is based on the user's interactions and is a value between 0 (very likely a bot) and 1.0 (very likely a human).

Action is a new concept that Google introduced so that we can execute many reCAPTCHA requests on the same web page.

An action must be specified every time we execute the reCAPTCHA v3. And, we have to verify that the value of the action property in the response corresponds to the expected name.

5.4. Retrieve the Response Token

The reCAPTCHA v3 response token is retrieved from the response request parameter using HttpServletRequest and validated with our CaptchaService. The mechanism is identical to the one seen above in the 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. Refactoring the Validation Service With v3

The refactored CaptchaService validation service class contains a processResponse method analog to the processResponse method of the previous version, but it takes care to check the action and the score parameters of the 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());
    }
}

In case validation fails, we'll throw an exception, but note that with v3, there's no reset method to invoke in the JavaScript client.

We'll still have the same implementation seen above for protecting server resources.

5.6. Updating the GoogleResponse Class

We need to add the new properties score and action to the GoogleResponse Java bean:

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

In this article, we integrated Google's reCAPTCHA library into our registration page and implemented a service to verify the captcha response with a server-side request.

Later, we upgraded the registration page with Google's reCAPTCHA v3 library and saw that the registration form becomes leaner because the user doesn't need to take any action anymore.

The full implementation of this tutorial is available over on GitHub.

Security bottom

I just announced the new Learn Spring Security course, including the full material focused on the new OAuth2 stack in Spring Security 5:

>> CHECK OUT THE COURSE
newest oldest most voted
Notify of
akuma8
Guest
akuma8

Hi Eugen,
Thanks again for this tutorial. I have one question about the REST Client connection configuration.
In this example you didn’tt config any HTTP Client connection, using directly the default “RestOperations ” interface, I would like to know if it is enough for a multi-users web site?
Or in this case we need to define an HTTP Pool connections.
Thanks again

Eugen Paraschiv
Guest

Hey Akuma,
Let’s jump right in. That’s simply a choice of how the bean is wired in – you can of course wire in the actual class if you want to – it’s going to be the same bean at runtime.
Now, in this particular case, this is simply a stateless GET, so yes – it’s OK for a multi-user system. That being said, that is a very good question to keep in mind whenever you’re using an HTTP client like this.
Cheers,
Eugen.

akuma8
Guest
akuma8

Thank you for replying, I will use the same solution because I don’t know how to well configure an HTTP pool.
Thanks again.

Comments are closed on this article!