feat: 完成人机交互验证 API 接入,增加短信防刷验证

This commit is contained in:
RockYang
2023-07-25 17:00:24 +08:00
parent cab955c292
commit c5be114db2
18 changed files with 705 additions and 213 deletions

View File

@@ -3,9 +3,9 @@ ProxyURL = "http://172.22.11.200:7777"
MysqlDns = "root:mysql_pass@tcp(localhost:3306)/chatgpt_plus?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local"
StaticDir = "./static"
StaticUrl = "http://localhost:5678/static"
AesEncryptKey = "YOUR_AES_KEY"
FunApiToken = "YOUR_FUN_API_TOKEN"
AesEncryptKey = "{YOUR_AES_KEY}"
StartWechatBot = false
EnabledMsgService = false
[Session]
Driver = "cookie"
@@ -27,9 +27,14 @@ StartWechatBot = false
Port = 6379
Password = ""
[ApiConfig]
ApiURL = "{URL}"
AppId = "{APP_ID}"
Token = "{TOKEN}"
[SmsConfig]
AccessKey = "YOUR_ACCESS_KEY"
AccessSecret = "YOUR_SECRET_KEY"
AccessKey = "{YOUR_ACCESS_KEY}"
AccessSecret = "{YOUR_SECRET_KEY}"
Product = "Dysmsapi"
Domain = "dysmsapi.aliyuncs.com"

View File

@@ -33,7 +33,7 @@ func NewDefaultConfig() *types.AppConfig {
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
},
Func: types.FunctionApiConfig{},
ApiConfig: types.ChatPlusApiConfig{},
StartWechatBot: false,
}
}

View File

@@ -15,7 +15,7 @@ type AppConfig struct {
StaticDir string // 静态资源目录
StaticUrl string // 静态资源 URL
Redis RedisConfig // redis 连接信息
Func FunctionApiConfig // function api configs
ApiConfig ChatPlusApiConfig // chatplus api configs
AesEncryptKey string
SmsConfig AliYunSmsConfig // 短信发送配置
StartWechatBot bool // 是否启动微信机器人
@@ -86,7 +86,7 @@ type SystemConfig struct {
UserInitCalls int `json:"user_init_calls"` // 新用户注册默认总送多少次调用
}
type FunctionApiConfig struct {
type ChatPlusApiConfig struct {
ApiURL string
AppId string
Token string

View File

@@ -0,0 +1,47 @@
package handler
import (
"chatplus/core/types"
"chatplus/service"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
)
// 今日头条函数实现
type CaptchaHandler struct {
service *service.CaptchaService
}
func NewCaptchaHandler(s *service.CaptchaService) *CaptchaHandler {
return &CaptchaHandler{service: s}
}
func (h *CaptchaHandler) Get(c *gin.Context) {
data, err := h.service.Get()
if err != nil {
resp.ERROR(c, err.Error())
return
}
resp.SUCCESS(c, data)
}
// Check verify the captcha data
func (h *CaptchaHandler) Check(c *gin.Context) {
var data struct {
Key string `json:"key"`
Dots string `json:"dots"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
if h.service.Check(data) {
resp.SUCCESS(c)
} else {
resp.ERROR(c)
}
}

View File

@@ -0,0 +1,65 @@
package handler
import (
"chatplus/core"
"chatplus/core/types"
"chatplus/service"
"chatplus/store"
"chatplus/utils"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
)
const CodeStorePrefix = "/verify/codes/"
type SmsHandler struct {
BaseHandler
db *store.LevelDB
sms *service.AliYunSmsService
captcha *service.CaptchaService
}
func NewSmsHandler(app *core.AppServer, db *store.LevelDB, sms *service.AliYunSmsService, captcha *service.CaptchaService) *SmsHandler {
handler := &SmsHandler{db: db, sms: sms, captcha: captcha}
handler.App = app
return handler
}
// VerifyCode 发送验证码短信
func (h *SmsHandler) VerifyCode(c *gin.Context) {
var data struct {
Mobile string `json:"mobile"`
Key string `json:"key"`
Dots string `json:"dots"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
if !h.captcha.Check(data) {
resp.ERROR(c, "验证码错误,请先完人机验证")
return
}
code := utils.RandomNumber(6)
err := h.sms.SendVerifyCode(data.Mobile, code)
if err != nil {
resp.ERROR(c, err.Error())
return
}
// 存储验证码,等待后面注册验证
err = h.db.Put(CodeStorePrefix+data.Mobile, code)
if err != nil {
resp.ERROR(c, "验证码保存失败")
return
}
resp.SUCCESS(c)
}
// Status check if the message service is enabled
func (h *SmsHandler) Status(c *gin.Context) {
resp.SUCCESS(c, h.App.Config.EnabledMsgService)
}

View File

@@ -58,12 +58,14 @@ func (h *UserHandler) Register(c *gin.Context) {
// 检查验证码
key := CodeStorePrefix + data.Mobile
var code int
err := h.levelDB.Get(key, &code)
if err != nil || code != data.Code {
logger.Info(code)
resp.ERROR(c, "短信验证码错误")
return
if h.App.Config.EnabledMsgService {
var code int
err := h.levelDB.Get(key, &code)
if err != nil || code != data.Code {
logger.Info(code)
resp.ERROR(c, "短信验证码错误")
return
}
}
// check if the username is exists
@@ -111,7 +113,7 @@ func (h *UserHandler) Register(c *gin.Context) {
var cfg model.Config
h.db.Where("marker = ?", "system").First(&cfg)
var config types.SystemConfig
err = utils.JsonDecode(cfg.Config, &config)
err := utils.JsonDecode(cfg.Config, &config)
if err != nil || config.UserInitCalls <= 0 {
user.Calls = types.UserInitCalls
} else {
@@ -124,7 +126,9 @@ func (h *UserHandler) Register(c *gin.Context) {
return
}
_ = h.levelDB.Delete(key) // 注册成功,删除短信验证码
if h.App.Config.EnabledMsgService {
_ = h.levelDB.Delete(key) // 注册成功,删除短信验证码
}
resp.SUCCESS(c, user)
}

View File

@@ -1,150 +0,0 @@
package handler
import (
"chatplus/core"
"chatplus/core/types"
"chatplus/service"
"chatplus/store"
"chatplus/utils"
"chatplus/utils/resp"
"fmt"
"time"
"github.com/gin-gonic/gin"
)
// 短信验证码控制器
type VerifyHandler struct {
BaseHandler
sms *service.AliYunSmsService
db *store.LevelDB
}
const TokenStorePrefix = "/verify/tokens/"
const CodeStorePrefix = "/verify/codes/"
const MobileStatPrefix = "/verify/stats/"
func NewVerifyHandler(app *core.AppServer, sms *service.AliYunSmsService, db *store.LevelDB) *VerifyHandler {
handler := &VerifyHandler{sms: sms, db: db}
handler.App = app
return handler
}
type VerifyToken struct {
Token string
Timestamp int64
}
// CodeStats 验证码发送统计
type CodeStats struct {
Mobile string
Count uint
Time int64
}
// Token 生成自验证 token
func (h *VerifyHandler) Token(c *gin.Context) {
// 如果不是通过浏览器访问,则返回错误的 token
// TODO: 引入验证码机制防刷机制
if c.GetHeader("Sec-Fetch-Mode") != "cors" {
token := fmt.Sprintf("%s:%d", utils.RandString(32), time.Now().Unix())
encrypt, err := utils.AesEncrypt(h.App.Config.AesEncryptKey, []byte(token))
if err != nil {
resp.ERROR(c, "Token 加密出错")
return
}
resp.SUCCESS(c, encrypt)
return
}
token := VerifyToken{
Token: utils.RandString(32),
Timestamp: time.Now().Unix(),
}
json := utils.JsonEncode(token)
encrypt, err := utils.AesEncrypt(h.App.Config.AesEncryptKey, []byte(json))
if err != nil {
resp.ERROR(c, "Token 加密出错")
return
}
err = h.db.Put(TokenStorePrefix+token.Token, token)
if err != nil {
resp.ERROR(c, "Token 存储失败")
return
}
resp.SUCCESS(c, encrypt)
}
// SendMsg 发送验证码短信
func (h *VerifyHandler) SendMsg(c *gin.Context) {
var data struct {
Mobile string `json:"mobile"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
decrypt, err := utils.AesDecrypt(h.App.Config.AesEncryptKey, data.Token)
if err != nil {
resp.ERROR(c, "Token 解密失败")
return
}
var token VerifyToken
err = utils.JsonDecode(string(decrypt), &token)
if err != nil {
resp.ERROR(c, "Token 解码失败")
return
}
if time.Now().Unix()-token.Timestamp > 30 {
resp.ERROR(c, "Token 已过期,请刷新页面重试")
return
}
// 验证当前手机号发送次数24 小时内相同手机号只允许发送 2 次
var stat CodeStats
err = h.db.Get(MobileStatPrefix+data.Mobile, &stat)
if err != nil {
stat = CodeStats{
Mobile: data.Mobile,
Count: 0,
Time: time.Now().Unix(),
}
} else if stat.Count == 2 {
if time.Now().Unix()-stat.Time > 86400 {
stat.Count = 0
stat.Time = time.Now().Unix()
} else {
resp.ERROR(c, "触发流量预警,请 24 小时后再操作!")
return
}
}
code := utils.RandomNumber(6)
err = h.sms.SendVerifyCode(data.Mobile, code)
if err != nil {
resp.ERROR(c, err.Error())
return
}
// 每个 token 用完一次立即失效
_ = h.db.Delete(TokenStorePrefix + token.Token)
// 存储验证码,等待后面注册验证
err = h.db.Put(CodeStorePrefix+data.Mobile, code)
if err != nil {
resp.ERROR(c, "验证码保存失败")
return
}
// 更新发送次数
stat.Count = stat.Count + 1
_ = h.db.Put(MobileStatPrefix+data.Mobile, stat)
logger.Infof("%+v", stat)
resp.SUCCESS(c)
}

View File

@@ -117,13 +117,13 @@ func main() {
// 创建函数
fx.Provide(func(config *types.AppConfig) (function.FuncZaoBao, error) {
return function.NewZaoBao(config.Func), nil
return function.NewZaoBao(config.ApiConfig), nil
}),
fx.Provide(func(config *types.AppConfig) (function.FuncWeiboHot, error) {
return function.NewWeiboHot(config.Func), nil
return function.NewWeiboHot(config.ApiConfig), nil
}),
fx.Provide(func(config *types.AppConfig) (function.FuncHeadlines, error) {
return function.NewHeadLines(config.Func), nil
return function.NewHeadLines(config.ApiConfig), nil
}),
// 创建控制器
@@ -131,8 +131,9 @@ func main() {
fx.Provide(handler.NewUserHandler),
fx.Provide(handler.NewChatHandler),
fx.Provide(handler.NewUploadHandler),
fx.Provide(handler.NewVerifyHandler),
fx.Provide(handler.NewSmsHandler),
fx.Provide(handler.NewRewardHandler),
fx.Provide(handler.NewCaptchaHandler),
fx.Provide(admin.NewConfigHandler),
fx.Provide(admin.NewAdminHandler),
@@ -143,6 +144,9 @@ func main() {
// 创建服务
fx.Provide(service.NewAliYunSmsService),
fx.Provide(func(config *types.AppConfig) *service.CaptchaService {
return service.NewCaptchaService(config.ApiConfig)
}),
// 注册路由
fx.Invoke(func(s *core.AppServer, h *handler.ChatRoleHandler) {
@@ -174,10 +178,15 @@ func main() {
fx.Invoke(func(s *core.AppServer, h *handler.UploadHandler) {
s.Engine.POST("/api/upload", h.Upload)
}),
fx.Invoke(func(s *core.AppServer, h *handler.VerifyHandler) {
group := s.Engine.Group("/api/verify/")
group.GET("token", h.Token)
group.POST("sms", h.SendMsg)
fx.Invoke(func(s *core.AppServer, h *handler.SmsHandler) {
group := s.Engine.Group("/api/sms/")
group.GET("status", h.Status)
group.POST("code", h.VerifyCode)
}),
fx.Invoke(func(s *core.AppServer, h *handler.CaptchaHandler) {
group := s.Engine.Group("/api/captcha/")
group.GET("get", h.Get)
group.POST("check", h.Check)
}),
fx.Invoke(func(s *core.AppServer, h *handler.RewardHandler) {
group := s.Engine.Group("/api/reward/")

View File

@@ -0,0 +1,62 @@
package service
import (
"chatplus/core/types"
"errors"
"fmt"
"github.com/imroc/req/v3"
"time"
)
type CaptchaService struct {
config types.ChatPlusApiConfig
client *req.Client
}
func NewCaptchaService(config types.ChatPlusApiConfig) *CaptchaService {
return &CaptchaService{
config: config,
client: req.C().SetTimeout(10 * time.Second),
}
}
func (s *CaptchaService) Get() (interface{}, error) {
if s.config.Token == "" {
return nil, errors.New("无效的 API Token")
}
url := fmt.Sprintf("%s/api/captcha/get", s.config.ApiURL)
var res types.BizVo
r, err := s.client.R().
SetHeader("AppId", s.config.AppId).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", s.config.Token)).
SetSuccessResult(&res).Get(url)
if err != nil || r.IsErrorState() {
return nil, fmt.Errorf("请求 API 失败:%v", err)
}
if res.Code != types.Success {
return nil, fmt.Errorf("请求 API 失败:%s", res.Message)
}
return res.Data, nil
}
func (s *CaptchaService) Check(data interface{}) bool {
url := fmt.Sprintf("%s/api/captcha/check", s.config.ApiURL)
var res types.BizVo
r, err := s.client.R().
SetHeader("AppId", s.config.AppId).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", s.config.Token)).
SetBodyJsonMarshal(data).
SetSuccessResult(&res).Post(url)
if err != nil || r.IsErrorState() {
return false
}
if res.Code != types.Success {
return false
}
return true
}

View File

@@ -13,11 +13,11 @@ import (
type FuncHeadlines struct {
name string
config types.FunctionApiConfig
config types.ChatPlusApiConfig
client *req.Client
}
func NewHeadLines(config types.FunctionApiConfig) FuncHeadlines {
func NewHeadLines(config types.ChatPlusApiConfig) FuncHeadlines {
return FuncHeadlines{
name: "今日头条",
config: config,

View File

@@ -13,11 +13,11 @@ import (
type FuncWeiboHot struct {
name string
config types.FunctionApiConfig
config types.ChatPlusApiConfig
client *req.Client
}
func NewWeiboHot(config types.FunctionApiConfig) FuncWeiboHot {
func NewWeiboHot(config types.ChatPlusApiConfig) FuncWeiboHot {
return FuncWeiboHot{
name: "微博热搜",
config: config,

View File

@@ -13,11 +13,11 @@ import (
type FuncZaoBao struct {
name string
config types.FunctionApiConfig
config types.ChatPlusApiConfig
client *req.Client
}
func NewZaoBao(config types.FunctionApiConfig) FuncZaoBao {
func NewZaoBao(config types.ChatPlusApiConfig) FuncZaoBao {
return FuncZaoBao{
name: "每日早报",
config: config,