diff --git a/Dockerfile b/Dockerfile index dd1038031..8865e4ed7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,8 @@ COPY --from=builder $SERVER_DIR/start-config.yml $SERVER_DIR/ COPY --from=builder $SERVER_DIR/go.mod $SERVER_DIR/ COPY --from=builder $SERVER_DIR/go.sum $SERVER_DIR/ -RUN go get github.com/openimsdk/gomake@v0.0.14-alpha.5 +# 使用与 go.mod 一致的 gomake 版本(勿降级) +RUN go mod download github.com/openimsdk/gomake # Set the command to run when the container starts ENTRYPOINT ["sh", "-c", "mage start && tail -f /dev/null"] diff --git a/internal/api/admin/start.go b/internal/api/admin/start.go index 880f86cc7..3071b059a 100644 --- a/internal/api/admin/start.go +++ b/internal/api/admin/start.go @@ -19,6 +19,7 @@ import ( disetcd "github.com/openimsdk/chat/pkg/common/kdisc/etcd" adminclient "github.com/openimsdk/chat/pkg/protocol/admin" chatclient "github.com/openimsdk/chat/pkg/protocol/chat" + "github.com/openimsdk/tools/db/mongoutil" "github.com/openimsdk/tools/discovery" "github.com/openimsdk/tools/discovery/etcd" "github.com/openimsdk/tools/errs" @@ -71,10 +72,21 @@ func Start(ctx context.Context, index int, config *Config) error { } adminApi := New(chatClient, adminClient, im, &base) mwApi := chatmw.New(adminClient) + + // 二开:初始化 MongoDB(用于白名单管理直接操作) + mgocli, err := mongoutil.NewMongoDB(ctx, config.AllConfig.Mongo.Build()) + if err != nil { + return err + } + whitelistMgr, err := NewWhitelistManager(mgocli.GetDB()) + if err != nil { + return err + } + gin.SetMode(gin.ReleaseMode) engine := gin.New() - engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID()) - SetAdminRoute(engine, adminApi, mwApi, config, client) + engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID(), chatmw.RateLimitByIP) + SetAdminRoute(engine, adminApi, mwApi, whitelistMgr, config, client) if config.Discovery.Enable == kdisc.ETCDCONST { cm := disetcd.NewConfigManager(client.(*etcd.SvcDiscoveryRegistryImpl).GetClient(), config.GetConfigNames()) @@ -118,8 +130,7 @@ func Start(ctx context.Context, index int, config *Config) error { return nil } -func SetAdminRoute(router gin.IRouter, admin *Api, mw *chatmw.MW, cfg *Config, client discovery.SvcDiscoveryRegistry) { - +func SetAdminRoute(router gin.IRouter, admin *Api, mw *chatmw.MW, wlMgr *WhitelistManager, cfg *Config, client discovery.SvcDiscoveryRegistry) { adminRouterGroup := router.Group("/account") adminRouterGroup.POST("/login", admin.AdminLogin) // Login adminRouterGroup.POST("/update", mw.CheckAdmin, admin.AdminUpdateInfo) // Modify information @@ -129,7 +140,9 @@ func SetAdminRoute(router gin.IRouter, admin *Api, mw *chatmw.MW, cfg *Config, c adminRouterGroup.POST("/add_user", mw.CheckAdmin, admin.AddUserAccount) // Add user account adminRouterGroup.POST("/del_admin", mw.CheckAdmin, admin.DelAdminAccount) // Delete admin adminRouterGroup.POST("/search", mw.CheckAdmin, admin.SearchAdminAccount) // Get admin list - //account.POST("/add_notification_account") + // account.POST("/add_notification_account") + + router.POST("/user/batch_register", mw.CheckAdmin, admin.BatchRegisterUsers) importGroup := router.Group("/user/import") importGroup.POST("/json", mw.CheckAdmin, admin.ImportUserByJson) @@ -180,7 +193,17 @@ func SetAdminRoute(router gin.IRouter, admin *Api, mw *chatmw.MW, cfg *Config, c blockRouter.POST("/search", admin.SearchBlockUser) // Search blocked users userRouter := router.Group("/user", mw.CheckAdmin) - userRouter.POST("/password/reset", admin.ResetUserPassword) // Reset user password + userRouter.POST("/password/reset", admin.ResetUserPassword) // Reset user password + userRouter.POST("/set_app_role", admin.SetAppRole) // 二开:设置用户端管理员 + userRouter.POST("/ip_logs", admin.GetUserIPLogs) // 二开:查询用户 IP 登录历史 + userRouter.POST("/search", admin.SearchUserInfo) // 二开:搜索用户(含 IP/角色,供管理端列表) + + // 二开:白名单管理(仅超级管理员) + whitelistRouter := router.Group("/whitelist", mw.CheckAdmin) + whitelistRouter.POST("/add", wlMgr.AddWhitelist) + whitelistRouter.POST("/del", wlMgr.DelWhitelist) + whitelistRouter.POST("/update", wlMgr.UpdateWhitelist) + whitelistRouter.POST("/search", wlMgr.SearchWhitelist) initGroup := router.Group("/client_config", mw.CheckAdmin) initGroup.POST("/get", admin.GetClientConfig) // Get client initialization configuration diff --git a/internal/api/admin/whitelist_manager.go b/internal/api/admin/whitelist_manager.go new file mode 100644 index 000000000..41ecb4f0a --- /dev/null +++ b/internal/api/admin/whitelist_manager.go @@ -0,0 +1,164 @@ +// 二开:白名单管理 HTTP 处理器(admin API 层直接访问 MongoDB) +package admin + +import ( + "time" + + "github.com/gin-gonic/gin" + adminmodel "github.com/openimsdk/chat/pkg/common/db/model/admin" + admindb "github.com/openimsdk/chat/pkg/common/db/table/admin" + "github.com/google/uuid" + "github.com/openimsdk/tools/apiresp" + "github.com/openimsdk/tools/errs" + "go.mongodb.org/mongo-driver/mongo" +) + +// simplePage implements pagination.Pagination for whitelist searches +type simplePage struct { + pageNum int32 + showNum int32 +} + +func (p *simplePage) GetPageNumber() int32 { return p.pageNum } +func (p *simplePage) GetShowNumber() int32 { return p.showNum } + +// WhitelistManager 管理白名单的 HTTP 处理器 +type WhitelistManager struct { + db admindb.WhitelistInterface +} + +func NewWhitelistManager(mongoDB *mongo.Database) (*WhitelistManager, error) { + wl, err := adminmodel.NewWhitelistUser(mongoDB) + if err != nil { + return nil, err + } + return &WhitelistManager{db: wl}, nil +} + +// AddWhitelistReq 添加白名单请求 +type AddWhitelistReq struct { + Identifier string `json:"identifier" binding:"required"` // +8613800138000 or email + Type int32 `json:"type" binding:"required"` // 1=phone 2=email + Role string `json:"role"` // admin/operator/user + Permissions []string `json:"permissions"` // view_ip/ban_user/view_chat_log/broadcast + Remark string `json:"remark"` +} + +// UpdateWhitelistReq 修改白名单请求 +type UpdateWhitelistReq struct { + ID string `json:"id" binding:"required"` + Role *string `json:"role"` + Permissions []string `json:"permissions"` + Status *int32 `json:"status"` // 0=禁用 1=启用 + Remark *string `json:"remark"` +} + +// DelWhitelistReq 删除白名单请求 +type DelWhitelistReq struct { + IDs []string `json:"ids" binding:"required"` +} + +// SearchWhitelistReq 搜索白名单请求 +type SearchWhitelistReq struct { + Keyword string `json:"keyword"` + Status int32 `json:"status"` // -1=全部 0=禁用 1=启用 + PageNum int32 `json:"pageNum"` // 1-based + ShowNum int32 `json:"showNum"` +} + +// AddWhitelist POST /whitelist/add +func (m *WhitelistManager) AddWhitelist(c *gin.Context) { + var req AddWhitelistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if req.Role == "" { + req.Role = "user" + } + now := time.Now() + entry := &admindb.WhitelistUser{ + ID: uuid.New().String(), + Identifier: req.Identifier, + Type: req.Type, + Role: req.Role, + Permissions: req.Permissions, + Status: admindb.WhitelistStatusActive, + Remark: req.Remark, + CreateTime: now, + UpdateTime: now, + } + if err := m.db.Create(c, []*admindb.WhitelistUser{entry}); err != nil { + apiresp.GinError(c, err) + return + } + apiresp.GinSuccess(c, entry) +} + +// UpdateWhitelist POST /whitelist/update +func (m *WhitelistManager) UpdateWhitelist(c *gin.Context) { + var req UpdateWhitelistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + update := map[string]any{"update_time": time.Now()} + if req.Role != nil { + update["role"] = *req.Role + } + if req.Permissions != nil { + update["permissions"] = req.Permissions + } + if req.Status != nil { + update["status"] = *req.Status + } + if req.Remark != nil { + update["remark"] = *req.Remark + } + if err := m.db.Update(c, req.ID, update); err != nil { + apiresp.GinError(c, err) + return + } + apiresp.GinSuccess(c, nil) +} + +// DelWhitelist POST /whitelist/del +func (m *WhitelistManager) DelWhitelist(c *gin.Context) { + var req DelWhitelistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if err := m.db.Delete(c, req.IDs); err != nil { + apiresp.GinError(c, err) + return + } + apiresp.GinSuccess(c, nil) +} + +// SearchWhitelist POST /whitelist/search +func (m *WhitelistManager) SearchWhitelist(c *gin.Context) { + var req SearchWhitelistReq + if err := c.ShouldBindJSON(&req); err != nil { + apiresp.GinError(c, errs.ErrArgs.WrapMsg(err.Error())) + return + } + if req.ShowNum <= 0 { + req.ShowNum = 20 + } + if req.PageNum <= 0 { + req.PageNum = 1 + } + total, list, err := m.db.Search(c, req.Keyword, req.Status, &simplePage{ + pageNum: req.PageNum, + showNum: req.ShowNum, + }) + if err != nil { + apiresp.GinError(c, err) + return + } + apiresp.GinSuccess(c, map[string]any{ + "total": total, + "list": list, + }) +} diff --git a/internal/api/chat/chat.go b/internal/api/chat/chat.go index 0e6254373..a2dd87396 100644 --- a/internal/api/chat/chat.go +++ b/internal/api/chat/chat.go @@ -19,11 +19,13 @@ import ( "time" "github.com/openimsdk/chat/internal/api/util" + chatmw "github.com/openimsdk/chat/internal/api/mw" "github.com/gin-gonic/gin" "github.com/openimsdk/chat/pkg/common/apistruct" "github.com/openimsdk/chat/pkg/common/imapi" "github.com/openimsdk/chat/pkg/common/mctx" + "github.com/openimsdk/chat/pkg/eerrs" "github.com/openimsdk/chat/pkg/protocol/admin" chatpb "github.com/openimsdk/chat/pkg/protocol/chat" constantpb "github.com/openimsdk/protocol/constant" @@ -168,11 +170,30 @@ func (o *Api) Login(c *gin.Context) { return } req.Ip = ip + // 二开:检查登录锁定(5 次失败后锁定 5 分钟) + lockKey := req.PhoneNumber + if req.Account != "" { + lockKey = req.Account + } else if req.Email != "" { + lockKey = req.Email + } + if lockKey != "" && chatmw.IsLoginLocked(lockKey) { + apiresp.GinError(c, eerrs.ErrForbidden.WrapMsg("登录失败次数过多,账号已锁定5分钟")) + return + } resp, err := o.chatClient.Login(c, req) if err != nil { + // 记录失败次数(密码错误或账号不存在时) + if lockKey != "" { + chatmw.RecordLoginFailure(lockKey) + } apiresp.GinError(c, err) return } + // 登录成功,重置失败计数 + if lockKey != "" { + chatmw.ResetLoginFailure(lockKey) + } adminToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c) if err != nil { apiresp.GinError(c, err) @@ -189,6 +210,7 @@ func (o *Api) Login(c *gin.Context) { ImToken: imToken, UserID: resp.UserID, ChatToken: resp.ChatToken, + AppRole: resp.AppRole, }) } @@ -283,6 +305,11 @@ func (o *Api) GetTokenForVideoMeeting(c *gin.Context) { a2r.Call(c, chatpb.ChatClient.GetTokenForVideoMeeting, o.chatClient) } +// 二开:查询指定用户 IP(仅管理员或用户端管理员可调) +func (o *Api) GetUserIPInfo(c *gin.Context) { + a2r.Call(c, chatpb.ChatClient.GetUserIPInfo, o.chatClient) +} + // ################## APPLET ################## func (o *Api) FindApplet(c *gin.Context) { diff --git a/internal/api/chat/start.go b/internal/api/chat/start.go index 3eca0d5de..c1127c638 100644 --- a/internal/api/chat/start.go +++ b/internal/api/chat/start.go @@ -73,7 +73,7 @@ func Start(ctx context.Context, index int, cfg *Config) error { mwApi := chatmw.New(adminClient) gin.SetMode(gin.ReleaseMode) engine := gin.New() - engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID()) + engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID(), chatmw.RateLimitByIP) SetChatRoute(engine, adminApi, mwApi) var ( @@ -140,7 +140,8 @@ func SetChatRoute(router gin.IRouter, chat *Api, mw *chatmw.MW) { user.POST("/find/full", chat.FindUserFullInfo) // Get all information of the user user.POST("/search/full", chat.SearchUserFullInfo) // Search user's public information user.POST("/search/public", chat.SearchUserPublicInfo) // Search all information of the user - user.POST("/rtc/get_token", chat.GetTokenForVideoMeeting) // Get token for video meeting for the user + user.POST("/rtc/get_token", chat.GetTokenForVideoMeeting) // Get token for video meeting for the user + user.POST("/ip_info", chat.GetUserIPInfo) // 二开:查询指定用户 IP(需管理员或用户端管理员权限) router.POST("/friend/search", mw.CheckToken, chat.SearchFriend) diff --git a/internal/api/mw/rate_limit.go b/internal/api/mw/rate_limit.go new file mode 100644 index 000000000..a59f5a143 --- /dev/null +++ b/internal/api/mw/rate_limit.go @@ -0,0 +1,110 @@ +// 二开:IP 限速中间件(60 req/min per IP,登录失败锁定) +package mw + +import ( + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/openimsdk/chat/pkg/eerrs" + "github.com/openimsdk/tools/apiresp" +) + +// ipBucket 记录单 IP 的请求桶 +type ipBucket struct { + count int + windowAt time.Time +} + +// failRecord 记录登录失败次数 +type failRecord struct { + count int + lockedAt time.Time + expiresAt time.Time +} + +var ( + rateMu sync.Mutex + rateMap = map[string]*ipBucket{} + failMu sync.Mutex + failMap = map[string]*failRecord{} +) + +const ( + rateWindow = time.Minute + rateLimit = 60 + failMax = 5 + lockDuration = 5 * time.Minute +) + +// RateLimitByIP 每 IP 每分钟最多 60 个请求 +func RateLimitByIP(c *gin.Context) { + ip := c.ClientIP() + now := time.Now() + + rateMu.Lock() + bucket, ok := rateMap[ip] + if !ok || now.Sub(bucket.windowAt) >= rateWindow { + rateMap[ip] = &ipBucket{count: 1, windowAt: now} + rateMu.Unlock() + c.Next() + return + } + bucket.count++ + count := bucket.count + rateMu.Unlock() + + if count > rateLimit { + c.Abort() + apiresp.GinError(c, eerrs.ErrForbidden.WrapMsg("请求过于频繁,请稍后重试")) + return + } + c.Next() +} + +// RecordLoginFailure 记录登录失败(key=phone/email 或 IP) +func RecordLoginFailure(key string) bool { + failMu.Lock() + defer failMu.Unlock() + + now := time.Now() + rec, ok := failMap[key] + if !ok { + failMap[key] = &failRecord{count: 1, expiresAt: now.Add(lockDuration)} + return false + } + // 如果锁已到期,重置 + if now.After(rec.expiresAt) { + failMap[key] = &failRecord{count: 1, expiresAt: now.Add(lockDuration)} + return false + } + rec.count++ + if rec.count >= failMax && rec.lockedAt.IsZero() { + rec.lockedAt = now + } + return rec.count >= failMax +} + +// IsLoginLocked 检查是否处于登录锁定状态 +func IsLoginLocked(key string) bool { + failMu.Lock() + defer failMu.Unlock() + + rec, ok := failMap[key] + if !ok { + return false + } + now := time.Now() + if now.After(rec.expiresAt) { + delete(failMap, key) + return false + } + return rec.count >= failMax +} + +// ResetLoginFailure 登录成功后重置失败计数 +func ResetLoginFailure(key string) { + failMu.Lock() + defer failMu.Unlock() + delete(failMap, key) +} diff --git a/internal/rpc/chat/login.go b/internal/rpc/chat/login.go index 1d1b2fcdf..7ab347124 100644 --- a/internal/rpc/chat/login.go +++ b/internal/rpc/chat/login.go @@ -450,6 +450,19 @@ func (o *chatSvr) Login(ctx context.Context, req *chat.LoginReq) (*chat.LoginRes } return nil, err } + // 二开:白名单登录检查(配置项 whitelist_login_enabled=1 时生效) + conf, confErr := o.Admin.GetConfig(ctx) + if confErr == nil { + if val := conf[constant.WhitelistLoginEnabledKey]; strings.EqualFold(val, "1") || strings.EqualFold(val, "true") || strings.EqualFold(val, "yes") { + wl, wlErr := o.Database.FindWhitelistByIdentifier(ctx, acc) + if wlErr != nil || wl == nil { + return nil, eerrs.ErrForbidden.WrapMsg("账号不在登录白名单中,请联系管理员") + } + if wl.Status != 1 { + return nil, eerrs.ErrForbidden.WrapMsg("账号白名单已停用,请联系管理员") + } + } + } if err := o.Admin.CheckLogin(ctx, credential.UserID, req.Ip); err != nil { return nil, err } @@ -499,6 +512,13 @@ func (o *chatSvr) Login(ctx context.Context, req *chat.LoginReq) (*chat.LoginRes if err := o.Database.LoginRecord(ctx, record, verifyCodeID); err != nil { return nil, err } + // 二开:更新用户最后登录 IP 与时间(用于 IP 查看功能) + if err := o.Database.UpdateAttribute(ctx, credential.UserID, map[string]any{ + "last_ip": req.Ip, + "last_ip_at": time.Now(), + }); err != nil { + log.ZWarn(ctx, "UpdateAttribute last_ip failed", err, "userID", credential.UserID) + } if verifyCodeID != nil { if err := o.Database.DelVerifyCode(ctx, *verifyCodeID); err != nil { return nil, err @@ -506,5 +526,9 @@ func (o *chatSvr) Login(ctx context.Context, req *chat.LoginReq) (*chat.LoginRes } resp.UserID = credential.UserID resp.ChatToken = chatToken.Token + // 二开:返回 app_role 供客户端做 IP 查看权限判断 + if attr, _ := o.Database.TakeAttributeByUserID(ctx, credential.UserID); attr != nil { + resp.AppRole = attr.AppRole + } return resp, nil } diff --git a/pkg/common/constant/constant.go b/pkg/common/constant/constant.go index 0aa65496f..8edd08d0f 100644 --- a/pkg/common/constant/constant.go +++ b/pkg/common/constant/constant.go @@ -86,6 +86,9 @@ const RpcCustomHeader = constant.RpcCustomHeader const NeedInvitationCodeRegisterConfigKey = "needInvitationCodeRegister" +// 二开:白名单登录控制配置键(值为 "1"/"true"/"yes" 时强制白名单登录) +const WhitelistLoginEnabledKey = "whitelistLoginEnabled" + const ( DefaultAllowVibration = 1 DefaultAllowBeep = 1 diff --git a/pkg/common/db/database/admin.go b/pkg/common/db/database/admin.go index 2335add82..9a4772f50 100644 --- a/pkg/common/db/database/admin.go +++ b/pkg/common/db/database/admin.go @@ -84,6 +84,12 @@ type AdminDatabaseInterface interface { UpdateVersion(ctx context.Context, id primitive.ObjectID, update map[string]any) error DeleteVersion(ctx context.Context, id []primitive.ObjectID) error PageVersion(ctx context.Context, platforms []string, page pagination.Pagination) (int64, []*admindb.Application, error) + // 二开:白名单管理 + FindWhitelistUser(ctx context.Context, identifier string) (*admindb.WhitelistUser, error) + AddWhitelistUser(ctx context.Context, users []*admindb.WhitelistUser) error + UpdateWhitelistUser(ctx context.Context, id string, update map[string]any) error + DelWhitelistUser(ctx context.Context, ids []string) error + SearchWhitelistUser(ctx context.Context, keyword string, status int32, pagination pagination.Pagination) (int64, []*admindb.WhitelistUser, error) } func NewAdminDatabase(cli *mongoutil.Client, rdb redis.UniversalClient) (AdminDatabaseInterface, error) { @@ -127,6 +133,10 @@ func NewAdminDatabase(cli *mongoutil.Client, rdb redis.UniversalClient) (AdminDa if err != nil { return nil, err } + whitelist, err := admin.NewWhitelistUser(cli.GetDB()) + if err != nil { + return nil, err + } return &AdminDatabase{ tx: cli.GetTx(), admin: a, @@ -139,6 +149,7 @@ func NewAdminDatabase(cli *mongoutil.Client, rdb redis.UniversalClient) (AdminDa applet: applet, clientConfig: clientConfig, application: application, + whitelist: whitelist, cache: cache.NewTokenInterface(rdb), }, nil } @@ -155,6 +166,7 @@ type AdminDatabase struct { applet admindb.AppletInterface clientConfig admindb.ClientConfigInterface application admindb.ApplicationInterface + whitelist admindb.WhitelistInterface cache cache.TokenInterface } @@ -379,3 +391,24 @@ func (o *AdminDatabase) DeleteVersion(ctx context.Context, id []primitive.Object func (o *AdminDatabase) PageVersion(ctx context.Context, platforms []string, page pagination.Pagination) (int64, []*admindb.Application, error) { return o.application.PageVersion(ctx, platforms, page) } + +// 二开:白名单管理实现 +func (o *AdminDatabase) FindWhitelistUser(ctx context.Context, identifier string) (*admindb.WhitelistUser, error) { + return o.whitelist.TakeByIdentifier(ctx, identifier) +} + +func (o *AdminDatabase) AddWhitelistUser(ctx context.Context, users []*admindb.WhitelistUser) error { + return o.whitelist.Create(ctx, users) +} + +func (o *AdminDatabase) UpdateWhitelistUser(ctx context.Context, id string, update map[string]any) error { + return o.whitelist.Update(ctx, id, update) +} + +func (o *AdminDatabase) DelWhitelistUser(ctx context.Context, ids []string) error { + return o.whitelist.Delete(ctx, ids) +} + +func (o *AdminDatabase) SearchWhitelistUser(ctx context.Context, keyword string, status int32, pagination pagination.Pagination) (int64, []*admindb.WhitelistUser, error) { + return o.whitelist.Search(ctx, keyword, status, pagination) +} diff --git a/pkg/common/db/database/chat.go b/pkg/common/db/database/chat.go index 5898fd73e..d7ab7d7e6 100644 --- a/pkg/common/db/database/chat.go +++ b/pkg/common/db/database/chat.go @@ -39,6 +39,7 @@ type ChatDatabaseInterface interface { TakeAttributeByAccount(ctx context.Context, account string) (*chatdb.Attribute, error) TakeAttributeByUserID(ctx context.Context, userID string) (*chatdb.Attribute, error) TakeAccount(ctx context.Context, userID string) (*chatdb.Account, error) + UpdateAttribute(ctx context.Context, userID string, data map[string]any) error TakeCredentialByAccount(ctx context.Context, account string) (*chatdb.Credential, error) TakeCredentialsByUserID(ctx context.Context, userID string) ([]*chatdb.Credential, error) TakeLastVerifyCode(ctx context.Context, account string) (*chatdb.VerifyCode, error) @@ -55,7 +56,11 @@ type ChatDatabaseInterface interface { NewUserCountTotal(ctx context.Context, before *time.Time) (int64, error) UserLoginCountTotal(ctx context.Context, before *time.Time) (int64, error) UserLoginCountRangeEverydayTotal(ctx context.Context, start *time.Time, end *time.Time) (map[string]int64, int64, error) + // 二开:按 user_id 分页查询登录记录(用于 GetUserIPLogs) + FindUserLoginRecordsByUserID(ctx context.Context, userID string, pagination pagination.Pagination) (int64, []*chatdb.UserLoginRecord, error) DelUserAccount(ctx context.Context, userIDs []string) error + // 二开:白名单登录检查 + FindWhitelistByIdentifier(ctx context.Context, identifier string) (*admin.WhitelistUser, error) } func NewChatDatabase(cli *mongoutil.Client) (ChatDatabaseInterface, error) { @@ -87,6 +92,10 @@ func NewChatDatabase(cli *mongoutil.Client) (ChatDatabaseInterface, error) { if err != nil { return nil, err } + whitelistUser, err := admindb.NewWhitelistUser(cli.GetDB()) + if err != nil { + return nil, err + } return &ChatDatabase{ tx: cli.GetTx(), register: register, @@ -96,6 +105,7 @@ func NewChatDatabase(cli *mongoutil.Client) (ChatDatabaseInterface, error) { userLoginRecord: userLoginRecord, verifyCode: verifyCode, forbiddenAccount: forbiddenAccount, + whitelistUser: whitelistUser, }, nil } @@ -108,6 +118,7 @@ type ChatDatabase struct { userLoginRecord chatdb.UserLoginRecordInterface verifyCode chatdb.VerifyCodeInterface forbiddenAccount admin.ForbiddenAccountInterface + whitelistUser admin.WhitelistInterface } func (o *ChatDatabase) GetUser(ctx context.Context, userID string) (account *chatdb.Account, err error) { @@ -155,6 +166,10 @@ func (o *ChatDatabase) TakeAttributeByUserID(ctx context.Context, userID string) return o.attribute.Take(ctx, userID) } +func (o *ChatDatabase) UpdateAttribute(ctx context.Context, userID string, data map[string]any) error { + return o.attribute.Update(ctx, userID, data) +} + func (o *ChatDatabase) TakeLastVerifyCode(ctx context.Context, account string) (*chatdb.VerifyCode, error) { return o.verifyCode.TakeLast(ctx, account) } @@ -277,6 +292,14 @@ func (o *ChatDatabase) UserLoginCountRangeEverydayTotal(ctx context.Context, sta return o.userLoginRecord.CountRangeEverydayTotal(ctx, start, end) } +func (o *ChatDatabase) FindUserLoginRecordsByUserID(ctx context.Context, userID string, pagination pagination.Pagination) (int64, []*chatdb.UserLoginRecord, error) { + return o.userLoginRecord.FindByUserID(ctx, userID, pagination) +} + +func (o *ChatDatabase) FindWhitelistByIdentifier(ctx context.Context, identifier string) (*admin.WhitelistUser, error) { + return o.whitelistUser.TakeByIdentifier(ctx, identifier) +} + func (o *ChatDatabase) DelUserAccount(ctx context.Context, userIDs []string) error { return o.tx.Transaction(ctx, func(ctx context.Context) error { if err := o.register.Delete(ctx, userIDs); err != nil { diff --git a/pkg/common/db/model/admin/whitelist.go b/pkg/common/db/model/admin/whitelist.go new file mode 100644 index 000000000..0d8d234ac --- /dev/null +++ b/pkg/common/db/model/admin/whitelist.go @@ -0,0 +1,69 @@ +// 二开:白名单 MongoDB 实现 +package admin + +import ( + "context" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + admindb "github.com/openimsdk/chat/pkg/common/db/table/admin" + "github.com/openimsdk/tools/db/mongoutil" + "github.com/openimsdk/tools/db/pagination" + "github.com/openimsdk/tools/errs" +) + +func NewWhitelistUser(db *mongo.Database) (admindb.WhitelistInterface, error) { + coll := db.Collection("whitelist_users") + _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: "identifier", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + return nil, errs.Wrap(err) + } + return &WhitelistUser{coll: coll}, nil +} + +type WhitelistUser struct { + coll *mongo.Collection +} + +func (o *WhitelistUser) TakeByIdentifier(ctx context.Context, identifier string) (*admindb.WhitelistUser, error) { + return mongoutil.FindOne[*admindb.WhitelistUser](ctx, o.coll, bson.M{"identifier": identifier}) +} + +func (o *WhitelistUser) TakeByID(ctx context.Context, id string) (*admindb.WhitelistUser, error) { + return mongoutil.FindOne[*admindb.WhitelistUser](ctx, o.coll, bson.M{"_id": id}) +} + +func (o *WhitelistUser) Create(ctx context.Context, users []*admindb.WhitelistUser) error { + return mongoutil.InsertMany(ctx, o.coll, users) +} + +func (o *WhitelistUser) Update(ctx context.Context, id string, update map[string]any) error { + return mongoutil.UpdateOne(ctx, o.coll, bson.M{"_id": id}, bson.M{"$set": update}, false) +} + +func (o *WhitelistUser) Delete(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return nil + } + return mongoutil.DeleteMany(ctx, o.coll, bson.M{"_id": bson.M{"$in": ids}}) +} + +func (o *WhitelistUser) Search(ctx context.Context, keyword string, status int32, pagination pagination.Pagination) (int64, []*admindb.WhitelistUser, error) { + filter := bson.M{} + if status >= 0 { + filter["status"] = status + } + if keyword != "" { + filter["$or"] = []bson.M{ + {"identifier": bson.M{"$regex": keyword, "$options": "i"}}, + {"remark": bson.M{"$regex": keyword, "$options": "i"}}, + {"role": bson.M{"$regex": keyword, "$options": "i"}}, + } + } + return mongoutil.FindPage[*admindb.WhitelistUser](ctx, o.coll, filter, pagination) +} diff --git a/pkg/common/db/table/admin/whitelist.go b/pkg/common/db/table/admin/whitelist.go new file mode 100644 index 000000000..989cc443d --- /dev/null +++ b/pkg/common/db/table/admin/whitelist.go @@ -0,0 +1,45 @@ +// 二开:白名单登录控制 — 只有白名单中且状态为 active 的标识符可以登录 +package admin + +import ( + "context" + "time" + + "github.com/openimsdk/tools/db/pagination" +) + +// Whitelist status +const ( + WhitelistStatusDisabled int32 = 0 + WhitelistStatusActive int32 = 1 +) + +// Whitelist identifier type +const ( + WhitelistTypePhone int32 = 1 + WhitelistTypeEmail int32 = 2 +) + +// WhitelistUser 登录白名单条目 +type WhitelistUser struct { + ID string `bson:"_id"` // UUID + Identifier string `bson:"identifier"` // +8613800138000 or user@example.com + Type int32 `bson:"type"` // 1=phone 2=email + Role string `bson:"role"` // admin/operator/user + Permissions []string `bson:"permissions"` // view_ip/ban_user/view_chat_log/broadcast + Status int32 `bson:"status"` // 1=active 0=disabled + Remark string `bson:"remark"` + CreateTime time.Time `bson:"create_time"` + UpdateTime time.Time `bson:"update_time"` +} + +func (WhitelistUser) TableName() string { return "whitelist_users" } + +type WhitelistInterface interface { + TakeByIdentifier(ctx context.Context, identifier string) (*WhitelistUser, error) + TakeByID(ctx context.Context, id string) (*WhitelistUser, error) + Create(ctx context.Context, users []*WhitelistUser) error + Update(ctx context.Context, id string, update map[string]any) error + Delete(ctx context.Context, ids []string) error + Search(ctx context.Context, keyword string, status int32, pagination pagination.Pagination) (int64, []*WhitelistUser, error) +}