1. 概述

本教程是 Spring Security 注册系列的延续,我们将通过集成 Google reCAPTCHA 来增强注册流程,有效区分人类用户和恶意机器人。

2. 集成 Google reCAPTCHA

要集成 Google reCAPTCHA 服务,需完成三个关键步骤:注册站点获取密钥、在页面引入库文件、通过服务端验证用户响应。

2.1 获取并存储 API 密钥

首先在 Google reCAPTCHA 管理台 注册站点,获取 site-keysecret-key

# application.properties
google.recaptcha.key.site=6LfaHiITAAAA...
google.recaptcha.key.secret=6LfaHiITAAAA...

通过 @ConfigurationProperties 注解的 bean 暴露配置:

@Component
@ConfigurationProperties(prefix = "google.recaptcha.key")
public class CaptchaSettings {
    private String site;
    private String secret;
    // 标准 getter/setter
}

2.2 前端展示验证码组件

修改 registration.html 模板,在表单中添加 reCAPTCHA 组件:

<!DOCTYPE html>
<html>
<head>
    <script src='https://www.google.com/recaptcha/api.js'></script>
</head>
<body>
    <form method="POST" enctype="utf8">
        <!-- 其他表单字段 -->
        
        <!-- reCAPTCHA 组件 -->
        <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>
    </form>
</body>
</html>

⚠️ 组件会自动在提交时附加 g-recaptcha-response 参数

3. 服务端验证机制

3.1 提取用户响应

在控制器中获取验证码响应并调用验证服务:

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); // 验证失败会抛出异常
        
        // 后续注册逻辑
    }
}

3.2 实现验证服务

验证服务需完成三步:响应净化、API 调用、结果解析:

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) {
        // 1. 响应净化
        if (!responseSanityCheck(response)) {
            throw new InvalidReCaptchaException("响应包含非法字符");
        }

        // 2. 构建 API 请求
        URI verifyUri = URI.create(String.format(
            "https://www.google.com/recaptcha/api/siteverify?secret=%s&response=%s&remoteip=%s",
            getReCaptchaSecret(), response, getClientIP()));

        // 3. 发送请求并解析结果
        GoogleResponse googleResponse = restTemplate.getForObject(verifyUri, GoogleResponse.class);
        
        if (!googleResponse.isSuccess()) {
            throw new ReCaptchaInvalidException("reCAPTCHA 验证失败");
        }
    }

    private boolean responseSanityCheck(String response) {
        return StringUtils.hasLength(response) && RESPONSE_PATTERN.matcher(response).matches();
    }
}

3.3 响应对象封装

使用 Jackson 注解封装 Google API 响应:

@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() {
        if (errorCodes == null) return false;
        return Arrays.stream(errorCodes)
            .anyMatch(e -> e == ErrorCode.InvalidResponse || e == ErrorCode.MissingResponse);
    }

    static enum ErrorCode {
        MissingSecret, InvalidSecret,
        MissingResponse, InvalidResponse;

        private static Map<String, ErrorCode> 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());
        }
    }
    // 标准 getter/setter
}

3.4 验证失败处理

前端 JavaScript 处理验证失败场景:

register(event) {
    event.preventDefault();
    var formData = $('form').serialize();
    
    $.post(serverContext + "user/registration", formData, function(data) {
        if (data.message == "success") {
            // 成功处理逻辑
        }
    })
    .fail(function(data) {
        grecaptcha.reset(); // 重置验证码
        if (data.responseJSON.error == "InvalidReCaptcha") { 
            $("#captchaError").show().html(data.responseJSON.message);
        }
    });
}

4. 服务端资源防护

4.1 实现尝试次数限制

使用 Guava Cache 限制客户端失败次数:

public class ReCaptchaAttemptService {
    private static final int MAX_ATTEMPT = 4;
    private LoadingCache<String, Integer> attemptsCache;

    public ReCaptchaAttemptService() {
        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) {
        attemptsCache.put(key, attemptsCache.getUnchecked(key) + 1);
    }

    public boolean isBlocked(String key) {
        return attemptsCache.getUnchecked(key) >= MAX_ATTEMPT;
    }
}

4.2 集成防护机制

重构验证服务,加入尝试次数检查:

public class CaptchaService implements ICaptchaService {
    @Autowired private ReCaptchaAttemptService reCaptchaAttemptService;

    @Override
    public void processResponse(String response) {
        // 检查是否超过尝试限制
        if (reCaptchaAttemptService.isBlocked(getClientIP())) {
            throw new InvalidReCaptchaException("客户端超过最大尝试次数");
        }

        // ... 原有验证逻辑 ...

        GoogleResponse googleResponse = restTemplate.getForObject(verifyUri, GoogleResponse.class);
        
        if (!googleResponse.isSuccess()) {
            if (googleResponse.hasClientError()) {
                reCaptchaAttemptService.reCaptchaFailed(getClientIP());
            }
            throw new ReCaptchaInvalidException("reCAPTCHA 验证失败");
        }
        
        reCaptchaAttemptService.reCaptchaSucceeded(getClientIP());
    }
}

5. 升级 reCAPTCHA v3

5.1 配置更新

application.properties 添加阈值配置:

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

更新 CaptchaSettings 类:

@Component
@ConfigurationProperties(prefix = "google.recaptcha.key")
public class CaptchaSettings {
    // ... 其他属性
    private float threshold;
    // 标准 getter/setter
}

5.2 前端集成

修改 registration.html 实现无感验证:

<!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(token) {
            $('#response').val(token);
            $('form').submit();
        });
    </script>
</body>
</html>

5.3 服务端验证增强

验证服务需检查新增的 scoreaction 字段:

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 验证失败");
        }
        
        reCaptchaAttemptService.reCaptchaSucceeded(getClientIP());
    }
}

5.4 更新响应对象

GoogleResponse 中添加新字段:

@JsonPropertyOrder({
    "success", "score", "action", 
    "challenge_ts", "hostname", "error-codes"
})
public class GoogleResponse {
    // ... 其他属性
    @JsonProperty("score")
    private float score;
    @JsonProperty("action")
    private String action;
    // 标准 getter/setter
}

6. 总结

本文实现了以下关键功能: ✅ 集成 reCAPTCHA v2 到 Spring Security 注册流程 ✅ 实现服务端验证机制 ✅ 添加客户端尝试次数限制 ✅ 升级到无感验证的 reCAPTCHA v3

完整实现代码可在 GitHub 获取。踩坑提示:生产环境建议结合 CDN 和速率限制实现更全面的防护。


原始标题:Registration with Spring – Integrate reCAPTCHA