- Migration 082: api_keys table (user_id, name, prefix, hashed_key, scopes, last_used_at, expires_at) - APIKey model, APIKeyService (Create, List, Delete, ValidateAPIKey) - APIKeyHandler: GET/POST/DELETE /api/v1/developer/api-keys - AuthMiddleware: X-API-Key and Bearer vza_* accepted as alternative to JWT - CSRF: skip for API key auth (stateless) - Key format: vza_ prefix, SHA-256 hashed storage
204 lines
5.3 KiB
Go
204 lines
5.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const apiKeyPrefix = "vza_"
|
|
const apiKeyPrefixLen = 8 // Display prefix length
|
|
|
|
// APIKeyService manages user API keys for developer portal
|
|
type APIKeyService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewAPIKeyService creates a new APIKeyService
|
|
func NewAPIKeyService(db *gorm.DB, logger *zap.Logger) *APIKeyService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &APIKeyService{db: db, logger: logger}
|
|
}
|
|
|
|
// CreateAPIKeyRequest represents the request to create an API key
|
|
type CreateAPIKeyRequest struct {
|
|
Name string `json:"name" binding:"required,max=100"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
// APIKeyResponse represents an API key in API responses (no full key)
|
|
type APIKeyResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Prefix string `json:"prefix"`
|
|
Scopes []string `json:"scopes"`
|
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// CreateAPIKeyResponse includes the full key only on creation (show once)
|
|
type CreateAPIKeyResponse struct {
|
|
APIKeyResponse
|
|
Key string `json:"key"` // Full key, only returned on create
|
|
}
|
|
|
|
func hashKey(key string) string {
|
|
h := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// GenerateKey generates a new API key with vza_ prefix
|
|
func (s *APIKeyService) GenerateKey() (fullKey, prefix string, err error) {
|
|
bytes := make([]byte, 24)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
}
|
|
secret := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes)
|
|
fullKey = apiKeyPrefix + secret
|
|
prefix = fullKey
|
|
if len(prefix) > apiKeyPrefixLen {
|
|
prefix = prefix[:apiKeyPrefixLen] + "..."
|
|
}
|
|
return fullKey, prefix, nil
|
|
}
|
|
|
|
// Create creates a new API key for the user
|
|
func (s *APIKeyService) Create(ctx context.Context, userID uuid.UUID, req *CreateAPIKeyRequest) (*CreateAPIKeyResponse, error) {
|
|
fullKey, prefix, err := s.GenerateKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
scopes := req.Scopes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{"read"}
|
|
}
|
|
scopes = normalizeScopes(scopes)
|
|
|
|
key := &models.APIKey{
|
|
UserID: userID,
|
|
Name: strings.TrimSpace(req.Name),
|
|
Prefix: prefix,
|
|
HashedKey: hashKey(fullKey),
|
|
Scopes: scopes,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create API key: %w", err)
|
|
}
|
|
|
|
s.logger.Info("API key created",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("name", key.Name),
|
|
zap.String("prefix", prefix))
|
|
|
|
return &CreateAPIKeyResponse{
|
|
APIKeyResponse: toResponse(key),
|
|
Key: fullKey,
|
|
}, nil
|
|
}
|
|
|
|
// List returns all API keys for a user
|
|
func (s *APIKeyService) List(ctx context.Context, userID uuid.UUID) ([]APIKeyResponse, error) {
|
|
var keys []models.APIKey
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&keys).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
res := make([]APIKeyResponse, len(keys))
|
|
for i := range keys {
|
|
res[i] = toResponse(&keys[i])
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// Delete removes an API key
|
|
func (s *APIKeyService) Delete(ctx context.Context, userID, keyID uuid.UUID) error {
|
|
result := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", keyID, userID).Delete(&models.APIKey{})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAPIKey validates an API key and returns the key record (for middleware)
|
|
func (s *APIKeyService) ValidateAPIKey(ctx context.Context, rawKey string) (*models.APIKey, error) {
|
|
if !strings.HasPrefix(rawKey, apiKeyPrefix) {
|
|
return nil, fmt.Errorf("invalid API key format")
|
|
}
|
|
hashed := hashKey(rawKey)
|
|
|
|
var key models.APIKey
|
|
if err := s.db.WithContext(ctx).Where("hashed_key = ?", hashed).First(&key).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("invalid API key")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if key.ExpiresAt != nil && key.ExpiresAt.Before(time.Now()) {
|
|
return nil, fmt.Errorf("API key expired")
|
|
}
|
|
|
|
// Update last_used_at (fire-and-forget, don't block)
|
|
now := time.Now()
|
|
go func() {
|
|
_ = s.db.WithContext(context.Background()).Model(&models.APIKey{}).
|
|
Where("id = ?", key.ID).Update("last_used_at", now).Error
|
|
}()
|
|
|
|
return &key, nil
|
|
}
|
|
|
|
// HasScope checks if the key has the required scope
|
|
func (s *APIKeyService) HasScope(key *models.APIKey, required string) bool {
|
|
for _, s := range key.Scopes {
|
|
if s == required || s == "admin" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeScopes(scopes []string) []string {
|
|
seen := make(map[string]bool)
|
|
var out []string
|
|
for _, s := range scopes {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s != "" && !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toResponse(k *models.APIKey) APIKeyResponse {
|
|
return APIKeyResponse{
|
|
ID: k.ID.String(),
|
|
Name: k.Name,
|
|
Prefix: k.Prefix,
|
|
Scopes: k.Scopes,
|
|
LastUsedAt: k.LastUsedAt,
|
|
ExpiresAt: k.ExpiresAt,
|
|
CreatedAt: k.CreatedAt,
|
|
}
|
|
}
|