Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Email-keyed login lockout (in-process, per API replica).
|
||||
//
|
||||
// Complements IP RateLimitAuth: rotating IPs still hit the same email budget.
|
||||
// ASSUMPTION (Product 10): a single API instance (or acknowledged per-replica
|
||||
// memory) is acceptable — same posture as HTTP rate limiters in ratelimit.go.
|
||||
// RATE_LIMIT_REPLICAS does not divide this lockout; multi-replica hard caps need edge/WAF.
|
||||
// Captcha is deferred; lockout + IP RPM are the primary login abuse controls.
|
||||
|
||||
const (
|
||||
loginLockoutMaxFails = 5
|
||||
loginLockoutDuration = 15 * time.Minute
|
||||
)
|
||||
|
||||
type loginLockState struct {
|
||||
fails int
|
||||
windowStart time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
// loginAttemptLockout tracks failed password attempts by normalized email.
|
||||
type loginAttemptLockout struct {
|
||||
mu sync.Mutex
|
||||
maxFails int
|
||||
lockFor time.Duration
|
||||
state map[string]*loginLockState
|
||||
}
|
||||
|
||||
func newLoginAttemptLockout(maxFails int, lockFor time.Duration) *loginAttemptLockout {
|
||||
if maxFails < 1 {
|
||||
maxFails = loginLockoutMaxFails
|
||||
}
|
||||
if lockFor <= 0 {
|
||||
lockFor = loginLockoutDuration
|
||||
}
|
||||
return &loginAttemptLockout{
|
||||
maxFails: maxFails,
|
||||
lockFor: lockFor,
|
||||
state: make(map[string]*loginLockState),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLoginEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// locked reports whether email is currently locked and Retry-After seconds.
|
||||
func (l *loginAttemptLockout) locked(email string) (bool, int) {
|
||||
key := normalizeLoginEmail(email)
|
||||
if key == "" || l == nil {
|
||||
return false, 0
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.state[key]
|
||||
if st == nil {
|
||||
return false, 0
|
||||
}
|
||||
if st.lockedUntil.After(now) {
|
||||
sec := int(st.lockedUntil.Sub(now).Seconds()) + 1
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return true, sec
|
||||
}
|
||||
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
|
||||
// Lock expired — reset failure window.
|
||||
delete(l.state, key)
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// recordFailure increments the failure count for email; locks after maxFails
|
||||
// within the lock window. No-ops for empty email.
|
||||
func (l *loginAttemptLockout) recordFailure(email string) {
|
||||
key := normalizeLoginEmail(email)
|
||||
if key == "" || l == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.state[key]
|
||||
if st == nil {
|
||||
st = &loginLockState{windowStart: now}
|
||||
l.state[key] = st
|
||||
}
|
||||
if st.lockedUntil.After(now) {
|
||||
return
|
||||
}
|
||||
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
|
||||
st.fails = 0
|
||||
st.windowStart = now
|
||||
st.lockedUntil = time.Time{}
|
||||
}
|
||||
if now.Sub(st.windowStart) > l.lockFor {
|
||||
st.fails = 0
|
||||
st.windowStart = now
|
||||
}
|
||||
st.fails++
|
||||
if st.fails >= l.maxFails {
|
||||
st.lockedUntil = now.Add(l.lockFor)
|
||||
st.fails = 0
|
||||
st.windowStart = now
|
||||
}
|
||||
}
|
||||
|
||||
// clear resets failures and lock for email (successful login).
|
||||
func (l *loginAttemptLockout) clear(email string) {
|
||||
key := normalizeLoginEmail(email)
|
||||
if key == "" || l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.state, key)
|
||||
}
|
||||
|
||||
func (s *Server) loginAttempts() *loginAttemptLockout {
|
||||
if s == nil {
|
||||
return newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
|
||||
}
|
||||
s.loginLockoutOnce.Do(func() {
|
||||
s.loginLockout = newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
|
||||
})
|
||||
return s.loginLockout
|
||||
}
|
||||
Reference in New Issue
Block a user