From 92a72d1bea0ee14fac1d7abc6bce7b66c9976547 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 9 Sep 2026 09:39:55 +0800 Subject: [PATCH] feat: enforce tenant login IP whitelists --- server/admin_internal_config.go | 38 +++++++++- server/auth_audit.go | 88 ++++++++++++++++++++++++ server/session.go | 3 + server/tenant_login_ip_whitelist_test.go | 71 +++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 server/tenant_login_ip_whitelist_test.go diff --git a/server/admin_internal_config.go b/server/admin_internal_config.go index bb68c04..789555d 100644 --- a/server/admin_internal_config.go +++ b/server/admin_internal_config.go @@ -34,6 +34,18 @@ type adminInternalClient struct { http *http.Client } +type tenantLoginIPWhitelistConfig struct { + TenantID types.TenantID `json:"tenant_id"` + AppList []string `json:"app_list"` + WebList []string `json:"web_list"` +} + +type tenantLoginIPWhitelistProvider interface { + TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) +} + +var loginIPWhitelistProvider tenantLoginIPWhitelistProvider + type adminInternalTenantDTO struct { ID types.TenantID `json:"id"` Code string `json:"code"` @@ -50,6 +62,7 @@ type adminInternalTenantDTO struct { func initAdminInternalAPI(raw json.RawMessage) error { store.SetAdminInternalConfigProvider(nil) + loginIPWhitelistProvider = nil if len(raw) == 0 || string(raw) == "null" { return nil } @@ -71,10 +84,12 @@ func initAdminInternalAPI(raw json.RawMessage) error { if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" { return errors.New("admin_internal_api enabled but base_url or secret is empty") } - store.SetAdminInternalConfigProvider(&adminInternalClient{ + client := &adminInternalClient{ config: cfg, http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond}, - }) + } + store.SetAdminInternalConfigProvider(client) + loginIPWhitelistProvider = client logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName) return nil } @@ -152,6 +167,25 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma return wrapper.Values, nil } +func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) { + if !id.IsValid() { + return nil, types.ErrMalformed + } + var result tenantLoginIPWhitelistConfig + path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/login-ip-whitelist" + if err := c.getJSON(path, nil, &result); err != nil { + return nil, err + } + result.TenantID = id + if result.AppList == nil { + result.AppList = []string{} + } + if result.WebList == nil { + result.WebList = []string{} + } + return &result, nil +} + func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error { if c == nil || c.http == nil { return types.ErrUnsupported diff --git a/server/auth_audit.go b/server/auth_audit.go index d236584..a723d44 100644 --- a/server/auth_audit.go +++ b/server/auth_audit.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "strings" + "sync" "time" "github.com/tinode/chat/server/auth" @@ -15,6 +16,18 @@ import ( "github.com/tinode/chat/server/store/types" ) +const tenantLoginIPWhitelistCacheTTL = 30 * time.Second + +type tenantLoginIPWhitelistCacheEntry struct { + config tenantLoginIPWhitelistConfig + expiresAt time.Time +} + +var tenantLoginIPWhitelistCache = struct { + sync.RWMutex + items map[types.TenantID]tenantLoginIPWhitelistCacheEntry +}{items: make(map[types.TenantID]tenantLoginIPWhitelistCacheEntry)} + func recordSessionLoginSuccess(s *Session, rec *auth.Rec, scheme, suppliedAccount, requestID string, at time.Time) { if s == nil || rec == nil || rec.Uid.IsZero() || !s.tenantID.IsValid() { return @@ -102,6 +115,81 @@ func checkUserLoginIPWhitelist(tenantID types.TenantID, uid types.Uid, remoteAdd return types.ErrPermissionDenied } +func checkTenantLoginIPWhitelist(tenantID types.TenantID, platform, remoteAddr string) error { + config, err := loadTenantLoginIPWhitelist(tenantID) + if err != nil { + return err + } + if config == nil { + return nil + } + values := config.AppList + if tenantLoginIPChannel(platform) == "web" { + values = config.WebList + } + if len(values) == 0 { + return nil + } + if whitelistContainsIP(values, sessionAuditIP(strings.Split(remoteAddr, ",")[0])) { + return nil + } + return types.ErrPermissionDenied +} + +func loadTenantLoginIPWhitelist(tenantID types.TenantID) (*tenantLoginIPWhitelistConfig, error) { + if loginIPWhitelistProvider == nil { + return nil, nil + } + now := time.Now() + tenantLoginIPWhitelistCache.RLock() + cached, ok := tenantLoginIPWhitelistCache.items[tenantID] + tenantLoginIPWhitelistCache.RUnlock() + if ok && now.Before(cached.expiresAt) { + result := cached.config + return &result, nil + } + config, err := loginIPWhitelistProvider.TenantLoginIPWhitelist(tenantID) + if err != nil { + if ok { + result := cached.config + return &result, nil + } + return nil, err + } + if config == nil { + config = &tenantLoginIPWhitelistConfig{TenantID: tenantID, AppList: []string{}, WebList: []string{}} + } + tenantLoginIPWhitelistCache.Lock() + tenantLoginIPWhitelistCache.items[tenantID] = tenantLoginIPWhitelistCacheEntry{config: *config, expiresAt: now.Add(tenantLoginIPWhitelistCacheTTL)} + tenantLoginIPWhitelistCache.Unlock() + result := *config + return &result, nil +} + +func tenantLoginIPChannel(platform string) string { + if strings.EqualFold(strings.TrimSpace(platform), "web") { + return "web" + } + return "app" +} + +func whitelistContainsIP(values []string, ipText string) bool { + ip := net.ParseIP(strings.TrimSpace(ipText)) + if ip == nil { + return false + } + for _, value := range values { + value = strings.TrimSpace(value) + if allowedIP := net.ParseIP(value); allowedIP != nil && allowedIP.Equal(ip) { + return true + } + if _, block, err := net.ParseCIDR(value); err == nil && block.Contains(ip) { + return true + } + } + return false +} + func loginAuditAccount(scheme, supplied, credential string) (string, string) { if parts := strings.SplitN(strings.TrimSpace(credential), ":", 2); len(parts) == 2 && parts[1] != "" { kind := strings.ToLower(strings.TrimSpace(parts[0])) diff --git a/server/session.go b/server/session.go index 593d616..8ddd3fb 100644 --- a/server/session.go +++ b/server/session.go @@ -1147,6 +1147,9 @@ func (s *Session) login(msg *ClientComMessage) { if err == nil && rec.State != types.StateOK { err = types.ErrPermissionDenied } + if err == nil { + err = checkTenantLoginIPWhitelist(s.tenantID, s.platf, s.remoteAddr) + } if err == nil { err = checkUserLoginIPWhitelist(s.tenantID, rec.Uid, s.remoteAddr) } diff --git a/server/tenant_login_ip_whitelist_test.go b/server/tenant_login_ip_whitelist_test.go new file mode 100644 index 0000000..0c32243 --- /dev/null +++ b/server/tenant_login_ip_whitelist_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "testing" + + "github.com/tinode/chat/server/store/types" +) + +type staticTenantLoginIPWhitelistProvider struct { + config *tenantLoginIPWhitelistConfig + err error +} + +func (p staticTenantLoginIPWhitelistProvider) TenantLoginIPWhitelist(types.TenantID) (*tenantLoginIPWhitelistConfig, error) { + return p.config, p.err +} + +func resetTenantLoginIPWhitelistTestState(t *testing.T) { + t.Helper() + previousProvider := loginIPWhitelistProvider + tenantLoginIPWhitelistCache.Lock() + previousCache := tenantLoginIPWhitelistCache.items + tenantLoginIPWhitelistCache.items = make(map[types.TenantID]tenantLoginIPWhitelistCacheEntry) + tenantLoginIPWhitelistCache.Unlock() + t.Cleanup(func() { + loginIPWhitelistProvider = previousProvider + tenantLoginIPWhitelistCache.Lock() + tenantLoginIPWhitelistCache.items = previousCache + tenantLoginIPWhitelistCache.Unlock() + }) +} + +func TestTenantLoginIPChannel(t *testing.T) { + for platform, expected := range map[string]string{ + "web": "web", "WEB": "web", "ios": "app", "android": "app", "": "app", + } { + if actual := tenantLoginIPChannel(platform); actual != expected { + t.Fatalf("platform %q: got %q, want %q", platform, actual, expected) + } + } +} + +func TestCheckTenantLoginIPWhitelist(t *testing.T) { + resetTenantLoginIPWhitelistTestState(t) + tenantID := types.TenantID(1) + loginIPWhitelistProvider = staticTenantLoginIPWhitelistProvider{config: &tenantLoginIPWhitelistConfig{ + TenantID: tenantID, + AppList: []string{"203.0.113.10", "2001:db8::/32"}, + WebList: []string{"198.51.100.0/24"}, + }} + + if err := checkTenantLoginIPWhitelist(tenantID, "ios", "203.0.113.10:443"); err != nil { + t.Fatalf("APP白名单地址应允许登录: %v", err) + } + if err := checkTenantLoginIPWhitelist(tenantID, "web", "198.51.100.8:443"); err != nil { + t.Fatalf("Web白名单网段应允许登录: %v", err) + } + if err := checkTenantLoginIPWhitelist(tenantID, "web", "203.0.113.10:443"); !errors.Is(err, types.ErrPermissionDenied) { + t.Fatalf("APP地址不应绕过Web白名单: %v", err) + } +} + +func TestCheckTenantLoginIPWhitelistEmptyMeansUnrestricted(t *testing.T) { + resetTenantLoginIPWhitelistTestState(t) + tenantID := types.TenantID(1) + loginIPWhitelistProvider = staticTenantLoginIPWhitelistProvider{config: &tenantLoginIPWhitelistConfig{TenantID: tenantID}} + if err := checkTenantLoginIPWhitelist(tenantID, "web", "192.0.2.8:443"); err != nil { + t.Fatalf("未配置渠道白名单时不应限制登录: %v", err) + } +}