Files
notifier/internal/auth/auth.go
T
igodwin 298c960808 Fix 8 high-severity audit findings across security, Go, API, and container domains
- Use typed context key for auth context to prevent collisions (auth.go)
- Eliminate nested locking in CheckRateLimit to prevent potential deadlock (auth.go)
- Add 1MB request body size limit middleware to prevent DoS (router.go)
- Return proper gRPC status codes instead of nil errors on failures (handler.go)
- Use key name instead of raw API key in admin URL paths to prevent secret leakage (keys.go, router.go, keystore_db.go, keystore_hybrid.go)
- Enforce RBAC authorization in service Send/SendBatch for both REST and gRPC (service.go)
- Pin runtime Docker image to alpine:3.21 for reproducible builds (Dockerfile)
- Enable readOnlyRootFilesystem with /tmp emptyDir in k8s deployment (deployment.yaml)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 20:17:51 -07:00

227 lines
5.1 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
)
// APIKeyStore manages API keys with rate limiting
type APIKeyStore struct {
mu sync.RWMutex
keys map[string]*APIKey
rateLimits map[string]*RateLimiter
}
// APIKey represents an API key with metadata
type APIKey struct {
Key string `json:"key"`
Name string `json:"name"`
ClientID string `json:"client_id"`
Roles []string `json:"roles"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
RateLimit int `json:"rate_limit"` // requests per minute, 0 = unlimited
}
// RateLimiter tracks rate limiting for a key
type RateLimiter struct {
maxRequests int
window time.Duration
resetTime time.Time
count int
mu sync.Mutex
}
// AuthContext holds auth information attached to request context
type AuthContext struct {
APIKey *APIKey
ClientID string
Roles []string
}
// NewAPIKeyStore creates a new API key store
func NewAPIKeyStore() *APIKeyStore {
return &APIKeyStore{
keys: make(map[string]*APIKey),
rateLimits: make(map[string]*RateLimiter),
}
}
// CreateKey generates a new API key
func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Generate random key
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
key := "nk_" + hex.EncodeToString(keyBytes)
now := time.Now().UTC()
apiKey := &APIKey{
Key: key,
ClientID: clientID,
Roles: roles,
CreatedAt: now,
IsActive: true,
RateLimit: rateLimit,
Name: fmt.Sprintf("%s-%d", clientID, now.Unix()),
}
if expiresIn != nil {
expiresAt := now.Add(*expiresIn)
apiKey.ExpiresAt = &expiresAt
}
s.keys[key] = apiKey
s.rateLimits[key] = &RateLimiter{
maxRequests: rateLimit,
window: time.Minute,
resetTime: time.Now().Add(time.Minute),
count: 0,
}
return apiKey, nil
}
// ValidateKey checks if an API key is valid and returns the key metadata
func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
key, exists := s.keys[keyStr]
if !exists {
return nil, fmt.Errorf("invalid API key")
}
if !key.IsActive {
return nil, fmt.Errorf("API key is inactive")
}
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
return nil, fmt.Errorf("API key has expired")
}
return key, nil
}
// CheckRateLimit checks if a key has exceeded its rate limit
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
// Look up key and limiter under the store lock, then release it
// before acquiring the per-key limiter lock to avoid nested locking.
s.mu.RLock()
key, exists := s.keys[keyStr]
if !exists {
s.mu.RUnlock()
return false, fmt.Errorf("invalid API key")
}
// Unlimited rate limit
if key.RateLimit <= 0 {
s.mu.RUnlock()
return true, nil
}
limiter, exists := s.rateLimits[keyStr]
if !exists {
s.mu.RUnlock()
return false, fmt.Errorf("rate limiter not found")
}
s.mu.RUnlock()
// Now lock only the per-key rate limiter
limiter.mu.Lock()
defer limiter.mu.Unlock()
now := time.Now()
if now.After(limiter.resetTime) {
limiter.count = 0
limiter.resetTime = now.Add(limiter.window)
}
if limiter.count >= limiter.maxRequests {
return false, nil
}
limiter.count++
return true, nil
}
// UpdateLastUsed updates the last used timestamp for a key
func (s *APIKeyStore) UpdateLastUsed(keyStr string) error {
s.mu.Lock()
defer s.mu.Unlock()
key, exists := s.keys[keyStr]
if !exists {
return fmt.Errorf("invalid API key")
}
now := time.Now().UTC()
key.LastUsedAt = &now
return nil
}
// DeactivateKey deactivates an API key
func (s *APIKeyStore) DeactivateKey(keyStr string) error {
s.mu.Lock()
defer s.mu.Unlock()
key, exists := s.keys[keyStr]
if !exists {
return fmt.Errorf("invalid API key")
}
key.IsActive = false
return nil
}
// GetKey retrieves key metadata (for management purposes)
func (s *APIKeyStore) GetKey(keyStr string) (*APIKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
key, exists := s.keys[keyStr]
if !exists {
return nil, fmt.Errorf("key not found")
}
return key, nil
}
// ListKeys lists all API keys for a client
func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
s.mu.RLock()
defer s.mu.RUnlock()
var keys []*APIKey
for _, key := range s.keys {
if key.ClientID == clientID {
keys = append(keys, key)
}
}
return keys
}
// authContextKey is an unexported type for context keys to avoid collisions.
type authContextKey struct{}
// ContextWithAuth adds auth context to a request context
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.Context {
return context.WithValue(ctx, authContextKey{}, auth)
}
// GetAuthContext retrieves auth context from a request context
func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
auth, ok := ctx.Value(authContextKey{}).(*AuthContext)
return auth, ok
}