package main import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "net" "strings" "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" ) 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: maskLoginAccount(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) { 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) event := &types.LoginFailureEvent{ UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(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 } return err } 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 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 maskLoginAccount(account string) string { account = strings.TrimSpace(account) if account == "" { return "" } if at := strings.LastIndex(account, "@"); at > 0 { return string([]rune(account[:at])[:1]) + "***" + account[at:] } runes := []rune(account) if isPhoneLike(account) && len(runes) > 7 { return string(runes[:3]) + "****" + string(runes[len(runes)-4:]) } if len(runes) <= 2 { return strings.Repeat("*", len(runes)) } return string(runes[:1]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1:]) } 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]) }