Course – LSS – NPI (cat=Security/Spring Security)
announcement - icon

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

>> 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 humans 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 the 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 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 on 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 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 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

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.

Course – LSS (cat=Security/Spring Security)

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

>> CHECK OUT THE COURSE
res – Security (video) (cat=Security/Spring Security)
Comments are closed on this article!