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:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent d03ce79c19
commit d90e25d1ff
5 changed files with 139 additions and 28 deletions
+65
View File
@@ -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)