fix confirmed deployment risks
This commit is contained in:
@@ -1,12 +1,23 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
restorePasswordMaxFailures = 5
|
||||
restorePasswordBlockWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
var backupRestoreAttemptLimiter = newRestoreAttemptLimiter(restorePasswordMaxFailures, restorePasswordBlockWindow)
|
||||
|
||||
type BackupHandler struct {
|
||||
backupService *service.BackupService
|
||||
userService *service.UserService
|
||||
@@ -165,6 +176,64 @@ type RestoreBackupRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type restoreAttemptState struct {
|
||||
failuresUntil time.Time
|
||||
failureCount int
|
||||
}
|
||||
|
||||
type restoreAttemptLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxFailures int
|
||||
window time.Duration
|
||||
states map[int64]restoreAttemptState
|
||||
}
|
||||
|
||||
func newRestoreAttemptLimiter(maxFailures int, window time.Duration) *restoreAttemptLimiter {
|
||||
return &restoreAttemptLimiter{
|
||||
maxFailures: maxFailures,
|
||||
window: window,
|
||||
states: make(map[int64]restoreAttemptState),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *restoreAttemptLimiter) allow(userID int64, now time.Time) (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
state, ok := l.states[userID]
|
||||
if !ok {
|
||||
return true, 0
|
||||
}
|
||||
if now.After(state.failuresUntil) {
|
||||
delete(l.states, userID)
|
||||
return true, 0
|
||||
}
|
||||
if state.failureCount < l.maxFailures {
|
||||
return true, 0
|
||||
}
|
||||
return false, state.failuresUntil.Sub(now)
|
||||
}
|
||||
|
||||
func (l *restoreAttemptLimiter) recordFailure(userID int64, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
state := l.states[userID]
|
||||
if now.After(state.failuresUntil) {
|
||||
state = restoreAttemptState{}
|
||||
}
|
||||
state.failureCount++
|
||||
state.failuresUntil = now.Add(l.window)
|
||||
l.states[userID] = state
|
||||
}
|
||||
|
||||
func (l *restoreAttemptLimiter) recordSuccess(userID int64) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
delete(l.states, userID)
|
||||
}
|
||||
|
||||
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
backupID := c.Param("id")
|
||||
if backupID == "" {
|
||||
@@ -185,6 +254,11 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if allowed, _ := backupRestoreAttemptLimiter.allow(sub.UserID, time.Now()); !allowed {
|
||||
response.ErrorFrom(c, infraerrors.TooManyRequests("RESTORE_PASSWORD_RATE_LIMITED", "too many failed password attempts, please try again later"))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取管理员用户并验证密码
|
||||
user, err := h.userService.GetByID(c.Request.Context(), sub.UserID)
|
||||
if err != nil {
|
||||
@@ -192,9 +266,11 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !user.CheckPassword(req.Password) {
|
||||
backupRestoreAttemptLimiter.recordFailure(sub.UserID, time.Now())
|
||||
response.BadRequest(c, "incorrect admin password")
|
||||
return
|
||||
}
|
||||
backupRestoreAttemptLimiter.recordSuccess(sub.UserID)
|
||||
|
||||
record, err := h.backupService.StartRestore(c.Request.Context(), backupID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRestoreAttemptLimiterBlocksAfterMaxFailures(t *testing.T) {
|
||||
limiter := newRestoreAttemptLimiter(2, time.Minute)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
if limited, _ := limiter.allow(1, now); !limited {
|
||||
t.Fatalf("first attempt should be allowed")
|
||||
}
|
||||
|
||||
limiter.recordFailure(1, now)
|
||||
|
||||
if limited, _ := limiter.allow(1, now.Add(time.Second)); !limited {
|
||||
t.Fatalf("second attempt should still be allowed")
|
||||
}
|
||||
|
||||
limiter.recordFailure(1, now.Add(2*time.Second))
|
||||
|
||||
allowed, retryAfter := limiter.allow(1, now.Add(3*time.Second))
|
||||
if allowed {
|
||||
t.Fatal("limiter should block after hitting max failures")
|
||||
}
|
||||
if retryAfter <= 0 {
|
||||
t.Fatalf("retryAfter should be positive, got %v", retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAttemptLimiterResetsAfterSuccess(t *testing.T) {
|
||||
limiter := newRestoreAttemptLimiter(2, time.Minute)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
limiter.recordFailure(1, now)
|
||||
limiter.recordSuccess(1)
|
||||
|
||||
allowed, retryAfter := limiter.allow(1, now.Add(time.Second))
|
||||
if !allowed {
|
||||
t.Fatalf("limiter should reset after success, retryAfter=%v", retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAttemptLimiterExpiresBlockWindow(t *testing.T) {
|
||||
limiter := newRestoreAttemptLimiter(1, time.Minute)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
limiter.recordFailure(1, now)
|
||||
|
||||
allowed, _ := limiter.allow(1, now.Add(61*time.Second))
|
||||
if !allowed {
|
||||
t.Fatal("limiter should allow attempts after block window expires")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user