diff --git a/VERSION b/VERSION index 0f00ab7c2..6026868be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.101.0 +0.901 diff --git a/veza-backend-api/internal/api/routes_auth.go b/veza-backend-api/internal/api/routes_auth.go index bc2b3399f..107a5a758 100644 --- a/veza-backend-api/internal/api/routes_auth.go +++ b/veza-backend-api/internal/api/routes_auth.go @@ -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) diff --git a/veza-backend-api/internal/api/routes_webhooks.go b/veza-backend-api/internal/api/routes_webhooks.go index 7e82d676d..66227df66 100644 --- a/veza-backend-api/internal/api/routes_webhooks.go +++ b/veza-backend-api/internal/api/routes_webhooks.go @@ -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)) diff --git a/veza-backend-api/internal/api/routes_webhooks_test.go b/veza-backend-api/internal/api/routes_webhooks_test.go new file mode 100644 index 000000000..084cf3f82 --- /dev/null +++ b/veza-backend-api/internal/api/routes_webhooks_test.go @@ -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") +} diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index 482d6e351..96408f19c 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -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 diff --git a/veza-backend-api/internal/config/middlewares_init.go b/veza-backend-api/internal/config/middlewares_init.go index e9c51665e..233a7b62b 100644 --- a/veza-backend-api/internal/config/middlewares_init.go +++ b/veza-backend-api/internal/config/middlewares_init.go @@ -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 { diff --git a/veza-backend-api/internal/config/services_init.go b/veza-backend-api/internal/config/services_init.go index 4c916e098..00b9d572a 100644 --- a/veza-backend-api/internal/config/services_init.go +++ b/veza-backend-api/internal/config/services_init.go @@ -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 diff --git a/veza-backend-api/internal/handlers/oauth_handlers.go b/veza-backend-api/internal/handlers/oauth_handlers.go index 44ae465df..b3060798c 100644 --- a/veza-backend-api/internal/handlers/oauth_handlers.go +++ b/veza-backend-api/internal/handlers/oauth_handlers.go @@ -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) } diff --git a/veza-backend-api/internal/handlers/oauth_handlers_test.go b/veza-backend-api/internal/handlers/oauth_handlers_test.go index 054e6aebb..26c817f7b 100644 --- a/veza-backend-api/internal/handlers/oauth_handlers_test.go +++ b/veza-backend-api/internal/handlers/oauth_handlers_test.go @@ -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) diff --git a/veza-backend-api/internal/middleware/auth.go b/veza-backend-api/internal/middleware/auth.go index cd04a1d27..75d2083dd 100644 --- a/veza-backend-api/internal/middleware/auth.go +++ b/veza-backend-api/internal/middleware/auth.go @@ -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 diff --git a/veza-backend-api/internal/middleware/auth_middleware_test.go b/veza-backend-api/internal/middleware/auth_middleware_test.go index 752374541..de68ebfab 100644 --- a/veza-backend-api/internal/middleware/auth_middleware_test.go +++ b/veza-backend-api/internal/middleware/auth_middleware_test.go @@ -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) diff --git a/veza-backend-api/internal/middleware/rbac_auth_middleware_test.go b/veza-backend-api/internal/middleware/rbac_auth_middleware_test.go index c115c38bf..888dc5cb6 100644 --- a/veza-backend-api/internal/middleware/rbac_auth_middleware_test.go +++ b/veza-backend-api/internal/middleware/rbac_auth_middleware_test.go @@ -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 } diff --git a/veza-backend-api/internal/services/oauth_service.go b/veza-backend-api/internal/services/oauth_service.go index 5dd9076fb..ae26f17e5 100644 --- a/veza-backend-api/internal/services/oauth_service.go +++ b/veza-backend-api/internal/services/oauth_service.go @@ -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) -} diff --git a/veza-backend-api/internal/services/oauth_service_test.go b/veza-backend-api/internal/services/oauth_service_test.go index 01cdbac7c..89bfbd208 100644 --- a/veza-backend-api/internal/services/oauth_service_test.go +++ b/veza-backend-api/internal/services/oauth_service_test.go @@ -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()) diff --git a/veza-backend-api/internal/services/password_service.go b/veza-backend-api/internal/services/password_service.go index c5d8d02a8..9ba361738 100644 --- a/veza-backend-api/internal/services/password_service.go +++ b/veza-backend-api/internal/services/password_service.go @@ -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 { diff --git a/veza-backend-api/internal/services/password_service_integration_test.go b/veza-backend-api/internal/services/password_service_integration_test.go index 87bd53e43..59364930d 100644 --- a/veza-backend-api/internal/services/password_service_integration_test.go +++ b/veza-backend-api/internal/services/password_service_integration_test.go @@ -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") -} diff --git a/veza-backend-api/internal/services/waveform_service.go b/veza-backend-api/internal/services/waveform_service.go index 3f387a451..d5f05a636 100644 --- a/veza-backend-api/internal/services/waveform_service.go +++ b/veza-backend-api/internal/services/waveform_service.go @@ -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()), ) diff --git a/veza-backend-api/internal/services/waveform_service_test.go b/veza-backend-api/internal/services/waveform_service_test.go new file mode 100644 index 000000000..e8d068eb9 --- /dev/null +++ b/veza-backend-api/internal/services/waveform_service_test.go @@ -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") +} diff --git a/veza-backend-api/tests/security/authorization_test.go b/veza-backend-api/tests/security/authorization_test.go index b7e8e34ff..976fdc263 100644 --- a/veza-backend-api/tests/security/authorization_test.go +++ b/veza-backend-api/tests/security/authorization_test.go @@ -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, )