c26b06f1cc
Go 控制面(SQLite + REST API)嵌入 Mihomo 数据面,Vue 3 多语言 Web 管理台。 含订阅/节点/规则/出站/监控/日志、OpenAPI、API Key、Gitea Actions CI 与开源文档; CI 使用 ubuntu-latest runner,Go/Docker 步骤走国内镜像与 shell,避免 GitHub 下载超时。 Co-authored-by: Cursor <cursoragent@cursor.com>
138 lines
3.3 KiB
Go
138 lines
3.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/prism/proxy/internal/store"
|
|
)
|
|
|
|
const (
|
|
defaultMaxAttempts = 5
|
|
defaultLockMinutes = 15
|
|
)
|
|
|
|
type LoginConfig struct {
|
|
MaxAttempts int
|
|
Window time.Duration
|
|
LockDuration time.Duration
|
|
}
|
|
|
|
type LoginResult struct {
|
|
Allowed bool
|
|
Locked bool
|
|
Remaining int
|
|
RetryAfter time.Duration
|
|
}
|
|
|
|
type LoginLimiter struct {
|
|
store *store.Store
|
|
}
|
|
|
|
func NewLoginLimiter(st *store.Store) *LoginLimiter {
|
|
return &LoginLimiter{store: st}
|
|
}
|
|
|
|
func ParseLoginConfig(settings map[string]string) LoginConfig {
|
|
maxAttempts := parsePositiveInt(settings["login_max_attempts"], defaultMaxAttempts)
|
|
lockMin := parsePositiveInt(settings["login_lock_minutes"], defaultLockMinutes)
|
|
lock := time.Duration(lockMin) * time.Minute
|
|
return LoginConfig{
|
|
MaxAttempts: maxAttempts,
|
|
Window: lock,
|
|
LockDuration: lock,
|
|
}
|
|
}
|
|
|
|
func (l *LoginLimiter) Check(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
|
|
attempt, err := l.store.GetLoginAttempt(ctx, ip)
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
if attempt == nil {
|
|
return LoginResult{Allowed: true, Remaining: cfg.MaxAttempts}, nil
|
|
}
|
|
now := time.Now()
|
|
if attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
|
|
return LoginResult{
|
|
Allowed: false,
|
|
Locked: true,
|
|
RetryAfter: attempt.LockedUntil.Sub(now),
|
|
}, nil
|
|
}
|
|
remaining := cfg.MaxAttempts - attempt.FailCount
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
if attempt.FailCount > 0 && now.Sub(attempt.WindowStart) > cfg.Window {
|
|
remaining = cfg.MaxAttempts
|
|
}
|
|
return LoginResult{Allowed: true, Remaining: remaining}, nil
|
|
}
|
|
|
|
func (l *LoginLimiter) RecordFailure(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
|
|
now := time.Now()
|
|
attempt, err := l.store.GetLoginAttempt(ctx, ip)
|
|
if err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
if attempt != nil && attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
|
|
return LoginResult{
|
|
Allowed: false,
|
|
Locked: true,
|
|
RetryAfter: attempt.LockedUntil.Sub(now),
|
|
}, nil
|
|
}
|
|
|
|
failCount := 1
|
|
windowStart := now
|
|
if attempt != nil {
|
|
if attempt.LockedUntil != nil || now.Sub(attempt.WindowStart) <= cfg.Window {
|
|
if now.Sub(attempt.WindowStart) <= cfg.Window {
|
|
failCount = attempt.FailCount + 1
|
|
windowStart = attempt.WindowStart
|
|
}
|
|
}
|
|
}
|
|
|
|
var lockedUntil *time.Time
|
|
locked := false
|
|
if failCount >= cfg.MaxAttempts {
|
|
t := now.Add(cfg.LockDuration)
|
|
lockedUntil = &t
|
|
locked = true
|
|
_ = l.store.AddAuditLog(ctx, "login_lock", "auth", fmt.Sprintf("ip=%s locked %v", ip, cfg.LockDuration))
|
|
}
|
|
if err := l.store.SetLoginAttempt(ctx, ip, store.LoginAttempt{
|
|
FailCount: failCount,
|
|
WindowStart: windowStart,
|
|
LockedUntil: lockedUntil,
|
|
}); err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
|
|
remaining := cfg.MaxAttempts - failCount
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
res := LoginResult{Allowed: !locked, Remaining: remaining, Locked: locked}
|
|
if locked && lockedUntil != nil {
|
|
res.RetryAfter = lockedUntil.Sub(now)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (l *LoginLimiter) RecordSuccess(ctx context.Context, ip string) error {
|
|
return l.store.ClearLoginAttempt(ctx, ip)
|
|
}
|
|
|
|
func parsePositiveInt(raw string, fallback int) int {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|