feat: enforce tenant login IP whitelists

This commit is contained in:
richjohnrambo
2026-09-09 09:40:37 +08:00
parent 9ac2b412cc
commit 92a72d1bea
4 changed files with 198 additions and 2 deletions
+36 -2
View File
@@ -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
+88
View File
@@ -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]))
+3
View File
@@ -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)
}
+71
View File
@@ -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)
}
}