240 lines
7.8 KiB
Go
240 lines
7.8 KiB
Go
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])
|
|
}
|