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
This commit is contained in:
senke 2026-02-20 00:18:36 +01:00
parent 93361bf89f
commit 32348bebce
15 changed files with 439 additions and 7 deletions

View file

@ -296,6 +296,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// Queue Routes
r.setupQueueRoutes(v1)
// Developer Portal (API Keys)
r.setupDeveloperRoutes(v1)
// Live Streams Routes
r.setupLiveRoutes(v1)

View file

@ -0,0 +1,27 @@
package api
import (
"github.com/gin-gonic/gin"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
)
// setupDeveloperRoutes configures developer portal routes (API keys)
func (r *APIRouter) setupDeveloperRoutes(router *gin.RouterGroup) {
if r.config == nil || r.config.AuthMiddleware == nil {
return
}
apiKeyService := services.NewAPIKeyService(r.db.GormDB, r.logger)
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyService, r.logger)
developer := router.Group("/developer")
developer.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(developer)
{
apiKeys := developer.Group("/api-keys")
apiKeys.GET("", apiKeyHandler.ListAPIKeys)
apiKeys.POST("", apiKeyHandler.CreateAPIKey)
apiKeys.DELETE("/:id", apiKeyHandler.DeleteAPIKey)
}
}

View file

@ -39,6 +39,7 @@ type Config struct {
JWTService *services.JWTService
UserService *services.UserService
S3StorageService *services.S3StorageService // BE-SVC-005: S3 storage service
APIKeyService *services.APIKeyService // v0.102 Lot C: developer API keys
// Middlewares
RateLimiter *middleware.RateLimiter

View file

@ -56,13 +56,14 @@ func (c *Config) initMiddlewares() error {
}
c.UserRateLimiter = middleware.NewUserRateLimiter(userRateLimiterConfig)
// Middleware d'authentification
// Middleware d'authentification (supports JWT and X-API-Key for developer keys)
c.AuthMiddleware = middleware.NewAuthMiddleware(
c.SessionService,
c.AuditService,
c.PermissionService,
c.JWTService,
c.UserService,
c.APIKeyService,
c.Logger,
)

View file

@ -74,5 +74,8 @@ func (c *Config) initServices() error {
c.PlaylistService.SetCacheService(c.CacheService)
}
// API Key Service (v0.102 Lot C - developer portal)
c.APIKeyService = services.NewAPIKeyService(c.Database.GormDB, c.Logger)
return nil
}

View file

@ -0,0 +1,74 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
)
// APIKeyHandler handles developer API key HTTP requests
type APIKeyHandler struct {
apiKeyService *services.APIKeyService
logger *zap.Logger
}
// NewAPIKeyHandler creates a new APIKeyHandler
func NewAPIKeyHandler(apiKeyService *services.APIKeyService, logger *zap.Logger) *APIKeyHandler {
return &APIKeyHandler{apiKeyService: apiKeyService, logger: logger}
}
// ListAPIKeys returns the user's API keys
func (h *APIKeyHandler) ListAPIKeys(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
keys, err := h.apiKeyService.List(c.Request.Context(), userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list API keys", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"keys": keys})
}
// CreateAPIKey creates a new API key
func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req services.CreateAPIKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
return
}
resp, err := h.apiKeyService.Create(c.Request.Context(), userID, &req)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create API key", err))
return
}
RespondSuccess(c, http.StatusCreated, resp)
}
// DeleteAPIKey deletes an API key
func (h *APIKeyHandler) DeleteAPIKey(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
keyID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid key ID"))
return
}
if err := h.apiKeyService.Delete(c.Request.Context(), userID, keyID); err != nil {
RespondWithAppError(c, apperrors.NewNotFoundError("API key not found"))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "API key revoked"})
}

View file

@ -36,23 +36,26 @@ type PermissionChecker interface {
// AuthMiddleware middleware d'authentification avec validation de session
// ÉTAPE 3.4: Utilise des interfaces pour permettre l'injection de dépendances et les tests
// v0.102: Supports X-API-Key for developer API keys (when apiKeyService is set)
type AuthMiddleware struct {
sessionService SessionValidator
auditService AuditRecorder
permissionService PermissionChecker
jwtService *services.JWTService // T0204: Use JWTService for validation
userService *services.UserService // T0204: Check TokenVersion
jwtService *services.JWTService // T0204: Use JWTService for validation
userService *services.UserService // T0204: Check TokenVersion
apiKeyService *services.APIKeyService // v0.102: Optional, for X-API-Key auth
logger *zap.Logger
}
// NewAuthMiddleware crée un nouveau middleware d'authentification
// ÉTAPE 3.4: Accepte des interfaces au lieu de types concrets pour permettre les tests avec mocks
// apiKeyService can be nil; when set, X-API-Key header is accepted as alternative to JWT
func NewAuthMiddleware(
sessionService SessionValidator,
auditService AuditRecorder,
permissionService PermissionChecker,
jwtService *services.JWTService,
userService *services.UserService,
apiKeyService *services.APIKeyService,
logger *zap.Logger,
) *AuthMiddleware {
return &AuthMiddleware{
@ -61,6 +64,7 @@ func NewAuthMiddleware(
permissionService: permissionService,
jwtService: jwtService,
userService: userService,
apiKeyService: apiKeyService,
logger: logger,
}
}
@ -71,6 +75,21 @@ func isSessionCheckRequest(path string) bool {
return strings.HasSuffix(path, "/auth/me") || path == "/auth/me"
}
// extractAPIKeyFromRequest extracts API key from X-API-Key or Authorization: Bearer (for developer keys)
func extractAPIKeyFromRequest(c *gin.Context) string {
if k := c.GetHeader("X-API-Key"); k != "" {
return k
}
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
return parts[1]
}
}
return ""
}
// authenticate performs the core authentication logic
// Returns userID and true if successful, otherwise handles error response and returns false
func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
@ -90,7 +109,21 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
}
if tokenString == "" {
// Session-check endpoint (GET /auth/me) is often called without token to probe login state; log at Debug to avoid noise
// v0.102: Try X-API-Key when no JWT (developer API keys)
if am.apiKeyService != nil {
if apiKey := extractAPIKeyFromRequest(c); apiKey != "" && strings.HasPrefix(apiKey, "vza_") {
key, err := am.apiKeyService.ValidateAPIKey(c.Request.Context(), apiKey)
if err == nil {
c.Set("user_id", key.UserID)
c.Set("api_key", key)
return key.UserID, true
}
am.logger.Warn("Invalid API key", zap.String("ip", c.ClientIP()))
response.Unauthorized(c, "Invalid API key")
c.Abort()
return uuid.Nil, false
}
}
if isSessionCheckRequest(c.Request.URL.Path) {
am.logger.Debug("Missing access token (cookie or Authorization header)",
zap.String("path", c.Request.URL.Path),
@ -107,6 +140,16 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
return uuid.Nil, false
}
// v0.102: If Bearer token looks like API key (vza_ prefix), try API key auth
if am.apiKeyService != nil && strings.HasPrefix(tokenString, "vza_") {
key, err := am.apiKeyService.ValidateAPIKey(c.Request.Context(), tokenString)
if err == nil {
c.Set("user_id", key.UserID)
c.Set("api_key", key)
return key.UserID, true
}
}
// T0204: Validate token using JWTService (checks sig, exp, iss, aud, alg)
claims, err := am.jwtService.ValidateToken(tokenString)
if err != nil {

View file

@ -275,7 +275,7 @@ func setupTestAuthMiddleware(t *testing.T, jwtService *services.JWTService) (*Au
// ÉTAPE 3.4: Les mocks implémentent maintenant directement les interfaces
// Plus besoin de wrappers ou de hacks - injection directe des mocks
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, mockPermissionService, jwtService, userService, logger)
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, mockPermissionService, jwtService, userService, nil, logger)
// Mock defaults for GetByID for generic tests (assume user found and version 0)
// We use .Maybe() because not all tests will hit it (e.g. invalid token format)

View file

@ -56,6 +56,12 @@ func (m *CSRFMiddleware) Middleware() gin.HandlerFunc {
return
}
// v0.102: Skip CSRF for API key auth (X-API-Key) - no cookies, stateless
if _, hasAPIKey := c.Get("api_key"); hasAPIKey {
c.Next()
return
}
// Récupérer le userID depuis le contexte (défini par AuthMiddleware)
userIDInterface, exists := c.Get("user_id")
if !exists {

View file

@ -55,7 +55,7 @@ func setupTestAuthMiddlewareWithRBAC(t *testing.T, permissionChecker PermissionC
jwtService := setupTestJWTService(t) // Reuse helper from auth_middleware_test.go
userService := services.NewUserService(mockUserRepository)
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, permissionChecker, jwtService, userService, logger)
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, permissionChecker, jwtService, userService, nil, logger)
return authMiddleware, mockSessionService, mockAuditService, mockUserRepository
}

View file

@ -0,0 +1,35 @@
package models
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
// APIKey represents a user API key for developer portal (v0.102 Lot C)
type APIKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
Name string `gorm:"size:100;not null" json:"name"`
Prefix string `gorm:"size:16;not null;index" json:"prefix"`
HashedKey string `gorm:"size:128;not null" json:"-"` // Never expose
Scopes pq.StringArray `gorm:"type:text[]" json:"scopes"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// TableName defines the table name for GORM
func (APIKey) TableName() string {
return "api_keys"
}
// BeforeCreate GORM hook to generate UUID if not set
func (k *APIKey) BeforeCreate(tx *gorm.DB) error {
if k.ID == uuid.Nil {
k.ID = uuid.New()
}
return nil
}

View file

@ -0,0 +1,204 @@
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,
}
}

View file

@ -0,0 +1,27 @@
-- 082_create_api_keys.sql
-- User API keys for developer portal (v0.102 Lot C)
-- Distinct from webhook API keys (whk_); user keys use vza_ prefix
CREATE TABLE IF NOT EXISTS public.api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
prefix VARCHAR(16) NOT NULL,
hashed_key VARCHAR(128) NOT NULL,
scopes TEXT[] NOT NULL DEFAULT ARRAY['read'],
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON public.api_keys(prefix);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON public.api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON public.api_keys(expires_at) WHERE expires_at IS NOT NULL;
COMMENT ON TABLE public.api_keys IS 'User API keys for developer portal (X-API-Key auth)';
COMMENT ON COLUMN public.api_keys.prefix IS 'First 8 chars of key for display (e.g. vza_abc1)';
COMMENT ON COLUMN public.api_keys.hashed_key IS 'SHA-256 hash of full key, never stored in plaintext';
COMMENT ON COLUMN public.api_keys.scopes IS 'Array of scopes: read, write, admin';

View file

@ -0,0 +1,7 @@
-- 082_create_api_keys_down.sql
-- Rollback: drop api_keys table
DROP INDEX IF EXISTS idx_api_keys_expires_at;
DROP INDEX IF EXISTS idx_api_keys_user_id;
DROP INDEX IF EXISTS idx_api_keys_prefix;
DROP TABLE IF EXISTS public.api_keys;

View file

@ -137,6 +137,7 @@ func setupAuthorizationTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *service
permissionService,
jwtService,
services.NewUserServiceWithDB(repositories.NewGormUserRepository(db), db),
nil,
logger,
)