fix: auth/2FA — password change requires 2FA gate, admin self-deletion blocked, twofa JSON responses, per-IP email-verify budget, OptionalAuth log sanitised
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -155,8 +155,60 @@ type emailVerifyAttempt struct {
|
||||
var (
|
||||
emailVerifyMu sync.Mutex
|
||||
emailVerifyAttempts = make(map[string]emailVerifyAttempt)
|
||||
ipVerifyMu sync.Mutex
|
||||
ipVerifyAttempts = make(map[string]emailVerifyAttempt)
|
||||
)
|
||||
|
||||
// ipAttemptsExhausted reports whether the IP's attempt budget is already
|
||||
// spent, rejecting the request before any DB work. Uses the same window as
|
||||
// email-verify tracking with a higher cap (50 per IP per window) as a
|
||||
// secondary per-IP fallback budget.
|
||||
func ipAttemptsExhausted(ip string) bool {
|
||||
ipVerifyMu.Lock()
|
||||
defer ipVerifyMu.Unlock()
|
||||
evictStaleIPVerifyAttemptsLocked()
|
||||
a, ok := ipVerifyAttempts[ip]
|
||||
return ok && a.count >= 50
|
||||
}
|
||||
|
||||
// ipAttemptFailed registers one failed attempt for the IP and reports whether
|
||||
// the budget for that IP is now exhausted.
|
||||
func ipAttemptFailed(ip string) bool {
|
||||
ipVerifyMu.Lock()
|
||||
defer ipVerifyMu.Unlock()
|
||||
evictStaleIPVerifyAttemptsLocked()
|
||||
now := clock.Now()
|
||||
a := ipVerifyAttempts[ip]
|
||||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||||
a.count = 0
|
||||
}
|
||||
a.count++
|
||||
a.lastAt = now
|
||||
ipVerifyAttempts[ip] = a
|
||||
return a.count >= 50
|
||||
}
|
||||
|
||||
// ipAttemptsClear drops the budget for an IP after a successful verify.
|
||||
func ipAttemptsClear(ip string) {
|
||||
ipVerifyMu.Lock()
|
||||
delete(ipVerifyAttempts, ip)
|
||||
ipVerifyMu.Unlock()
|
||||
}
|
||||
|
||||
// evictStaleIPVerifyAttemptsLocked bounds the IP attempts map. Caller must
|
||||
// hold ipVerifyMu.
|
||||
func evictStaleIPVerifyAttemptsLocked() {
|
||||
if len(ipVerifyAttempts) < emailVerifyMaxTrackedCodes {
|
||||
return
|
||||
}
|
||||
now := clock.Now()
|
||||
for k, a := range ipVerifyAttempts {
|
||||
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
|
||||
delete(ipVerifyAttempts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emailVerifyAttemptsExhausted reports whether the key's (a user id, or a
|
||||
// submitted code with no resolvable user) attempt budget is already spent,
|
||||
// rejecting the request before any DB work.
|
||||
@@ -1020,6 +1072,16 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Per-IP fallback budget: when no user resolves, the attempt budget is
|
||||
// keyed on the submitted CODE value — rotating codes gives unlimited
|
||||
// guesses. The per-IP check prevents a single source from exhausting the
|
||||
// endpoint regardless of code rotation.
|
||||
ip := mw.ClientIP(r)
|
||||
if ipAttemptsExhausted(ip) {
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
codeDigest := twofa.Hash(code)
|
||||
|
||||
var userID string
|
||||
@@ -1036,6 +1098,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// No row with this digest at all — a guess. No user can be
|
||||
// resolved, so the attempt budget stays keyed per submitted code.
|
||||
ipAttemptFailed(ip)
|
||||
if emailVerifyAttemptFailed(code) {
|
||||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -1063,6 +1126,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Code exists but expired — count it against the user's budget too.
|
||||
if !expiresAt.After(clock.Now()) {
|
||||
ipAttemptFailed(ip)
|
||||
if emailVerifyAttemptFailed(userID) {
|
||||
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -1132,6 +1196,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// key the submitted code used on earlier guesses).
|
||||
emailVerifyAttemptsClear(userID)
|
||||
emailVerifyAttemptsClear(code)
|
||||
ipAttemptsClear(ip)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user