Implement secure Stripe credential management using RSA encryption
This commit is contained in:
parent
d771e2a3a9
commit
b1639dbcd8
10 changed files with 341 additions and 9 deletions
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,5 +217,6 @@ func createTestCoreHandlers(t *testing.T, loginUC *auth.LoginUseCase) *handlers.
|
|||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,15 @@ import (
|
|||
// PaymentHandler handles Stripe payment operations
|
||||
type PaymentHandler struct {
|
||||
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,12 +68,16 @@ func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// Get Stripe secret key
|
||||
stripeSecretKey := os.Getenv("STRIPE_SECRET_KEY")
|
||||
// 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
|
||||
sessionID, checkoutURL, err := createStripeCheckoutSession(stripeSecretKey, req)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
135
backend/internal/services/credentials_service.go
Normal file
135
backend/internal/services/credentials_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
12
backoffice/src/external-services/external-services.module.ts
Normal file
12
backoffice/src/external-services/external-services.module.ts
Normal 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 { }
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue