RecaptchaVerifier.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service.auth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.rest.springboot.model.dto.RecaptchaSiteVerifyResponse;
import org.entrystore.rest.springboot.model.exception.CustomResponseException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.UnknownHttpStatusCodeException;

import java.util.Set;

/**
 * Verifies the validity of a reCaptcha user response token.
 * <p>
 * Uses reCaptcha API 2.0.
 *
 * @author Hannes Ebner
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class RecaptchaVerifier {

	/** Codes that indicate the deployment's own configuration is wrong, not the caller's token. */
	private static final Set<String> DEPLOYMENT_ERROR_CODES =
		Set.of("missing-input-secret", "invalid-input-secret", "bad-request");

	@Qualifier("recaptchaRestClient")
	private final RestClient recaptchaRestClient;

	private final RecaptchaSettings recaptchaSettings;

	@Value("${entrystore.auth.recaptcha.url:https://www.google.com/recaptcha/api/siteverify}")
	private String url;

	/**
	 * Verifies a user response token using reCaptcha 2.0 API.
	 *
	 * @param rcResponseV2 The user response token (usually contained in g-recaptcha-response POST parameter).
	 * @param userIP       The user's IP-address. Optional, i.e., may be null.
	 * @return True if the user response token has been successfully verified, false otherwise.
	 */
	public boolean verify(String rcResponseV2, String userIP) {
		// The secret is a request parameter, so never build or log a URL carrying it — that writes the
		// deployment's reCaptcha private key into the log on every verification. Log the endpoint alone.
		log.debug("Verifying reCaptcha response via {}", url);

		MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
		params.add("secret", recaptchaSettings.getSecret());
		params.add("response", rcResponseV2);
		if (userIP != null) {
			params.add("remoteip", userIP);
		}

		ResponseEntity<RecaptchaSiteVerifyResponse> response;
		try {
			response = recaptchaRestClient.post()
				.uri(url)
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.body(params)
				.retrieve()
				.toEntity(RecaptchaSiteVerifyResponse.class);
		} catch (ResourceAccessException | HttpServerErrorException | UnknownHttpStatusCodeException e) {
			throw new CustomResponseException(
				"reCaptcha verifier is currently unavailable. Please try again later.",
				HttpStatus.SERVICE_UNAVAILABLE, e);
		} catch (HttpClientErrorException e) {
			// 4xx is deliberately not remapped: a misconfigured secret must surface, not be masked as a
			// transient 503. Rethrown explicitly because the RestClientException catch below would
			// otherwise swallow it into a rejected captcha.
			throw e;
		} catch (RestClientException e) {
			// The body is decoded inside toEntity, so a 2xx whose body is not this record — an
			// intercepting proxy's text/html page, a truncated reply — surfaces here as
			// UnknownContentTypeException or a bare RestClientException. Neither is a server fault of
			// ours, and treating it as "not verified" both fails closed and keeps the deterministic
			// statuses this verifier was hardened for; letting it propagate answers 500 on an
			// unauthenticated endpoint instead.
			log.warn("Could not read the reCaptcha siteverify response; treating the token as unverified", e);
			return false;
		}

		RecaptchaSiteVerifyResponse result = response.getBody();
		if (!response.getStatusCode().is2xxSuccessful() || result == null) {
			return false;
		}

		if (!result.success() && !result.errorCodes().isEmpty()) {
			// A secret-related code means the deployment is misconfigured and *every* sign-up will fail,
			// so it belongs above DEBUG: at the default level a total sign-up outage would otherwise be
			// indistinguishable from ordinary users mistyping captchas. A per-user code such as
			// timeout-or-duplicate stays at DEBUG.
			if (result.errorCodes().stream().anyMatch(DEPLOYMENT_ERROR_CODES::contains)) {
				log.warn("reCaptcha rejected the request for a configuration reason, error codes: {}",
					result.errorCodes());
			} else {
				log.debug("reCaptcha verification rejected, error codes: {}", result.errorCodes());
			}
		}
		return result.success();
	}

}