feat: migrate credentials management to backoffice nestjs
This commit is contained in:
parent
2e7da0b28e
commit
c26ad578b9
9 changed files with 158 additions and 94 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
31
backoffice/src/credentials/credentials.controller.ts
Normal file
31
backoffice/src/credentials/credentials.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
19
backoffice/src/credentials/credentials.entity.ts
Normal file
19
backoffice/src/credentials/credentials.entity.ts
Normal 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;
|
||||
}
|
||||
13
backoffice/src/credentials/credentials.module.ts
Normal file
13
backoffice/src/credentials/credentials.module.ts
Normal 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 { }
|
||||
82
backoffice/src/credentials/credentials.service.ts
Normal file
82
backoffice/src/credentials/credentials.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -708,12 +708,12 @@ export interface ConfiguredService {
|
|||
}
|
||||
|
||||
export const credentialsApi = {
|
||||
list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"),
|
||||
save: (serviceName: string, payload: any) => apiRequest<void>("/api/v1/system/credentials", {
|
||||
list: () => backofficeRequest<{ services: ConfiguredService[] }>("/system/credentials"),
|
||||
save: (serviceName: string, payload: any) => backofficeRequest<void>("/system/credentials", {
|
||||
method: "POST",
|
||||
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",
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue