feat(security): v0.901 Ironclad - fix 5 critical/high vulnerabilities
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s

- OAuth: use JWTService+SessionService, httpOnly cookies (VEZA-SEC-001)
- Remove PasswordService.GenerateJWT (VEZA-SEC-002)
- Hyperswitch webhook: mandatory verification, 500 if secret empty (VEZA-SEC-005)
- Auth middleware: TokenBlacklist.IsBlacklisted check (VEZA-SEC-006)
- Waveform: ValidateExecPath before exec (VEZA-SEC-007)
This commit is contained in:
senke 2026-02-26 19:34:45 +01:00
parent 5063c95a5c
commit 51984e9a1f
19 changed files with 397 additions and 118 deletions

View file

@ -1 +1 @@
0.101.0
0.901

View file

@ -115,8 +115,7 @@ func (r *APIRouter) setupAuthRoutes(router *gin.RouterGroup) error {
checkUsernameGroup.GET("/check-username", handlers.CheckUsername(authService))
// BE-API-042: OAuth routes
jwtSecretBytes := []byte(r.config.JWTSecret)
oauthService := services.NewOAuthService(r.db, r.logger, jwtSecretBytes)
oauthService := services.NewOAuthService(r.db, r.logger, jwtService, sessionService, userService)
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
appDomain := os.Getenv("APP_DOMAIN")
@ -142,7 +141,7 @@ func (r *APIRouter) setupAuthRoutes(router *gin.RouterGroup) error {
oauthService.InitializeConfigs(googleClientID, googleClientSecret, githubClientID, githubClientSecret, discordClientID, discordClientSecret, spotifyClientID, spotifyClientSecret, baseURL)
}
oauthHandler := handlers.NewOAuthHandler(oauthService, r.logger, r.config.CORSOrigins, r.config.FrontendURL)
oauthHandler := handlers.NewOAuthHandler(oauthService, r.logger, r.config.CORSOrigins, r.config.FrontendURL, r.config)
oauthGroup := authGroup.Group("/oauth")
{
oauthGroup.GET("/providers", oauthHandler.GetOAuthProviders)

View file

@ -62,15 +62,16 @@ func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc {
response.InternalServerError(c, "Failed to read webhook body")
return
}
if webhookSecret != "" {
sig := c.GetHeader("x-webhook-signature-512")
if err := hyperswitch.VerifyWebhookSignature(body, sig, webhookSecret); err != nil {
r.logger.Warn("Hyperswitch webhook: signature verification failed", zap.Error(err))
response.Unauthorized(c, "Invalid webhook signature")
return
}
} else {
r.logger.Warn("Hyperswitch webhook: HYPERSWITCH_WEBHOOK_SECRET not set, skipping signature verification")
if webhookSecret == "" {
r.logger.Error("Hyperswitch webhook: HYPERSWITCH_WEBHOOK_SECRET not configured, rejecting webhook")
response.InternalServerError(c, "Webhook secret not configured")
return
}
sig := c.GetHeader("x-webhook-signature-512")
if err := hyperswitch.VerifyWebhookSignature(body, sig, webhookSecret); err != nil {
r.logger.Warn("Hyperswitch webhook: signature verification failed", zap.Error(err))
response.Unauthorized(c, "Invalid webhook signature")
return
}
if err := marketService.ProcessPaymentWebhook(c.Request.Context(), body); err != nil {
r.logger.Error("Hyperswitch webhook: processing failed", zap.Error(err))

View file

@ -0,0 +1,92 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/config"
"veza-backend-api/internal/database"
"veza-backend-api/internal/metrics"
)
func setupWebhookTestRouter(t *testing.T, webhookSecret string) *gin.Engine {
t.Helper()
os.Setenv("ENABLE_CLAMAV", "false")
os.Setenv("CLAMAV_REQUIRED", "false")
gin.SetMode(gin.TestMode)
router := gin.New()
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := gormDB.DB()
require.NoError(t, err)
vezaDB := &database.Database{
DB: sqlDB,
GormDB: gormDB,
Logger: zap.NewNop(),
}
cfg := &config.Config{
HyperswitchWebhookSecret: webhookSecret,
JWTSecret: "test-jwt-secret-key-minimum-32-characters-long",
JWTIssuer: "veza-api",
JWTAudience: "veza-app",
Logger: zap.NewNop(),
RedisClient: nil,
ErrorMetrics: metrics.NewErrorMetrics(),
UploadDir: "uploads/test",
Env: "development",
Database: vezaDB,
CORSOrigins: []string{"*"},
HandlerTimeout: 30 * time.Second,
RateLimitLimit: 100,
RateLimitWindow: 60,
AuthRateLimitLoginAttempts: 10,
AuthRateLimitLoginWindow: 15,
}
require.NoError(t, cfg.InitServicesForTest())
require.NoError(t, cfg.InitMiddlewaresForTest())
apiRouter := NewAPIRouter(vezaDB, cfg)
require.NoError(t, apiRouter.Setup(router))
return router
}
// TestHyperswitchWebhook_EmptySecret_Returns500 verifies that when HyperswitchWebhookSecret
// is empty, the webhook handler returns 500 (VEZA-SEC-005).
func TestHyperswitchWebhook_EmptySecret_Returns500(t *testing.T) {
router := setupWebhookTestRouter(t, "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader([]byte(`{"test": true}`)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code, "empty webhook secret must return 500")
}
// TestHyperswitchWebhook_ValidSecret_InvalidSignature_Returns401 verifies that with a configured secret,
// invalid signature returns 401 (smoke test that verification path is exercised).
func TestHyperswitchWebhook_ValidSecret_InvalidSignature_Returns401(t *testing.T) {
router := setupWebhookTestRouter(t, "test-secret-at-least-32-chars-long")
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/hyperswitch", bytes.NewReader([]byte(`{"test": true}`)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "invalid signature must return 401")
}

View file

@ -41,6 +41,7 @@ type Config struct {
S3StorageService *services.S3StorageService // BE-SVC-005: S3 storage service
APIKeyService *services.APIKeyService // v0.102 Lot C: developer API keys
PresenceService *services.PresenceService // v0.301 Lot P1: user presence (online/away/offline)
TokenBlacklist *services.TokenBlacklist // VEZA-SEC-006: token revocation (nil if Redis unavailable)
// Middlewares
RateLimiter *middleware.RateLimiter

View file

@ -70,6 +70,7 @@ func (c *Config) initMiddlewares() error {
c.JWTService,
c.UserService,
c.APIKeyService,
c.TokenBlacklist, // VEZA-SEC-006: nil if Redis unavailable (implements TokenBlacklistChecker)
c.Logger,
)
if c.PresenceService != nil {

View file

@ -54,6 +54,7 @@ func (c *Config) initServices() error {
// Service de cache (only when Redis is available; nil client causes panics)
if c.RedisClient != nil {
c.CacheService = services.NewCacheService(c.RedisClient, c.Logger)
c.TokenBlacklist = services.NewTokenBlacklist(c.RedisClient) // VEZA-SEC-006
}
// Service de playlist

View file

@ -1,11 +1,15 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"veza-backend-api/internal/config"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
@ -14,16 +18,17 @@ import (
// OAuthServiceInterface defines the methods needed for OAuth handlers
type OAuthServiceInterface interface {
GetAuthURL(provider string) (string, error)
HandleCallback(provider, code, state string) (*services.OAuthUser, string, error)
HandleCallback(ctx context.Context, provider, code, state, ipAddress, userAgent string) (*services.OAuthUser, *models.TokenPair, error)
GetAvailableProviders() []string
}
// OAuthHandlers handles OAuth authentication flows
type OAuthHandlers struct {
oauthService OAuthServiceInterface
logger interface{}
logger interface{}
allowedRedirectOrigins []string // SECURITY: allowlist for OAuth redirect URLs
frontendURL string // URL du frontend pour redirect OAuth (depuis config)
frontendURL string // URL du frontend pour redirect OAuth (depuis config)
cfg *config.Config
}
// OAuthHandlersInstance is the global instance
@ -39,22 +44,24 @@ func InitOAuthHandlers(oauthService *services.OAuthService) {
// NewOAuthHandler creates a new OAuth handler instance
// BE-API-042: Implement OAuth callback endpoint
// frontendURL: from config.FrontendURL (FRONTEND_URL or VITE_FRONTEND_URL env)
func NewOAuthHandler(oauthService *services.OAuthService, logger interface{}, allowedRedirectOrigins []string, frontendURL string) *OAuthHandlers {
func NewOAuthHandler(oauthService *services.OAuthService, logger interface{}, allowedRedirectOrigins []string, frontendURL string, cfg *config.Config) *OAuthHandlers {
return &OAuthHandlers{
oauthService: oauthService,
logger: logger,
allowedRedirectOrigins: allowedRedirectOrigins,
frontendURL: frontendURL,
frontendURL: frontendURL,
cfg: cfg,
}
}
// NewOAuthHandlerWithInterface creates a new OAuth handler instance with an interface (for testing)
func NewOAuthHandlerWithInterface(oauthService OAuthServiceInterface, logger interface{}) *OAuthHandlers {
func NewOAuthHandlerWithInterface(oauthService OAuthServiceInterface, logger interface{}, cfg *config.Config) *OAuthHandlers {
return &OAuthHandlers{
oauthService: oauthService,
logger: logger,
allowedRedirectOrigins: nil, // Tests use nil = dev fallback
logger: logger,
allowedRedirectOrigins: nil, // Tests use nil = dev fallback
frontendURL: "http://localhost:5173", // Tests use localhost
cfg: cfg,
}
}
@ -127,22 +134,54 @@ func (oh *OAuthHandlers) OAuthCallback(c *gin.Context) {
return
}
// Handle callback
user, token, err := oh.oauthService.HandleCallback(provider, code, state)
// Handle callback (VEZA-SEC-001: returns TokenPair, creates session)
user, tokens, err := oh.oauthService.HandleCallback(c.Request.Context(), provider, code, state, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Redirect to frontend with token (frontendURL from config)
frontendURL := oh.frontendURL
// SECURITY: Validate redirect URL against allowlist to prevent open redirect
frontendURL := oh.frontendURL
if !oh.isAllowedRedirectOrigin(frontendURL) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid redirect configuration"})
return
}
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s&user_id=%s", strings.TrimSuffix(frontendURL, "/"), token, user.ID.String())
// VEZA-SEC-001: Set httpOnly cookies (same as login flow)
if oh.cfg != nil {
refreshTokenExpires := 14 * 24 * time.Hour
refreshTokenCookie := &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
Path: oh.cfg.CookiePath,
Domain: oh.cfg.CookieDomain,
MaxAge: int(refreshTokenExpires.Seconds()),
HttpOnly: oh.cfg.CookieHttpOnly,
Secure: oh.cfg.ShouldUseSecureCookies(),
SameSite: oh.cfg.GetCookieSameSite(),
}
http.SetCookie(c.Writer, refreshTokenCookie)
accessTokenExpires := 5 * time.Minute // Match JWTService default
if oh.cfg.JWTService != nil {
accessTokenExpires = oh.cfg.JWTService.GetConfig().AccessTokenTTL
}
accessTokenCookie := &http.Cookie{
Name: "access_token",
Value: tokens.AccessToken,
Path: oh.cfg.CookiePath,
Domain: oh.cfg.CookieDomain,
MaxAge: int(accessTokenExpires.Seconds()),
HttpOnly: oh.cfg.CookieHttpOnly,
Secure: oh.cfg.ShouldUseSecureCookies(),
SameSite: oh.cfg.GetCookieSameSite(),
}
http.SetCookie(c.Writer, accessTokenCookie)
}
// Redirect to frontend (tokens in cookies, not URL)
redirectURL := fmt.Sprintf("%s/auth/callback?user_id=%s", strings.TrimSuffix(frontendURL, "/"), user.ID.String())
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}

View file

@ -1,11 +1,14 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
@ -25,12 +28,15 @@ func (m *MockOAuthService) GetAuthURL(provider string) (string, error) {
return args.String(0), args.Error(1)
}
func (m *MockOAuthService) HandleCallback(provider, code, state string) (*services.OAuthUser, string, error) {
args := m.Called(provider, code, state)
func (m *MockOAuthService) HandleCallback(ctx context.Context, provider, code, state, ipAddress, userAgent string) (*services.OAuthUser, *models.TokenPair, error) {
args := m.Called(ctx, provider, code, state, ipAddress, userAgent)
if args.Get(0) == nil {
return nil, args.String(1), args.Error(2)
return nil, nil, args.Error(2)
}
return args.Get(0).(*services.OAuthUser), args.String(1), args.Error(2)
if args.Get(1) == nil {
return args.Get(0).(*services.OAuthUser), nil, args.Error(2)
}
return args.Get(0).(*services.OAuthUser), args.Get(1).(*models.TokenPair), args.Error(2)
}
func (m *MockOAuthService) GetAvailableProviders() []string {
@ -46,7 +52,7 @@ func setupTestOAuthRouter(mockService *MockOAuthService) *gin.Engine {
router := gin.New()
logger := zap.NewNop()
handler := NewOAuthHandlerWithInterface(mockService, logger)
handler := NewOAuthHandlerWithInterface(mockService, logger, nil)
api := router.Group("/api/v1/auth/oauth")
{
@ -133,9 +139,13 @@ func TestOAuthHandlers_OAuthCallback_Success(t *testing.T) {
ID: userID,
Email: "test@example.com",
}
token := "test-jwt-token"
tokens := &models.TokenPair{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresIn: int(5 * time.Minute.Seconds()),
}
mockService.On("HandleCallback", "google", "test-code", "test-state").Return(mockUser, token, nil)
mockService.On("HandleCallback", mock.Anything, "google", "test-code", "test-state", mock.Anything, mock.Anything).Return(mockUser, tokens, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/auth/oauth/google/callback?code=test-code&state=test-state", nil)
@ -145,8 +155,10 @@ func TestOAuthHandlers_OAuthCallback_Success(t *testing.T) {
// Assert
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
location := w.Header().Get("Location")
assert.Contains(t, location, "token=test-jwt-token")
assert.Contains(t, location, "user_id="+userID.String())
assert.Contains(t, location, "/auth/callback")
// Tokens now in cookies, not URL
assert.NotContains(t, location, "token=")
mockService.AssertExpectations(t)
}
@ -185,7 +197,7 @@ func TestOAuthHandlers_OAuthCallback_ServiceError(t *testing.T) {
mockService := new(MockOAuthService)
router := setupTestOAuthRouter(mockService)
mockService.On("HandleCallback", "google", "test-code", "test-state").Return(nil, "", assert.AnError)
mockService.On("HandleCallback", mock.Anything, "google", "test-code", "test-state", mock.Anything, mock.Anything).Return(nil, nil, assert.AnError)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/auth/oauth/google/callback?code=test-code&state=test-state", nil)
@ -203,7 +215,7 @@ func TestNewOAuthHandlerWithInterface(t *testing.T) {
logger := zap.NewNop()
// Execute
handler := NewOAuthHandlerWithInterface(mockService, logger)
handler := NewOAuthHandlerWithInterface(mockService, logger, nil)
// Assert
assert.NotNil(t, handler)

View file

@ -39,6 +39,11 @@ type PresenceUpdater interface {
UpdatePresence(ctx context.Context, userID uuid.UUID, status string) error
}
// TokenBlacklistChecker checks if a token is blacklisted (VEZA-SEC-006)
type TokenBlacklistChecker interface {
IsBlacklisted(ctx context.Context, token string) (bool, error)
}
// 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)
@ -51,11 +56,13 @@ type AuthMiddleware struct {
userService *services.UserService // T0204: Check TokenVersion
apiKeyService *services.APIKeyService // v0.102: Optional, for X-API-Key auth
presenceService PresenceUpdater // v0.301: Optional, updates last_seen_at on auth
tokenBlacklist TokenBlacklistChecker // VEZA-SEC-006: Optional, nil if Redis unavailable
logger *zap.Logger
}
// NewAuthMiddleware crée un nouveau middleware d'authentification
// apiKeyService can be nil; when set, X-API-Key header is accepted as alternative to JWT
// tokenBlacklist can be nil; when set (Redis available), blacklisted tokens are rejected (VEZA-SEC-006)
func NewAuthMiddleware(
sessionService SessionValidator,
auditService AuditRecorder,
@ -63,6 +70,7 @@ func NewAuthMiddleware(
jwtService *services.JWTService,
userService *services.UserService,
apiKeyService *services.APIKeyService,
tokenBlacklist TokenBlacklistChecker,
logger *zap.Logger,
) *AuthMiddleware {
return &AuthMiddleware{
@ -72,6 +80,7 @@ func NewAuthMiddleware(
jwtService: jwtService,
userService: userService,
apiKeyService: apiKeyService,
tokenBlacklist: tokenBlacklist,
logger: logger,
}
}
@ -174,6 +183,22 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
return uuid.Nil, false
}
// VEZA-SEC-006: Check token blacklist (revoked tokens)
if am.tokenBlacklist != nil {
blacklisted, err := am.tokenBlacklist.IsBlacklisted(c.Request.Context(), tokenString)
if err != nil {
am.logger.Warn("Token blacklist check failed", zap.Error(err))
response.Unauthorized(c, "Invalid token")
c.Abort()
return uuid.Nil, false
}
if blacklisted {
response.Unauthorized(c, "Token revoked")
c.Abort()
return uuid.Nil, false
}
}
userID := claims.UserID
// T0204: Check TokenVersion against DB to ensure immediate revocation

View file

@ -253,6 +253,16 @@ func (m *MockPermissionService) HasPermission(ctx context.Context, userID uuid.U
return args.Bool(0), args.Error(1)
}
// MockTokenBlacklist for VEZA-SEC-006 tests
type MockTokenBlacklist struct {
mock.Mock
}
func (m *MockTokenBlacklist) IsBlacklisted(ctx context.Context, token string) (bool, error) {
args := m.Called(ctx, token)
return args.Bool(0), args.Error(1)
}
// setupTestAuthMiddleware crée un AuthMiddleware configuré pour les tests
// ÉTAPE 3.4: Utilise les interfaces pour permettre l'injection directe des mocks
func setupTestAuthMiddleware(t *testing.T, jwtService *services.JWTService) (*AuthMiddleware, *MockSessionService, *MockAuditService, *MockPermissionService, *MockUserRepository) {
@ -275,7 +285,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, nil, logger)
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, mockPermissionService, jwtService, userService, nil, 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)
@ -340,6 +350,57 @@ func TestAuthMiddleware_ValidToken(t *testing.T) {
mockSessionService.AssertExpectations(t)
}
// TestAuthMiddleware_BlacklistedToken_Returns401 verifies that a valid JWT but blacklisted token returns 401 (VEZA-SEC-006)
func TestAuthMiddleware_BlacklistedToken_Returns401(t *testing.T) {
gin.SetMode(gin.TestMode)
_, _, _, _, mockUserRepository := setupTestAuthMiddleware(t, nil)
userUUID := uuid.MustParse("00000000-0000-0000-0000-000000000042")
token := generateTestToken(t, userUUID, 15*time.Minute)
// Create middleware with mock blacklist that returns true (blacklisted)
mockBlacklist := new(MockTokenBlacklist)
mockBlacklist.On("IsBlacklisted", mock.Anything, token).Return(true, nil)
// Rebuild auth middleware with blacklist
logger, _ := zap.NewDevelopment()
userService := services.NewUserService(mockUserRepository)
mockUserRepository.On("GetByID", userUUID.String()).Return(&models.User{
ID: userUUID,
TokenVersion: 0,
}, nil)
jwtService := setupTestJWTService(t)
mockSessionService2 := new(MockSessionService)
mockAuditService2 := new(MockAuditService)
mockPermissionService2 := new(MockPermissionService)
mockAuditService2.On("LogAction", mock.Anything, mock.Anything).Return(nil).Maybe()
authWithBlacklist := NewAuthMiddleware(
mockSessionService2, mockAuditService2, mockPermissionService2,
jwtService, userService, nil, mockBlacklist, logger,
)
router := gin.New()
router.Use(authWithBlacklist.RequireAuth())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"})
})
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
var response map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.False(t, response["success"].(bool))
errorObj := response["error"].(map[string]interface{})
assert.Equal(t, "Token revoked", errorObj["message"])
mockBlacklist.AssertExpectations(t)
}
func TestAuthMiddleware_MissingHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
authMiddleware, _, _, _, _ := setupTestAuthMiddleware(t, nil)

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, nil, logger)
authMiddleware := NewAuthMiddleware(mockSessionService, mockAuditService, permissionChecker, jwtService, userService, nil, nil, logger)
return authMiddleware, mockSessionService, mockAuditService, mockUserRepository
}

View file

@ -12,9 +12,9 @@ import (
"time"
"veza-backend-api/internal/database"
"veza-backend-api/internal/models"
"veza-backend-api/internal/utils"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/oauth2"
@ -23,14 +23,16 @@ import (
// OAuthService handles OAuth authentication
type OAuthService struct {
db *database.Database
logger *zap.Logger
googleConfig *oauth2.Config
githubConfig *oauth2.Config
discordConfig *oauth2.Config
spotifyConfig *oauth2.Config
jwtSecret []byte
circuitBreaker *CircuitBreakerHTTPClient
db *database.Database
logger *zap.Logger
googleConfig *oauth2.Config
githubConfig *oauth2.Config
discordConfig *oauth2.Config
spotifyConfig *oauth2.Config
jwtService *JWTService
sessionService *SessionService
userService *UserService
circuitBreaker *CircuitBreakerHTTPClient
}
// OAuthAccount represents an OAuth account linking
@ -61,12 +63,14 @@ type OAuthState struct {
}
// NewOAuthService creates a new OAuth service
func NewOAuthService(db *database.Database, logger *zap.Logger, jwtSecret []byte) *OAuthService {
func NewOAuthService(db *database.Database, logger *zap.Logger, jwtService *JWTService, sessionService *SessionService, userService *UserService) *OAuthService {
httpClient := &http.Client{Timeout: 10 * time.Second}
return &OAuthService{
db: db,
logger: logger,
jwtSecret: jwtSecret,
jwtService: jwtService,
sessionService: sessionService,
userService: userService,
circuitBreaker: NewCircuitBreakerHTTPClient(httpClient, "oauth-service", logger),
}
}
@ -253,12 +257,13 @@ func (os *OAuthService) GetAuthURL(provider string) (string, error) {
return url, nil
}
// HandleCallback processes the OAuth callback
func (os *OAuthService) HandleCallback(provider, code, state string) (*OAuthUser, string, error) {
// HandleCallback processes the OAuth callback.
// ipAddress and userAgent are used for session creation (optional, can be empty).
func (os *OAuthService) HandleCallback(ctx context.Context, provider, code, state, ipAddress, userAgent string) (*OAuthUser, *models.TokenPair, error) {
// Validate state
_, err := os.ValidateStateToken(state)
if err != nil {
return nil, "", err
return nil, nil, err
}
var config *oauth2.Config
@ -272,47 +277,69 @@ func (os *OAuthService) HandleCallback(provider, code, state string) (*OAuthUser
case "spotify":
config = os.spotifyConfig
default:
return nil, "", fmt.Errorf("unknown provider: %s", provider)
return nil, nil, fmt.Errorf("unknown provider: %s", provider)
}
if config == nil {
return nil, "", fmt.Errorf("%s OAuth not configured", provider)
return nil, nil, fmt.Errorf("%s OAuth not configured", provider)
}
// Exchange code for token
token, err := config.Exchange(context.Background(), code)
token, err := config.Exchange(ctx, code)
if err != nil {
return nil, "", err
return nil, nil, err
}
// Get user info from provider
oauthUser, err := os.getUserInfo(provider, token.AccessToken)
if err != nil {
return nil, "", err
return nil, nil, err
}
// Check if user already exists (by provider account or email) — audit 1.8: OAuth ID lookup first
existingUser, err := os.getOrCreateUser(provider, oauthUser)
if err != nil {
return nil, "", err
return nil, nil, err
}
// Save/update OAuth account
err = os.saveOAuthAccount(provider, oauthUser, existingUser.ID, token)
if err != nil {
return nil, "", err
return nil, nil, err
}
// Generate JWT for the user
jwtToken, err := os.generateJWT(existingUser.ID)
// VEZA-SEC-001: Get full user for JWT (TokenVersion, Role, etc.)
user, err := os.userService.GetByID(existingUser.ID)
if err != nil {
return nil, "", err
return nil, nil, fmt.Errorf("failed to get user: %w", err)
}
// Generate tokens via JWTService (proper issuer, audience, token_version)
tokens, err := os.jwtService.GenerateTokenPair(user)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate tokens: %w", err)
}
// Create session for refresh token validation
_, err = os.sessionService.CreateSession(ctx, &SessionCreateRequest{
UserID: user.ID,
Token: tokens.RefreshToken,
IPAddress: ipAddress,
UserAgent: userAgent,
ExpiresIn: os.jwtService.GetConfig().RefreshTokenTTL,
})
if err != nil {
os.logger.Warn("Failed to create session after OAuth callback",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
// Continue - tokens still valid, session is optional for some flows
}
return &OAuthUser{
ID: existingUser.ID,
Email: existingUser.Email,
}, jwtToken, nil
}, tokens, nil
}
// OAuthUser represents an OAuth authenticated user
@ -580,14 +607,3 @@ func (os *OAuthService) saveOAuthAccount(provider string, oauthUser *OAuthUser,
return err
}
// generateJWT generates a JWT token for the user
func (os *OAuthService) generateJWT(userID uuid.UUID) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID.String(),
"sub": userID.String(),
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
return token.SignedString(os.jwtSecret)
}

View file

@ -15,8 +15,26 @@ import (
"go.uber.org/zap"
"veza-backend-api/internal/database"
"veza-backend-api/internal/repositories"
)
// setupOAuthServiceForTests creates OAuthService with minimal deps for tests that don't call HandleCallback.
// Uses sqlite in-memory when db has no GormDB (e.g. from sqlmock).
func setupOAuthServiceForTests(t *testing.T, db *database.Database) *OAuthService {
t.Helper()
jwtService, err := NewJWTService("test-secret-key-minimum-32-characters-long", "veza-api", "veza-app")
require.NoError(t, err)
// Use db for SessionService (needs sql.DB)
sessionService := NewSessionService(db, zap.NewNop())
// UserService needs GormDB - use db.GormDB if available, else nil (tests don't call HandleCallback)
var userService *UserService
if db.GormDB != nil {
userRepo := repositories.NewGormUserRepository(db.GormDB)
userService = NewUserServiceWithDB(userRepo, db.GormDB)
}
return NewOAuthService(db, zap.NewNop(), jwtService, sessionService, userService)
}
// Helper to setup mock DB
func setupMockDB(t *testing.T) (*database.Database, sqlmock.Sqlmock) {
db, mock, err := sqlmock.New()
@ -158,7 +176,7 @@ func TestOAuthService_GetAuthURL_Discord(t *testing.T) {
db, mock := setupMockDB(t)
defer db.DB.Close()
svc := NewOAuthService(db, zap.NewNop(), []byte("secret"))
svc := setupOAuthServiceForTests(t, db)
svc.InitializeConfigs("", "", "", "", "discord-client", "discord-secret", "", "", "http://localhost:8080")
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)).
@ -180,7 +198,7 @@ func TestOAuthService_GetAuthURL_Spotify(t *testing.T) {
db, mock := setupMockDB(t)
defer db.DB.Close()
svc := NewOAuthService(db, zap.NewNop(), []byte("secret"))
svc := setupOAuthServiceForTests(t, db)
svc.InitializeConfigs("", "", "", "", "", "", "spotify-client", "spotify-secret", "http://localhost:8080")
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)).
@ -202,7 +220,7 @@ func TestOAuthService_GetAvailableProviders(t *testing.T) {
db, _ := setupMockDB(t)
defer db.DB.Close()
svc := NewOAuthService(db, zap.NewNop(), []byte("secret"))
svc := setupOAuthServiceForTests(t, db)
providers := svc.GetAvailableProviders()
assert.Empty(t, providers)
@ -243,7 +261,7 @@ func TestOAuthService_GetUserInfo_Discord(t *testing.T) {
db, mock := setupMockDB(t)
defer db.DB.Close()
svc := NewOAuthService(db, zap.NewNop(), []byte("secret"))
svc := setupOAuthServiceForTests(t, db)
svc.InitializeConfigs("", "", "", "", "did", "dsec", "", "", "http://localhost")
svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop())
@ -265,7 +283,7 @@ func TestOAuthService_GetUserInfo_Spotify(t *testing.T) {
db, _ := setupMockDB(t)
defer db.DB.Close()
svc := NewOAuthService(db, zap.NewNop(), []byte("secret"))
svc := setupOAuthServiceForTests(t, db)
svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost")
svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop())
@ -286,7 +304,7 @@ func TestOAuthService_GetUserInfo_Spotify_FallbackEmail(t *testing.T) {
db, _ := setupMockDB(t)
defer db.DB.Close()
svc := NewOAuthService(db, zap.NewNop(), []byte("secret"))
svc := setupOAuthServiceForTests(t, db)
svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost")
svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop())

View file

@ -11,7 +11,6 @@ import (
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
@ -256,16 +255,6 @@ func (ps *PasswordService) ChangePassword(userID uuid.UUID, oldPassword, newPass
return nil
}
// GenerateJWT generates a JWT token for the user (used internally)
func (ps *PasswordService) GenerateJWT(userID uuid.UUID, secret []byte) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID.String(), // Convert UUID to string for JWT claims
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
return token.SignedString(secret)
}
// UpdatePassword updates a user's password by user ID
// T0194: Updates password with bcrypt hash
func (ps *PasswordService) UpdatePassword(userID uuid.UUID, newPassword string) error {

View file

@ -329,30 +329,3 @@ func TestPasswordService_UpdatePassword_WeakPassword(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "weak password")
}
func TestPasswordService_GenerateJWT_Success(t *testing.T) {
service, _, _ := setupTestPasswordServiceIntegration(t)
userID := uuid.New()
secret := []byte("test_secret_key")
token, err := service.GenerateJWT(userID, secret)
assert.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestPasswordService_GenerateJWT_DifferentUsers(t *testing.T) {
service, _, _ := setupTestPasswordServiceIntegration(t)
userID1 := uuid.New()
userID2 := uuid.New()
secret := []byte("test_secret_key")
token1, err1 := service.GenerateJWT(userID1, secret)
assert.NoError(t, err1)
token2, err2 := service.GenerateJWT(userID2, secret)
assert.NoError(t, err2)
assert.NotEqual(t, token1, token2, "Different users should generate different tokens")
}

View file

@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"veza-backend-api/internal/models"
"veza-backend-api/internal/utils"
)
type WaveformData struct {
@ -64,6 +65,9 @@ func (s *WaveformService) GenerateWaveformAsync(ctx context.Context, trackID uui
}
func (s *WaveformService) generateWaveform(ctx context.Context, trackID uuid.UUID, inputPath string) error {
if !utils.ValidateExecPath(inputPath) {
return fmt.Errorf("invalid input path")
}
wavPath := filepath.Join(s.tempDir, fmt.Sprintf("waveform_%s.wav", trackID))
jsonPath := filepath.Join(s.tempDir, fmt.Sprintf("waveform_%s.json", trackID))
defer os.Remove(wavPath)
@ -117,6 +121,9 @@ func (s *WaveformService) generateWaveform(ctx context.Context, trackID uuid.UUI
}
func (s *WaveformService) generateFallbackWaveform(ctx context.Context, trackID uuid.UUID, inputPath string) error {
if !utils.ValidateExecPath(inputPath) {
return fmt.Errorf("invalid input path")
}
s.logger.Warn("audiowaveform not available, generating fallback waveform via FFmpeg",
zap.String("track_id", trackID.String()),
)

View file

@ -0,0 +1,43 @@
package services
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupTestWaveformService(t *testing.T) *WaveformService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
logger := zap.NewNop()
return NewWaveformService(db, logger, nil)
}
// TestGenerateWaveform_InvalidPath_ReturnsError verifies that paths containing ".." are rejected before exec (VEZA-SEC-007)
func TestGenerateWaveform_InvalidPath_ReturnsError(t *testing.T) {
svc := setupTestWaveformService(t)
ctx := context.Background()
trackID := uuid.New()
err := svc.generateWaveform(ctx, trackID, "/tmp/../etc/passwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid input path")
}
// TestGenerateFallbackWaveform_InvalidPath_ReturnsError verifies ValidateExecPath in fallback path (VEZA-SEC-007)
func TestGenerateFallbackWaveform_InvalidPath_ReturnsError(t *testing.T) {
svc := setupTestWaveformService(t)
ctx := context.Background()
trackID := uuid.New()
err := svc.generateFallbackWaveform(ctx, trackID, "/path/with/../traversal")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid input path")
}

View file

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