在学习过程中,我遇到了一个场景:用户使用邮箱进行登录注册,此时需要向对应邮箱发送验证码。之后用户输入验证码后,我们需要对验证码进行校验。这里我选择使用第三方包base64Captcha生成验证码,并且使用redis作为验证码池进行存储,代码如下:
1.创建一个redis验证码池,确保你已经配置了redis环境,这里必须实现以下base64Captcha中的Store接口
type Store interface {
// Set sets the digits for the captcha id.
//验证码存入验证码池的方法,id为键,验证码为值
Set(id string, value string) error
// Get returns stored digits for the captcha id. Clear indicates
// whether the captcha must be deleted from the store.
//获取验证码的方法
Get(id string, clear bool) string
//Verify captcha's answer directly
//校验验证码的方法
Verify(id, answer string, clear bool) bool
}
对这个接口的实现方法如下
//自定义一个验证码池
//以redis作为验证码池
var ctx = context.Background()
const CAPTCHA = "captcha:"
type RedisStore struct {
}
// Set sets the digits for the captcha id.
func (r RedisStore) Set(id string, value string) error {
key := CAPTCHA + id
err := global.RedisDb.Set(ctx, key, value, time.Minute*2).Err()
if err != nil {
log.Println(err.Error())
}
return err
}
// Get returns stored digits for the captcha id. Clear indicates
// whether the captcha must be deleted from the store.
func (r RedisStore) Get(id string, clear bool) string {
key := CAPTCHA + id
val, err := global.RedisDb.Get(ctx, key).Result()
if err != nil {
fmt.Println(err)
return ""
}
if clear {
err := global.RedisDb.Del(ctx, key).Err()
if err != nil {
fmt.Println(err)
return ""
}
}
return val
}
// Verify captcha's answer directly
func (r RedisStore) Verify(id, answer string, clear bool) bool {
v := RedisStore{}.Get(id, clear)
//fmt.Println("key:"+id+";value:"+v+";answer:"+answer)
return v == answer
}
2.创建一个用于发送验证码到邮箱的utils工具(具体的邮箱配置忽略)
package utils
import (
"context"
"fmt"
"github.com/jordan-wright/email"
"github.com/mojocn/base64Captcha"
"net/smtp"
)
// 发送邮箱验证码
func SendEmailValidate(em []string, myType int) (string, error) {
e := email.NewEmail()
e.From = fmt.Sprintf("发件人邮箱")
e.To = em
// 生成6位随机验证码
driver := base64Captcha.NewDriverDigit(80, 240, 5, 0.7, 80)
//这里在生成验证码对象时,就使用了redis作为验证码池
cp := base64Captcha.NewCaptcha(driver, cache.RedisStore{})
id, _, anwser, err := cp.Generate()
fmt.Println("id", id)
fmt.Println("anwser", anwser)
if err != nil {
fmt.Println("err")
return "", err
}
ems := em[0]
//为id添加验证码类型,1为注册,2为登录
if myType == 1 {
id = fmt.Sprintf("%s&%s", id, "emailtoregister")
} else if myType == 2 {
id = fmt.Sprintf("%s&%s", id, "emailtologin")
} else {
id = fmt.Sprintf("%s&%s", id, "emailtoforgetpassword")
}
cmd := global.RedisDb.Set(context.Background(), ems, id, 2*time.Minute)
if cmd.Err() != nil {
log.Fatal(cmd.Err())
return "", cmd.Err()
}
//设置文件发送的内容
content := fmt.Sprintf(`
<div>
<div>
您好!
</div>
<div style="padding: 8px 40px 8px 50px;">
<p>您提交的邮箱验证,本次验证码为<u><strong>%s</strong></u>,为了保证账号安全,验证码有效期为5分钟。请确认为本人操作,切勿向他人泄露,感谢您的理解与使用。</p>
</div>
<div>
<p>此邮箱为系统邮箱,请勿回复。</p>
</div>
</div>
`, anwser)
e.Text = []byte(content)
//设置服务器相关的配置
err = e.Send("smtp.qq.com:25", smtp.PlainAuth("", "发件人邮箱", "发件人的密钥", "smtp.qq.com"))
fmt.Println("to", e.To)
return anwser, err
}
3.gin框架发送验证码的路由
// 发送注册验证码
func GetValidateCode(c *gin.Context) {
// 获取目的邮箱
ems := c.Param("email")
temp := strings.Split(ems, "@")
if len(temp) < 2 {
c.JSON(http.StatusBadRequest, gin.H{
"msg": "请输入正确邮箱",
"code": 400,
"data": nil,
})
return
}
em := []string{ems}
fmt.Println(ems)
fmt.Println(em)
//发送验证码,并将验证码id和email存入redis
_, err := utils.SendEmailValidate(em, 1)
if err != nil {
log.Println(err)
c.JSON(http.StatusBadRequest, gin.H{
"status": 400,
"msg": "验证码发送失败",
"ERROR-CONTROLLER": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"msg": "验证码发送成功",
"status": 200,
})
return
}
代码部分到此结束,接下来我要讲解以下验证码的校验思路,来看两个问题:
1.假定,现在恶意攻击者准备用别人的邮箱以爆破的方式登入别人的账号时,如果我们只是简单的对验证码做存储和校验会遇到什么问题呢?
1)验证码池冗余:可以预见的一个问题是,如果攻击者不断向我们的后端发起请求验证码的请求,即便我们设置了过期时间,但是在大量请求的情况下验证码池还是会冗余大量的验证码。而在我们底层,只有base64Captcha自动生成的id和验证码做绑定。即便id的生成是随机的,但这也无疑增加了爆破成功的概率。
为了解决这个问题,我们可以在id为键,存贮验证码的基础上再加一层,即邮箱为键,绑定id。这样一来,无论请求方如何请求我们的验证码,因为邮箱是不变的,且对于id来说是唯一的的,所以每次请求都会把上一次存储的id覆盖,也就意味着上一次请求的验证码是无效的。
2)验证码越权:第二个问题,在上述问题中,我们使用了邮箱和id进行绑定。但是依旧存在问题,假设现在,一个用户在邮箱注册处获得了一个验证码,结果他用这个验证码去进行登录。这在业务上极其不合理,但是按照底层逻辑来说这是能实现的。因为邮箱是不变的,无论登录和注册,我获取验证码都是通过这一个邮箱号。
为了解决这个问题,我选择的方法是:在原生的id后拼接标识符,以达到标识这个验证码对应的是哪一个接口的目的。也即id + 分隔符 + 标识符。这样一来,我只要通过分隔符切分,取到数组的最后一个,再在对应路由中,判断这个验证码是否为对应功能即可。具体验证方法如下:
// ValidateEmailCode
// @Title ValidateEmailCode
// @Description 验证邮箱验证码,并注册用户。
// @Author hyy 2022-03-05 18:19:18
// @Param c type description
func ValidateEmailCode(c *gin.Context) {
//检验邮箱是否合法
var user forms.CreateUserByEmail
err := c.ShouldBindJSON(&user)
fmt.Println(user)
if err != nil {
log.Println(err.Error())
c.JSON(http.StatusBadRequest, gin.H{
"status": 400,
"msg": "注册失败,json解析失败",
"ERROR-CONTROLLER": err.Error(),
})
return
}
myEmail := user.Email
// 默认用户权限为2
if user.RoleId == 0 {
user.RoleId = 3
}
// 通过邮箱获取在redis中绑定的id
id, err := global.RedisDb.Get(context.Background(), myEmail).Result()
//分隔符切分
myResp := strings.Split(id, "&")
if len(myResp) < 2 {
c.JSON(http.StatusInternalServerError, gin.H{
"msg": "验证码失效",
"code": 500,
"data": nil,
})
return
}
//判断接口是否对应
if myResp[1] != "emailtoregister" {
c.JSON(http.StatusBadRequest, gin.H{
"msg": "验证码失效",
"data": nil,
"code": 400,
})
return
}
id = myResp[0]
if err != nil {
log.Println(err.Error())
c.JSON(http.StatusBadRequest, gin.H{
"status": 400,
"msg": "Redis获取vCode失败",
"ERROR-CONTROLLER": err.Error(),
})
return
}
版权归原作者 桐生战兔868 所有, 如有侵权,请联系我们删除。