diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 23f5d26..a2ee02c 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -30,9 +30,10 @@ type CoreHandlers struct { notificationService *services.NotificationService ticketService *services.TicketService adminService *services.AdminService + credentialsService *services.CredentialsService } -func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, upd *user.UpdateUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService, notificationService *services.NotificationService, ticketService *services.TicketService, adminService *services.AdminService) *CoreHandlers { +func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, upd *user.UpdateUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService, notificationService *services.NotificationService, ticketService *services.TicketService, adminService *services.AdminService, credentialsService *services.CredentialsService) *CoreHandlers { return &CoreHandlers{ loginUC: l, registerCandidateUC: reg, @@ -46,6 +47,7 @@ func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c notificationService: notificationService, ticketService: ticketService, adminService: adminService, + credentialsService: credentialsService, } } @@ -980,3 +982,51 @@ func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Token saved successfully"}) } + +// SaveCredentials saves encrypted credentials for external services. +// @Summary Save Credentials +// @Description Saves encrypted credentials payload (e.g. Stripe key encrypted by Backoffice) +// @Tags System +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body map[string]string true "Credentials Payload" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/system/credentials [post] +func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Double check role is ADMIN or SUPERADMIN just in case middleware missed it (defense in depth) + // But middleware should handle it. + + var req struct { + ServiceName string `json:"serviceName"` + EncryptedPayload string `json:"encryptedPayload"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ServiceName == "" || req.EncryptedPayload == "" { + http.Error(w, "serviceName and encryptedPayload are required", http.StatusBadRequest) + return + } + + if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, req.EncryptedPayload, userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Credentials saved successfully"}) +} diff --git a/backend/internal/api/handlers/core_handlers_test.go b/backend/internal/api/handlers/core_handlers_test.go index a02e918..0753fff 100644 --- a/backend/internal/api/handlers/core_handlers_test.go +++ b/backend/internal/api/handlers/core_handlers_test.go @@ -217,5 +217,6 @@ func createTestCoreHandlers(t *testing.T, loginUC *auth.LoginUseCase) *handlers. nil, nil, nil, + nil, ) } diff --git a/backend/internal/handlers/payment_handler.go b/backend/internal/handlers/payment_handler.go index e4b151d..3f366e4 100644 --- a/backend/internal/handlers/payment_handler.go +++ b/backend/internal/handlers/payment_handler.go @@ -18,12 +18,16 @@ import ( // PaymentHandler handles Stripe payment operations type PaymentHandler struct { - jobService *services.JobService + jobService *services.JobService + credentialsService *services.CredentialsService } // NewPaymentHandler creates a new payment handler -func NewPaymentHandler(jobService *services.JobService) *PaymentHandler { - return &PaymentHandler{jobService: jobService} +func NewPaymentHandler(jobService *services.JobService, credentialsService *services.CredentialsService) *PaymentHandler { + return &PaymentHandler{ + jobService: jobService, + credentialsService: credentialsService, + } } // CreateCheckoutRequest represents a checkout session request @@ -64,11 +68,15 @@ func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request) return } - // Get Stripe secret key - stripeSecretKey := os.Getenv("STRIPE_SECRET_KEY") - if stripeSecretKey == "" { - http.Error(w, "Payment service not configured", http.StatusInternalServerError) - return + // Get Stripe secret key from encrypted vault + stripeSecretKey, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe") + if err != nil { + // Fallback to Env if not found (for migration/dev) + stripeSecretKey = os.Getenv("STRIPE_SECRET_KEY") + if stripeSecretKey == "" { + http.Error(w, "Payment service not configured", http.StatusInternalServerError) + return + } } // Create Stripe checkout session via API diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 7a0a13e..171eb38 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -66,6 +66,7 @@ func NewRouter() http.Handler { authMiddleware := middleware.NewMiddleware(authService) adminService := services.NewAdminService(database.DB) + credentialsService := services.NewCredentialsService(database.DB) coreHandlers := apiHandlers.NewCoreHandlers( loginUC, @@ -80,6 +81,7 @@ func NewRouter() http.Handler { notificationService, // Added ticketService, // Added adminService, // Added for RBAC support + credentialsService, // Added for Encrypted Credentials ) adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService) @@ -87,6 +89,7 @@ func NewRouter() http.Handler { // Initialize Legacy Handlers jobHandler := handlers.NewJobHandler(jobService) applicationHandler := handlers.NewApplicationHandler(applicationService) + paymentHandler := handlers.NewPaymentHandler(jobService, credentialsService) // --- IP HELPER --- GetClientIP := func(r *http.Request) string { @@ -206,6 +209,9 @@ func NewRouter() http.Handler { mux.Handle("GET /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.GetTicket))) mux.Handle("POST /api/v1/support/tickets/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.AddMessage))) + // System Credentials Route + mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(coreHandlers.SaveCredentials)))) + // Application Routes mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication) mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications) @@ -213,6 +219,11 @@ func NewRouter() http.Handler { mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus) mux.HandleFunc("DELETE /api/v1/applications/{id}", applicationHandler.DeleteApplication) + // Payment Routes + mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout))) + mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook) + mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus) + // --- STORAGE ROUTES --- // Initialize S3 Storage (optional - graceful degradation if not configured) s3Storage, err := storage.NewS3Storage() diff --git a/backend/internal/services/credentials_service.go b/backend/internal/services/credentials_service.go new file mode 100644 index 0000000..b8af811 --- /dev/null +++ b/backend/internal/services/credentials_service.go @@ -0,0 +1,135 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "database/sql" + "encoding/base64" + "encoding/pem" + "fmt" + "os" + "sync" +) + +type CredentialsService struct { + DB *sql.DB + // Cache for decrypted keys + cache map[string]string + cacheMutex sync.RWMutex +} + +func NewCredentialsService(db *sql.DB) *CredentialsService { + return &CredentialsService{ + DB: db, + cache: make(map[string]string), + } +} + +// SaveCredentials saves the encrypted payload for a service +func (s *CredentialsService) SaveCredentials(ctx context.Context, serviceName, encryptedPayload, updatedBy string) error { + query := ` + INSERT INTO external_services_credentials (service_name, encrypted_payload, updated_by, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (service_name) + DO UPDATE SET + encrypted_payload = EXCLUDED.encrypted_payload, + updated_by = EXCLUDED.updated_by, + updated_at = NOW() + ` + _, err := s.DB.ExecContext(ctx, query, serviceName, encryptedPayload, updatedBy) + if err != nil { + return err + } + + // Invalidate cache + s.cacheMutex.Lock() + delete(s.cache, serviceName) + s.cacheMutex.Unlock() + + return nil +} + +// GetDecryptedKey retrieves and decrypts the key for a service +func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName string) (string, error) { + // Check cache first + s.cacheMutex.RLock() + if val, ok := s.cache[serviceName]; ok { + s.cacheMutex.RUnlock() + return val, nil + } + s.cacheMutex.RUnlock() + + // Fetch from DB + var encryptedPayload string + query := `SELECT encrypted_payload FROM external_services_credentials WHERE service_name = $1` + err := s.DB.QueryRowContext(ctx, query, serviceName).Scan(&encryptedPayload) + if err == sql.ErrNoRows { + return "", fmt.Errorf("credentials for service %s not found", serviceName) + } + if err != nil { + return "", err + } + + // Decrypt + decrypted, err := s.decryptPayload(encryptedPayload) + if err != nil { + return "", fmt.Errorf("failed to decrypt credentials: %w", err) + } + + // Update cache + s.cacheMutex.Lock() + s.cache[serviceName] = decrypted + s.cacheMutex.Unlock() + + return decrypted, nil +} + +func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, error) { + // 1. Decode Private Key from Env + rawPrivateKey, err := base64.StdEncoding.DecodeString(os.Getenv("RSA_PRIVATE_KEY_BASE64")) + if err != nil { + return "", fmt.Errorf("failed to decode env RSA private key: %w", err) + } + + block, _ := pem.Decode(rawPrivateKey) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the private key") + } + + privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try generic PKCS8 if PKCS1 fails + if key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes); err2 == nil { + if rsaKey, ok := key.(*rsa.PrivateKey); ok { + privKey = rsaKey + } else { + return "", fmt.Errorf("key is not RSA") + } + } else { + return "", err + } + } + + // 2. Decode ciphertext + ciphertext, err := base64.StdEncoding.DecodeString(encryptedPayload) + if err != nil { + return "", err + } + + // 3. Decrypt using RSA-OAEP + plaintext, err := rsa.DecryptOAEP( + sha256.New(), + rand.Reader, + privKey, + ciphertext, + nil, + ) + if err != nil { + return "", err + } + + return string(plaintext), nil +} diff --git a/backend/migrations/024_create_external_services_credentials.sql b/backend/migrations/024_create_external_services_credentials.sql new file mode 100644 index 0000000..c41f6b4 --- /dev/null +++ b/backend/migrations/024_create_external_services_credentials.sql @@ -0,0 +1,17 @@ +-- Migration: Create external_services_credentials table +-- Description: Stores encrypted credentials for third-party services (Stripe, Cloudflare, etc.) + +CREATE TABLE IF NOT EXISTS external_services_credentials ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + service_name VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'stripe', 'cloudflare' + encrypted_payload TEXT NOT NULL, -- RSA Encrypted Base64 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_by UUID, -- ID of the admin who updated it + + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Index for fast lookup by service name +CREATE INDEX idx_service_name ON external_services_credentials(service_name); + +COMMENT ON TABLE external_services_credentials IS 'Stores securely encrypted credentials for external services'; diff --git a/backoffice/src/app.module.ts b/backoffice/src/app.module.ts index 2521eee..8e18a5e 100644 --- a/backoffice/src/app.module.ts +++ b/backoffice/src/app.module.ts @@ -7,6 +7,7 @@ import { PlansModule } from './plans'; import { AdminModule } from './admin'; import { AuthModule } from './auth'; import { FcmTokensModule } from './fcm-tokens/fcm-tokens.module'; +import { ExternalServicesModule } from './external-services/external-services.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { FcmTokensModule } from './fcm-tokens/fcm-tokens.module'; PlansModule, AdminModule, FcmTokensModule, + ExternalServicesModule, ], controllers: [AppController], providers: [AppService], diff --git a/backoffice/src/external-services/external-services.controller.ts b/backoffice/src/external-services/external-services.controller.ts new file mode 100644 index 0000000..b5c29aa --- /dev/null +++ b/backoffice/src/external-services/external-services.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Post, Body, Headers, UseGuards, UnauthorizedException } from '@nestjs/common'; +import { ExternalServicesService } from './external-services.service'; +import { IsNotEmpty, IsString } from 'class-validator'; + +class SaveStripeKeyDto { + @IsNotEmpty() + @IsString() + secretKey: string; +} + +@Controller('admin/credentials') +export class ExternalServicesController { + constructor(private readonly externalServicesService: ExternalServicesService) { } + + @Post('stripe') + async saveStripeKey( + @Body() dto: SaveStripeKeyDto, + @Headers('authorization') authHeader: string + ) { + if (!authHeader) { + throw new UnauthorizedException('Missing Authorization header'); + } + + return await this.externalServicesService.saveStripeKey(dto.secretKey, authHeader); + } +} diff --git a/backoffice/src/external-services/external-services.module.ts b/backoffice/src/external-services/external-services.module.ts new file mode 100644 index 0000000..8aba665 --- /dev/null +++ b/backoffice/src/external-services/external-services.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ExternalServicesService } from './external-services.service'; +import { ExternalServicesController } from './external-services.controller'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [HttpModule, ConfigModule], + controllers: [ExternalServicesController], + providers: [ExternalServicesService], +}) +export class ExternalServicesModule { } diff --git a/backoffice/src/external-services/external-services.service.ts b/backoffice/src/external-services/external-services.service.ts new file mode 100644 index 0000000..fe8dbff --- /dev/null +++ b/backoffice/src/external-services/external-services.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { publicEncrypt, constants } from 'crypto'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class ExternalServicesService { + private readonly logger = new Logger(ExternalServicesService.name); + private readonly publicKey: string; + private readonly coreApiUrl: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + const b64Key = this.configService.get('RSA_PUBLIC_KEY_BASE64'); + if (b64Key) { + try { + this.publicKey = Buffer.from(b64Key, 'base64').toString('utf-8'); + } catch (e) { + this.logger.error('Failed to decode RSA Public Key', e); + } + } else { + this.logger.warn('RSA_PUBLIC_KEY_BASE64 is missing'); + } + + this.coreApiUrl = this.configService.get('CORE_API_URL') || 'http://localhost:8521'; + } + + async saveStripeKey(rawSecret: string, authHeader: string) { + if (!this.publicKey) { + throw new Error('RSA Public Key is not configured in Backoffice'); + } + + try { + const encrypted = publicEncrypt( + { + key: this.publicKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from(rawSecret, 'utf-8') + ); + + const encryptedBase64 = encrypted.toString('base64'); + + // Send to Core API + const response = await firstValueFrom( + this.httpService.post( + `${this.coreApiUrl}/api/v1/system/credentials`, + { + serviceName: 'stripe', + encryptedPayload: encryptedBase64, + }, + { + headers: { + Authorization: authHeader, + }, + } + ) + ); + + return response.data; + } catch (error) { + this.logger.error('Failed to save Stripe key', error); + throw error; + } + } +}