Laravel 短信验证码使用安全设计

Laravel 短信验证码使用安全设计

说明

本文是对《PHP安全之道:项目安全的架构、技术与实践》第八章中关于短信安全的实现。

另外,验证码为了方便使用的是腾讯防水墙;短信因为个人申请比较麻烦,所以代码里面只是预留了位置,大家可以使用专门针对 laravel 封装的短信库,会方便上手很多。

控制器

app\Http\Controllers\SmsController.php

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<?php

namespace App\Http\Controllers;

use TencentCloud\Common\Credential;
use TencentCloud\Common\Profile\ClientProfile;
use TencentCloud\Common\Profile\HttpProfile;
use TencentCloud\Common\Exception\TencentCloudSDKException;
use TencentCloud\Captcha\V20190722\CaptchaClient;
use TencentCloud\Captcha\V20190722\Models\DescribeCaptchaResultRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;

class SmsController extends Controller
{
/**
* 获取用户输入
*
* @return array
*/
protected function get_input()
{
return request()->only('tel', 'randstr', 'ticket');
}

/**
* 验证用户输入
*
* @param array $input 用户输入内容
* @return string|bool
*/
protected function check_input(array $input)
{
$validator = Validator::make($input, [
'tel' => 'required|regex:/^1[0-9]{10}$/',
'randstr' => 'required|string',
'ticket' => 'required|string',
]);

return $validator->fails()
? (string) $validator->errors()->first()
: true;
}

/**
* 验证滑动验证码
* 腾讯防水墙:https://007.qq.com/
* 文档:https://cloud.tencent.com/document/product/1110/36926
*
* @param array $input 用户输入内容
* @return string|bool
*/
protected function check_captcha(array $input)
{
try {
$cred = new Credential(
config('config.captcha.secret_id'),
config('config.captcha.secret_key')
);

$httpProfile = new HttpProfile();
$httpProfile->setEndpoint('captcha.tencentcloudapi.com');

$clientProfile = new ClientProfile();
$clientProfile->setHttpProfile($httpProfile);

$client = new CaptchaClient($cred, '', $clientProfile);

$req = new DescribeCaptchaResultRequest();

$req->fromJsonString(json_encode([
'CaptchaType' => 9,
'Ticket' => $input['ticket'],
'UserIp' => request()->ip(),
'Randstr' => $input['randstr'],
'CaptchaAppId' => config('config.captcha.captcha_app_id'),
'AppSecretKey' => config('config.captcha.app_secret_key'),
'NeedGetCaptchaTime' => 1,
]));

$resp = $client->DescribeCaptchaResult($req);

// 将接口返回的数据转换为数组
$data = json_decode($resp->toJsonString(), true);

// 验证失败则返回错误信息
if($data['CaptchaCode'] !== 1) {
return $data['CaptchaMsg'];
}

// 判断用户获取验证码到现在验证是否时间过长
if($data['GetCaptchaTime'] < time() - config('config.web_captcha_expires')) {
return '验证超时';
}

// 恶意等级,0 - 100。
// $data['EvilLevel']

return true;
} catch(TencentCloudSDKException $e) {
return $e->getMessage();
}
}

/**
* 生成短信验证码
* 为了避免同时存在相同的验证码,生成一个随机数后会进行判断是否存在短信记录。
* 当然需不需要加锁来避免被占用可以根据自身需求,比较概率太小了。
*
* @param int $retry 重试次数
* @return int|bool
*/
protected function generate_sms_code(int $retry = 10)
{
$value = random_int(100000, 999999);

if(Redis::exists('sms_info:' . $value)) {
if($retry === 0) {
return false;
}

return $this->generate_sms_code($retry--);
}

return $value;
}

/**
* 发送短信验证码
*
* @param int $tel 手机号
* @param int $code 短信验证码
* @return bool
*/
protected function send_sms(int $tel, int $code)
{
// 这里要接入短信服务商 SDK 进行短信发送
//
//

$data = Redis::pipeline(function ($pipe) use($tel, $code) {
$time = time();

// 保存当前发送时间
$pipe->set('sms_send:' . $tel, $time);

// 保存短信详细信息,数据需要序列化进行保存
$pipe->set('sms_info:' . $code, serialize([
'tel' => $tel,
'ua' => request()->userAgent(),
'ip' => request()->ip(),
'time' => $time,
]));

// 设置过期时间,多 1 秒是为了避免准时过期
$pipe->expire('sms_send:' . $tel, config('config.sms_cache_expires') + 1);
$pipe->expire('sms_info:' . $code, config('config.sms_cache_expires') + 1);
});

return is_array($data);
}

public function index(Request $request)
{
// 获取用户输入
$input = $this->get_input();

// 验证用户输入
if(($error = $this->check_input($input)) !== true) {
return error($error);
}

// 验证当前手机号发送短信是否请求频繁
if(has_restrict('sms_count_tel:' . $input['tel'])) {
return error('当前手机号请求过于频繁');
}

// 验证当前IP发送短信是否请求频繁
if(has_restrict('sms_count_ip:' . request()->ip())) {
return error('当前IP请求过于频繁');
}

// 验证滑动验证码
if(($error = $this->check_captcha($input)) !== true) {
return error($error);
}

// 获取上一次发送时间,判断是否还未过发送间隔时间
if(get_lash_send_time($input['tel']) > time() - config('config.sms_send_interval')) {
return error('当前短信发送过于频繁');
}

// 生成一个短信验证码
$code = $this->generate_sms_code();

if(!$code) {
return error('生成短信验证码失败');
}

// 发送短信
if(($error = $this->send_sms($input['tel'], $code)) !== true) {
return error($error);
}

return result([
'message' => '【测试】验证码:' . $code,
]);
}
}

app\Http\Controllers\AuthController.php

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;

class AuthController extends Controller
{
/**
* 获取用户输入
*
* @return array
*/
protected function get_input()
{
return request()->only('tel', 'code');
}

/**
* 验证用户输入
*
* @param array $input 用户输入内容
* @return string|bool
*/
protected function check_input(array $input)
{
$validator = Validator::make($input, [
'tel' => 'required|regex:/^1[0-9]{10}$/',
'code' => 'required|regex:/^[1-9][0-9]{5}$/',
]);

return $validator->fails()
? (string) $validator->errors()->first()
: true;
}

/**
* 处理验证错误
* 对验证失败进行统计,可根据需要进行其他处理
*
* @param array $input 用户输入内容
* @return string|bool
*/
protected function check_error(int $tel)
{
Redis::pipeline(function ($pipe) use($tel) {
$pipe->incrby('auth_error_tel:' . $tel, 1);

// 设置过期时间,多 1 秒是为了避免准时过期
$pipe->expire('auth_error_tel:' . $tel, conifg('config.sms_cache_expires') + 1);
});

return true;
}

public function index(Request $request)
{
// 获取用户输入
$input = $this->get_input();

// 验证用户输入
if(($error = $this->check_input($input)) !== true) {
return error($error);
}

// 验证当前手机号登陆是否请求频繁
if(has_restrict('auth_count_tel:' . $input['tel'])) {
return error('当前手机号请求过于频繁');
}

// 验证当前IP登陆是否请求频繁
if(has_restrict('auth_count_ip:' . request()->ip())) {
return error('当前IP请求过于频繁');
}

// 验证当前手机号登陆是否多次错误
if(get_auth_error_num($input['tel']) > config('config.auth_error_max_num')) {
return error('当前验证失败次数过多');
}

// 获取验证码详细信息
$data = Redis::get('sms_info:' . $input['code']);

// 查询不到验证码详细信息
if(is_null($data)) {
$this->check_error($input['tel']);
return error('验证码不存在');
}

// 对数据进行反序列化解码
$data = unserialize($data);

// 上一次发送时间和详细信息里面记录的时间不一致
if($data['time'] !== get_lash_send_time($input['tel'])) {
return error('验证失败');
}

// 对保存的数据和现在已有的数据进行对比,看看是否一致,
// IP UA 这些尽量不要进行比对,可能存在异地使用的情况,
// 最后需要判断验证码是否超过了使用时间。
if($data['tel'] !== (int) $input['tel']
// || $data['ua'] !== request()->userAgent()
// || $data['ip'] !== request()->ip()
|| $data['time'] < time() - config('config.sms_check_expires')
) {
$this->check_error($input['tel']);
return error('验证失败');
}

// 释放验证码
Redis::del('sms_info:' . $input['code']);

// 登陆逻辑处理
//
//

// 登陆成功
return result();
}
}

辅助函数

app\helpers.php

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
<?php

use Illuminate\Support\Facades\Redis;

if(!function_exists('result')) {
/**
* 返回数据
*
* @param string|array $data 数据
* @param int $status 状态码
* @return \Illuminate\Http\Response
*/
function result($data = null, int $status = 200) {
return response()->json(
is_null($data) ? ['code' => 1] : ['code' => 1, 'data' => $data],
$status
);
}
}

if(!function_exists('error')) {
/**
* 返回错误
*
* @param string $msg 错误信息
* @param int $status 状态码
* @return \Illuminate\Http\Response
*/
function error($msg = '参数错误', int $status = 200) {
return response()->json(
['code' => 0, 'message' => $msg],
$status
);
}
}

if(!function_exists('has_restrict')) {
/**
* 是否达到限制
*
* @param string $key 键名
* @param int $ttl 有效期
* @param int $limit 最大数量
* @return bool
*/
function has_restrict(string $key, int $ttl = 300, int $limit = 10) {
$data = Redis::pipeline(function ($pipe) use($key, $ttl) {
$time = time();
// 记录当前记录
// ZADD 值重复为添加失败
$pipe->zadd($key, $time . random_int(100000, 999999), $time);
// 清除不在 tts 范围内的记录
$pipe->zremrangebyscore($key, 0, $time - $ttl);
// 获取记录总数量
$pipe->zcard($key);
// 设置过期时间,多 1 秒是为了避免准时过期
$pipe->expire($key, $ttl + 1);
});

if(!is_array($data)) {
return true;
}

return $data[2] >= $limit;
}
}

if(!function_exists('get_lash_send_time')) {
/**
* 获取最后一次发送时间
*
* @param int $tel 手机号
* @return int
*/
function get_lash_send_time(int $tel) {
return (int) Redis::get('sms_send:' . $tel);
}
}

if(!function_exists('get_auth_error_num')) {
/**
* 获取登陆验证失败次数
*
* @param int $tel 手机号
* @return int
*/
function get_auth_error_num(int $tel) {
return (int) Redis::get('auth_error_tel:' . $tel);
}
}

辅助函数自动加载

composer.json

1
2
3
4
5
6
"autoload": {
...
"files": [
"app/helpers.php"
]
},
1
$ composer du

Redis

phpredis 安装比较麻烦,使用我们用 predis 进行测试,predis 已经被作者抛弃了的。

.env

1
REDIS_CLIENT=predis

配置

config\config.php

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
<?php

return [
'captcha' => [
// 腾讯云 SecretId
'secret_id' => 'xx',
// 腾讯云 SecretKey
'secret_key' => 'xx',
// 验证码应用ID
'captcha_app_id' => 111,
// 验证码应用密钥
'app_secret_key' => 'xx',
],

// 登陆错误最大次数
'auth_error_max_num' => 5,

// 前端验证码有效期,单位秒
'web_captcha_expires' => 60,

// 短信验证码有效期,单位秒
'sms_check_expires' => 60,

// 验证码发送间隔,单位秒
'sms_send_interval' => 60,

// 验证码缓存有效期,单位秒
'sms_cache_expires' => 5 * 60,
];

路由

routes\web.php

1
2
3
4
5
6
7
Route::get('/', function () {
\View::addExtension('html','php');
return view()->file(public_path('index.html'));
});

Route::post('/sms', 'SmsController@index');
Route::post('/auth', 'AuthController@index');

前端

public\index.html

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<!DOCTYPE html>
<html lang="zh-CN">

<head>
<meta charset="utf-8" />
<title>手机号登陆</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no" />
<meta name="renderer" content="webkit" />
<meta name="ROBOTS" content="noarchive" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta content="telephone=no; email=no" name="format-detection" />
<link rel="stylesheet" href="https://lib.baomitu.com/element-ui/2.12.0/theme-chalk/index.css" />
<script src="https://lib.baomitu.com/vue/2.6.10/vue.min.js"></script>
<script src="https://lib.baomitu.com/axios/0.19.2/axios.min.js"></script>
<script src="https://lib.baomitu.com/element-ui/2.12.0/index.js"></script>
<script src="https://ssl.captcha.qq.com/TCaptcha.js"></script>
<style>
html, body, #app {
height: 100%;
}

body {
margin: 0;
}

#app {
display: flex;
justify-content: center;
align-items: center;
}

.el-card {
width: 400px;
}
</style>
</head>

<body>
<div id="app">
<el-card>
<div slot="header" class="clearfix">
手机号登陆
</div>
<el-form :model="ruleForm" label-width="100px">
<el-form-item label="手机号" prop="tel">
<el-input v-model="ruleForm.tel"></el-input>
<el-button style="margin-top: 10px;" @click="handleCaptcha">获取验证码</el-button>
</el-form-item>
<el-form-item label="验证码" prop="code">
<el-input v-model="ruleForm.code"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin">登陆</el-button>
</el-form-item>
</el-form>
</el-card>
</div>

<script>
const vm = new Vue({
el: '#app',
data: {
ruleForm: {
tel: '',
code: '',
},
},
mounted () {
},
methods: {
handleCaptcha () {
new TencentCaptcha('这里要替换成你的<验证码应用ID>', res => {
if(res.ret !== 0) {
return $this.$message.error('本地验证失败');
}

axios.post('/sms', {
tel: this.ruleForm.tel,
randstr: res.randstr,
ticket: res.ticket,
}).then(response => {
this.$message(JSON.stringify(response.data));
});
}).show();
},
handleLogin () {
axios.post('/auth', this.ruleForm).then(response => {
this.$message(JSON.stringify(response.data));
});
},
},
});
</script>
</body>

</html>

测试

往上