Merge pull request #4 from sok-im/feature/captcha

Feature/captcha
This commit is contained in:
haoyunlt 2026-04-01 21:19:13 +08:00 committed by GitHub
commit 171d3365ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1100 additions and 2 deletions

View File

@ -122,6 +122,17 @@ builds:
- amd64 - amd64
- arm64 - arm64
- binary: openim-rpc-captcha
id: openim-rpc-captcha
main: ./cmd/openim-rpc/openim-rpc-captcha/main.go
goos:
- darwin
- windows
- linux
goarch:
- amd64
- arm64
- binary: openim-rpc-conversation - binary: openim-rpc-conversation
id: openim-rpc-conversation id: openim-rpc-conversation
main: ./cmd/openim-rpc/openim-rpc-conversation/main.go main: ./cmd/openim-rpc/openim-rpc-conversation/main.go
@ -355,6 +366,7 @@ nfpms:
- openim-msgtransfer - openim-msgtransfer
- openim-push - openim-push
- openim-rpc-auth - openim-rpc-auth
- openim-rpc-captcha
- openim-rpc-conversation - openim-rpc-conversation
- openim-rpc-friend - openim-rpc-friend
- openim-rpc-group - openim-rpc-group

View File

@ -0,0 +1,12 @@
package main
import (
"github.com/openimsdk/open-im-server/v3/pkg/common/cmd"
"github.com/openimsdk/tools/system/program"
)
func main() {
if err := cmd.NewCaptchaRpcCmd().Exec(); err != nil {
program.ExitWithError(err)
}
}

View File

@ -0,0 +1,12 @@
rpc:
registerIP: ""
listenIP: 0.0.0.0
autoSetPorts: false
ports: [10520]
prometheus:
enable: false
ports: [12520]
verifyPadding: 8
expireSeconds: 120

View File

@ -9,6 +9,7 @@ rpcRegisterName:
auth: auth auth: auth
conversation: conversation conversation: conversation
third: third third: third
captcha: captcha
imAdminUserID: [ imAdmin ] imAdminUserID: [ imAdmin ]

6
go.mod
View File

@ -104,6 +104,7 @@ require (
github.com/go-openapi/swag v0.22.4 // indirect github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-zookeeper/zk v1.0.3 // indirect github.com/go-zookeeper/zk v1.0.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
@ -173,6 +174,7 @@ require (
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wenlng/go-captcha/v2 v2.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/scram v1.1.2 // indirect
@ -193,7 +195,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect golang.org/x/arch v0.7.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/image v0.15.0 // indirect golang.org/x/image v0.16.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
@ -228,3 +230,5 @@ require (
golang.org/x/crypto v0.32.0 // indirect golang.org/x/crypto v0.32.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
) )
replace github.com/openimsdk/protocol => ./protocol

6
go.sum
View File

@ -178,6 +178,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -448,6 +450,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wenlng/go-captcha/v2 v2.0.5 h1:+1FpVwJZmLCqEHxOt+HvpUArFGo107nRxOeRVHkZhTc=
github.com/wenlng/go-captcha/v2 v2.0.5/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
@ -516,6 +520,8 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=

49
internal/api/captcha.go Normal file
View File

@ -0,0 +1,49 @@
package api
import (
"github.com/gin-gonic/gin"
pbcaptcha "github.com/openimsdk/protocol/captcha"
"github.com/openimsdk/tools/a2r"
"github.com/openimsdk/tools/apiresp"
"github.com/openimsdk/tools/log"
)
type CaptchaApi struct {
Client pbcaptcha.CaptchaClient
}
func NewCaptchaApi(client pbcaptcha.CaptchaClient) *CaptchaApi {
return &CaptchaApi{Client: client}
}
func (c *CaptchaApi) GenerateCaptcha(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbcaptcha.GenerateCaptchaReq](ctx)
if err != nil {
log.ZError(ctx, "captcha generate request parse failed", err)
apiresp.GinError(ctx, err)
return
}
resp, err := c.Client.GenerateCaptcha(ctx, req)
if err != nil {
log.ZError(ctx, "captcha generate rpc failed", err)
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}
func (c *CaptchaApi) VerifyCaptcha(ctx *gin.Context) {
req, err := a2r.ParseRequestNotCheck[pbcaptcha.VerifyCaptchaReq](ctx)
if err != nil {
log.ZError(ctx, "captcha verify request parse failed", err)
apiresp.GinError(ctx, err)
return
}
resp, err := c.Client.VerifyCaptcha(ctx, req)
if err != nil {
log.ZError(ctx, "captcha verify rpc failed", err, "captchaID", req.GetCaptchaID(), "x", req.GetX(), "y", req.GetY())
apiresp.GinError(ctx, err)
return
}
apiresp.GinSuccess(ctx, resp)
}

View File

@ -47,6 +47,7 @@ func Start(ctx context.Context, index int, cfg *Config) error {
// Determine whether zk is passed according to whether it is a clustered deployment // Determine whether zk is passed according to whether it is a clustered deployment
client, err = kdisc.NewDiscoveryRegister(&cfg.Discovery, &cfg.Share, []string{ client, err = kdisc.NewDiscoveryRegister(&cfg.Discovery, &cfg.Share, []string{
cfg.Share.RpcRegisterName.MessageGateway, cfg.Share.RpcRegisterName.MessageGateway,
cfg.Share.RpcRegisterName.Captcha,
}) })
if err != nil { if err != nil {
return errs.WrapMsg(err, "failed to register discovery service") return errs.WrapMsg(err, "failed to register discovery service")

View File

@ -7,6 +7,7 @@ import (
"github.com/openimsdk/open-im-server/v3/pkg/rpcli" "github.com/openimsdk/open-im-server/v3/pkg/rpcli"
pbAuth "github.com/openimsdk/protocol/auth" pbAuth "github.com/openimsdk/protocol/auth"
pbcaptcha "github.com/openimsdk/protocol/captcha"
"github.com/openimsdk/protocol/conversation" "github.com/openimsdk/protocol/conversation"
"github.com/openimsdk/protocol/group" "github.com/openimsdk/protocol/group"
"github.com/openimsdk/protocol/msg" "github.com/openimsdk/protocol/msg"
@ -98,6 +99,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
if err != nil { if err != nil {
return nil, err return nil, err
} }
captchaConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Captcha)
if err != nil {
return nil, err
}
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
@ -115,6 +120,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
r.Use(prommetricsGin(), gin.RecoveryWithWriter(gin.DefaultErrorWriter, mw.GinPanicErr), mw.CorsHandler(), mw.GinParseOperationID(), GinParseToken(rpcli.NewAuthClient(authConn))) r.Use(prommetricsGin(), gin.RecoveryWithWriter(gin.DefaultErrorWriter, mw.GinPanicErr), mw.CorsHandler(), mw.GinParseOperationID(), GinParseToken(rpcli.NewAuthClient(authConn)))
u := NewUserApi(user.NewUserClient(userConn), client, config.Share.RpcRegisterName) u := NewUserApi(user.NewUserClient(userConn), client, config.Share.RpcRegisterName)
m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID) m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID)
cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn))
bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn)) bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn))
userRouterGroup := r.Group("/user") userRouterGroup := r.Group("/user")
{ {
@ -288,6 +294,12 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
conversationGroup.POST("/get_pinned_conversation_ids", c.GetPinnedConversationIDs) conversationGroup.POST("/get_pinned_conversation_ids", c.GetPinnedConversationIDs)
} }
{
captchaGroup := r.Group("/captcha")
captchaGroup.POST("/generate", cp.GenerateCaptcha)
captchaGroup.POST("/verify", cp.VerifyCaptcha)
}
{ {
statisticsGroup := r.Group("/statistics") statisticsGroup := r.Group("/statistics")
statisticsGroup.POST("/user/register", u.UserRegisterCount) statisticsGroup.POST("/user/register", u.UserRegisterCount)

View File

@ -0,0 +1,167 @@
package captcha
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/openimsdk/open-im-server/v3/pkg/common/config"
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
pbcaptcha "github.com/openimsdk/protocol/captcha"
"github.com/openimsdk/tools/db/mongoutil"
"github.com/openimsdk/tools/discovery"
"github.com/openimsdk/tools/log"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"google.golang.org/grpc"
"github.com/wenlng/go-captcha/v2/base/option"
"github.com/wenlng/go-captcha/v2/slide"
)
type Config struct {
RpcConfig config.Captcha
MongodbConfig config.Mongo
Share config.Share
Discovery config.Discovery
}
type server struct {
pbcaptcha.UnimplementedCaptchaServer
conf config.Captcha
capt slide.Captcha
collection *mongo.Collection
}
type captchaDoc struct {
CaptchaID string `bson:"captcha_id"`
X int `bson:"x"`
Y int `bson:"y"`
ExpiredAt time.Time `bson:"expired_at"`
CreateTime time.Time `bson:"create_time"`
VerifyTime time.Time `bson:"verify_time,omitempty"`
}
func Start(ctx context.Context, cfg *Config, _ discovery.SvcDiscoveryRegistry, grpcServer *grpc.Server) error {
mongoClient, err := mongoutil.NewMongoDB(ctx, cfg.MongodbConfig.Build())
if err != nil {
log.ZError(ctx, "captcha connect mongodb failed", err)
return err
}
collection := mongoClient.GetDB().Collection("captcha")
_, err = collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "captcha_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "expired_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
},
})
if err != nil {
log.ZError(ctx, "captcha create mongodb indexes failed", err)
return err
}
resources, err := loadResources()
if err != nil {
log.ZError(ctx, "captcha load resources failed", err)
return err
}
builder := slide.NewBuilder()
builder.SetResources(resources...)
s := &server{
conf: cfg.RpcConfig,
capt: builder.Make(),
collection: collection,
}
if s.conf.ExpireSeconds <= 0 {
s.conf.ExpireSeconds = 120
}
if s.conf.VerifyPadding <= 0 {
s.conf.VerifyPadding = 8
}
pbcaptcha.RegisterCaptchaServer(grpcServer, s)
return nil
}
func (s *server) GenerateCaptcha(ctx context.Context, _ *pbcaptcha.GenerateCaptchaReq) (*pbcaptcha.GenerateCaptchaResp, error) {
captData, err := s.capt.Generate()
if err != nil {
log.ZError(ctx, "captcha generate failed", err)
return nil, err
}
block := captData.GetData()
masterImage, err := captData.GetMasterImage().ToBase64DataWithQuality(option.QualityNone)
if err != nil {
log.ZError(ctx, "captcha encode master image failed", err)
return nil, err
}
tileImage, err := captData.GetTileImage().ToBase64Data()
if err != nil {
log.ZError(ctx, "captcha encode tile image failed", err)
return nil, err
}
id := uuid.NewString()
now := time.Now()
expiredAt := now.Add(time.Duration(s.conf.ExpireSeconds) * time.Second)
_, err = s.collection.InsertOne(ctx, captchaDoc{
CaptchaID: id,
X: block.X,
Y: block.Y,
ExpiredAt: expiredAt,
CreateTime: now,
})
if err != nil {
log.ZError(ctx, "captcha insert mongodb failed", err, "captchaID", id)
return nil, err
}
return &pbcaptcha.GenerateCaptchaResp{
CaptchaID: id,
MasterImage: masterImage,
TileImage: tileImage,
TileY: int32(block.DY),
ExpireAt: expiredAt.Unix(),
}, nil
}
func (s *server) VerifyCaptcha(ctx context.Context, req *pbcaptcha.VerifyCaptchaReq) (*pbcaptcha.VerifyCaptchaResp, error) {
now := time.Now()
filter := bson.M{
"captcha_id": req.CaptchaID,
"verify_time": bson.M{"$exists": false},
}
update := bson.M{
"$set": bson.M{
"verify_time": now,
},
}
var doc captchaDoc
err := s.collection.FindOneAndUpdate(
ctx,
filter,
update,
options.FindOneAndUpdate().SetReturnDocument(options.Before),
).Decode(&doc)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
log.ZWarn(ctx, "captcha not found or already verified", err, "captchaID", req.CaptchaID)
return nil, servererrs.ErrRecordNotFound.WrapMsg("captcha not found, expired, or already verified", "captchaID", req.CaptchaID)
}
log.ZError(ctx, "captcha verify query failed", err, "captchaID", req.CaptchaID)
return nil, servererrs.ErrDatabase.WrapMsg("verify captcha query failed", "captchaID", req.CaptchaID)
}
if now.After(doc.ExpiredAt) {
log.ZWarn(ctx, "captcha expired", nil, "captchaID", req.CaptchaID, "expiredAt", doc.ExpiredAt.Unix())
return nil, servererrs.ErrFileUploadedExpired.WrapMsg("captcha expired", "captchaID", req.CaptchaID)
}
success := slide.Validate(int(req.X), int(req.Y), doc.X, doc.Y, s.conf.VerifyPadding)
if !success {
log.ZWarn(ctx, "captcha validate failed", nil, "captchaID", req.CaptchaID, "x", req.X, "y", req.Y)
}
return &pbcaptcha.VerifyCaptchaResp{Success: success}, nil
}

View File

@ -0,0 +1,13 @@
package captcha
import "embed"
// resourceFS embeds background images and tile images at compile time.
// Background images come from go-captcha-resources (sourcedata/images/image-{1..5}).
// Tile images come from go-captcha-resources (sourcedata/tiles/tile-{1..4}):
// overlay.png → GraphImage.OverlayImage
// shadow.png → GraphImage.ShadowImage
// mask.png → GraphImage.MaskImage
//
//go:embed resources/images/*.jpg resources/tiles/*/*.png
var resourceFS embed.FS

View File

@ -0,0 +1,78 @@
package captcha
import (
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"github.com/wenlng/go-captcha/v2/slide"
)
// loadResources reads the embedded files and returns slide.Resource options
// ready to be passed to slide.NewBuilder().SetResources(...).
func loadResources() ([]slide.Resource, error) {
backgrounds, err := loadBackgrounds()
if err != nil {
return nil, fmt.Errorf("load captcha backgrounds: %w", err)
}
graphImages, err := loadGraphImages()
if err != nil {
return nil, fmt.Errorf("load captcha graph images: %w", err)
}
return []slide.Resource{
slide.WithBackgrounds(backgrounds),
slide.WithGraphImages(graphImages),
}, nil
}
// loadBackgrounds decodes the embedded JPEG background images.
func loadBackgrounds() ([]image.Image, error) {
const count = 5
images := make([]image.Image, 0, count)
for i := 1; i <= count; i++ {
path := fmt.Sprintf("resources/images/image-%d.jpg", i)
img, err := decodeEmbedImage(path)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
images = append(images, img)
}
return images, nil
}
// loadGraphImages decodes the 4 sets of tile overlay/shadow/mask PNG images.
func loadGraphImages() ([]*slide.GraphImage, error) {
const count = 4
graphs := make([]*slide.GraphImage, 0, count)
for i := 1; i <= count; i++ {
overlay, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/overlay.png", i))
if err != nil {
return nil, fmt.Errorf("decode tile-%d overlay: %w", i, err)
}
shadow, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/shadow.png", i))
if err != nil {
return nil, fmt.Errorf("decode tile-%d shadow: %w", i, err)
}
mask, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/mask.png", i))
if err != nil {
return nil, fmt.Errorf("decode tile-%d mask: %w", i, err)
}
graphs = append(graphs, &slide.GraphImage{
OverlayImage: overlay,
ShadowImage: shadow,
MaskImage: mask,
})
}
return graphs, nil
}
func decodeEmbedImage(path string) (image.Image, error) {
f, err := resourceFS.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/magefile/mage/sh"
"github.com/openimsdk/gomake/mageutil" "github.com/openimsdk/gomake/mageutil"
"github.com/openimsdk/open-im-server/v3/version" "github.com/openimsdk/open-im-server/v3/version"
"github.com/openimsdk/tools/utils/datautil" "github.com/openimsdk/tools/utils/datautil"
@ -37,6 +38,14 @@ func Build() {
if len(bin) != 0 { if len(bin) != 0 {
bin = bin[1:] bin = bin[1:]
} }
mageutil.WithSpinner("Generating protocol artifacts...", func() {
if err := sh.Run("mage", "-d", "protocol", "GenGo"); err != nil {
mageutil.PrintRed("protocol compilation failed: " + err.Error())
os.Exit(1)
}
})
mageutil.WithSpinner("Building binaries...", func() { mageutil.Build(bin, nil, nil) }) mageutil.WithSpinner("Building binaries...", func() { mageutil.Build(bin, nil, nil) })
} }
@ -54,11 +63,28 @@ func BuildWithCustomConfig() {
ToolsDir: &customToolsDir, ToolsDir: &customToolsDir,
} }
mageutil.WithSpinner("Generating protocol artifacts...", func() {
if err := sh.Run("mage", "-d", "protocol", "GenGo"); err != nil {
mageutil.PrintRed("protocol compilation failed: " + err.Error())
os.Exit(1)
}
})
mageutil.WithSpinner("Building binaries with custom config...", func() { mageutil.WithSpinner("Building binaries with custom config...", func() {
mageutil.Build(bin, config, nil) mageutil.Build(bin, config, nil)
}) })
} }
// Protocol generates protobuf artifacts under `./protocol`.
func Protocol() {
mageutil.WithSpinner("Generating protocol artifacts...", func() {
if err := sh.Run("mage", "-d", "protocol", "GenGo"); err != nil {
mageutil.PrintRed("protocol compilation failed: " + err.Error())
os.Exit(1)
}
})
}
func Start() { func Start() {
mageutil.InitForSSC() mageutil.InitForSSC()
err := setMaxOpenFiles() err := setMaxOpenFiles()

View File

@ -34,7 +34,9 @@ var (
OpenIMMsgGatewayCfgFileName string OpenIMMsgGatewayCfgFileName string
OpenIMMsgTransferCfgFileName string OpenIMMsgTransferCfgFileName string
OpenIMPushCfgFileName string OpenIMPushCfgFileName string
OpenIMCaptchaCfgFileName string
OpenIMRPCAuthCfgFileName string OpenIMRPCAuthCfgFileName string
OpenIMRPCCaptchaCfgFileName string
OpenIMRPCConversationCfgFileName string OpenIMRPCConversationCfgFileName string
OpenIMRPCFriendCfgFileName string OpenIMRPCFriendCfgFileName string
OpenIMRPCGroupCfgFileName string OpenIMRPCGroupCfgFileName string
@ -62,7 +64,9 @@ func init() {
OpenIMMsgGatewayCfgFileName = "openim-msggateway.yml" OpenIMMsgGatewayCfgFileName = "openim-msggateway.yml"
OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml" OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml"
OpenIMPushCfgFileName = "openim-push.yml" OpenIMPushCfgFileName = "openim-push.yml"
OpenIMCaptchaCfgFileName = "openim-captcha.yml"
OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml" OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml"
OpenIMRPCCaptchaCfgFileName = "openim-rpc-captcha.yml"
OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml" OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml"
OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml" OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml"
OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml" OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml"
@ -77,7 +81,7 @@ func init() {
KafkaConfigFileName, RedisConfigFileName, KafkaConfigFileName, RedisConfigFileName,
MongodbConfigFileName, MinioConfigFileName, LogConfigFileName, MongodbConfigFileName, MinioConfigFileName, LogConfigFileName,
OpenIMAPICfgFileName, OpenIMCronTaskCfgFileName, OpenIMMsgGatewayCfgFileName, OpenIMAPICfgFileName, OpenIMCronTaskCfgFileName, OpenIMMsgGatewayCfgFileName,
OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMRPCAuthCfgFileName, OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMCaptchaCfgFileName, OpenIMRPCAuthCfgFileName, OpenIMRPCCaptchaCfgFileName,
OpenIMRPCConversationCfgFileName, OpenIMRPCFriendCfgFileName, OpenIMRPCGroupCfgFileName, OpenIMRPCConversationCfgFileName, OpenIMRPCFriendCfgFileName, OpenIMRPCGroupCfgFileName,
OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, DiscoveryConfigFilename, OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, DiscoveryConfigFilename,
} }

View File

@ -0,0 +1,47 @@
package cmd
import (
"context"
"github.com/openimsdk/open-im-server/v3/internal/rpc/captcha"
"github.com/openimsdk/open-im-server/v3/pkg/common/startrpc"
"github.com/openimsdk/open-im-server/v3/version"
"github.com/openimsdk/tools/system/program"
"github.com/spf13/cobra"
)
type CaptchaRpcCmd struct {
*RootCmd
ctx context.Context
configMap map[string]any
captchaConfig *captcha.Config
}
func NewCaptchaRpcCmd() *CaptchaRpcCmd {
var captchaConfig captcha.Config
ret := &CaptchaRpcCmd{captchaConfig: &captchaConfig}
ret.configMap = map[string]any{
OpenIMRPCCaptchaCfgFileName: &captchaConfig.RpcConfig,
MongodbConfigFileName: &captchaConfig.MongodbConfig,
ShareFileName: &captchaConfig.Share,
DiscoveryConfigFilename: &captchaConfig.Discovery,
}
ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap))
ret.ctx = context.WithValue(context.Background(), "version", version.Version)
ret.Command.RunE = func(cmd *cobra.Command, args []string) error {
return ret.runE()
}
return ret
}
func (c *CaptchaRpcCmd) Exec() error {
return c.Execute()
}
func (c *CaptchaRpcCmd) runE() error {
return startrpc.Start(c.ctx, &c.captchaConfig.Discovery, &c.captchaConfig.RpcConfig.Prometheus, c.captchaConfig.RpcConfig.RPC.ListenIP,
c.captchaConfig.RpcConfig.RPC.RegisterIP, c.captchaConfig.RpcConfig.RPC.AutoSetPorts, c.captchaConfig.RpcConfig.RPC.Ports,
c.Index(), c.captchaConfig.Share.RpcRegisterName.Captcha, &c.captchaConfig.Share, c.captchaConfig,
nil,
captcha.Start)
}

View File

@ -249,6 +249,18 @@ type Auth struct {
} `mapstructure:"tokenPolicy"` } `mapstructure:"tokenPolicy"`
} }
type Captcha struct {
RPC struct {
RegisterIP string `mapstructure:"registerIP"`
ListenIP string `mapstructure:"listenIP"`
AutoSetPorts bool `mapstructure:"autoSetPorts"`
Ports []int `mapstructure:"ports"`
} `mapstructure:"rpc"`
Prometheus Prometheus `mapstructure:"prometheus"`
VerifyPadding int `mapstructure:"verifyPadding"`
ExpireSeconds int `mapstructure:"expireSeconds"`
}
type Conversation struct { type Conversation struct {
RPC struct { RPC struct {
RegisterIP string `mapstructure:"registerIP"` RegisterIP string `mapstructure:"registerIP"`
@ -408,6 +420,7 @@ type RpcRegisterName struct {
Auth string `mapstructure:"auth"` Auth string `mapstructure:"auth"`
Conversation string `mapstructure:"conversation"` Conversation string `mapstructure:"conversation"`
Third string `mapstructure:"third"` Third string `mapstructure:"third"`
Captcha string `mapstructure:"captcha"`
} }
func (r *RpcRegisterName) GetServiceNames() []string { func (r *RpcRegisterName) GetServiceNames() []string {
@ -421,6 +434,7 @@ func (r *RpcRegisterName) GetServiceNames() []string {
r.Auth, r.Auth,
r.Conversation, r.Conversation,
r.Third, r.Third,
r.Captcha,
} }
} }
@ -626,6 +640,7 @@ var (
OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml" OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml"
OpenIMPushCfgFileName = "openim-push.yml" OpenIMPushCfgFileName = "openim-push.yml"
OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml" OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml"
OpenIMRPCCaptchaCfgFileName = "openim-rpc-captcha.yml"
OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml" OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml"
OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml" OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml"
OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml" OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml"
@ -689,6 +704,10 @@ func (a *Auth) GetConfigFileName() string {
return OpenIMRPCAuthCfgFileName return OpenIMRPCAuthCfgFileName
} }
func (c *Captcha) GetConfigFileName() string {
return OpenIMRPCCaptchaCfgFileName
}
func (c *Conversation) GetConfigFileName() string { func (c *Conversation) GetConfigFileName() string {
return OpenIMRPCConversationCfgFileName return OpenIMRPCConversationCfgFileName
} }

318
scripts/test/captcha_api_test.sh Executable file
View File

@ -0,0 +1,318 @@
#!/usr/bin/env bash
# ============================================================
# Captcha API 接口测试脚本
#
# 覆盖接口:
# POST /captcha/generate —— 生成滑块验证码
# POST /captcha/verify —— 验证滑块验证码
#
# 依赖curl / jq
# 用法:
# chmod +x captcha_api_test.sh
# ./captcha_api_test.sh
# ./captcha_api_test.sh --host http://127.0.0.1:10002
# ============================================================
set -euo pipefail
# ──────────────────────────────────────────────
# 可配置参数(可通过环境变量覆盖)
# ──────────────────────────────────────────────
HOST="${HOST:-http://127.0.0.1:10002}"
ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}"
ADMIN_SECRET="${ADMIN_SECRET:-openIM123}"
PLATFORM_ID="${PLATFORM_ID:-1}" # 1=iOS 2=Android 3=Windows ...
# 命令行参数解析
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--admin-user-id) ADMIN_USER_ID="$2"; shift 2 ;;
--admin-secret) ADMIN_SECRET="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
# ──────────────────────────────────────────────
# 颜色输出
# ──────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS=0; FAIL=0
pass() { echo -e "${GREEN} [PASS]${NC} $1"; PASS=$((PASS+1)); }
fail() { echo -e "${RED} [FAIL]${NC} $1"; FAIL=$((FAIL+1)); }
info() { echo -e "${CYAN} [INFO]${NC} $1"; }
section() { echo -e "\n${YELLOW}══ $1 ══${NC}"; }
# ──────────────────────────────────────────────
# 生成唯一 operationID每次调用递增
# ──────────────────────────────────────────────
_OP_SEQ=0
new_op_id() {
(( _OP_SEQ++ ))
echo "captcha-test-$$-${_OP_SEQ}"
}
# ──────────────────────────────────────────────
# 断言工具函数
# ──────────────────────────────────────────────
assert_err_code() {
local resp="$1" expected="$2" desc="$3"
local actual
actual=$(echo "$resp" | jq -r '.errCode // "null"')
if [[ "${actual}" == "${expected}" ]]; then
pass "${desc} (errCode=${actual})"
else
fail "${desc} - expected errCode=${expected}, got errCode=${actual}"
info "resp: ${resp}"
fi
}
assert_not_empty() {
local resp="$1" jq_path="$2" desc="$3"
local val
val=$(echo "$resp" | jq -r "$jq_path // empty")
if [[ -n "${val}" && "${val}" != "null" ]]; then
pass "${desc} (val=${val:0:40}...)"
else
fail "${desc} - '${jq_path}' is empty or null"
info "resp: ${resp}"
fi
}
assert_eq() {
local resp="$1" jq_path="$2" expected="$3" desc="$4"
local actual
# 不使用 // emptyjq 的 // 运算符会把布尔 false 视为 false 并走替代分支
actual=$(echo "$resp" | jq -r "$jq_path")
if [[ "${actual}" == "${expected}" ]]; then
pass "${desc} (val=${actual})"
else
fail "${desc} - expected=${expected}, got=${actual}"
info "resp: ${resp}"
fi
}
# errCode 非 0 即通过
assert_err_nonzero() {
local resp="$1" desc="$2"
local actual
actual=$(echo "$resp" | jq -r '.errCode // "null"')
if [[ "${actual}" != "0" && "${actual}" != "null" ]]; then
pass "${desc} (errCode=${actual})"
else
fail "${desc} - expected errCode!=0, got errCode=${actual}"
info "resp: ${resp}"
fi
}
# ──────────────────────────────────────────────
# 前置:获取 Admin Token
# ──────────────────────────────────────────────
section "前置:获取 Admin Token"
TOKEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(new_op_id)" \
-d "{\"secret\":\"${ADMIN_SECRET}\",\"platformID\":${PLATFORM_ID},\"userID\":\"${ADMIN_USER_ID}\"}" \
"${HOST}/auth/get_admin_token")
info "Token 响应: $TOKEN_RESP"
ERR_CODE=$(echo "$TOKEN_RESP" | jq -r '.errCode // "null"')
if [[ "$ERR_CODE" != "0" ]]; then
echo -e "${RED}[ERROR]${NC} 获取 Admin Token 失败 (errCode=$ERR_CODE),中止测试"
exit 1
fi
TOKEN=$(echo "$TOKEN_RESP" | jq -r '.data.token')
info "获取到 token: ${TOKEN:0:40}..."
# ──────────────────────────────────────────────
# 用例 1生成验证码 —— 正常流程
# ──────────────────────────────────────────────
section "用例 1 / POST /captcha/generate —— 正常生成验证码"
GEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{}' \
"${HOST}/captcha/generate")
info "响应摘要: $(echo "${GEN_RESP}" | jq -c '{errCode,errMsg,data:{captchaID:.data.captchaID,expireAt:.data.expireAt}}')"
GEN_ERR=$(echo "${GEN_RESP}" | jq -r '.errCode // "null"')
GEN_MSG=$(echo "${GEN_RESP}" | jq -r '.errMsg // ""')
# 检测服务端是否因缺少背景图资源而报 500
if [[ "${GEN_ERR}" == "500" && "${GEN_MSG}" == *"background"* ]]; then
fail "用例 1 跳过 - captcha 服务未配置背景图资源 (errMsg=${GEN_MSG})"
info "修复方式:在 captcha.Start() 中通过 slide.NewBuilder().SetBackground(...).Make() 注入背景图"
CAPTCHA_ID=""
EXPIRE_AT=""
else
assert_err_code "${GEN_RESP}" "0" "生成验证码 errCode 应为 0"
assert_not_empty "${GEN_RESP}" ".data.captchaID" "captchaID 非空"
assert_not_empty "${GEN_RESP}" ".data.masterImage" "masterImage(背景图 Base64) 非空"
assert_not_empty "${GEN_RESP}" ".data.tileImage" "tileImage(滑块图 Base64) 非空"
assert_not_empty "${GEN_RESP}" ".data.expireAt" "expireAt(过期 Unix 时间戳) 非空"
CAPTCHA_ID=$(echo "${GEN_RESP}" | jq -r '.data.captchaID')
EXPIRE_AT=$(echo "${GEN_RESP}" | jq -r '.data.expireAt')
info "captchaID = ${CAPTCHA_ID}"
info "expireAt = ${EXPIRE_AT}"
fi
# ──────────────────────────────────────────────
# 用例 2生成验证码 —— 不携带 Token
# ──────────────────────────────────────────────
section "用例 2 / POST /captcha/generate —— 无 Token 应被鉴权中间件拦截"
NO_TOKEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(new_op_id)" \
-d '{}' \
"${HOST}/captcha/generate")
info "响应: $NO_TOKEN_RESP"
assert_err_nonzero "$NO_TOKEN_RESP" "无 Token 被鉴权中间件拦截"
# ──────────────────────────────────────────────
# 用例 3验证验证码 —— 坐标错误x=999, y=999
# ──────────────────────────────────────────────
section "用例 3 / POST /captcha/verify —— 坐标错误success 应为 false"
if [[ -z "${CAPTCHA_ID}" ]]; then
fail "用例 3 跳过 - 依赖用例 1 生成的 captchaID但用例 1 未成功"
else
VERIFY_WRONG_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":999,\"y\":999}" \
"${HOST}/captcha/verify")
info "响应: ${VERIFY_WRONG_RESP}"
assert_err_code "${VERIFY_WRONG_RESP}" "0" "验证请求本身成功 errCode=0"
assert_eq "${VERIFY_WRONG_RESP}" ".data.success" "false" "坐标错误时 success=false"
fi
# ──────────────────────────────────────────────
# 用例 4验证验证码 —— 重复使用同一 captchaID
# 用例 3 已消耗该 IDverify_time 已被 FindOneAndUpdate 写入),
# 再次调用服务端 filter 匹配不到记录,应返回错误
# ──────────────────────────────────────────────
section "用例 4 / POST /captcha/verify —— 重复使用同一 captchaID幂等应返回错误"
if [[ -z "${CAPTCHA_ID}" ]]; then
fail "用例 4 跳过 - 依赖用例 1 生成的 captchaID但用例 1 未成功"
else
VERIFY_REUSE_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":0,\"y\":0}" \
"${HOST}/captcha/verify")
info "响应: ${VERIFY_REUSE_RESP}"
assert_err_nonzero "${VERIFY_REUSE_RESP}" "重复使用 captchaID 被拒绝"
fi
# ──────────────────────────────────────────────
# 用例 5验证验证码 —— captchaID 不存在
# ──────────────────────────────────────────────
section "用例 5 / POST /captcha/verify —— captchaID 不存在,应返回错误"
VERIFY_NOTFOUND_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{"captchaID":"00000000-0000-0000-0000-000000000000","x":10,"y":10}' \
"${HOST}/captcha/verify")
info "响应: $VERIFY_NOTFOUND_RESP"
assert_err_nonzero "$VERIFY_NOTFOUND_RESP" "captchaID 不存在时返回错误"
# ──────────────────────────────────────────────
# 用例 6验证验证码 —— captchaID 为空字符串
# ──────────────────────────────────────────────
section "用例 6 / POST /captcha/verify —— captchaID 为空字符串,应返回错误"
VERIFY_EMPTY_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{"captchaID":"","x":10,"y":10}' \
"${HOST}/captcha/verify")
info "响应: $VERIFY_EMPTY_RESP"
assert_err_nonzero "$VERIFY_EMPTY_RESP" "captchaID 为空时返回错误"
# ──────────────────────────────────────────────
# 用例 7验证验证码 —— 不携带 Token
# ──────────────────────────────────────────────
section "用例 7 / POST /captcha/verify —— 无 Token 应被鉴权中间件拦截"
VERIFY_NOTOKEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID:-00000000-0000-0000-0000-000000000000}\",\"x\":10,\"y\":10}" \
"${HOST}/captcha/verify")
info "响应: $VERIFY_NOTOKEN_RESP"
assert_err_nonzero "$VERIFY_NOTOKEN_RESP" "无 Token 被鉴权中间件拦截"
# ──────────────────────────────────────────────
# 用例 8完整正向链路 —— 新生成 + 用偏差坐标验证
# 服务端不回传正确坐标,用 (0,0) 验证 success=false
# 正确坐标可从 MongoDB 查询:
# db.captcha.findOne({captcha_id: "<ID>"}, {x:1,y:1})
# ──────────────────────────────────────────────
section "用例 8 / 完整正向链路 —— 新生成验证码 → 坐标偏差验证"
GEN_RESP2=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{}' \
"${HOST}/captcha/generate")
GEN_ERR2=$(echo "${GEN_RESP2}" | jq -r '.errCode // "null"')
GEN_MSG2=$(echo "${GEN_RESP2}" | jq -r '.errMsg // ""')
if [[ "${GEN_ERR2}" == "500" && "${GEN_MSG2}" == *"background"* ]]; then
fail "用例 8 跳过 - captcha 服务未配置背景图资源 (errMsg=${GEN_MSG2})"
else
CAPTCHA_ID2=$(echo "${GEN_RESP2}" | jq -r '.data.captchaID')
EXPIRE_AT2=$(echo "${GEN_RESP2}" | jq -r '.data.expireAt')
MASTER_LEN=$(echo "${GEN_RESP2}" | jq -r '.data.masterImage | length')
TILE_LEN=$(echo "${GEN_RESP2}" | jq -r '.data.tileImage | length')
assert_err_code "${GEN_RESP2}" "0" "新一轮生成验证码成功"
assert_not_empty "${GEN_RESP2}" ".data.captchaID" "captchaID2 非空"
info "captchaID2 = ${CAPTCHA_ID2}"
info "expireAt = ${EXPIRE_AT2}"
info "masterImage 长度 = ${MASTER_LEN} chars(Base64)"
info "tileImage 长度 = ${TILE_LEN} chars(Base64)"
info "查询真实坐标: db.captcha.findOne({captcha_id:\"${CAPTCHA_ID2}\"},{x:1,y:1})"
VERIFY_LINK_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID2}\",\"x\":0,\"y\":0}" \
"${HOST}/captcha/verify")
assert_err_code "${VERIFY_LINK_RESP}" "0" "验证接口响应正常 errCode=0"
assert_eq "${VERIFY_LINK_RESP}" ".data.success" "false" "偏差坐标(0,0) success=false"
fi
# ──────────────────────────────────────────────
# 汇总
# ──────────────────────────────────────────────
echo ""
echo -e "══════════════════════════════════════════"
echo -e " 测试汇总:${GREEN}PASS=${PASS}${NC} ${RED}FAIL=${FAIL}${NC}"
echo -e "══════════════════════════════════════════"
[[ $FAIL -eq 0 ]]

View File

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Captcha 验证码测试</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:#f0f2f5;padding:20px;min-height:100vh}
/* ── 卡片 ── */
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);
padding:20px;max-width:500px;margin:0 auto 16px}
.card-title{font-size:15px;font-weight:600;color:#1a1a2e;margin-bottom:14px;
display:flex;align-items:center;gap:6px}
/* ── 表单 ── */
.field{margin-bottom:10px}
.field label{display:block;font-size:11px;color:#888;margin-bottom:3px}
input[type=text]{width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:8px;
font-size:12px;outline:none;transition:border .15s}
input[type=text]:focus{border-color:#4f46e5}
/* ── 按钮 ── */
.btn{display:inline-flex;align-items:center;gap:5px;padding:8px 16px;border:none;
border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;transition:.15s}
.btn:hover{filter:brightness(.92)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn-indigo{background:#4f46e5;color:#fff}
.btn-gray {background:#6b7280;color:#fff}
.btn-green {background:#10b981;color:#fff}
.btn-row {display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
/* ── 状态标签 ── */
.tag{display:inline-block;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600}
.tag-ok {background:#d1fae5;color:#065f46}
.tag-err {background:#fee2e2;color:#991b1b}
.tag-info{background:#e0e7ff;color:#3730a3}
#status,#verify-status{margin-top:10px;min-height:24px}
/* ── 验证码区域 ── */
#captcha-section{display:none}
/* 背景图 + 滑块叠层 */
.slide-wrap{
position:relative;width:300px;height:220px;
border:2px solid #e5e7eb;border-radius:10px;
overflow:hidden;background:#f3f4f6;
cursor:default;user-select:none;
}
#master-img{position:absolute;top:0;left:0;width:300px;height:220px;display:block}
#tile-img {position:absolute;cursor:grab;transition:none}
#tile-img:active{cursor:grabbing}
/* 滑轨 */
.slider-row{display:flex;align-items:center;gap:6px;margin-top:8px}
.slider-row span{font-size:11px;color:#6b7280;min-width:14px}
.slider-row input[type=range]{flex:1;accent-color:#4f46e5;height:4px}
.slider-row .val{font-size:11px;color:#4f46e5;min-width:30px;text-align:right}
/* 提示箭头 */
.hint{font-size:11px;color:#9ca3af;margin-top:6px}
/* ── 调试区 ── */
#debug-area{display:none;margin-top:14px}
.debug-label{font-size:10px;color:#9ca3af;margin-bottom:4px}
.image-preview{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
.image-preview figure{text-align:center}
.image-preview figcaption{font-size:10px;color:#888;margin-top:4px}
pre{background:#1e1e2e;color:#cdd6f4;border-radius:8px;padding:12px;
font-size:10px;overflow:auto;max-height:160px;word-break:break-all}
</style>
</head>
<body>
<!-- ① 配置 -->
<div class="card">
<div class="card-title">⚙️ 配置</div>
<div class="field">
<label>API Host</label>
<input type="text" id="host" value="http://127.0.0.1:10002">
</div>
<div class="field">
<label>Token留空则自动获取 Admin Token</label>
<input type="text" id="token" placeholder="eyJhbGci...">
</div>
<div class="field">
<label>Admin Secret</label>
<input type="text" id="secret" value="openIM123">
</div>
<div class="btn-row">
<button class="btn btn-gray" onclick="getToken()">🔑 获取 Token</button>
<button class="btn btn-indigo" id="btn-gen" onclick="generate()">🖼 生成验证码</button>
</div>
<div id="status"></div>
</div>
<!-- ② 验证码交互 -->
<div class="card" id="captcha-section">
<div class="card-title">🧩 滑块验证码</div>
<p class="hint">拖动滑块或直接拖拽图中的小方块,使其嵌入背景缺口后点击「验证」。</p>
<div style="margin-top:12px">
<!-- 背景图 + 浮动滑块 -->
<div class="slide-wrap" id="slide-wrap">
<img id="master-img" alt="">
<img id="tile-img" alt="">
</div>
<!-- X 轴滑轨 -->
<div class="slider-row">
<span>←→</span>
<input type="range" id="sx" min="0" max="240" value="0" oninput="syncTile()">
<span class="val" id="sx-val">0 px</span>
</div>
<!-- Y 轴调试用 -->
<div class="slider-row">
<span></span>
<input type="range" id="sy" min="0" max="160" value="0" oninput="syncTile()">
<span class="val" id="sy-val">0 px</span>
</div>
</div>
<div class="btn-row">
<button class="btn btn-green" onclick="verify()">✅ 验证</button>
<button class="btn btn-indigo" onclick="generate()">🔄 刷新</button>
</div>
<div id="verify-status"></div>
<!-- 调试:直接渲染两张图 + 原始数据 -->
<div id="debug-area">
<hr style="border:none;border-top:1px solid #f0f0f0;margin:14px 0">
<div class="debug-label">图片预览(直接从 Base64 渲染)</div>
<div class="image-preview">
<figure>
<img id="dbg-master" style="border-radius:6px;border:1px solid #e5e7eb" alt="">
<figcaption>masterImageJPEG 背景)</figcaption>
</figure>
<figure>
<img id="dbg-tile" style="border-radius:4px;border:1px solid #e5e7eb;background:#ddd" alt="">
<figcaption>tileImagePNG 滑块)</figcaption>
</figure>
</div>
<div class="debug-label" style="margin-top:10px">原始响应(去除图片数据)</div>
<pre id="raw-json"></pre>
</div>
</div>
<script>
/* ── 工具 ── */
const $ = id => document.getElementById(id);
let captchaState = { id:'', tileW:68, tileH:68, tileY:0 };
let opSeq = 0;
function setStatus(elId, html, type='info'){
$(elId).innerHTML = `<span class="tag tag-${type}">${html}</span>`;
}
async function post(path, body){
const host = $('host').value.replace(/\/$/,'');
const tok = $('token').value.trim();
const headers = {
'Content-Type':'application/json',
'operationID':`demo-${Date.now()}-${++opSeq}`,
};
if(tok) headers['token'] = tok;
const r = await fetch(host+path, {method:'POST', headers, body:JSON.stringify(body)});
return r.json();
}
/* ── 1. 获取 Token ── */
async function getToken(){
setStatus('status','获取 Token 中…','info');
try{
const d = await post('/auth/get_admin_token',{
secret:$('secret').value, platformID:1, userID:'imAdmin'
});
if(d.errCode!==0){ setStatus('status',`获取失败: ${d.errMsg}`,'err'); return; }
$('token').value = d.data.token;
setStatus('status','Token 获取成功 ✓','ok');
}catch(e){ setStatus('status',`请求异常: ${e.message}`,'err'); }
}
/* ── 2. 生成验证码 ── */
async function generate(){
if(!$('token').value.trim()){ await getToken(); if(!$('token').value.trim()) return; }
setStatus('status','生成验证码中…','info');
$('btn-gen').disabled = true;
try{
const d = await post('/captcha/generate',{});
/* 展示原始响应(隐去图片数据保持可读) */
const preview = JSON.parse(JSON.stringify(d));
if(preview.data){
if(preview.data.masterImage) preview.data.masterImage = `<base64 JPEG, ${preview.data.masterImage.length} chars>`;
if(preview.data.tileImage) preview.data.tileImage = `<base64 PNG, ${preview.data.tileImage.length} chars>`;
}
$('raw-json').textContent = JSON.stringify(preview, null, 2);
if(d.errCode!==0){
setStatus('status',`生成失败 errCode=${d.errCode}: ${d.errMsg} — ${d.errDlt}`,'err');
return;
}
const {captchaID, masterImage, tileImage, tileY, expireAt} = d.data;
captchaState.id = captchaID;
captchaState.tileY = tileY || 0;
/* ── 渲染 masterImageJPEG── */
const masterSrc = `data:image/jpeg;base64,${masterImage}`;
$('master-img').src = masterSrc;
$('dbg-master').src = masterSrc;
$('dbg-master').style.width = '300px';
/* ── 渲染 tileImagePNG── */
const tileSrc = `data:image/png;base64,${tileImage}`;
$('dbg-tile').src = tileSrc;
/* 等 tile 图片加载完毕后设置滑块 */
await loadImage($('tile-img'), tileSrc);
captchaState.tileW = $('tile-img').naturalWidth || 68;
captchaState.tileH = $('tile-img').naturalHeight || 68;
/* 更新滑轨范围,初始化 Y = tileY */
$('sx').max = 300 - captchaState.tileW;
$('sy').max = 220 - captchaState.tileH;
$('sx').value = 0;
$('sy').value = captchaState.tileY;
syncTile();
const expire = new Date(expireAt * 1000).toLocaleTimeString();
setStatus('status',
`验证码已生成 | id: ${captchaID.slice(0,8)}… | tileY: ${tileY} | 过期: ${expire}`,'ok');
$('captcha-section').style.display = 'block';
$('debug-area').style.display = 'block';
$('verify-status').innerHTML = '';
}catch(e){
setStatus('status',`请求异常: ${e.message}`,'err');
}finally{
$('btn-gen').disabled = false;
}
}
/* 等待 img.onloadPromise 封装) */
function loadImage(imgEl, src){
return new Promise((resolve, reject)=>{
imgEl.onload = resolve;
imgEl.onerror = reject;
imgEl.src = src;
});
}
/* ── 3. 同步滑块位置 ── */
function syncTile(){
const x = parseInt($('sx').value);
const y = parseInt($('sy').value);
$('sx-val').textContent = x + ' px';
$('sy-val').textContent = y + ' px';
$('tile-img').style.left = x + 'px';
$('tile-img').style.top = y + 'px';
}
/* ── 4. 鼠标 / 触屏拖拽 ── */
(()=>{
let drag=false, sx0=0, sliderX0=0;
const wrap = $('slide-wrap');
const start = (cx)=>{ drag=true; sx0=cx; sliderX0=parseInt($('sx').value); };
const move = (cx)=>{
if(!drag) return;
const nx = Math.max(0, Math.min(parseInt($('sx').max), sliderX0+(cx-sx0)));
$('sx').value = nx;
syncTile();
};
const end = ()=>{ drag=false; };
wrap.addEventListener('mousedown', e=>{ if(e.target.id==='tile-img') start(e.clientX); });
document.addEventListener('mousemove', e=>move(e.clientX));
document.addEventListener('mouseup', end);
wrap.addEventListener('touchstart', e=>{
if(e.target.id==='tile-img'){ start(e.touches[0].clientX); e.preventDefault(); }
},{passive:false});
document.addEventListener('touchmove', e=>{
if(drag){ move(e.touches[0].clientX); e.preventDefault(); }
},{passive:false});
document.addEventListener('touchend', end);
})();
/* ── 5. 验证 ── */
async function verify(){
if(!captchaState.id){ alert('请先生成验证码'); return; }
const x = parseInt($('sx').value);
const y = parseInt($('sy').value);
setStatus('verify-status',`提交验证 x=${x} y=${y}…`,'info');
try{
const d = await post('/captcha/verify',{captchaID:captchaState.id, x, y});
if(d.errCode!==0){
setStatus('verify-status',`请求失败 errCode=${d.errCode}: ${d.errMsg}`,'err');
return;
}
if(d.data.success){
setStatus('verify-status',`🎉 验证通过x=${x} y=${y}`,'ok');
}else{
setStatus('verify-status',`❌ 验证失败,偏差过大,请重新拖动后再试`,'err');
}
}catch(e){
setStatus('verify-status',`请求异常: ${e.message}`,'err');
}
}
</script>
</body>
</html>

View File

@ -7,6 +7,7 @@ serviceBinaries:
openim-msgtransfer: 8 openim-msgtransfer: 8
openim-rpc-conversation: 1 openim-rpc-conversation: 1
openim-rpc-auth: 1 openim-rpc-auth: 1
openim-rpc-captcha: 1
openim-rpc-group: 1 openim-rpc-group: 1
openim-rpc-friend: 1 openim-rpc-friend: 1
openim-rpc-msg: 1 openim-rpc-msg: 1