Smail/backend/services/auth/protocol_limiter.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

174 lines
5.1 KiB
Go

package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
)
const (
protocolPairFailureLimit = 10
protocolAccountFailureLimit = 20
protocolRemoteFailureLimit = 100
protocolLoginWindow = 5 * time.Minute
protocolLoginBlockTime = 15 * time.Minute
protocolLoginMaxEntries = 10000
)
type protocolLoginAttempt struct {
failures int
windowStart time.Time
blockedUntil time.Time
lastSeen time.Time
}
type protocolLoginLimiter struct {
mu sync.Mutex
attempts map[string]protocolLoginAttempt
now func() time.Time
failureLimit int
}
var (
protocolPairAttempts = newProtocolLoginLimiter(protocolPairFailureLimit)
protocolAccountAttempts = newProtocolLoginLimiter(protocolAccountFailureLimit)
protocolRemoteAttempts = newProtocolLoginLimiter(protocolRemoteFailureLimit)
)
func AuthenticateProtocol(protocol string, remoteAddress string, account string, plain string) (*models.User, error) {
normalizedProtocol, protocolErr := credentials.NormalizeProtocol(protocol)
normalizedAccount := strings.ToLower(strings.TrimSpace(account))
if normalizedIdentifier, err := NormalizeIdentifierKey(account); err == nil {
normalizedAccount = normalizedIdentifier
}
normalizedRemote := protocolRemoteHost(remoteAddress)
pairKey := hashedProtocolLoginKey("pair", normalizedProtocol, normalizedRemote, normalizedAccount)
accountKey := hashedProtocolLoginKey("account", normalizedAccount)
remoteKey := hashedProtocolLoginKey("remote", normalizedRemote)
if protocolPairAttempts.blocked(pairKey) || protocolAccountAttempts.blocked(accountKey) || protocolRemoteAttempts.blocked(remoteKey) {
return nil, errors.New("invalid credentials")
}
fail := func() (*models.User, error) {
protocolPairAttempts.recordFailure(pairKey)
protocolAccountAttempts.recordFailure(accountKey)
protocolRemoteAttempts.recordFailure(remoteKey)
return nil, errors.New("invalid credentials")
}
if protocolErr != nil || db.Instance == nil {
return fail()
}
identity, err := ResolveIdentifier(account)
if err != nil || identity.Kind != IdentifierEmail || identity.UserID <= 0 {
return fail()
}
service, err := credentials.NewService(db.Instance)
if err != nil || !service.VerifyProtocolCode(identity.UserID, normalizedProtocol, plain) {
return fail()
}
var user models.User
has, err := db.Instance.ID(identity.UserID).Where("disabled = 0").Get(&user)
if err != nil || !has || user.ID <= 0 {
return fail()
}
protocolPairAttempts.clear(pairKey)
protocolAccountAttempts.clear(accountKey)
return &user, nil
}
func newProtocolLoginLimiter(failureLimit int) *protocolLoginLimiter {
return &protocolLoginLimiter{
attempts: make(map[string]protocolLoginAttempt),
now: time.Now,
failureLimit: failureLimit,
}
}
func protocolRemoteHost(remoteAddress string) string {
remoteAddress = strings.TrimSpace(remoteAddress)
host, _, err := net.SplitHostPort(remoteAddress)
if err == nil && host != "" {
return strings.ToLower(host)
}
if remoteAddress == "" {
return "unknown"
}
return strings.ToLower(remoteAddress)
}
func hashedProtocolLoginKey(parts ...string) string {
hash := sha256.New()
for _, part := range parts {
_, _ = hash.Write([]byte(part))
_, _ = hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil))
}
func (limiter *protocolLoginLimiter) blocked(key string) bool {
now := limiter.now()
limiter.mu.Lock()
defer limiter.mu.Unlock()
limiter.cleanupLocked(now)
attempt, exists := limiter.attempts[key]
return exists && attempt.blockedUntil.After(now)
}
func (limiter *protocolLoginLimiter) recordFailure(key string) {
now := limiter.now()
limiter.mu.Lock()
defer limiter.mu.Unlock()
limiter.cleanupLocked(now)
attempt := limiter.attempts[key]
if attempt.windowStart.IsZero() || now.Sub(attempt.windowStart) > protocolLoginWindow {
attempt = protocolLoginAttempt{windowStart: now}
}
attempt.failures++
attempt.lastSeen = now
if attempt.failures >= limiter.failureLimit {
attempt.blockedUntil = now.Add(protocolLoginBlockTime)
}
if _, exists := limiter.attempts[key]; !exists && len(limiter.attempts) >= protocolLoginMaxEntries {
limiter.evictOldestLocked()
}
limiter.attempts[key] = attempt
}
func (limiter *protocolLoginLimiter) clear(key string) {
limiter.mu.Lock()
delete(limiter.attempts, key)
limiter.mu.Unlock()
}
func (limiter *protocolLoginLimiter) cleanupLocked(now time.Time) {
for key, attempt := range limiter.attempts {
windowExpired := !attempt.windowStart.IsZero() && now.Sub(attempt.windowStart) > protocolLoginWindow
blockExpired := attempt.blockedUntil.IsZero() || !attempt.blockedUntil.After(now)
if windowExpired && blockExpired {
delete(limiter.attempts, key)
}
}
}
func (limiter *protocolLoginLimiter) evictOldestLocked() {
oldestKey := ""
var oldestTime time.Time
for key, attempt := range limiter.attempts {
if oldestKey == "" || attempt.lastSeen.Before(oldestTime) {
oldestKey = key
oldestTime = attempt.lastSeen
}
}
if oldestKey != "" {
delete(limiter.attempts, oldestKey)
}
}