319 lines
10 KiB
Go
319 lines
10 KiB
Go
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])
|
|
}
|