在SpringBoot App中使用GoogleReCaptcha3过滤非法的请求

现在的应用中对于登录,注册,短信验证码。。。这些场景来说,验证码真的是必不可少。随着技术的发展,也使得验证码从当初的图形验证码,发展到今天的滑块,倒立文字点击,数学计算,手势滑动,拼图,刮图。。。等等各种花样,总之一个目的,阻止机器人的访问

验证码这玩意儿,确实给用户带来了很不好的体验,很多应用的验证码确实玄乎的很让人让人抓狂。

ReCaptcha

这是谷歌的一个验证码程序,它免费,强大,很多世界级别的应用都在使用它(靠谱)。

它现在有2个比较流行的版本!!

ReCaptcha 2,它长这样


这玩意儿,有时候挺让人烦的,一看到这种验证码,愁得慌。

ReCaptcha 3

没法展示给你看… 是的,没图,没摁钮。这是最高级的一个版本,不骚扰用户。而是偷偷的读取一些客户端的环境数据(具体是啥我也不知道),提交给服务器,最后服务器给出一个数字评分。0 - 1。0 肯定是机器人,1肯定不是。应用需要通过这个评分来决定当前请求是否合法。

这也是这篇文章,要接入的版本,下面,从注册开始,演示一个接入ReCaptcha 3的案例。

注册

不要问为什么打不开下面的这些地址,我也不知道。 :cn:

注册地址 https://www.google.com/recaptcha/admin/create


很简单,按照自己的需求填写名字,应用的访问域名。即可。

注册成功


有2个密钥,需要记住。第一个叫做前端密钥,会暴露在客户端。第二个叫做后端密钥,用于和远程服务器通信,不能暴露给客户端。

管理控制台

https://www.google.com/recaptcha/admin
控制台可以查看一些验证信息,例如请求数量之类的,很简单,自己看就懂。

文档

更多的细节,可以看看官方文档

创建一个SpringBoot应用

可以从 http://start.springboot.io/ 创建

创建过程就省略了,不需要任何第三方的依赖,基本的SpringBoot依赖就行。

把key配置在yml中

recaptcha:
  client-key: "6LdvzboZAAAAALrLVyjabnd5xfb06izncgK0JXCt"
  server-key: "6LdvzboZAAAAAE1mLwijdq9noxVUbWRBmqm-4Ava"

配置在yml中,程序可以灵活读取

Web客户端集成

<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>ReCaptcha V3</title>
		<!-- render参数值就是前端密钥 -->
		<script src="https://www.recaptcha.net/recaptcha/api.js?render=6LdvzboZAAAAALrLVyjabnd5xfb06izncgK0JXCt"></script>
	</head>
	<body>
		<input name="name" placeholder="输入名字"  id="name"/>
		<button id="button">提交</button>
	</body>
	<script type="text/javascript">
		// 前端密钥
		const CLIENT_KEY = "6LdvzboZAAAAALrLVyjabnd5xfb06izncgK0JXCt";
		
		// grecaptcha.ready => 验证码初始化成功后后回调
		grecaptcha.ready(() => {
			console.log('验证码初始化ok');
		});
		
		// 表单提交
		document.querySelector('#button').addEventListener('click', () => {
			
			// grecaptcha.execute => 生成Token
			/**
				第一个参数就是前端key
				第二个参数是一个对象,action属性是一个自定义的“场景名称”。一个APP可以有N个验证场景,在后台可以查看不同场景下的验证数据。
				第三方个参数是回调方法,Token就是形参,一般在这个方法里面发起请求
			**/
			grecaptcha.execute(CLIENT_KEY, {action: 'test'}).then((token) => {
				
				// 构建请求体
				const body = new URLSearchParams();
				body.set('token', token);									// 验证码回调token
				body.set('name', document.querySelector('#name').value);	// 表单数据
				
				// 发起请求
				fetch('/test', {
					method: 'POST',
					body: body
				}).then(resp => {
					if (resp.ok){
						resp.json().then(message => {
							if (message.success){
								document.querySelector('#name').value = '';
							} else {
								alert('人机验证失败');
							}
						})
					}
				});
			});
		});
	</script>
</html>

如果是模版引擎渲染,可以把配置文件中的前端key渲染到页面(这里直接在前端写死的)。

重点的几个东西

js库的加载

<script src="https://www.recaptcha.net/recaptcha/api.js?render={客户端key}"></script>

验证码初始化完成的回调,这不是必须的

grecaptcha.ready(() => {
	console.log('验证码初始化ok');
});

通过执行execute方法,获取到Token

grecaptcha.execute("{客户端key}", {action: '{action}'}).then((token) => {
	// 拿到token后,提交数据给服务器
});

服务器端的验证

验证的步骤

1. 使用Http客户端对远程服务器发起POST请求,有三个参数

https://www.recaptcha.net/recaptcha/api/siteverify
名称 说明 是否必须
secret 后端key
response 客户端生成的Token
remoteip 客户端ip

2. 服务器响应

{
  "success": true|false,      // 此请求是否是站点的有效reCAPTCHA令牌
  "score": number             // 此请求的分数(0.0-1.0),人机判断的参考值。1 是人类,0是机器。
  "action": string            // 定义的验证场景
  "challenge_ts": timestamp,  // 加载的时间戳(ISO格式yyyy-MM-dd'T'HH:MM:ssZZ)
  "hostname": string,         // 使用reCAPTCHA的站点的主机名
  "error-codes": [...]        // 可选的错误代码
}

错误代码的说明

Error code 说明
missing-input-secret secret参数丢失
invalid-input-secret secret参数无效或格式错误
missing-input-response 缺少响应参数
invalid-input-response 响应参数无效或格式错误
timeout-or-duplicate 响应不再有效:太旧或以前使用过。

3. 自己根据评分判断是否要放行

完整的代码

import java.util.Collections;

import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

@RestController
@RequestMapping("/test")
public class TestController {
	
	private static final Logger LOGGER = LoggerFactory.getLogger(TestController.class);
	
	// 需要自己先把RestTemplate注册到IOC
	@Autowired
	private RestTemplate restTemplate;
	
	// 从配置文件中读取到后端key
	@Value("${recaptcha.server-key}")
	private String serverKey;
	
	// 请求地址
	private static final String SITEVE_RIFY = "https://www.recaptcha.net/recaptcha/api/siteverify";
	
	@PostMapping
	public Object test (HttpServletRequest request,
						@RequestParam("name") String name,
						@RequestParam("token") String token) {
		
		LOGGER.info("name={}, token={}", name, token);
		
		HttpHeaders httpHeaders = new HttpHeaders();
		httpHeaders.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
		httpHeaders.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);

		MultiValueMap<String, Object> requestBody = new LinkedMultiValueMap<>();
		requestBody.add("secret", this.serverKey);		// 服务端key
		requestBody.add("response", token);				// 客户端提交的token
		requestBody.add("remoteip", request.getRemoteAddr()); // 客户的ip地址,不是必须的参数。
		
		ResponseEntity<String> responseEntity = restTemplate.postForEntity(SITEVE_RIFY, new HttpEntity<>(requestBody, httpHeaders), String.class);

		JsonObject jsonObject = JsonParser.parseString(responseEntity.getBody()).getAsJsonObject();
		
		LOGGER.info("recaptcha response={}", jsonObject);
		
		
		// 是否执行成功
		if (!jsonObject.get("success").getAsBoolean()){
			// 在失败的情况下,获取到异常状态码
			JsonArray errorCodes = jsonObject.get("error-codes").getAsJsonArray();
			LOGGER.error("recaptcha error={}", errorCodes);
			return Collections.singletonMap("success", Boolean.FALSE); 
		}
		
		// 评分
		double score = jsonObject.get("score").getAsDouble();
		
		if (score < 0.5) {
			// 如果低于0.5分,服务不接受该请求
			return Collections.singletonMap("success", Boolean.FALSE); 
		}
		
		return Collections.singletonMap("success", Boolean.TRUE);
	}
}

一次执行日志

i.s.web.controller.TestController    : name=qwdqw, token=03AGdBq26J8YBZT6VuzU27VyuOk-KmKxN-UB6ETQ_MKOuDyea8upMmMBsX6H3TZ5NNK_VwgvJpxEJVppdmHNERK2d4Eo_w-YpxCi0TJmTyWJLRXD7279DScPOLxuRbj0nH_pTyYJw7OCf9o06gOeBQUqF7bCI_I4rakW4LvQSXd5d2jyFBdOf-FET6vqYzOYB93LyOsKcZdMci9YxIJ-9p8x_gm9YetFvyzQBt5il7iDHEqeLAd7HfLSh6UVOeDtHDncbkIgKWitHv4DuEO8_O8Pm7Fz6Sdc_GoAJgPeYAHkZs5vMvPqwv6H7hUKhh8RI-zCm3cYKe6nYK3Fc7Mc1Xr5bRnJqrSgrJkLBva4v2y-gSffm7E8GmtFgE9Kgr1iaualNUVzmYAiTx
i.s.web.controller.TestController    : recaptcha response={"success":true,"challenge_ts":"2020-08-05T13:50:45Z","hostname":"localhost","score":0.9,"action":"test"}

隐私条款图标

使用这个版本的验证码,会在屏幕的右下角显示一个小图标。毕竟用了人家的东西,建议保留。
image

你非要隐藏它,可以添加一个css。

.grecaptcha-badge { 
	display: none; 
} 

最后

就是这么简单,为了通用。可以抽象出一个验证拦截器,再定义一个注解。通过注解描述接口最低允许的评分。在拦截器中获取到注解值,进行判断和校验。这样通用性和灵活性就提高了很多。