vdi-cloud/platform/internal/server/login_limits.go

60 lines
1.9 KiB
Go

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<now()-interval '5 minutes' THEN 1 ELSE least(login_limits.attempts+1,$2+1) END,
window_started=CASE WHEN login_limits.window_started<now()-interval '5 minutes' THEN now() ELSE login_limits.window_started END RETURNING attempts,window_started`, key, limit.max)
if err != nil {
s.dbError(c, err)
return false
}
if row.Attempts > 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
}