60 lines
1.9 KiB
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
|
|
}
|