feat: enforce tenant login IP whitelists
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user