feat: migrate credentials management to backoffice nestjs

This commit is contained in:
Tiago Yamamoto 2025-12-31 16:25:32 -03:00
parent 2e7da0b28e
commit c26ad578b9
9 changed files with 158 additions and 94 deletions

View file

@ -2,7 +2,6 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net" "net"
"net/http" "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"}) 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 // hasAdminRole checks if roles array contains admin or superadmin
func hasAdminRole(roles []string) bool { func hasAdminRole(roles []string) bool {
for _, r := range roles { for _, r := range roles {

View file

@ -242,31 +242,6 @@ func NewRouter() http.Handler {
// Storage (Presigned URL) // Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) 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)))) mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache))))
// Email Templates & Settings (Admin Only) // Email Templates & Settings (Admin Only)

View file

@ -56,12 +56,15 @@ func (s *CredentialsService) SaveCredentials(ctx context.Context, serviceName, e
// GetDecryptedKey retrieves and decrypts the key for a service // GetDecryptedKey retrieves and decrypts the key for a service
func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName string) (string, error) { func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName string) (string, error) {
// Check cache first // Check cache first
s.cacheMutex.RLock() // Cache DISABLED to support external updates from Backoffice
if val, ok := s.cache[serviceName]; ok { /*
s.cacheMutex.RLock()
if val, ok := s.cache[serviceName]; ok {
s.cacheMutex.RUnlock()
return val, nil
}
s.cacheMutex.RUnlock() s.cacheMutex.RUnlock()
return val, nil */
}
s.cacheMutex.RUnlock()
// Fetch from DB // Fetch from DB
var encryptedPayload string var encryptedPayload string

View file

@ -9,6 +9,7 @@ import { AuthModule } from './auth';
import { FcmTokensModule } from './fcm-tokens/fcm-tokens.module'; import { FcmTokensModule } from './fcm-tokens/fcm-tokens.module';
import { ExternalServicesModule } from './external-services/external-services.module'; import { ExternalServicesModule } from './external-services/external-services.module';
import { EmailModule } from './email/email.module'; import { EmailModule } from './email/email.module';
import { CredentialsModule } from './credentials/credentials.module';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
@ -32,6 +33,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
FcmTokensModule, FcmTokensModule,
ExternalServicesModule, ExternalServicesModule,
EmailModule, // Register Email Module EmailModule, // Register Email Module
CredentialsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View file

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

View file

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

View file

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

View file

@ -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<ExternalServicesCredentials>,
) { }
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');
}
}
}

View file

@ -708,12 +708,12 @@ export interface ConfiguredService {
} }
export const credentialsApi = { export const credentialsApi = {
list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"), list: () => backofficeRequest<{ services: ConfiguredService[] }>("/system/credentials"),
save: (serviceName: string, payload: any) => apiRequest<void>("/api/v1/system/credentials", { save: (serviceName: string, payload: any) => backofficeRequest<void>("/system/credentials", {
method: "POST", method: "POST",
body: JSON.stringify({ serviceName, payload }), body: JSON.stringify({ serviceName, payload }),
}), }),
delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, { delete: (serviceName: string) => backofficeRequest<void>(`/system/credentials/${serviceName}`, {
method: "DELETE", method: "DELETE",
}), }),
}; };