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.