diff --git a/build/goreleaser.yaml b/build/goreleaser.yaml index c24fb65da..e5b49f7ee 100644 --- a/build/goreleaser.yaml +++ b/build/goreleaser.yaml @@ -122,6 +122,17 @@ builds: - amd64 - 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 id: openim-rpc-conversation main: ./cmd/openim-rpc/openim-rpc-conversation/main.go @@ -355,6 +366,7 @@ nfpms: - openim-msgtransfer - openim-push - openim-rpc-auth + - openim-rpc-captcha - openim-rpc-conversation - openim-rpc-friend - openim-rpc-group diff --git a/cmd/openim-rpc/openim-rpc-captcha/main.go b/cmd/openim-rpc/openim-rpc-captcha/main.go new file mode 100644 index 000000000..8a3e9a938 --- /dev/null +++ b/cmd/openim-rpc/openim-rpc-captcha/main.go @@ -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) + } +} diff --git a/config/openim-rpc-captcha.yml b/config/openim-rpc-captcha.yml new file mode 100644 index 000000000..a58453b3a --- /dev/null +++ b/config/openim-rpc-captcha.yml @@ -0,0 +1,12 @@ +rpc: + registerIP: "" + listenIP: 0.0.0.0 + autoSetPorts: false + ports: [10520] + +prometheus: + enable: false + ports: [12520] + +verifyPadding: 8 +expireSeconds: 120 diff --git a/config/share.yml b/config/share.yml index 916df78c4..64ce4c6f6 100644 --- a/config/share.yml +++ b/config/share.yml @@ -9,6 +9,7 @@ rpcRegisterName: auth: auth conversation: conversation third: third + captcha: captcha imAdminUserID: [ imAdmin ] diff --git a/go.mod b/go.mod index b7293c2bd..658c3227d 100644 --- a/go.mod +++ b/go.mod @@ -104,6 +104,7 @@ require ( github.com/go-openapi/swag v0.22.4 // indirect github.com/go-playground/universal-translator v0.18.1 // 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/protobuf v1.5.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/numcpus v0.11.0 // 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/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect @@ -193,7 +195,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.7.0 // 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/oauth2 v0.25.0 // indirect golang.org/x/sys v0.42.0 // indirect @@ -228,3 +230,5 @@ require ( golang.org/x/crypto v0.32.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) + +replace github.com/openimsdk/protocol => ./protocol diff --git a/go.sum b/go.sum index c0aa7a720..0a08f3f4c 100644 --- a/go.sum +++ b/go.sum @@ -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.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= 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/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= @@ -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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 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/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= 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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= diff --git a/internal/api/captcha.go b/internal/api/captcha.go new file mode 100644 index 000000000..9cedb3d3e --- /dev/null +++ b/internal/api/captcha.go @@ -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) +} diff --git a/internal/api/init.go b/internal/api/init.go index 0ac30924f..f236450f9 100644 --- a/internal/api/init.go +++ b/internal/api/init.go @@ -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 client, err = kdisc.NewDiscoveryRegister(&cfg.Discovery, &cfg.Share, []string{ cfg.Share.RpcRegisterName.MessageGateway, + cfg.Share.RpcRegisterName.Captcha, }) if err != nil { return errs.WrapMsg(err, "failed to register discovery service") diff --git a/internal/api/router.go b/internal/api/router.go index 87a1f1e7d..ad3794d63 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -7,6 +7,7 @@ import ( "github.com/openimsdk/open-im-server/v3/pkg/rpcli" pbAuth "github.com/openimsdk/protocol/auth" + pbcaptcha "github.com/openimsdk/protocol/captcha" "github.com/openimsdk/protocol/conversation" "github.com/openimsdk/protocol/group" "github.com/openimsdk/protocol/msg" @@ -98,6 +99,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co if err != nil { return nil, err } + captchaConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Captcha) + if err != nil { + return nil, err + } gin.SetMode(gin.ReleaseMode) r := gin.New() 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))) u := NewUserApi(user.NewUserClient(userConn), client, config.Share.RpcRegisterName) 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)) 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) } + { + captchaGroup := r.Group("/captcha") + captchaGroup.POST("/generate", cp.GenerateCaptcha) + captchaGroup.POST("/verify", cp.VerifyCaptcha) + } + { statisticsGroup := r.Group("/statistics") statisticsGroup.POST("/user/register", u.UserRegisterCount) diff --git a/internal/rpc/captcha/captcha.go b/internal/rpc/captcha/captcha.go new file mode 100644 index 000000000..063a0c11e --- /dev/null +++ b/internal/rpc/captcha/captcha.go @@ -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 +} diff --git a/internal/rpc/captcha/embed.go b/internal/rpc/captcha/embed.go new file mode 100644 index 000000000..9ec2e8d8f --- /dev/null +++ b/internal/rpc/captcha/embed.go @@ -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 diff --git a/internal/rpc/captcha/resources.go b/internal/rpc/captcha/resources.go new file mode 100644 index 000000000..1264ef1fd --- /dev/null +++ b/internal/rpc/captcha/resources.go @@ -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 +} diff --git a/internal/rpc/captcha/resources/images/image-1.jpg b/internal/rpc/captcha/resources/images/image-1.jpg new file mode 100644 index 000000000..f3be01297 Binary files /dev/null and b/internal/rpc/captcha/resources/images/image-1.jpg differ diff --git a/internal/rpc/captcha/resources/images/image-2.jpg b/internal/rpc/captcha/resources/images/image-2.jpg new file mode 100644 index 000000000..ca35458f6 Binary files /dev/null and b/internal/rpc/captcha/resources/images/image-2.jpg differ diff --git a/internal/rpc/captcha/resources/images/image-3.jpg b/internal/rpc/captcha/resources/images/image-3.jpg new file mode 100644 index 000000000..104aeb77a Binary files /dev/null and b/internal/rpc/captcha/resources/images/image-3.jpg differ diff --git a/internal/rpc/captcha/resources/images/image-4.jpg b/internal/rpc/captcha/resources/images/image-4.jpg new file mode 100644 index 000000000..dc12c2bf4 Binary files /dev/null and b/internal/rpc/captcha/resources/images/image-4.jpg differ diff --git a/internal/rpc/captcha/resources/images/image-5.jpg b/internal/rpc/captcha/resources/images/image-5.jpg new file mode 100644 index 000000000..4a11044a9 Binary files /dev/null and b/internal/rpc/captcha/resources/images/image-5.jpg differ diff --git a/internal/rpc/captcha/resources/tiles/tile-1/mask.png b/internal/rpc/captcha/resources/tiles/tile-1/mask.png new file mode 100644 index 000000000..c7f5fad16 Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-1/mask.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-1/overlay.png b/internal/rpc/captcha/resources/tiles/tile-1/overlay.png new file mode 100644 index 000000000..b70daf7ca Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-1/overlay.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-1/shadow.png b/internal/rpc/captcha/resources/tiles/tile-1/shadow.png new file mode 100644 index 000000000..eafdb74c5 Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-1/shadow.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-2/mask.png b/internal/rpc/captcha/resources/tiles/tile-2/mask.png new file mode 100644 index 000000000..4148942ff Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-2/mask.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-2/overlay.png b/internal/rpc/captcha/resources/tiles/tile-2/overlay.png new file mode 100644 index 000000000..755643c38 Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-2/overlay.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-2/shadow.png b/internal/rpc/captcha/resources/tiles/tile-2/shadow.png new file mode 100644 index 000000000..e2444ecd2 Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-2/shadow.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-3/mask.png b/internal/rpc/captcha/resources/tiles/tile-3/mask.png new file mode 100644 index 000000000..832d4712b Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-3/mask.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-3/overlay.png b/internal/rpc/captcha/resources/tiles/tile-3/overlay.png new file mode 100644 index 000000000..dd046a36f Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-3/overlay.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-3/shadow.png b/internal/rpc/captcha/resources/tiles/tile-3/shadow.png new file mode 100644 index 000000000..31ae5ecd1 Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-3/shadow.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-4/mask.png b/internal/rpc/captcha/resources/tiles/tile-4/mask.png new file mode 100644 index 000000000..49f8182da Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-4/mask.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-4/overlay.png b/internal/rpc/captcha/resources/tiles/tile-4/overlay.png new file mode 100644 index 000000000..2396c766c Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-4/overlay.png differ diff --git a/internal/rpc/captcha/resources/tiles/tile-4/shadow.png b/internal/rpc/captcha/resources/tiles/tile-4/shadow.png new file mode 100644 index 000000000..ded38823a Binary files /dev/null and b/internal/rpc/captcha/resources/tiles/tile-4/shadow.png differ diff --git a/magefile.go b/magefile.go index 9b34dfa60..3d76fa212 100644 --- a/magefile.go +++ b/magefile.go @@ -8,6 +8,7 @@ import ( "fmt" "os" + "github.com/magefile/mage/sh" "github.com/openimsdk/gomake/mageutil" "github.com/openimsdk/open-im-server/v3/version" "github.com/openimsdk/tools/utils/datautil" @@ -37,6 +38,14 @@ func Build() { if len(bin) != 0 { 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) }) } @@ -54,11 +63,28 @@ func BuildWithCustomConfig() { 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.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() { mageutil.InitForSSC() err := setMaxOpenFiles() diff --git a/pkg/common/cmd/constant.go b/pkg/common/cmd/constant.go index 45dbcafda..e76219ed4 100644 --- a/pkg/common/cmd/constant.go +++ b/pkg/common/cmd/constant.go @@ -34,7 +34,9 @@ var ( OpenIMMsgGatewayCfgFileName string OpenIMMsgTransferCfgFileName string OpenIMPushCfgFileName string + OpenIMCaptchaCfgFileName string OpenIMRPCAuthCfgFileName string + OpenIMRPCCaptchaCfgFileName string OpenIMRPCConversationCfgFileName string OpenIMRPCFriendCfgFileName string OpenIMRPCGroupCfgFileName string @@ -62,7 +64,9 @@ func init() { OpenIMMsgGatewayCfgFileName = "openim-msggateway.yml" OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml" OpenIMPushCfgFileName = "openim-push.yml" + OpenIMCaptchaCfgFileName = "openim-captcha.yml" OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml" + OpenIMRPCCaptchaCfgFileName = "openim-rpc-captcha.yml" OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml" OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml" OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml" @@ -77,7 +81,7 @@ func init() { KafkaConfigFileName, RedisConfigFileName, MongodbConfigFileName, MinioConfigFileName, LogConfigFileName, OpenIMAPICfgFileName, OpenIMCronTaskCfgFileName, OpenIMMsgGatewayCfgFileName, - OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMRPCAuthCfgFileName, + OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMCaptchaCfgFileName, OpenIMRPCAuthCfgFileName, OpenIMRPCCaptchaCfgFileName, OpenIMRPCConversationCfgFileName, OpenIMRPCFriendCfgFileName, OpenIMRPCGroupCfgFileName, OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, DiscoveryConfigFilename, } diff --git a/pkg/common/cmd/rpc_captcha.go b/pkg/common/cmd/rpc_captcha.go new file mode 100644 index 000000000..0d3042eca --- /dev/null +++ b/pkg/common/cmd/rpc_captcha.go @@ -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) +} diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index df92eed4c..8888e5791 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -249,6 +249,18 @@ type Auth struct { } `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 { RPC struct { RegisterIP string `mapstructure:"registerIP"` @@ -408,6 +420,7 @@ type RpcRegisterName struct { Auth string `mapstructure:"auth"` Conversation string `mapstructure:"conversation"` Third string `mapstructure:"third"` + Captcha string `mapstructure:"captcha"` } func (r *RpcRegisterName) GetServiceNames() []string { @@ -421,6 +434,7 @@ func (r *RpcRegisterName) GetServiceNames() []string { r.Auth, r.Conversation, r.Third, + r.Captcha, } } @@ -626,6 +640,7 @@ var ( OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml" OpenIMPushCfgFileName = "openim-push.yml" OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml" + OpenIMRPCCaptchaCfgFileName = "openim-rpc-captcha.yml" OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml" OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml" OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml" @@ -689,6 +704,10 @@ func (a *Auth) GetConfigFileName() string { return OpenIMRPCAuthCfgFileName } +func (c *Captcha) GetConfigFileName() string { + return OpenIMRPCCaptchaCfgFileName +} + func (c *Conversation) GetConfigFileName() string { return OpenIMRPCConversationCfgFileName } diff --git a/scripts/test/captcha_api_test.sh b/scripts/test/captcha_api_test.sh new file mode 100755 index 000000000..58531d25f --- /dev/null +++ b/scripts/test/captcha_api_test.sh @@ -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 + # 不使用 // empty:jq 的 // 运算符会把布尔 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 已消耗该 ID(verify_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: ""}, {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 ]] diff --git a/scripts/test/captcha_demo.html b/scripts/test/captcha_demo.html new file mode 100644 index 000000000..eea374b15 --- /dev/null +++ b/scripts/test/captcha_demo.html @@ -0,0 +1,316 @@ + + + + +Captcha 验证码测试 + + + + + +
+
⚙️ 配置
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
🧩 滑块验证码
+

拖动滑块或直接拖拽图中的小方块,使其嵌入背景缺口后点击「验证」。

+ +
+ +
+ + +
+ + +
+ ←→ + + 0 px +
+ +
+ + + 0 px +
+
+ +
+ + +
+
+ + +
+
+
图片预览(直接从 Base64 渲染)
+
+
+ +
masterImage(JPEG 背景)
+
+
+ +
tileImage(PNG 滑块)
+
+
+
原始响应(去除图片数据)
+

+  
+
+ + + + diff --git a/start-config.yml b/start-config.yml index 1231b5d0d..66051f962 100644 --- a/start-config.yml +++ b/start-config.yml @@ -7,6 +7,7 @@ serviceBinaries: openim-msgtransfer: 8 openim-rpc-conversation: 1 openim-rpc-auth: 1 + openim-rpc-captcha: 1 openim-rpc-group: 1 openim-rpc-friend: 1 openim-rpc-msg: 1