feat: 接入后台消息治理与登录审计

This commit is contained in:
richjohnrambo
2026-09-07 11:53:24 +08:00
parent 90185b6c88
commit 6b20a633ce
17 changed files with 1062 additions and 45 deletions
+207
View File
@@ -0,0 +1,207 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"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 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])
}