短信登陆验证机制在Go系统开发时的应用与实践

短信登陆设计

需求分析

在功能上,需求可以简单的描述为:

  • 允许用户使用手机号码接收验证码进行登陆。在登录的时候,如果手机号码是一个全新的手机号码,直接注册一个新的账号。
  • 手机验证码的有效期为 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位数字,需要考虑两个安全问题:

  1. 控制住验证码发送的频率,不至于一下子就发送几万条数据。
    • 同一个手机号,一分钟之内只能发送一次。
    • 验证码有效期为十分钟。
    • 本身整个系统也有限流,也要保护系统。
  2. 验证码不能被攻击者破解,因为验证码只有六位数字,也就只有十万种可能,所以不能让用户频繁输入验证码来暴力破解。
    • 一个验证码,如果已经验证通过,那么就不能在使用。
    • 一个验证码,如果已经三次验证失败,那么这个验证码就不可再使用。在这种情况下,只会告诉用户输入的验证码不对,但是不会提示验证码过于频繁失败问题。

服务接口抽象

只有两个接口:发送验证码和验证验证码。

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,就得从新写一遍相关逻辑代码。请添加图片描述

  • 19
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值