前言
小C他们团队最近在做一个文章社区平台,由于人手不够,后端部分也是由前端同学开发,用的框架是 nest.js
。
他们平台上线之后,注册的用户量日渐增长,老板十分开心。
由于注册的时候需要发送短信验证码来校验用户身份的真实性,所以老板一口气又去买了 10W
条短信,幻想着哪一天到达 10W
用户。
好景不长的是,刚买了没多久,就收到了短信平台的告警,说的是短信快用完了?
老板又震惊又愤怒,找到小C:怎么这么快就用完了?我看也没几个新用户注册啊?
小C看了一下:老板,我们发短信验证码的接口只是做了一层前端的验证校验,没有做其他的任何限制,抓个包就能调用发送,我们的短信被盗刷了!
老板:😠,那你还不赶紧修复一下,不然我后续买多少条短信都不够用的。
PS:在本文行文中,以发送邮件验证码来替代发送短信验证码。
前端校验
前端的验证码校验用的是rc-slider-captcha这个库,这是一个滑块验证码相关的前端库。
它从交互上支持两种形式的滑块验证,一种是滑动缺口图去完成拼图
另一种是纯轨迹滑动,没有拼图
这个库的作者还很贴心的做了一个客户端的拼图生成器,结合用起来确实非常舒服。
我这边使用的是纯轨迹滑动的接入方式,实现起来也很简单,当点击发送验证码的时候会弹出弹窗,然后让用户进行滑块验证。
通过滑动的轨迹判断用户验证是否通过,如果通过就执行发送验证码的逻辑。
<Modal open={visible} onCancel={() => setVisible(false)} title="安全验证" destroyOnClose footer={false} centered width={368} style={{ maxWidth: "100%" }} > <SliderCaptcha autoRefreshOnError={true} mode="slider" onVerify={(data) => { if (data.x >= 260) { setTimeout(() => { setVisible(false); sendPhoneCode(); // 发送验证码 }); return Promise.resolve(); } return Promise.reject(); }} /> </Modal>
但是呢,这种做法只能说是掩耳盗铃,别人一抓包找到你真正发送短信验证码的接口就可以开始搞事情了。本质上的原因是我们发送验证码的时候,没有校验用户是否通过了前端的滑块验证。
防接口盗刷
这个时候我们就需要一种前后端一起验证的机制,我这里使用的是Google reCAPTCHA。
你当然也可以使用别的第三方的服务,这个可以通过google搜「验证码」进行查询
在这里注册好一个网站
注册好之后会有一对密钥对,我们需要自行记录一下
然后就可以在我们的网站中接入这个谷歌验证了:
可以通过下面这个script引入验证码组件:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
然后写一个验证码容器 div
:
<div id="robot"></div>
然后在点击发送验证码的时候唤起谷歌验证组件:
const handleClick = async () => { const res = await form.validateFields(["email"]); try { window.grecaptcha.reset(); } catch (error) {} window.grecaptcha.render("robot", { sitekey: "你的sitekey", //公钥 callback: function (code: string) { doSend(res.email, code); }, "expired-callback": () => { message.error("验证过期"); }, "error-callback": () => { message.error("验证错误"); }, }); };
在验证通过之后,我们就把用户输入的邮箱以及谷歌验证码组件的返回值一起发送给后端。后端去调用谷歌验证的接口,来判断用户的验证是否通过。如果通过,则走发送验证码(我这里使用发送邮件验证码的服务来替代短信验证码,大家懂这个意思就行)的逻辑:
async sendVerifyCode( email: string, googleCode: string, remoteip: string, ): Promise<string> { if (!googleCode) { throw new Error('验证code不可为空'); } const res = await axios.post( 'https://www.google.com/recaptcha/api/siteverify', { secret: this.siteKey, // 私钥 response: googleCode, }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); if (!res.data.success) { throw Error('非法验证'); } const code = generateRandomNumber(); const text = `您的验证码是:${code},5分钟内有效`; await this.emailService.sendMail(email, 'jueyin注册', text); await this.redisService.set(`${VERIFY_CODE_PREFIX}:${email}`, code, 5 * 60); return code; }
按钮倒计时
一般在发送完验证码之后,会有一个倒计时的流程,不会让用户马上又能重新发送验证码
这里可以用到 redis
进行一个限流,在 redis
中设置一个 60s
过期的 key
。当请求过来时,检查一下这个 key
是否还存在,如果存在,则抛出异常,如果不存在则设置这个 key
,并发送验证码。
const cache = await this.redisService.get(`code::cache::${email}`); if (cache) { throw Error('请稍后再发送验证码'); } await this.redisService.set(`code::cache::${email}`, true, 60);
账号限频
同样的,我们可以通过 redis
来对发送的账号进行限制频率,比如说我只让一个邮箱或者手机号每天只能发送 5
条验证码。
我可以这么写:
private async refreshCount(email: string) { const redisClient = this.redisService.getClient(); const key = `code::count::${email}`; let countRes = await redisClient.get(key); const exist = await redisClient.exists(key); const count = Number(countRes); if (exist && count > 5) { throw Error('请求太过频繁,请稍后再试'); } if (exist) { const ttl = await redisClient.ttl(key); await redisClient.set(key, count + 1); await redisClient.expire(key, ttl); } else { await redisClient.setex(key, 1, 60 * 60 * 12); // 一天 } }
在用户第一次请求时,初始化
redis
的key
的值为1
,并设置过期时间为一天后续的请求时,增加这个
key
的值,如果超过了阈值,则抛异常