使用 飞书应用消息推送 开发企业级动态码认证服务

背景 : 动态码认证是各类应用常用的认证方法,常见的有两种: OTP动态码 和 手机短信码。 但对于企业来说. 这两种方式实现起来都不够灵活方便, OTP需要手动绑定和解绑密钥对于, 手机验证码还需要去找短信服务商开通短信付费服务。 有没有一种又简单,又白嫖的方法呢? 答案是肯定的, 我们可以利用办公社交软件的 消息推送功能 来充当动态码推送。 对于企业来说,省去了管理 OTP 密钥的麻烦, 对于用户来说也省去了打开 身份令牌APP 的操作,直接在 办公通讯APP(如飞书, 企业微信,钉钉等)即可操作, 可谓是一举多得。看完此贴,你将有能力使用飞书机器人推送消息开发一个动态码认证服务,并将使用 著名的渗透测试工具 burpsuite 进行验证码轰炸 和 验证码爆破 的渗透性测试

飞书创建应用并发布

首先你需要登入 飞书开放平台, 这里以个人身份登录即可,不需要你有企业账号。

点击右上角的开发者后台

选择创建企业自建应用

填写好应用名称,描述,图标

记录好 APPID 和 APP Secret

添加应用能力,确保已添加机器人

点击权限控制, 确保通过手机号(注册飞书用的手机号)可以获取到用户ID 权限打开

确保以应用身份发送消息权限打开

点击管理控制与发布,点击创建版本

填写好版本号和说明(都由自己定义,只是个标记而已),保存好以后发布即可

自此飞书方面已经准备完毕,下面是

Docker 部署 redis

我们使用 docker 部署一个简单的 redis 单节点容器, 直接命令行运行。

css 复制代码
docker run  --name some-redis  -p 6379:6379  redis:latest

如果对 docker 不熟悉的同学可以看看我的另外两篇博客: Docker 部署 Nginx 实现一个极简的 负载均衡保姆级从0到1讲解go远程开发环境搭建(从Docker安装到使用Goland远程部署和调试

运行成功后 使用

shell 复制代码
docker ps 

应该可以看到下面这种输出:

shell 复制代码
CONTAINER ID    IMAGE             COMMAND                CREATED       STATUS         PORTS                  NAMES
467cc414d3e5   redis:latest   "docker-entrypoint.s..."   9 hours ago   Up 9 hours   0.0.0.0:6379->6379/tcp   some-redis

代码调用 飞书应用

新建工程

创建 feishu_msg_auth 文件夹,命令行输入

shell 复制代码
go mod init feishu_msg_auth
go mod tidy

随后创建各目录与文件如下:

shell 复制代码
feishu_msg_auth

    -- feishu_msg
        -- feishu_msg.go  发送飞书验证码, 校验飞书验证码
        
    -- redis_client
        -- redis_client.go, 初始化 redis 连接 , 后续使用分布式锁防止动态码轰炸那盒爆破时 需要用到  
        
    -- session_store
        -- session_store.go, 初始化 redis session
        
    -- main
        -- main.go 主函数

    go.mod
        go.sum

main.go

主函数非常简单, 初始化了 redis session store, 我们的动态码将会存于session,session最终存于 redis 中。初始化了一个 redis client, redis client 内部以及维护了一个连接池,后需防止动态码轰炸, 动态码爆破的时候需要用到 分布式锁, 需要用到 redis 连接。最后注册了两个路由,粉笔是发送动态码 和 校验动态码的 handler.

go 复制代码
package main

import (
   "feishu_msg_auth/feishu_msg"
   "feishu_msg_auth/redis_client"
   "feishu_msg_auth/session_store"

   "net/http"
)

func main() {

   // init redis session store
   err := session_store.InitRedisStore()
   if err != nil {
      panic(err)
   }

   // init redis client
   err = redis_client.InitRedisClient()
   if err != nil {
      panic(err)
   }

   // route for send code and verify code
   http.HandleFunc("/sendFeishuMsg", feishu_msg.SendFeishuMsgHandler)
   http.HandleFunc("/verifyFeishuMsg", feishu_msg.VerifyMsgHandler)
   go func() {
      http.ListenAndServe(":2000", nil)
   }()

   select {}

}

redis_client.go

一个简单的 redis client 初始化函数

go 复制代码
package redis_client

import (
   "errors"
   "github.com/redis/go-redis/v9"
)

var RedisClient *redis.Client

func InitRedisClient() error {
   client := redis.NewClient(&redis.Options{
      Network: "tcp",
      Addr:    "localhost:6379",
   })

   if client == nil {
      return errors.New("client==nil")
   }

   RedisClient = client
   return nil

}

session_store.go

我们初始化了一个 session store, 以后存入和取出操作都由 seesion store 完成。 存取的seesion 内容位 FeishuMsgClaim 类型,里面包含了三个字段, 消息(也就是动态码)的发送时间, 动态码, 动态码校验错误次数counter。session 的 key 以 auth_session: 开头. session 对应的 cookie 使用 lax 模式, 有效期1800秒, 对同一个 host 下所有路径生效。

go 复制代码
package session_store

import (
   "encoding/gob"
   "fmt"
   "github.com/go-redis/redis"
   "github.com/gorilla/sessions"
   "github.com/rbcervilla/redisstore"
   "net/http"
)

type FeishuMsgClaim struct {
   LastTime    int64 // timestamp
   Code        string
   FailedCount uint8
}

var SessionStore *redisstore.RedisStore

func InitRedisStore() error {
   client := redis.NewUniversalClient(&redis.UniversalOptions{
      Addrs: []string{"localhost:6379"},
   })

 
   store, err := redisstore.NewRedisStore(client)
   if err != nil {
      fmt.Println("failed to create redis store: ", err)
      return err
   }

 
   store.KeyPrefix("auth_session:")
   store.Options(sessions.Options{
      Path:     "/",
      SameSite: http.SameSiteLaxMode,
      MaxAge:   1800,
   })

   gob.Register(FeishuMsgClaim{})

   SessionStore = store

   return nil
}

feishu_msg.go

feishu_msg.go 详细展示了如何请求飞书发送消息(也就是动态码),然后校验动态码这两个功能。我们定义了几个结构体, 分别是:

  1. SendFeishuMsgReq, 请求发送验证码请求结构, 传入手机号, 实际应用中可以传入用户名再查询手机号,我这里怎么简单怎么来。
  2. VerifyFeishuMsgReq, 请求校验验证码请求结构, 传入手机号和动态码, 实际应用中可以不传入手机号, 因为在前序过程中可以把手机号也存入 session中,我这里怎么简单怎么来。
  3. FeishuUserInfoResp, UserData,User 结构体,都是飞书返回的用户信息结构体, 根据手机号, 应用ID,应用Secret 请求飞书,返回用户信息,获取UserId.

以及定义了额外的3个常量, 分别是 feishuMsgCoolTime 代表的是发送消息的冷却时间,在冷却时间内无法再次发送消息; feishuMsgExpire 代表的是消息(也就是动态码)过期的时间; feishuMsgMaxFailCount 代表的是最大尝试校验动态码的错误次数

SendFeishuMsgHandler 作为处理发送消息(动态码)的handler, 一进来检查方法,解析请求,接着尝试获取分布式锁。随后查看是否请求处于冷却时间内, 接着根据手机号, 应用ID,应用Secret 请求飞书,返回用户信息,获取UserId,然后根据userid 再次请求飞书发送验证码。最后将动态码(包括获取的时间, 动态码本身, 错误次数初始值)存入session。有人问一定要获取分布式锁吗? 不是已经有了从 session中获取冷却时间判断是否处于冷却时间内的操作吗? 从现在的代码上说确实不需要获取 redis 分布式锁, 但实际上可能还会有其他操作,比如 session 存于 mysql 中,请求参数不是手机而是账号,根据账号去mysql 中查询手机,这些额外的操作都会消耗IO/CPU性能。如果有攻击者短时间内发送大量的请求过来,可能把中间的某一部分给打挂掉。

在函数中有这么一句:

ini 复制代码
option = append(option, lark.WithOpenBaseUrl("https://open.feishu.cn"))

其中的 open.feishu.cn, 代表的是你飞书的域名, 比如我使用的就是飞书开发者平台, 对应的就是 open.feishu.cn, 这是默认的域名。 如果你是 已经大公司, 你的飞书是私有化部署的, 那么就填私有化飞书的域名。

VerifyMsgHandler 作为处理发送消息(动态码)的handler, 一进来检查方法,解析请求,接着尝试获取分布式锁。随后从session 中拿出动态码进行校验,如果在验证码有效期内且校验成功且失败次数 < feishuMsgExpire返回登录成功,如果校验失败则会记录失败次数, 如果错误次数 >= feishuMsgExpire, 则会返回动态码超时。注意如果超过feishuMsgExpire次,不应该删除 session, 否则获取不到错误次数已经超过feishuMsgExpire的信息。

我们不通过 defer 来释放分布式锁,是因为我们就是想进一步的降低请求的频率,将请频繁的请求尽早地拦截下来,以免对后续的步骤造成压力。一般用户看到验证码到输入验证码是超过3秒的,或者用户看错了,再看一次动态码,再次输入,一般也超过3秒, 所以3秒是一个合情的时间,

go 复制代码
package feishu_msg

import (
   "context"
   "crypto/rand"
   "encoding/json"
   "feishu_msg_auth/redis_client"
   "feishu_msg_auth/session_store"
   "fmt"
   "github.com/bsm/redislock"
   "github.com/gorilla/sessions"
   lark "github.com/larksuite/oapi-sdk-go/v3"
   larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
   larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
   "io"
   "math/big"
   "net/http"
   "time"
)

type SendFeishuMsgReq struct {
   Phone string `json:"phone"`
}

type VerifyFeishuMsgReq struct {
   Phone string `json:"phone"`
   Code  string `json:"passwd"`
}

type FeishuUserInfoResp struct {
   Code int      `json:"code"`
   Msg  string   `json:"msg"`
   Data UserData `json:"data"`
}

type UserData struct {
   UserList []User `json:"user_list"`
}

type User struct {
   UserId string `json:"user_id"`
   Email  string `json:"email"`
   Mobile string `json:"mobile"`
}

const feishuMsgCoolTime = 30

const feishuMsgExpire = 300

const feishuMsgMaxFailCount = 3

func SendFeishuMsgHandler(w http.ResponseWriter, r *http.Request) {

   // check method
   if r.Method != http.MethodPost {
      w.Write([]byte("Expect Post method"))
      return
   }

   // parse body
   bodyBytes, err := io.ReadAll(r.Body)
   if err != nil {
      fmt.Println("Fail to read request body")
      w.Write([]byte("Fail to read request body"))
      return
   }

   var sendReq SendFeishuMsgReq
   err = json.Unmarshal(bodyBytes, &sendReq)
   if err != nil {
      fmt.Println("Fail to convert request body into  SendSmsReq ")
      w.Write([]byte("Fail to convert request body into  SendSmsReq "))
      return
   }

   // try obtaining distributed lock
   locker := redislock.New(redis_client.RedisClient)
   ctx := context.Background()

   _, err = locker.Obtain(ctx, "send_feishu_msg:"+sendReq.Phone, 3*time.Second, nil)
   if err == redislock.ErrNotObtained {
      fmt.Println("Could not obtain send_feishu_msg lock!")
      w.Write([]byte("req too frequent"))
      return
   } else if err != nil {
      fmt.Println("obtain send_feishu_msg lock err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   // Get session
   session, err := session_store.SessionStore.Get(r, "auth")
   if err != nil {
      fmt.Println("failed getting session: ", err)
      w.Write([]byte("failed getting session"))
      return
   }

   // check if too frequency
   claim, ok := session.Values[sendReq.Phone+":feishu_msg"]
   if ok {

      feishuMsgClaim, ok := claim.(session_store.FeishuMsgClaim)
      if ok {
         waitTime := feishuMsgClaim.LastTime + feishuMsgCoolTime - time.Now().Unix()
         if waitTime > 0 {
            fmt.Println("req too frequent")
            w.Write([]byte("req too frequent"))
            return
         }
      }
   }

   // create feishu client

   var option = []lark.ClientOptionFunc{lark.WithEnableTokenCache(true)}
   option = append(option, lark.WithOpenBaseUrl("https://open.feishu.cn"))
   feishuClient := lark.NewClient("cli_a59bb317db37100e", "xxxxxx", option...) // replace the app secret of your app

   // get user info
   var userInfoReq *larkcontact.BatchGetIdUserReq
   userInfoReq = larkcontact.NewBatchGetIdUserReqBuilder().Body(larkcontact.NewBatchGetIdUserReqBodyBuilder().Mobiles([]string{sendReq.Phone}).Build()).Build()

   batchGetIdUserResp, err := feishuClient.Contact.User.BatchGetId(context.Background(), userInfoReq)
   if err != nil {
      fmt.Println("get user info failed, err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   if !batchGetIdUserResp.Success() {
      fmt.Println("!userInfoResp.Success()")
      w.Write([]byte("internal err"))
      return
   }

   // parse user info
   var userInfoResp FeishuUserInfoResp
   err = json.Unmarshal(batchGetIdUserResp.RawBody, &userInfoResp)
   if err != nil {
      fmt.Println("json.Unmarshal(userInfoResp.RawBody,&userInfoResp) err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   // generate 6 digits
   rnd, err := rand.Int(rand.Reader, big.NewInt(999999))
   if err != nil {
      fmt.Println("generate random number:", err)
      return
   }
   code := fmt.Sprintf("%06d", rnd)

   // 发送飞书验证码
   msgContent := fmt.Sprintf("[XXXXX] Your code is %s, please subimit in %d seconds.", code, feishuMsgExpire)
   msgContent = fmt.Sprintf("{"text":"%s"}", msgContent)

   sendMsgReq := larkim.NewCreateMessageReqBuilder().ReceiveIdType("open_id").
      Body(larkim.NewCreateMessageReqBodyBuilder().ReceiveId(userInfoResp.Data.UserList[0].UserId).MsgType("text").Content(msgContent).Build()).Build()

   createMessageResp, err := feishuClient.Im.Message.Create(context.Background(), sendMsgReq)
   if err != nil {
      fmt.Println("feishuClient.Im.Message.Createerr = ", err)
      w.Write([]byte("internal err"))
      return
   }

   if !createMessageResp.Success() {
      fmt.Println("!createMessageResp.Success()")
      w.Write([]byte("internal err"))
      return
   }

   // save random code into session
   msgClaim := session_store.FeishuMsgClaim{
      LastTime:    time.Now().Unix(),
      Code:        code,
      FailedCount: 0,
   }

   session.Values[sendReq.Phone+":feishu_msg"] = msgClaim

   err = sessions.Save(r, w)
   if err != nil {
      fmt.Println("save seesion err: ", err)
      w.Write([]byte("save seesion err"))
      return
   }

   // retuen random code
   fmt.Printf("send code successfully, cooli_down time is %d  \n", feishuMsgCoolTime)
   w.Write([]byte(fmt.Sprintf("send code successfully, cooli_down time is %d  \n", feishuMsgCoolTime)))

}

func VerifyMsgHandler(w http.ResponseWriter, r *http.Request) {

   // check method
   if r.Method != http.MethodPost {
      w.Write([]byte("Expect Post method"))
      return
   }

   // parse body
   bodyBytes, err := io.ReadAll(r.Body)
   if err != nil {
      fmt.Println("Fail to read request body")
      w.Write([]byte("Fail to read request body"))
      return
   }

   var verifyReq VerifyFeishuMsgReq
   err = json.Unmarshal(bodyBytes, &verifyReq)
   if err != nil {
      fmt.Println("Fail to convert request body into LoginReq ")
      w.Write([]byte("Fail to convert request body into LoginReq "))
      return
   }

   // try obtain distributed lock
   locker := redislock.New(redis_client.RedisClient)
   ctx := context.Background()

   _, err = locker.Obtain(ctx, "verify_feishu_msg:"+verifyReq.Phone, 3*time.Second, nil)
   if err == redislock.ErrNotObtained {
      fmt.Println("Could not obtain verify_feishu_msg lock!")
      w.Write([]byte("req too frequent"))
      return
   } else if err != nil {
      fmt.Println("obtain verify_feishu_msg lock err = ", err)
      w.Write([]byte("internal err"))
      return
   }

   // get session
   session, err := session_store.SessionStore.Get(r, "auth")
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   claim, ok := session.Values[verifyReq.Phone+":feishu_msg"]
   if !ok {
      fmt.Println("Failed to get session. ")
      w.Write([]byte("Sms Code is expire. "))
      return
   }

   feishuMsgClaim, ok := claim.(session_store.FeishuMsgClaim)
   if !ok {
      fmt.Println("claim.(session_store.FeishuMsgClaim) err")
      w.Write([]byte("Sms Code is expire. "))
      return
   }

   if time.Now().Unix()-feishuMsgClaim.LastTime > feishuMsgExpire {
      fmt.Println("time.Now().Unix()-feishuMsgClaim.LastTime > feishuMsgExpire")
      w.Write([]byte("Sms Code is expire. "))
      return
   }

   if verifyReq.Code == feishuMsgClaim.Code && feishuMsgClaim.FailedCount < feishuMsgMaxFailCount {

      // verify code successfully,delete the code in seesion
      delete(session.Values, verifyReq.Phone+":feishu_msg")
      err = session.Save(r, w)
      if err != nil {
         w.Write([]byte("Failed to save session"))
         fmt.Println(err)
         return
      }

      // login successfully
      fmt.Println("Login Success ")
      w.Write([]byte("Login Success "))
      return
   }

   // code not match and try number less than feishuMsgMaxFailCount
   if feishuMsgClaim.FailedCount < feishuMsgMaxFailCount {
      feishuMsgClaim.FailedCount++

      session.Values[verifyReq.Phone+":feishu_msg"] = feishuMsgClaim
      err = session.Save(r, w)
      if err != nil {
         w.Write([]byte("Failed to save session"))
         fmt.Println(err)
         return
      }

      fmt.Printf("code not match, failed count < %d \n", feishuMsgMaxFailCount)
      w.Write([]byte("code not match"))
      return
   } else {
      fmt.Println(fmt.Sprintf("code not match, failed count >= %d \n", feishuMsgMaxFailCount))
      w.Write([]byte("code expired"))
      return
   }

}

使用 Postman 测试

main 目录下 :

shell 复制代码
go run main.go 

随后使用 postman 测试一下

发送成功返回

返回的 cookie

发送过于频繁

校验失败返回

校验成功返回

错误次数>3次

此时命令行应该也会打印错误信息如下:

shell 复制代码
code not match, failed count >= 3 

发送成功后你的飞书上应该会收到下面这条消息:

使用 Burp Suite 进行渗透测试

下面我们将使用 著名的渗透测试工具Burp Suite对所写的动态码服务进行 动态码轰炸(也可以称作是短信轰炸, 短时间内请求发送大量消息) 和 动态码爆破的(尝试通过枚举,暴力破解动态码) 测试。

配置 postman 代理

首先我们更改postman的代理配置:

这将会把 postman 的请求转发到 127.0.0.1(localhost)的 2001端口

安装 Burp Suite

前往Burpsuite 官网下载对应版本,正常安装即可

打开后选择 Temporary project in memory:

再选择 Use Burp defaults

配置 burpsuite 代理

打开 Burp Suite 拦截器

进行 动态码轰炸测试

还是像之前一样,使用 postman 发送请求

随后在 BurpSuite 应该可以看到 拦截到了此次请求

将拦截下来的请求送入 Intruder

将 payload 设置为 null, 所谓 null 就是不对请求进行任何修改, 只是单纯地重复发起请求。 配置好以后, 点击右上角的 Start Attack。

查看 动态码轰炸 结果

可以看到发送了11次请求, 只有第一次是请求成功的,其他次数要么是 获取不到 分布式锁而报错请求过于频繁,要么是处于动态码发送冷却时间内 报错请求过于频繁,符合预期

控制台输出:

shell 复制代码
send code successfully, cooli_down time is 30  
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
req too frequent
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!
Could not obtain send_feishu_msg lock!

动态码爆破测试

还是和之前一样,使用postman发送请求校验短信的请求

随后将拦截器中记录到的请求送入 intruder. 和之前的payload 不一样,我们这里需要对 password 字段进行选择,并点击 右边的Add 按钮, 这将会在接下来的 payload 配置中对 password 字段进行替换,从而实现一个个枚举暴力破解动态码。

在 payload 设置中,payload set 选择为1,代表只对一个地方进行替换, payload type 选择 simple list, 表示使用一个 列表逐个对请求进行替换。

下面的 payload settings 我导入了一个 txt 文件, 文件内容如下:

txt 复制代码
000001
000002
000003
000004
000005
000006
000007
000008
000009
000010
000011
000012
000013
000014
000015
000016
000017
000018
000019
000020
000021
000022
000023
000024
000025
000026
000027
000028
000029

文件内容很简单, 简单列举了从000000到000029, 代表 password字段会从 000000被替换到000029. 配置好以后点击右上角 Start Attack 发起攻击,观察结果。

第一次返回动态码错误(被拦截下来的那一次)

第二次错误码请求

可以看到已经被换成立 payload 中的001了, 返回 请求过于频繁。

第11次返回动态码错误

在第1次到底14次只会返回 请求过于频繁(获取不到分布式锁, 通过后台命令行日志输出 Could not obtain verify_feishu_msg lock! 可验证 ),或者是动态码错误。

第15次返回错误码过期(错误次数等于3次)

在此之后只会返回 请求过于频繁(获取不到分布式锁, 通过后台命令行日志输出 Could not obtain verify_feishu_msg lock! 可验证 ),或者是动态码过期(通过后台命令行日志输出 code not match, failed count >= 3 可验证)。 可加, 完全符合预期。

特别的是之所以我们不主动释放分布式锁,是因为我们就是想进一步的降低请求的频率,将请频繁的请求尽早地拦截下来,以免对后续的步骤造成压力。3秒是一个合情的时间,一般用户看到验证码到输入验证码是超过3秒的。

巨人的肩膀:

  1. open.feishu.cn/document/se...
  2. github.com/larksuite/o...
  3. github.com/redis/go-re...
  4. github.com/rbcervilla/...
相关推荐
艾伦~耶格尔24 分钟前
Spring Boot 三层架构开发模式入门
java·spring boot·后端·架构·三层架构
man201728 分钟前
基于spring boot的篮球论坛系统
java·spring boot·后端
攸攸太上1 小时前
Spring Gateway学习
java·后端·学习·spring·微服务·gateway
罗曼蒂克在消亡2 小时前
graphql--快速了解graphql特点
后端·graphql
潘多编程2 小时前
Spring Boot与GraphQL:现代化API设计
spring boot·后端·graphql
大神薯条老师2 小时前
Python从入门到高手4.3节-掌握跳转控制语句
后端·爬虫·python·深度学习·机器学习·数据分析
2401_857622663 小时前
Spring Boot新闻推荐系统:性能优化策略
java·spring boot·后端
知否技术3 小时前
为什么nodejs成为后端开发者的新宠?
前端·后端·node.js
AskHarries3 小时前
如何优雅的处理NPE问题?
java·spring boot·后端
计算机学姐4 小时前
基于SpringBoot+Vue的高校运动会管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis