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 (
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
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 = {
|
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",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue