Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
136 lines
3.2 KiB
Go
136 lines
3.2 KiB
Go
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
|
|
}
|