短信登陆设计
需求分析
在功能上,需求可以简单的描述为:
- 允许用户使用手机号码接收验证码进行登陆。在登录的时候,如果手机号码是一个全新的手机号码,直接注册一个新的账号。
- 手机验证码的有效期为 10 分钟。
- 用户每一分钟之内只能发送一次验证码
- 用户登录/注册之后就转到网站首页。
在非功能上为:
- 保护系统,防止攻击者恶意发送短信。
发送验证码流程
登录流程
深入分析
是否要拆?
使用“手机验证登录”,要讨论两件事情,一个是验证码,一个是登录。
那么,这两个功能是强耦合的吗?也就是,别的业务是否也会用到手机验证码呢?在一些安全性要求比较高的系统中,我们进行一些敏感的操作往往也是需要使用验证码的。因此,手机验证码应该是一个独立的功能。
服务供应商
当我们把手机验证码独立为一个功能之后。短信,一般都是运营商提供的,我们有没有可能要换供应商呢?比如,今天用了腾讯的短信,明天使用了阿里的短信,后天还不指定会使用哪一家的呢?
综合考虑,主要有两大变化:
- 不同业务可能都需要使用到短信功能和验证码功能
- 可能需要更换供应商
服务划分
所以,我们需要按模块来讲功能进行划分:
- 一个用来短信发送的独立服务
- 在发送短信独立服务的基础上,封装一个验证码功能
- 在验证码功能的基础上,封装一个登录功能。
这种业务设计一般为超前半步设计。
短信服务
因为其他的业务都是在这个业务的基础上进行的,所以我们需要弄这个。
腾讯短信 API
引入函数库。
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
初始化信息客户端,里面要传入各种鉴权参数。
其次是发送一条短信的请求构造,里面传入的主要是短信本身相关的参数。
type SendSmsRequest struct {
*tchttp.BaseRequest
// 下发手机号码,采用 E.164 标准,格式为+[国家或地区码][手机号],单次请求最多支持200个手机号且要求全为境内手机号或全为境外手机号。
// 例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号。
// 注:发送国内短信格式还支持0086、86或无任何国家或地区码的11位手机号码,前缀默认为+86。
PhoneNumberSet []*string `json:"PhoneNumberSet,omitnil,omitempty" name:"PhoneNumberSet"`
// 短信 SdkAppId,在 [短信控制台](https://console.cloud.tencent.com/smsv2/app-manage) 添加应用后生成的实际 SdkAppId,示例如1400006666。
SmsSdkAppId *string `json:"SmsSdkAppId,omitnil,omitempty" name:"SmsSdkAppId"`
// 模板 ID,必须填写已审核通过的模板 ID。模板 ID 可前往 [国内短信](https://console.cloud.tencent.com/smsv2/csms-template) 或 [国际/港澳台短信](https://console.cloud.tencent.com/smsv2/isms-template) 的正文模板管理查看,若向境外手机号发送短信,仅支持使用国际/港澳台短信模板。
TemplateId *string `json:"TemplateId,omitnil,omitempty" name:"TemplateId"`
// 短信签名内容,使用 UTF-8 编码,必须填写已审核通过的签名,例如:腾讯云,签名信息可前往 [国内短信](https://console.cloud.tencent.com/smsv2/csms-sign) 或 [国际/港澳台短信](https://console.cloud.tencent.com/smsv2/isms-sign) 的签名管理查看。
// <dx-alert infotype="notice" title="注意">发送国内短信该参数必填,且需填写签名内容而非签名ID。</dx-alert>
SignName *string `json:"SignName,omitnil,omitempty" name:"SignName"`
// 模板参数,若无模板参数,则设置为空。
// <dx-alert infotype="notice" title="注意">模板参数的个数需要与 TemplateId 对应模板的变量个数保持一致。</dx-alert>
TemplateParamSet []*string `json:"TemplateParamSet,omitnil,omitempty" name:"TemplateParamSet"`
// 短信码号扩展号,默认未开通,如需开通请联系 [腾讯云短信小助手](https://cloud.tencent.com/document/product/382/3773#.E6.8A.80.E6.9C.AF.E4.BA.A4.E6.B5.81)。
ExtendCode *string `json:"ExtendCode,omitnil,omitempty" name:"ExtendCode"`
// 用户的 session 内容,可以携带用户侧 ID 等上下文信息,server 会原样返回。注意长度需小于512字节。
SessionContext *string `json:"SessionContext,omitnil,omitempty" name:"SessionContext"`
// 国内短信无需填写该项;国际/港澳台短信已申请独立 SenderId 需要填写该字段,默认使用公共 SenderId,无需填写该字段。
// 注:月度使用量达到指定量级可申请独立 SenderId 使用,详情请联系 [腾讯云短信小助手](https://cloud.tencent.com/document/product/382/3773#.E6.8A.80.E6.9C.AF.E4.BA.A4.E6.B5.81)。
SenderId *string `json:"SenderId,omitnil,omitempty" name:"SenderId"`
}
接口抽象
我们要考虑发送一条消息,需要传入什么参数:目标手机号码,appId,签名,模版,参数。
type Service interface {
Send(ctx context.Context, tpl string, args []string, number ...string) error
}
定义与初始化
在实现时,我们依旧保持使用依赖注入的形式,要求外界传入一个 client,而不是自己的内部初始化一个 client。
type Service struct {
appId *string
signName *string
client *sms.Client
}
func NewService(client *sms.Client, appId, signName *string) *Service {
return &Service{
appId: appId,
signName: signName,
client: client,
}
}
实现发送
func (s *Service) Send(ctx context.Context, tplId string, args []string, numbers []string) error {
req := sms.NewSendSmsRequest()
req.SenderId = s.appId
req.SignName = s.signName
req.TemplateId = &tplId
req.PhoneNumberSet = s.toStringPtrSlice(numbers)
req.TemplateParamSet = s.toStringPtrSlice(args)
resp, err := s.client.SendSms(req)
if err != nil {
return err
}
for _, status := range resp.Response.SendStatusSet {
if status.Code == nil || *(status.Code) != "Ok" {
return fmt.Errorf("send sms failed, code: %s, message: %s", *(status.Code), *(status.Message))
}
}
return nil
}
验证码服务
分析安全问题
验证码一般都是6位数字,需要考虑两个安全问题:
- 控制住验证码发送的频率,不至于一下子就发送几万条数据。
- 同一个手机号,一分钟之内只能发送一次。
- 验证码有效期为十分钟。
- 本身整个系统也有限流,也要保护系统。
- 验证码不能被攻击者破解,因为验证码只有六位数字,也就只有十万种可能,所以不能让用户频繁输入验证码来暴力破解。
- 一个验证码,如果已经验证通过,那么就不能在使用。
- 一个验证码,如果已经三次验证失败,那么这个验证码就不可再使用。在这种情况下,只会告诉用户输入的验证码不对,但是不会提示验证码过于频繁失败问题。
服务接口抽象
只有两个接口:发送验证码和验证验证码。
func (svc *CodeService) Send(ctx context.Context,
// 区别业务场景
biz string,
phone string) error {}
func (svc *CodeService) Verify(ctx context.Context, biz string, phone string, code string) (bool, error) {}
发送验证码
验证码是一个要在有效期内使用的东西,所以最适合使用 redis 进行存储,并且设置过期时间为十分钟。
可以将 redis 的 key
设置为 phone_code:$biz:$phone
的形态。
同时,还需要控制住发送短信的频率。
整体思路为:
- 如果 redis 中没有这个
key
,那么就直接发送; - 如果 redis 中有这个
key
,但是没有过期时间,说明系统异常; - 如果
key
有过期时间,但是过期时间还有9
分钟,发送太频繁,拒绝; - 否则,重新发送一个验证码。
考虑如何在 redis 层面上实现。
利用 lua 脚本将我们的检查和做某事的逻辑封装成一个整体操作。
为什么考虑使用 redis 进行实现呢?因为 redis 是单线程的,不用考虑并发安全的问题。但是呢,redis是发不了验证码的,所以事实上,只需要在 redis 中将校验码存好即可,就认为是发送过去了。
发送验证码的 lua 脚本实现
---
--- Created by ypb.
--- DateTime: 2024/7/21 21:15
---
-- 验证码我们保存在 redis 的 key 中
-- phone_code:login:
local key = KEYS[1]
-- 验证次数,一个验证码,最多重复三次,这个纪录还可以验证几次
local cntKey = key..":cnt"
-- 你的验证码为 123456
local val = ARGV[1]
-- 过期时间
local ttl = tonumber(redis.call("ttl", key))
if ttl == -1 then
-- key 存在,但是没有过期时间
return -2
elseif ttl == -2 or ttl < 540 then
redis.call("set", key, val)
redis.call("expire", key, 600)
redis.call("set", cntKey, 3)
redis.call("expire", cntKey, 600)
-- 符合我们的预期
return 0
else
-- 发送太频繁
return -1
end
校验验证码
校验验证码的流程:
- 查询验证码,如果验证码不存在,说明还没有发送;
- 验证码存在,验证次数少于等于
3
次,比较输入的验证码和预期的验证码是否相等; - 验证码存在,验证次数大于
3
次,直接返回不相等。
验证验证码的 lua 脚本实现
---
--- Created by ypb.
--- DateTime: 2024/7/21 21:14
---
local key = KEY[1]
-- 用户输入的 code
local expectedCode = ARGV[1]
local code = redis.call("get", key)
local cntKey = key..":cnt"
-- 转成一个数字
local cnt = tonumber(redis.call("get", cntKey))
if cnt <= 0 then
-- 用户一直输入错误
return -1
elseif expectedCode == code then
-- 用户输入正确
redis.call("set", cntKey, -1)
return 0
else
-- 用户手抖输入错误
redis.call("decr", cntKey)
return -2
end
代码实现
我们使用在 go 程序中直接编译 lua 基本的方式进行。
在 cache 层面上实现,直接与 redis 数据打交道。
var (
ErrCodeSendTooMany = errors.New("send code too many")
ErrCodeVerifyError = errors.New("code verify error")
ErrUnknownError = errors.New("unknown error")
)
// 编译器在编译的时候,把 set_code 中的代码放进 luaSetCode 这个变量中
//
//go:embed lua/set_code.lua
var luaSetCode string
// 把 verify_code 中的代码放进 luaVerifyCode 这个变量中
//
//go:embed lua/verify_code.lua
var luaVerifyCode string
type CodeCache struct {
client redis.Cmdable
}
func NewCodeCache(client redis.Cmdable) *CodeCache {
return &CodeCache{
client: client,
}
}
func (cc *CodeCache) key(biz, phone string) string {
return fmt.Sprintf("phone_code:%s:%s", biz, phone)
}
func (cc *CodeCache) Set(ctx context.Context, biz, phone, code string) error {
res, err := cc.client.Eval(ctx, luaSetCode, []string{cc.key(biz, phone)}, code).Int()
if err != nil {
return err
}
switch res {
case 0:
return nil
case -1:
return ErrCodeSendTooMany
case -2:
return ErrUnknownError
default:
return errors.New("系统错误")
}
}
func (cc *CodeCache) Verify(ctx context.Context, biz, phone, inputCode string) (bool, error) {
res, err := cc.client.Eval(ctx, luaVerifyCode, []string{cc.key(biz, phone)}, inputCode).Int()
if err != nil {
return false, err
}
switch res {
case 0:
return true, nil
case -1:
return false, ErrCodeSendTooMany
case -2:
return false, nil
}
return false, ErrUnknownError
}
实现 repository 层面上的代码,与区域治理对象打交道。
var (
ErrCodeTooMany = cache.ErrCodeSendTooMany
ErrCodeVerifyError = cache.ErrCodeVerifyError
)
type CodeRepository struct {
cache *cache.CodeCache
}
func NewCodeRepository(c *cache.CodeCache) *CodeRepository {
return &CodeRepository{
cache: c,
}
}
func (cr *CodeRepository) Store(ctx context.Context, biz string,
phone string, code string) error {
return cr.cache.Set(ctx, biz, phone, code)
}
func (cr *CodeRepository) Verify(ctx context.Context, biz, phone, inputCode string) (bool, error) {
return cr.cache.Verify(ctx, biz, phone, inputCode)
}
实现 service 层面上的代码。
const codeTplId = "1877556"
type CodeService struct {
repo *repository.CodeRepository
smsSvc sms.Service
}
func (svc *CodeService) genCode() string {
num := rand.Intn(1000000)
return fmt.Sprintf("%06d", num)
}
func (svc *CodeService) Send(ctx context.Context,
// 区别业务场景
biz string,
phone string) error {
// 生成一个验证码
code := svc.genCode()
err := svc.repo.Store(ctx, biz, phone, code)
if err != nil {
return err
}
err = svc.smsSvc.Send(ctx, codeTplId, []string{code}, phone)
return err
}
func (svc *CodeService) Verify(ctx context.Context, biz string, phone string, code string) (bool, error) {
return svc.repo.Verify(ctx, biz, phone, code)
}
存在问题
我们这样将检测和校验的业务代码放在了lua脚本中执行。但其实,如果按照DDD 设计,这部分代码是要放在 CodeService
中的。也就是说, CodeService
来负责监测和校验,并且需要确保使用安全和系统安全。然而, CodeService
很难做到并发问题,因此我们将这部分代码饭仔 redis 中。缺点就是,它不是一个通用型代码,如过存储我们更换问 memcache,就得从新写一遍相关逻辑代码。