Implement secure Stripe credential management using RSA encryption

This commit is contained in:
Tiago Yamamoto 2025-12-26 11:03:52 -03:00
parent d771e2a3a9
commit b1639dbcd8
10 changed files with 341 additions and 9 deletions

View file

@ -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"})
}

View file

@ -217,5 +217,6 @@ func createTestCoreHandlers(t *testing.T, loginUC *auth.LoginUseCase) *handlers.
nil,
nil,
nil,
nil,
)
}

View file

@ -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

View file

@ -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()

View file

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

View file

@ -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';

View file

@ -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],

View file

@ -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);
}
}

View file

@ -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 { }

View file

@ -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<string>('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<string>('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;
}
}
}