veza/veza-backend-api/internal/services/api_key_service.go
senke 32348bebce feat(developer): add API keys backend (Lot C)
- 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
2026-02-20 00:18:36 +01:00

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,
}
}