阿里云人机验证和短信发送

阿里云人机验证和短信发送

背景

最近的工作中需要实现用户注册、登录功能,都是通过短信进行二次验证的。使用人机验证,就是用来防止登录或者注册的短信接口被刷。调研了好几个平台,包括阿里云、易盾、腾讯云,它们的实现都是使用webview打开网页的方式进行处理。
我能理解它们肯定主要是给网页前端使用的,用在手机应用内,确实可能不算很多。但是确实接入起来我觉得比较麻烦,要是有封装好的AndroidSDK或者iOSSDK,直接调用接口,自己弹出界面(不用做屏幕适配了,只管收回调),肯定是最好的。可惜~没有。

这里留存一下几个厂商的文档,万一后面要用呢。
阿里云:https://www.aliyun.com/product/security/captcha
易盾:https://dun.163.com/product/captcha
腾讯:https://cloud.tencent.com/document/product/1110/49810

技术选型

  1. 阿里云人机验证 – 拼图验证
  2. 阿里云短信

阿里云人机验证(验证码2.0)实现流程

阿里云文档
查看文档,了解下什么是验证码2.0,开通服务什么的按文档来就行。
Web和H5客户端接入文档
Android端接入文档
服务端接入文档

流程概述

用户在客户端点击获取手机验证码 -> 弹出人机验证(webview加载html) -> js代码初始化阿里云SDK,展示拼图验证 -> 用户完成拼图 -> 阿里云SDK回调验证参数(一长串) -> js传递给自己的服务端 -> 服务端调用阿里云接口验证(验证通过则发送短信,不通过则返回验证失败) -> 返回结果给客户端 -> 客户端根据验证结果关闭界面或者继续进行验证。

这里面其实有一些被盗用的风险,我在后面会提到。

验证码2.0的SDK接入(前端)

参考Web和H5客户端接入文档,实现一个html页面。在其中初始化阿里云SDK,并展示拼图验证。下面是代码,注释很详细,不多说。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<title>阿里云验证码 V2 嵌入式接入</title>

<!-- 1. 全局配置(必须放在 JS 之前) -->
<script>
window.AliyunCaptchaConfig = {
region: "cn", // 必填,验证码示例所属地区,支持中国内地(cn)、新加坡(sgp)
prefix: "xxxx", // 必填,身份标。开通阿里云验证码2.0后,您可以在控制台概览页面的实例基本信息卡片区域,获取身份标
};
</script>

<!-- 2. 加载验证码 SDK -->
<script src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"></script>
</head>
<body>
<!-- 3. 验证码容器(必须) -->
<div id="captcha-element"></div>

<script>
var captchaInstance = null;

// 5. 初始化验证码(嵌入式 embed)
window.initAliyunCaptcha({
SceneId: "xxxx", // 场景ID。根据步骤二新建验证场景后,您可以在验证码场景列表,获取该场景的场景ID
mode: "embed", // embed 嵌入式 / popup 弹窗
element: "#captcha-element", // 验证码渲染节点
immediate: true,
getInstance: function (ins) {
captchaInstance = ins;
},
// 验证完成 → 调用后端验签
captchaVerifyCallback: async function (captchaVerifyParam) {
console.log("验证参数:", captchaVerifyParam);
// 这里需要调用服务端,服务端请求阿里云验证,我这里直接写死结果
var result = { code: 200 };

// 返回给验证码SDK:必须包含 captchaResult
return {
captchaResult: result.code == 200, // 验证码是否通过
bizResult: result.code == 200, // 业务是否通过
};
},
// 7. 最终业务结果回调
onBizResultCallback: function (bizResult) {
// 根据结果,调用Android端提供的接口,把结果回传给Android端
// window.captchaJsInterface.onCaptchaResult(JSON.stringify(result));
},
slideStyle: { width: 360, height: 40 },
language: "cn",
});
</script>
</body>
</html>

服务端验证

我的服务端使用的是springboot,直接依赖阿里云的JavaSDK,

1
2
3
4
5
6
<!--阿里云人机验证SDK https://next.api.alibabacloud.com/api-tools/sdk/captcha?spm=a2c4g.11186623.0.0.37dc4792ma395v&version=2023-03-05&language=java-tea&tab=primer-doc -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>captcha20230305</artifactId>
<version>1.2.0</version>
</dependency>

初始化阿里云客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public com.aliyun.captcha20230305.Client captchaClient(){
log.debug("captchaClient start.");
// 人机校验会自动从系统属性,环境变量中的字段来获取AccessKey ID、AccessKey Secret,你也可以直接写死在代码里面,但是不建议这样子
// https://help.aliyun.com/zh/sdk/developer-reference/v2-manage-access-credentials#4a6acea829klj
com.aliyun.credentials.Client credential = new com.aliyun.credentials.Client();
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
config.setCredential(credential);
config.endpoint = "captcha.cn-shanghai.aliyuncs.com";
try {
return new com.aliyun.captcha20230305.Client(config);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException(e);
}
}

服务端拿着阿里云返回的captchaVerifyParam,请求阿里云验证,将结果返回给前端。
阿里云响应数据字段说明参见文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public int verifyAliCaptcha(String captchaVerifyParam, String sceneId) {
com.aliyun.captcha20230305.models.VerifyIntelligentCaptchaRequest verifyIntelligentCaptchaRequest = new com.aliyun.captcha20230305.models.VerifyIntelligentCaptchaRequest()
.setCaptchaVerifyParam(captchaVerifyParam)
.setSceneId(sceneId);
try {
com.aliyun.captcha20230305.models.VerifyIntelligentCaptchaResponse resp = captchaClient.verifyIntelligentCaptchaWithOptions(verifyIntelligentCaptchaRequest, new com.aliyun.teautil.models.RuntimeOptions());
log.info(new com.google.gson.Gson().toJson(resp));
if (!resp.body.success){
// 请求不成功,直接报错
log.error("captcha verify failed-1, body={}", resp.body);
return 0;
}
if (!resp.body.result.verifyResult){
log.error("captcha verify failed-2, body={}", resp.body);
return 0;
}else{
return 1;
}
} catch (TeaException error) {
log.error("verifyAliCaptcha TeaException "+error.getMessage()+" 诊断地址:"+error.getData().get("Recommend").toString());
} catch (Exception _error) {
TeaException error = new TeaException(_error.getMessage(), _error);
log.error("verifyAliCaptcha Exception " + error.getMessage()+" 诊断地址:"+error.getData().get("Recommend").toString());
}
return 0;
}

前端拿着结果,可以决定是否关闭拼图验证或验证通过。

阿里云短信发送

依赖阿里云SDK

1
2
3
4
5
6
<!--阿里云发送短信 最新版本号:https://mvnrepository.com/artifact/com.aliyun/dysmsapi20170525-->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>4.5.0</version>
</dependency>

初始化阿里云客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Bean
public com.aliyun.dysmsapi20170525.Client smsClient(){
log.warn("smsClient start.");
// 短信发送的SDK不会自动从环境变量读取AccessKey ID、AccessKey Secret,需要自己读出来传入
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
// 配置 AccessKey ID,请确保代码运行环境配置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID。
.setAccessKeyId(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"))
// 配置 AccessKey Secret,请确保代码运行环境配置了环境变量 ALIBABA_CLOUD_ACCESS_KEY_SECRET。
.setAccessKeySecret(System.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"));
// System.getenv()方法表示获取系统环境变量,不要直接在getenv()中填入AccessKey信息。

// 配置 Endpoint。
config.endpoint = "dysmsapi.aliyuncs.com";

try {
return new com.aliyun.dysmsapi20170525.Client(config);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException(e);
}
}

发送短信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean sendCaptcha(String phone, String captcha) {
com.aliyun.dysmsapi20170525.models.SendSmsRequest sendSmsRequest = new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
.setPhoneNumbers(phone)
.setSignName("xx网络/公司")
.setTemplateCode("模板id")
// TemplateParam为序列化后的JSON字符串。其中\"表示转义后的双引号。
.setTemplateParam("{\"code\":\"" + captcha +"\"}");
try {
com.aliyun.dysmsapi20170525.models.SendSmsResponse sendSmsResponse = smsClient.sendSms(sendSmsRequest);
if (sendSmsResponse.statusCode == 200 && sendSmsResponse.body.code.equals("OK")) {
log.info(new com.google.gson.Gson().toJson(sendSmsResponse));
return true;
}else{
log.error("sendCaptcha failed, body={}", sendSmsResponse.body);
return false;
}
} catch (Exception e) {
log.error("sendCaptcha Exception "+e.getMessage(),e);
}
return false;
}

总结

1、上面的代码和流程已经能够支持我完成需求了,但是有一天我无意中看到阿里云的文档计费说明中提到计费是从完成哪一步计算的,所谓的V2版本和V3版本,也是不清不楚的,经过我问客服。
才知道如何区分V2还是V3版本:

1
2
3
版本识别:可通过客户端调用参数区分。V3架构使用 SceneId 及 success/fail 回调函数;V2架构则使用 captchaVerifyCallback 或 onBizResultCallback。

前端使用 captchaVerifyCallback 且服务端调用 verifyIntelligentCaptchaWithOptions 接口,属于 V2 架构。

从计费标准来说:我现在用的是拼图验证。从计费说明都写的拼图验证计算次数是步骤3.也就是我方服务器到阿里云验证的流程来计费的,所以V2和V3对于我来说没有区别,计费都是一样的,也就不用管加密不加密,场景id泄露不泄露的问题了。(问题引申

2、人机验证被盗用的问题分析和处理
由于我们的场景id、身份标以及我们请求后端的接口代码都是在html中的,那么我们的整个验证流程可以说是完全裸奔,那么就存在我们的人机验证功能被盗用的可能。举例说明:
(1)小明通过获取html内容,阅读js代码,在它的网页上来使用人机验证功能(假设它搭了个盗版资源网站,用户登录时做人机验证),用着我们的场景id,用着我们的身份标,阿里云正常加载出来拼图验证码,正常回调验证参数。同时,小明在js中请求我们的服务端,因为它知道我们的接口加签方式以及我们的各种参数有效值,服务端完全分辨不出来这个请求是被盗用的,傻乎乎的到阿里云验证,并且返回验证结果。那么小明就白嫖了我们出钱买的人机验证服务了。
这个情况,小明的用户都是用浏览器来访问的,我们可以通过后端接口限制跨域来杜绝,只要是在浏览器中,就绕不过跨域的限制。
(2)小明就是想搞我们,它不通过浏览器访问,那么跨域的限制就解决不了,它通过各种方式,拿到了阿里云返回的有效验证参数,通过代码请求我们接口,我们也就区分不出来了它是被盗用的请求,还是傻乎乎的去阿里云验证,返回结果给它。
这时候只能上IP频次限制了,小明一个人手上的IP毕竟有限,翻不起大浪。
(3)最狠的方案,后端永远返回通过,阿里云那边的验证结果只有我们自己知道,前端拿到后端返回的结果(永远都是通过,直接就关闭拼图验证码了),用户收到短信了,就填呗,没收到就再次获取。这是兜底的方案,用户体验不好。但是却什么都不用做,就能解决问题,因为小明根本不敢用我们的验证,它永远都是通过!