Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const APIKeyPrefix = "prism_"
|
||||
|
||||
func GenerateAPIKey() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return APIKeyPrefix + hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func HashAPIKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func APIKeyDisplayPrefix(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if len(key) <= 12 {
|
||||
return key
|
||||
}
|
||||
return key[:12] + "…"
|
||||
}
|
||||
|
||||
func IsAPIKeyFormat(key string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(key), APIKeyPrefix)
|
||||
}
|
||||
|
||||
func ValidateAPIKeyFormat(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if !strings.HasPrefix(key, APIKeyPrefix) {
|
||||
return fmt.Errorf("invalid api key format")
|
||||
}
|
||||
raw := strings.TrimPrefix(key, APIKeyPrefix)
|
||||
if len(raw) != 32 {
|
||||
return fmt.Errorf("invalid api key length")
|
||||
}
|
||||
if _, err := hex.DecodeString(raw); err != nil {
|
||||
return fmt.Errorf("invalid api key encoding")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGenerateAndHashAPIKey(t *testing.T) {
|
||||
key, err := GenerateAPIKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateAPIKeyFormat(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if HashAPIKey(key) == "" {
|
||||
t.Fatal("empty hash")
|
||||
}
|
||||
if HashAPIKey(key) != HashAPIKey(key) {
|
||||
t.Fatal("hash not stable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prism/proxy/internal/store"
|
||||
)
|
||||
|
||||
func TestLoginLimiterLockAndClear(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "data"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
limiter := NewLoginLimiter(st)
|
||||
cfg := LoginConfig{MaxAttempts: 3, Window: time.Minute, LockDuration: 2 * time.Minute}
|
||||
ip := "203.0.113.1"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
res, err := limiter.RecordFailure(ctx, ip, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Locked {
|
||||
t.Fatalf("unexpected lock at attempt %d", i+1)
|
||||
}
|
||||
if res.Remaining != cfg.MaxAttempts-i-1 {
|
||||
t.Fatalf("remaining=%d want %d", res.Remaining, cfg.MaxAttempts-i-1)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := limiter.RecordFailure(ctx, ip, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Locked || res.RetryAfter <= 0 {
|
||||
t.Fatalf("expected lock, got %+v", res)
|
||||
}
|
||||
|
||||
check, err := limiter.Check(ctx, ip, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if check.Allowed {
|
||||
t.Fatal("expected blocked while locked")
|
||||
}
|
||||
|
||||
if err := limiter.RecordSuccess(ctx, ip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check, err = limiter.Check(ctx, ip, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !check.Allowed || check.Remaining != cfg.MaxAttempts {
|
||||
t.Fatalf("expected reset, got %+v", check)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user