serverless+腾讯云短信实现短信验证码登录

云函数(Serverless Cloud Function,SCF)是腾讯云为企业和开发者们提供的无服务器执行环境,帮助我们在无需购买和管理服务器的情况下运行代码。 本文会演示使用serverless云函数开发一个短信验证码登录注册服务。

传统开发协作流程(多角色参与): 1.后台开发:短信接口发送短信API、校验短信验证码是否有效API、存储用户验证状态 2.运维开发:接口部署,容灾 3.前端(客户端)开发:前端逻辑开发(调用接口,查询状态等)

传统开发模式的问题:多角色参与、工作量大、维护成本高

Serverless云函数开发模式:全流程基本上可一个人完成所有功能

这里是我们要实现的短信验证码登录的流程图,主要涉及到serverless云函数开发、短信服务、云db存储用户信息。

准备工作

  • 已 注册腾讯云 账号,并完成 企业实名认证。
  • 已 购买 短信套餐包。
  • 准备短信签名归属方资质证明文件,详细的文件清单以及规范请参见 签名审核标准。 本文以使用企业营业执照作为资质证明文件为例。
  • 了解短信正文内容审核规范,详情请参见 正文模板审核标准。
  • 已获取短信应用的 SDKAppID。

相关资料

  • Demo 源码
  • 其他产品文档
  • 私有网络产品文档
  • 云数据库 MySQL 产品文档
  • NAT 网关产品文档
  • 云函数产品文档

步骤1:配置短信内容

短信签名、短信正文模板提交后,我们会在2个小时左右完成审核,您可以 配置告警联系人 并设置接收模板和签名审核通知,便于及时接收审核通知。

步骤1.1:创建签名

  1. 登录 短信控制台。
  2. 在左侧导航栏选择【国内短信】>【签名管理】,单击【创建签名】。

参数

取值样例

签名用途

自用(签名为本账号实名认证的公司、网站、产品名等)

签名类型

APP

签名内容

测试 Demo

证明类型

小程序设置页面截图

证明上传

  1. 单击【确定】。 等待签名审核,当状态变为【已通过】时,短信签名才可用。

步骤1.2:创建正文模板

  1. 登录 短信控制台。
  2. 在左侧导航栏选择【国内短信】>【正文模板管理】,单击【创建正文模板】。

参数

取值样例

模板名称

验证码短信

短信类型

普通短信

短信内容

您的注册验证码:{1},请于{2}分钟内填写,如非本人操作,请忽略本短信。

  1. 单击【确定】。 等待正文模板审核,当状态变为【已通过】时,正文模板才可用,请记录模板 ID。

步骤2:设置短信发送频率限制(可选)

!个人认证用户不支持修改频率限制,如需使用该功能,请将 “个人认证” 变更为 “企业认证”,具体操作请参见 实名认证变更指引。

为了保障业务和通道安全,减少业务被刷后的经济损失,建议 设置发送频率限制。另外,您也可以结合使用 腾讯云验证码 以便最大程度地保护业务安全。

本文以短信的默认频率限制策略为例。

  • 同一号码同一内容30秒内最多发送1条。
  • 同一手机号一个自然日最多发送10条。

步骤3:配置私有网络和子网

默认情况下,云函数部署在公共网络中,只可以访问公网。如果开发者需要访问腾讯云的 TencentDB 等资源,需要建立私有网络来确保数据安全及连接安全。

  1. 按需 规划网络。

参数

取值样例

所属地域

华南地区(广州)

名称

Demo VPC

IPv4 CIDR

10.0.0.0/16

子网名称

Demo 子网

IPv4 CIDR

10.0.0.0/16

可用区

广州三区

步骤4:配置 MySQL 数据库

云数据库 MySQL 实例需与 步骤3 配置私有网络的地域和子网的可用区保持一致。

参数

取值样例

计费模式

按量计费

地域

广州

数据库版本

MySQL5.7

架构

高可用版

主可用区

广州三区

备可用区

广州四区

实例规格

4核8000MB

硬盘

200GB

网络

Demo VPC,Demo 子网

实例名

立即命名:Demo 数据库

购买数量

1

参数

取值样例

支持字符集

UTF8

表名大小写敏感

自定义端口

3306

设置 root 帐号密码

请自定义设置

确认密码

请再次输入密码

  1. 登录 MySQL 数据库,具体操作请参见 登录 phpMyAdmin。
  2. 根据实际需求,创建数据表和字段用于存储用户的手机号、头像、用户昵称等信息,具体操作请参见 创建数据库和表。

步骤5:新建云函数

云函数目前支持 Python、Node.js、PHP、Java 以及 Golang 语言开发,本文以 Node.js 为例。

参数

取值样例

函数名称

Demo

运行环境

Nodejs 8.9

创建方式

模板函数:helloworld

  1. 部署函数并配置触发方式为【API网关触发器】,具体操作请参见 部署函数。

步骤6:配置 NAT 网关

部署在 VPC 中的云函数默认隔离外网。若想使云函数同时具备内网访问和外网访问能力,可通过以下两种方式实现:

  • 通过配置云函数公网访问能力,且公网访问可控制出口地址唯一,请参考 固定公网出口 IP。
  • 通过 VPC 添加 NAT 网关,请参考 私有网络中配置 NAT。

本文以添加 NAT 网关为例。

  1. 新建 NAT 网关,具体操作请参考 私有网络中配置 NAT。
  • NAT 网关要和函数、VPC 部署在同一地域。
  • NAT 网关的所属网络需要选择函数所在的 VPC。

参数

取值样例

网关名称

Demo NAT

所属网络

Demo VPC

网关类型

小型

出带宽上限

100Mbps

弹性 IP

新建弹性 IP

  1. 根据实际需求创建路由策略,具体操作请参考 私有网络中配置 NAT。

步骤7:部署短信 SDK

  1. 执行以下命令,安装 SDK。npm install tencentcloud-sdk-nodejs --save
  2. 在代码中引用短信模块代码。
  3. 配置发送短信核心逻辑。
代码语言:txt
复制
/**
 * 
 * @param {*} 功能:通过 SDK 发送短信 
 * @param {*} 参数:手机号、短信验证码 
 */
async function sendSms(phone, code) {
    const tencentcloud = require('tencentcloud-sdk-nodejs');
    const SmsClient = tencentcloud.sms.v20190711.Client;
    const Credential = tencentcloud.common.Credential;
    const ClientProfile = tencentcloud.common.ClientProfile;
    const HttpProfile = tencentcloud.common.HttpProfile;
    //腾讯云账户 secretId,secretKey,切勿泄露
    const secretId = "secretId";//需要配置为真实的 secretId
    const secretKey = "secretKey";//需要配置为真实的 secretKey
    let cred = new Credential(secretId, secretKey);
    let httpProfile = new HttpProfile();
    httpProfile.endpoint = "sms.tencentcloudapi.com";
    let clientProfile = new ClientProfile();
    clientProfile.httpProfile = httpProfile;
    let client = new SmsClient(cred, "ap-guangzhou", clientProfile);
    phone = "+86" + phone;//国内手机号
    let req = {
        PhoneNumberSet: [phone],//发送短信的手机号
        TemplateID: "",//<a href="#Step1_2">步骤1.2</a> 中创建并记录的模板 ID
        Sign: "",//<a href="#Step1_1">步骤1.1</a> 中创建的签名
        TemplateParamSet: [code],//随机验证码
        SmsSdkAppid: ""//短信应用 ID
    }
    function smsPromise() {
        return new Promise((resolve, reject) => {
            client.SendSms(req, function (errMsg, response) {
                if (errMsg) {
                    reject(errMsg)
                } else {
                    if (response.SendStatusSet && response.SendStatusSet[0] && response.SendStatusSet[0].Code === "Ok") {
                        resolve({
                            errorCode: 0,
                            errorMessage: response.SendStatusSet[0].Message,
                            data: {
                                codeStr: response.SendStatusSet[0].Code,
                                requestId: response.RequestId
                            }
                        })
                    } else {
                        resolve({
                            errorCode: -1003,//短信验证码发送失败
                            errorMessage: response.SendStatusSet[0].Message,
                            data: {
                                codeStr: response.SendStatusSet[0].Code,
                                requestId: response.RequestId
                            }
                        })
                    }
                }
            });
        })
    }
    let queryResult = await smsPromise()
    return queryResult
}

步骤8:检验验证码核心逻辑

验证码的时效性要求较高,您可以把验证码存在内存中或存在云数据库 Redis 中。以手机号作为 key,存储发送时间、验证码、验证次数、是否已验证过等信息。出于安全考虑,建议设置防止暴力破解的限制,本文以验证码最多验证3次为例。

代码语言:txt
复制
/*
 * 功能:根据手机号获取短信验证码
 */
async function getSms(queryString) {
  const code = Math.random().toString().slice(-6);//生成6位数随机验证码
  const sessionId = Math.random().toString().slice(-8);//生成8位随机数
  const sessionCode = {
      code: code,
      sessionId: sessionId,
      sendTime: new Date().getTime(),
      num: 0,//验证次数,最多可验证3次
      used: 1//1-未使用,2-已使用
  }
  clearCacheCode()

cacheCode[queryString.phone] = sessionCode

步骤9:配置登录模块

登录模块主要用于用户注册或登录,首次登录(即注册)时将保存用户的手机号、用户名、头像、注册时间等信息。

代码语言:txt
复制
/*

  • 功能:登录
    */
    async function loginSms(queryString) {
    const connection = mysql.createConnection({
    host: '', // The ip address of cloud database instance, 云数据库实例 IP 地址
    user: '', // The name of cloud database, for example, root, 云数据库用户名,例如 root
    password: '', // Password of cloud database, 云数据库密码
    database: '' // Name of the cloud database, 数据库名称
    });
    connection.connect();

    if(queryString.token) {
    return await verifyToken(connection, queryString)
    }

    if(!queryString.code || !queryString.sessionId) {
    return {
    errorCode: -1001,
    errorMessage: "缺少参数"
    }
    }

    let result = cacheCode[queryString.phone]
    if(!result || result.used === 2 || result.num >= 3) {
    return {
    errorCode: -1100,
    errorMessage: "验证码已失效"
    }
    }
    if(result.sessionId !== queryString.sessionId) {
    return {
    errorCode: -1103,
    errorMessage: "sessionId不匹配"
    }
    }

    if(result.code == queryString.code) {
    cacheCode[queryString.phone].used = 2;//将验证码更新为已使用
    const queryInfoSql = select * from info where phone = ?
    let queryInfoResult = await wrapPromise(connection, queryInfoSql, [queryString.phone])
    if(queryInfoResult.length === 0) {//没有找到记录,未注册
    return await generateInfo(connection, queryString)
    } else {
    let infoResult = queryInfoResult[0]
    return {
    errorCode: 0,
    errorMessage: "登录成功",
    data: {
    phone: infoResult.phone,
    token: getToken(infoResult.userId, infoResult),
    name: infoResult.name,
    avatar: infoResult.avatar,
    userId: infoResult.userId.toString()
    }
    }
    }
    } else {
    updateCacheCode(queryString.phone, result)
    return {
    errorCode: -1102,
    errorMessage: "验证码错误,请重新输入"
    }
    }
    }

另外,为了登录更便捷,您可以通过 Json web token 标准来生成 token 维护登录状态,实现短时间内登录无需短信验证码的功能。

代码语言:txt
复制
/*

  • 功能:利用 json web token 签发一个 token
    */
    function getToken(userId, infoResult) {
    return jwt.sign({
    phone: infoResult.phone,
    userId: userId,
    name: infoResult.name,
    avatar: infoResult.avatar
    }, privateKey, {expiresIn: tokenExpireTime});
    }