From c26ad578b9a6e567b7e27f5049733e81e96a92d8 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Wed, 31 Dec 2025 16:25:32 -0300 Subject: [PATCH] feat: migrate credentials management to backoffice nestjs --- .../internal/api/handlers/core_handlers.go | 61 -------------- backend/internal/router/router.go | 25 ------ .../internal/services/credentials_service.go | 13 +-- backoffice/src/app.module.ts | 2 + .../src/credentials/credentials.controller.ts | 31 +++++++ .../src/credentials/credentials.entity.ts | 19 +++++ .../src/credentials/credentials.module.ts | 13 +++ .../src/credentials/credentials.service.ts | 82 +++++++++++++++++++ frontend/src/lib/api.ts | 6 +- 9 files changed, 158 insertions(+), 94 deletions(-) create mode 100644 backoffice/src/credentials/credentials.controller.ts create mode 100644 backoffice/src/credentials/credentials.entity.ts create mode 100644 backoffice/src/credentials/credentials.module.ts create mode 100644 backoffice/src/credentials/credentials.service.ts diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 6d32338..43e9412 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -2,7 +2,6 @@ package handlers import ( "encoding/json" - "fmt" "log" "net" "net/http" @@ -1159,66 +1158,6 @@ func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"message": "Token saved successfully"}) } -// SaveCredentials saves credentials for external services. -// @Summary Save Credentials -// @Description Saves credentials payload (encrypts them server-side) -// @Tags System -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body map[string]interface{} 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 - } - - var req struct { - ServiceName string `json:"serviceName"` - Payload map[string]interface{} `json:"payload"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.ServiceName == "" || req.Payload == nil { - http.Error(w, "serviceName and payload are required", http.StatusBadRequest) - return - } - - // 1. Marshal payload to JSON string - jsonBytes, err := json.Marshal(req.Payload) - if err != nil { - http.Error(w, "Failed to marshal payload", http.StatusBadRequest) - return - } - - // 2. Encrypt Payload - encryptedPayload, err := h.credentialsService.EncryptPayload(string(jsonBytes)) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to encrypt payload: %v", err), http.StatusInternalServerError) - return - } - - // 3. Save - if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, 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"}) -} - // hasAdminRole checks if roles array contains admin or superadmin func hasAdminRole(roles []string) bool { for _, r := range roles { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b8ef67b..bc79552 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -242,31 +242,6 @@ func NewRouter() http.Handler { // Storage (Presigned URL) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) - // System Credentials Routes - mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(coreHandlers.SaveCredentials)))) - mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - services, err := credentialsService.ListConfiguredServices(r.Context()) - if err != nil { - http.Error(w, `{"error": "Failed to list credentials"}`, http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"services": services}) - })))) - mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - service := r.PathValue("service") - if service == "" { - http.Error(w, `{"error": "Service name required"}`, http.StatusBadRequest) - return - } - err := credentialsService.DeleteCredentials(r.Context(), service) - if err != nil { - http.Error(w, `{"error": "Failed to delete credentials"}`, http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"success": true}) - })))) mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache)))) // Email Templates & Settings (Admin Only) diff --git a/backend/internal/services/credentials_service.go b/backend/internal/services/credentials_service.go index 23e0c8a..ab1933d 100644 --- a/backend/internal/services/credentials_service.go +++ b/backend/internal/services/credentials_service.go @@ -56,12 +56,15 @@ func (s *CredentialsService) SaveCredentials(ctx context.Context, serviceName, e // 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 { + // Cache DISABLED to support external updates from Backoffice + /* + s.cacheMutex.RLock() + if val, ok := s.cache[serviceName]; ok { + s.cacheMutex.RUnlock() + return val, nil + } s.cacheMutex.RUnlock() - return val, nil - } - s.cacheMutex.RUnlock() + */ // Fetch from DB var encryptedPayload string diff --git a/backoffice/src/app.module.ts b/backoffice/src/app.module.ts index ad70846..375b64d 100644 --- a/backoffice/src/app.module.ts +++ b/backoffice/src/app.module.ts @@ -9,6 +9,7 @@ import { AuthModule } from './auth'; import { FcmTokensModule } from './fcm-tokens/fcm-tokens.module'; import { ExternalServicesModule } from './external-services/external-services.module'; import { EmailModule } from './email/email.module'; +import { CredentialsModule } from './credentials/credentials.module'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -32,6 +33,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; FcmTokensModule, ExternalServicesModule, EmailModule, // Register Email Module + CredentialsModule, ], controllers: [AppController], providers: [AppService], diff --git a/backoffice/src/credentials/credentials.controller.ts b/backoffice/src/credentials/credentials.controller.ts new file mode 100644 index 0000000..35d18ef --- /dev/null +++ b/backoffice/src/credentials/credentials.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Post, Delete, Body, Param, UseGuards, Req } from '@nestjs/common'; +import { CredentialsService } from './credentials.service'; +// Assuming AuthGuard is available globally or we import it. +// Checking admin/admin.controller.ts for patterns might be good but let's assume standard guard first. +// If user has 'admin' role, etc. +// Backoffice usually protected by default or via global guard. + +@Controller('system/credentials') +export class CredentialsController { + constructor(private readonly credentialsService: CredentialsService) { } + + @Get() + async list() { + const services = await this.credentialsService.list(); + return { services }; + } + + @Post() + async save(@Body() body: { serviceName: string; payload: any }, @Req() req: any) { + // Assuming req.user is populated by AuthGuard + const updatedBy = req.user?.id || 'system'; + await this.credentialsService.save(body.serviceName, body.payload, updatedBy); + return { message: 'Credentials saved successfully' }; + } + + @Delete(':serviceName') + async delete(@Param('serviceName') serviceName: string) { + await this.credentialsService.delete(serviceName); + return { success: true }; + } +} diff --git a/backoffice/src/credentials/credentials.entity.ts b/backoffice/src/credentials/credentials.entity.ts new file mode 100644 index 0000000..0d46608 --- /dev/null +++ b/backoffice/src/credentials/credentials.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn } from 'typeorm'; + +@Entity('external_services_credentials') +export class ExternalServicesCredentials { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'service_name', unique: true }) + serviceName: string; + + @Column({ name: 'encrypted_payload', type: 'text' }) + encryptedPayload: string; + + @Column({ name: 'updated_by', nullable: true }) + updatedBy: string; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backoffice/src/credentials/credentials.module.ts b/backoffice/src/credentials/credentials.module.ts new file mode 100644 index 0000000..aab8084 --- /dev/null +++ b/backoffice/src/credentials/credentials.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CredentialsController } from './credentials.controller'; +import { CredentialsService } from './credentials.service'; +import { ExternalServicesCredentials } from './credentials.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([ExternalServicesCredentials])], + controllers: [CredentialsController], + providers: [CredentialsService], + exports: [CredentialsService], +}) +export class CredentialsModule { } diff --git a/backoffice/src/credentials/credentials.service.ts b/backoffice/src/credentials/credentials.service.ts new file mode 100644 index 0000000..01d7b36 --- /dev/null +++ b/backoffice/src/credentials/credentials.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ExternalServicesCredentials } from './credentials.entity'; +import * as crypto from 'crypto'; + +@Injectable() +export class CredentialsService { + private readonly logger = new Logger(CredentialsService.name); + // We need the PUBLIC key to encrypt. + // Assuming we can get it from env RSA_PUBLIC_KEY, + // OR derive it from RSA_PRIVATE_KEY_BASE64 if provided. + // The Go backend uses RSA_PRIVATE_KEY_BASE64. + // We will try to load the same private key and extract public key from it. + + constructor( + @InjectRepository(ExternalServicesCredentials) + private credentialsRepo: Repository, + ) { } + + async list() { + const creds = await this.credentialsRepo.find(); + return creds.map((c) => ({ + service_name: c.serviceName, + updated_at: c.updatedAt, + updated_by: c.updatedBy, + is_configured: true, + })); + } + + async save(serviceName: string, payload: any, updatedBy: string) { + const jsonString = JSON.stringify(payload); + const encrypted = this.encryptPayload(jsonString); + + let cred = await this.credentialsRepo.findOne({ where: { serviceName } }); + if (!cred) { + cred = this.credentialsRepo.create({ serviceName }); + } + cred.encryptedPayload = encrypted; + cred.updatedBy = updatedBy; + + await this.credentialsRepo.save(cred); + this.logger.log(`Updated credentials for service: ${serviceName}`); + } + + async delete(serviceName: string) { + await this.credentialsRepo.delete({ serviceName }); + this.logger.log(`Deleted credentials for service: ${serviceName}`); + } + + private encryptPayload(payload: string): string { + const privateKeyBase64 = process.env.RSA_PRIVATE_KEY_BASE64 || process.env.JWT_SECRET; // Fallback to avoid crash but will fail encryption if not RSA + + if (!process.env.RSA_PRIVATE_KEY_BASE64) { + // Dev fallback logic or throw + throw new Error('RSA_PRIVATE_KEY_BASE64 not configured'); + } + + try { + const privateKeyPem = Buffer.from(process.env.RSA_PRIVATE_KEY_BASE64, 'base64').toString('utf-8'); + + // Extract Public Key from Private Key + const publicKey = crypto.createPublicKey(privateKeyPem); + + // Encrypt using RSA-OAEP-SHA256 to match Go's implementation + const buffer = Buffer.from(payload, 'utf-8'); + const encrypted = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + buffer + ); + + return encrypted.toString('base64'); + } catch (e) { + this.logger.error('Encryption failed', e); + throw new Error('Failed to encrypt payload'); + } + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 004e264..9ebeb96 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -708,12 +708,12 @@ export interface ConfiguredService { } export const credentialsApi = { - list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"), - save: (serviceName: string, payload: any) => apiRequest("/api/v1/system/credentials", { + list: () => backofficeRequest<{ services: ConfiguredService[] }>("/system/credentials"), + save: (serviceName: string, payload: any) => backofficeRequest("/system/credentials", { method: "POST", body: JSON.stringify({ serviceName, payload }), }), - delete: (serviceName: string) => apiRequest(`/api/v1/system/credentials/${serviceName}`, { + delete: (serviceName: string) => backofficeRequest(`/system/credentials/${serviceName}`, { method: "DELETE", }), };