package main import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "net" "strings" "sync" "time" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "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 } username := "" if user, err := store.Users.Get(s.tenantID, rec.Uid); err == nil && user != nil { username = user.Username } accountType, account := loginAuditAccount(scheme, suppliedAccount, rec.Credential) if account == "" { accountType, account = classifyLoginAccount(username) } event := &types.LoginAuditEvent{ UserID: rec.Uid, Username: username, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: account, LoginType: loginAuditType(scheme), EventType: "login", SessionID: truncateAuditValue(s.sid, 96), DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf), AppVersion: truncateAuditValue(versionToString(s.ver), 32), IP: sessionAuditIP(s.remoteAddr), UserAgent: truncateAuditValue(s.userAgent, 512), RiskLevel: "low", RequestID: truncateAuditValue(requestID, 64), LoginAt: at.UTC().Round(time.Millisecond), Metadata: map[string]any{"auth_level": rec.AuthLevel.String(), "tenant_code": s.tenantCode}, } if err := store.SecurityAudit.RecordLogin(s.tenantID, event); err != nil { logs.Warn.Printf("login audit write failed tenant:%d uid:%s sid:%s err:%v", s.tenantID, rec.Uid, s.sid, err) } } func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *auth.Rec, requestID string, cause error, at time.Time, riskActionOverride ...string) { if s == nil || !s.tenantID.IsValid() { return } suppliedAccount := "" if strings.EqualFold(strings.TrimSpace(scheme), "basic") { suppliedAccount = strings.SplitN(string(secret), ":", 2)[0] } credential := "" uid := types.ZeroUid if rec != nil { credential = rec.Credential uid = rec.Uid } accountType, account := loginAuditAccount(scheme, suppliedAccount, credential) failureType, reason, riskLevel, riskAction := classifyLoginFailure(scheme, cause) for _, override := range riskActionOverride { if strings.TrimSpace(override) == "" { continue } riskAction = strings.TrimSpace(override) riskLevel = "high" } event := &types.LoginFailureEvent{ UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: account, FailureType: failureType, Reason: reason, Detail: truncateAuditValue(loginFailureDetail(cause), 500), IP: sessionAuditIP(s.remoteAddr), DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf), AppVersion: truncateAuditValue(versionToString(s.ver), 32), UserAgent: truncateAuditValue(s.userAgent, 512), RiskLevel: riskLevel, RiskAction: riskAction, RequestID: truncateAuditValue(requestID, 64), AttemptAt: at.UTC().Round(time.Millisecond), Metadata: map[string]any{"scheme": strings.TrimSpace(scheme), "tenant_code": s.tenantCode}, } if err := store.SecurityAudit.RecordLoginFailure(s.tenantID, event); err != nil { logs.Warn.Printf("login failure audit write failed tenant:%d sid:%s err:%v", s.tenantID, s.sid, err) } } func checkUserLoginIPWhitelist(tenantID types.TenantID, uid types.Uid, remoteAddr string) error { result, err := store.InternalAdminQueries.Query(tenantID, "SELECT ip_cidr FROM im_user_ip_whitelists WHERE user_id=? AND status=1 AND deleted_at IS NULL ORDER BY id", "SELECT COUNT(*) FROM im_user_ip_whitelists WHERE user_id=? AND status=1 AND deleted_at IS NULL", []any{store.DecodeUid(uid)}, 1, 500) if err != nil { if errors.Is(err, types.ErrUnsupported) { return nil } logs.Warn.Printf("login ip whitelist user lookup failed tenant:%d uid:%s err:%v", tenantID, uid, err) return nil } if result == nil || len(result.Items) == 0 { return nil } ipText := sessionAuditIP(strings.Split(remoteAddr, ",")[0]) ip := net.ParseIP(ipText) if ip == nil { return types.ErrPermissionDenied } for _, item := range result.Items { value := strings.TrimSpace(fmt.Sprint(item["ip_cidr"])) if allowedIP := net.ParseIP(value); allowedIP != nil && allowedIP.Equal(ip) { return nil } if _, block, parseErr := net.ParseCIDR(value); parseErr == nil && block.Contains(ip) { return nil } } return types.ErrPermissionDenied } func checkTenantLoginIPWhitelist(tenantID types.TenantID, platform, remoteAddr string) error { config, err := loadTenantLoginIPWhitelist(tenantID) if err != nil { logs.Warn.Printf("login ip whitelist tenant lookup failed tenant:%d platform:%s err:%v", tenantID, platform, err) return nil } 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 } config = &tenantLoginIPWhitelistConfig{TenantID: tenantID, AppList: []string{}, WebList: []string{}} } 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])) if kind == "tel" { kind = "phone" } return kind, strings.TrimSpace(parts[1]) } if strings.EqualFold(strings.TrimSpace(scheme), "basic") { return classifyLoginAccount(supplied) } return "", "" } func classifyLoginAccount(account string) (string, string) { account = strings.TrimSpace(account) switch { case account == "": return "", "" case strings.Contains(account, "@"): return "email", account case isPhoneLike(account): return "phone", account default: return "username", account } } func isPhoneLike(value string) bool { digits := 0 for i, r := range value { if r >= '0' && r <= '9' { digits++ continue } if !(i == 0 && r == '+') { return false } } return digits >= 7 } func hashLoginAccount(account string) string { account = strings.ToLower(strings.TrimSpace(account)) if account == "" { return "" } sum := sha256.Sum256([]byte(account)) return hex.EncodeToString(sum[:]) } func loginAuditType(scheme string) string { switch strings.ToLower(strings.TrimSpace(scheme)) { case "basic": return "password" case "token": return "token_refresh" case "code", "sms", "sms_code": return "sms_code" case "sso": return "sso" case "qr", "qr_code": return "qr_code" default: return "password" } } func classifyLoginFailure(scheme string, cause error) (failureType, reason, riskLevel, riskAction string) { failureType, reason, riskLevel, riskAction = "password_error", "AUTH_FAILED", "medium", "audit_only" detail := strings.ToLower(loginFailureDetail(cause)) switch { case strings.Contains(detail, "rate") || strings.Contains(detail, "too many"): return "rate_limited", "RATE_LIMITED", "high", "blocked" case errors.Is(cause, types.ErrPolicy) || strings.Contains(detail, "risk"): return "risk_blocked", "RISK_BLOCKED", "high", "blocked" case errors.Is(cause, types.ErrPermissionDenied): return "account_disabled", "ACCOUNT_DISABLED", "high", "blocked" case errors.Is(cause, types.ErrUserNotFound) || errors.Is(cause, types.ErrNotFound): return "account_not_found", "ACCOUNT_NOT_FOUND", "medium", "audit_only" case loginAuditType(scheme) == "sms_code": return "code_error", "CODE_ERROR", "medium", "audit_only" default: return } } func loginFailureDetail(cause error) string { if cause == nil { return "authentication failed" } return cause.Error() } func sessionAuditIP(remoteAddr string) string { remoteAddr = strings.TrimSpace(remoteAddr) if host, _, err := net.SplitHostPort(remoteAddr); err == nil { return truncateAuditValue(host, 64) } return truncateAuditValue(strings.Trim(remoteAddr, "[]"), 64) } func normalizeAuditPlatform(platform string) string { platform = strings.ToLower(strings.TrimSpace(platform)) if platform == "" { return "unknown" } return truncateAuditValue(platform, 32) } func truncateAuditValue(value string, max int) string { runes := []rune(strings.TrimSpace(value)) if len(runes) <= max { return string(runes) } return string(runes[:max]) }