package utils import ( "errors" "sync" "time" ) // security policy configuration type SecurityPolicy struct { CaptchaThreshold int // number of failed attempts at which the captcha is required; less than 0 means disabled, 0 means always enabled BanThreshold int // number of failed attempts at which a ban is triggered; 0 means disabled AttemptsWindow time.Duration BanDuration time.Duration } // captcha provider interface type CaptchaProvider interface { Generate() (id string, content string, answer string, err error) //Validate(ip, code string) bool Expiration() time.Duration // captcha expiration time, should be less than AttemptsWindow Draw(content string) (string, error) // draw the captcha } // captcha metadata type CaptchaMeta struct { Id string Content string Answer string ExpiresAt time.Time } // IP ban record type BanRecord struct { ExpiresAt time.Time Reason string } // login limiter type LoginLimiter struct { mu sync.Mutex policy SecurityPolicy attempts map[string][]time.Time // captchas map[string]CaptchaMeta bannedIPs map[string]BanRecord provider CaptchaProvider cleanupStop chan struct{} } var defaultSecurityPolicy = SecurityPolicy{ CaptchaThreshold: 3, BanThreshold: 5, AttemptsWindow: 5 * time.Minute, BanDuration: 30 * time.Minute, } func NewLoginLimiter(policy SecurityPolicy) *LoginLimiter { // set default values if policy.AttemptsWindow == 0 { policy.AttemptsWindow = 5 * time.Minute } if policy.BanDuration == 0 { policy.BanDuration = 30 * time.Minute } ll := &LoginLimiter{ policy: policy, attempts: make(map[string][]time.Time), captchas: make(map[string]CaptchaMeta), bannedIPs: make(map[string]BanRecord), cleanupStop: make(chan struct{}), } go ll.cleanupRoutine() return ll } // register a captcha provider func (ll *LoginLimiter) RegisterProvider(p CaptchaProvider) { ll.mu.Lock() defer ll.mu.Unlock() ll.provider = p } // isDisabled checks whether the login limiter is disabled func (ll *LoginLimiter) isDisabled() bool { return ll.policy.CaptchaThreshold < 0 && ll.policy.BanThreshold == 0 } // record a failed login attempt func (ll *LoginLimiter) RecordFailedAttempt(ip string) { if ll.isDisabled() { return } ll.mu.Lock() defer ll.mu.Unlock() if banned, _ := ll.isBanned(ip); banned { return } now := time.Now() windowStart := now.Add(-ll.policy.AttemptsWindow) // clean up expired attempts validAttempts := ll.pruneAttempts(ip, windowStart) // record a new attempt validAttempts = append(validAttempts, now) ll.attempts[ip] = validAttempts // check ban conditions if ll.policy.BanThreshold > 0 && len(validAttempts) >= ll.policy.BanThreshold { ll.banIP(ip, "excessive failed attempts") return } return } // generate a captcha func (ll *LoginLimiter) RequireCaptcha() (error, CaptchaMeta) { ll.mu.Lock() defer ll.mu.Unlock() if ll.provider == nil { return errors.New("no captcha provider available"), CaptchaMeta{} } id, content, answer, err := ll.provider.Generate() if err != nil { return err, CaptchaMeta{} } // store the captcha ll.captchas[id] = CaptchaMeta{ Id: id, Content: content, Answer: answer, ExpiresAt: time.Now().Add(ll.provider.Expiration()), } return nil, ll.captchas[id] } // verify the captcha func (ll *LoginLimiter) VerifyCaptcha(id, answer string) bool { ll.mu.Lock() defer ll.mu.Unlock() // find a matching captcha if ll.provider == nil { return false } // get and verify the captcha captcha, exists := ll.captchas[id] if !exists { return false } // clean up expired captchas if time.Now().After(captcha.ExpiresAt) { delete(ll.captchas, id) return false } // verify and clean up state if answer == captcha.Answer { delete(ll.captchas, id) return true } return false } func (ll *LoginLimiter) DrawCaptcha(content string) (err error, str string) { str, err = ll.provider.Draw(content) return } // clear the record window func (ll *LoginLimiter) RemoveAttempts(ip string) { ll.mu.Lock() defer ll.mu.Unlock() _, exists := ll.attempts[ip] if exists { delete(ll.attempts, ip) } } // CheckSecurityStatus checks the security status func (ll *LoginLimiter) CheckSecurityStatus(ip string) (banned bool, captchaRequired bool) { if ll.isDisabled() { return } ll.mu.Lock() defer ll.mu.Unlock() // check ban status if banned, _ = ll.isBanned(ip); banned { return } // clean up expired data ll.pruneAttempts(ip, time.Now().Add(-ll.policy.AttemptsWindow)) // check captcha requirement captchaRequired = len(ll.attempts[ip]) >= ll.policy.CaptchaThreshold return } // background cleanup task func (ll *LoginLimiter) cleanupRoutine() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: ll.cleanupExpired() case <-ll.cleanupStop: return } } } // internal utility methods func (ll *LoginLimiter) isBanned(ip string) (bool, BanRecord) { record, exists := ll.bannedIPs[ip] if !exists { return false, BanRecord{} } if time.Now().After(record.ExpiresAt) { delete(ll.bannedIPs, ip) return false, BanRecord{} } return true, record } func (ll *LoginLimiter) banIP(ip, reason string) { ll.bannedIPs[ip] = BanRecord{ ExpiresAt: time.Now().Add(ll.policy.BanDuration), Reason: reason, } delete(ll.attempts, ip) delete(ll.captchas, ip) } func (ll *LoginLimiter) pruneAttempts(ip string, cutoff time.Time) []time.Time { var valid []time.Time for _, t := range ll.attempts[ip] { if t.After(cutoff) { valid = append(valid, t) } } if len(valid) == 0 { delete(ll.attempts, ip) } else { ll.attempts[ip] = valid } return valid } func (ll *LoginLimiter) pruneCaptchas(id string) { if captcha, exists := ll.captchas[id]; exists { if time.Now().After(captcha.ExpiresAt) { delete(ll.captchas, id) } } } func (ll *LoginLimiter) cleanupExpired() { ll.mu.Lock() defer ll.mu.Unlock() now := time.Now() // clean up ban records for ip, record := range ll.bannedIPs { if now.After(record.ExpiresAt) { delete(ll.bannedIPs, ip) } } // clean up attempt records for ip := range ll.attempts { ll.pruneAttempts(ip, now.Add(-ll.policy.AttemptsWindow)) } // clean up captchas for id := range ll.captchas { ll.pruneCaptchas(id) } }