OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress, ingress key governance, monitoring, and security controls. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func deriveKey(secret string) []byte {
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func Encrypt(plaintext, secret string) (string, error) {
|
||||
key := deriveKey(secret)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func Decrypt(encoded, secret string) (string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := deriveKey(secret)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
|
||||
func GenerateToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package loginlimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter tracks failed admin login attempts per client IP.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*record
|
||||
maxAttempts int
|
||||
lockout time.Duration
|
||||
}
|
||||
|
||||
type record struct {
|
||||
failures int
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
func New(maxAttempts int, lockout time.Duration) *Limiter {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 5
|
||||
}
|
||||
if lockout <= 0 {
|
||||
lockout = 15 * time.Minute
|
||||
}
|
||||
return &Limiter{
|
||||
attempts: make(map[string]*record),
|
||||
maxAttempts: maxAttempts,
|
||||
lockout: lockout,
|
||||
}
|
||||
}
|
||||
|
||||
// Check reports whether the IP is locked and how long to wait.
|
||||
func (l *Limiter) Check(ip string) (locked bool, retryAfter time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
r := l.attempts[ip]
|
||||
if r == nil {
|
||||
return false, 0
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Before(r.lockedUntil) {
|
||||
return true, r.lockedUntil.Sub(now)
|
||||
}
|
||||
if r.failures >= l.maxAttempts {
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure count and locks the IP when the limit is reached.
|
||||
func (l *Limiter) RecordFailure(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
r := l.attempts[ip]
|
||||
if r == nil {
|
||||
r = &record{}
|
||||
l.attempts[ip] = r
|
||||
}
|
||||
if now.Before(r.lockedUntil) {
|
||||
return
|
||||
}
|
||||
if r.failures >= l.maxAttempts && !r.lockedUntil.IsZero() && now.After(r.lockedUntil) {
|
||||
r.failures = 0
|
||||
r.lockedUntil = time.Time{}
|
||||
}
|
||||
r.failures++
|
||||
if r.failures >= l.maxAttempts {
|
||||
r.lockedUntil = now.Add(l.lockout)
|
||||
}
|
||||
}
|
||||
|
||||
// Configure updates rate limit parameters at runtime.
|
||||
func (l *Limiter) Configure(maxAttempts int, lockout time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if maxAttempts > 0 {
|
||||
l.maxAttempts = maxAttempts
|
||||
}
|
||||
if lockout > 0 {
|
||||
l.lockout = lockout
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears failure state after a successful login.
|
||||
func (l *Limiter) Reset(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, ip)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package loginlimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterLockout(t *testing.T) {
|
||||
l := New(3, time.Minute)
|
||||
ip := "192.168.1.1"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if locked, _ := l.Check(ip); locked {
|
||||
t.Fatalf("unexpected lock at attempt %d", i)
|
||||
}
|
||||
l.RecordFailure(ip)
|
||||
}
|
||||
|
||||
if locked, _ := l.Check(ip); locked {
|
||||
t.Fatal("should not lock before max attempts")
|
||||
}
|
||||
l.RecordFailure(ip)
|
||||
|
||||
locked, retry := l.Check(ip)
|
||||
if !locked {
|
||||
t.Fatal("expected lock after max failures")
|
||||
}
|
||||
if retry <= 0 {
|
||||
t.Fatal("expected positive retry duration")
|
||||
}
|
||||
|
||||
l.Reset(ip)
|
||||
if locked, _ := l.Check(ip); locked {
|
||||
t.Fatal("expected unlock after reset")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// HashIngressKey stores a SHA-256 hex digest for O(1) cache lookup.
|
||||
func HashIngressKey(key string) string {
|
||||
return HashToken(key)
|
||||
}
|
||||
|
||||
func IsLegacyIngressHash(hash string) bool {
|
||||
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$")
|
||||
}
|
||||
|
||||
// CheckIngressKey verifies legacy bcrypt-hashed ingress keys.
|
||||
func CheckIngressKey(hash, key string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)) == nil
|
||||
}
|
||||
Reference in New Issue
Block a user