package server import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "github.com/gin-gonic/gin" "math" "strconv" "strings" "time" ) // Count attempts before password hashing. Shared storage keeps the budget across // API replicas and restarts; neither passwords nor cleartext account/IP keys persist. func (s *Server) allowLoginAttempt(c *gin.Context, username string) bool { tx, err := s.DB.BeginTxx(c.Request.Context(), nil) if err != nil { s.dbError(c, err) return false } defer tx.Rollback() retry := 0 for _, limit := range []struct { key string max int }{{"account:" + strings.ToLower(strings.TrimSpace(username)), 10}, {"ip:" + c.ClientIP(), 30}} { mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret)) mac.Write([]byte("login-limit:" + limit.key)) key := hex.EncodeToString(mac.Sum(nil)) var row struct { Attempts int `db:"attempts"` Started time.Time `db:"window_started"` } err = tx.GetContext(c.Request.Context(), &row, `INSERT INTO login_limits(key,attempts) VALUES($1,1) ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN login_limits.window_started limit.max { seconds := int(math.Ceil(time.Until(row.Started.Add(5 * time.Minute)).Seconds())) if seconds > retry { retry = seconds } } } if err = tx.Commit(); err != nil { s.dbError(c, err) return false } if retry > 0 { c.Header("Retry-After", strconv.Itoa(retry)) s.fail(c, 429, "login_rate_limited", "登录尝试过于频繁,请在 "+strconv.Itoa(retry)+" 秒后重试") return false } return true }