9 Commits

Author SHA1 Message Date
Alessio b75e57fe31 [Feature] Aggiunto supporto OAuth2 client_credentials per Salesforce
Implementato il flusso OAuth2 grant_type=client_credentials come alternativa
al flusso password gia' esistente per l'autenticazione Salesforce server-to-server.
La modifica e' completamente retrocompatibile (default rimane Password).

## Dettaglio modifiche

### CredentialManager/Models/CredentialModels.cs
- Aggiunto enum SalesforceGrantType con valori Password e ClientCredentials
- Aggiunta proprieta' GrantType (default: Password) su RestApiCredential
- Aggiunta proprieta' GrantType (default: Password) su SalesforceCredential

### DataConnection/REST/Configuration/RestServiceOptions.cs
- Aggiunta proprieta' SalesforceGrantType per passare il tipo di flusso al client

### DataConnection/REST/Implementations/SalesforceServiceClient.cs
- Iniettato ILogger<SalesforceServiceClient> con NullLogger come fallback
- Sostituiti ~165 Console.WriteLine con chiamate ILogger appropriate
  (LogDebug per dettagli, LogInformation per eventi, LogWarning/LogError per problemi)
- Aggiunto AuthenticateWithPasswordAsync: incapsula il flusso grant_type=password
- Aggiunto AuthenticateWithClientCredentialsAsync: implementa grant_type=client_credentials
  (richiede solo ClientId e ClientSecret, nessun utente, URL My Domain obbligatorio)
- Aggiunto SendTokenRequestAsync: helper condiviso per la POST al token endpoint
- Aggiornato AuthenticateAsync() override: instrada al flusso corretto in base a GrantType
- Rimosso modificatore static da NormalizeNumericValues (usava _logger, causava CS0120)

### Data_Coupler/Services/DataConnectionFactory.cs
- Mappatura del campo GrantType dalle opzioni Salesforce a RestServiceOptions
- Passaggio dell'ILogger al costruttore di SalesforceServiceClient

### CredentialManager/Services/CredentialService.cs
- SaveRestApiCredentialAsync (blocco Salesforce): serializza GrantType in AdditionalParameters
- SaveSalesforceCredentialAsync: aggiunto GrantType nel dizionario iniziale
- MapToRestApiCredential: deserializza GrantType da AdditionalParameters con Enum.TryParse
- MapToSalesforceCredential: idem per il tipo SalesforceCredential

### DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs
- TestSalesforceOAuthLogin aggiornato: per ClientCredentials invia solo client_id e
  client_secret (senza username/password/security_token); per Password comportamento invariato

### Data_Coupler/Pages/CredentialManagement.razor
- Aggiunto dropdown 'Tipo di Autenticazione OAuth2' nella sezione Salesforce
- I campi Username, Password e Security Token vengono nascosti quando si seleziona
  il flusso ClientCredentials
- Alert contestuale: warning My Domain URL per ClientCredentials, info per Password
- GrantType propagato correttamente in EditRestApiCredential e TestRestApiConnectionFromModal

### AGENTS.md
- Aggiunta sezione di documentazione per la nuova funzionalita' OAuth2 client_credentials
2026-05-24 23:11:22 +02:00
Alessio Dal Santo 9fab99112b [Fix] Sicurezza e affidabilità storico esecuzioni schedulazioni
- SchedulingHistory.razor / .cs: iniettato IWebHostEnvironment per nascondere
  lo stack trace (con percorsi di file) in produzione; in produzione viene
  mostrato solo il messaggio di errore sanitizzato e un avviso che invita a
  consultare i log dell'applicazione; in sviluppo il dettaglio completo resta
  visibile invariato.

- Scheduling.razor.cs (ExecuteScheduleManually): isolata la notifica JS
  (ShowSuccessMessage / ShowErrorMessage) in un blocco try-catch separato per
  TaskCanceledException / OperationCanceledException. In questo modo una
  disconnessione del browser durante un'esecuzione lunga non sovrascrive più
  il risultato già salvato correttamente come 'success' con uno stato 'failed'
  e lo stack trace di un'eccezione JSInterop. L'evento viene registrato come
  avviso di log senza impatto sul record storico.

- ScheduledJobService.cs: aggiunto commento esplicativo sul motivo per cui
  il dettaglio completo (ex.ToString) è salvato nel DB ma la UI ne mostra
  solo la versione sanitizzata in produzione.
2026-05-08 13:46:56 +02:00
Alessio Dal Santo 91dbe9ae11 [Feature] Aggiunta protezione machine-binding tramite MachineGuard
- Nuovo progetto MachineGuard: libreria che verifica se la macchina corrente
  è autorizzata all'esecuzione tramite DPAPI (Data Protection API di Windows)
- Nuovo progetto MachineGuardSetup: tool di configurazione da eseguire come
  Amministratore per registrare la macchina autorizzata
- Data_Coupler.sln: aggiunti entrambi i nuovi progetti alla soluzione
- Data_Coupler.csproj: aggiunto riferimento al progetto MachineGuard
- Program.cs: integrazione MachineGuard all'avvio dell'applicazione;
  se la macchina non è autorizzata l'app viene arrestata immediatamente
  con log critico e scrittura nel Windows Event Log
2026-03-30 16:42:43 +02:00
Alessio Dal Santo e43b7dc869 [Fix] Correzione controllo sicurezza query SQL: uso regex con word boundary per evitare falsi positivi su nomi colonna (es. UpdateDate, CreateDate) e gestione corretta dei prefissi sp_/xp_ 2026-03-30 16:40:39 +02:00
Alessio Dal Santo f1f75d59ac [Fix] Dockerfile.windows: aggiunge ContinuousIntegrationBuild=true a dotnet build e publish
Build and Push Docker Images / Build Linux Container (push) Successful in 6m9s
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Senza questo flag il target GenerateVersionJson nel .csproj veniva eseguito
dentro il container Docker Windows dove:
- Non esiste .git (fatal: not a git repository)
- version.json copiato da COPY e' read-only o protetto
→ MSB3491: Access to the path '...\wwwroot\version.json' is denied

Il Dockerfile Linux aveva gia /p:ContinuousIntegrationBuild=true.
La condizione nel .csproj e':
  Condition="'' != 'true' AND '' != 'true'"
quindi con il flag il target viene saltato e version.json usa quello
generato dal workflow prima del docker build.
2026-03-22 16:18:14 +01:00
Alessio Dal Santo 46fc21bf7b [Fix] GitHub Actions: aggiunge generazione version.json prima del Docker build
Il workflow GitHub non generava version.json sul runner prima del build,
quindi Docker copiava il file statico del repository (con versione vecchia 2.1.0).

La Gitea Actions usava gia questo approccio correttamente.

Fix applicato: lo step 'Calcola versione' ora genera anche version.json in
Data_Coupler/wwwroot/version.json per entrambi i job Linux e Windows,
con versione, commit SHA, branch, data build e ambiente (GitHub Actions).

Il VersionService legge version.json all'avvio per display nell'UI.
2026-03-22 16:11:30 +01:00
Alessio Dal Santo e125e758fb [Fix] Refactoring calcolo versione CI/CD: usa git describe invece di dotnet msbuild
Problemi risolti:
- GitHub Windows: errore PowerShell 'Missing closing )' causato da (cd Dir; cmd)
  sintassi bash non valida in PowerShell
- GitHub Linux: versione 1.0.0 invece di 2.3.2 perche il tag v2.3.2 esiste solo
  su Gitea e non su GitHub, quindi MinVer trovava il vecchio tag v1.0.0

Soluzione:
- Sostituito dotnet msbuild -getProperty:Version con git describe --tags --abbrev=0
  che e lo strumento nativo Git per ottenere l'ultimo tag raggiungibile
- Funziona identicamente su Linux (bash) e Windows (PowerShell)
- Non richiede dotnet installato ne accesso al .git dentro Docker
- Rimosso il dotnet build intermedio sul runner (non piu necessario)
- Corretti i percorsi version.json: ora usa Data_Coupler/wwwroot/version.json
  dal root del repo invece di wwwroot/ relativo dopo cd
2026-03-22 15:49:42 +01:00
Alessio Dal Santo c15e6c9065 [Fix] Passa versione MinVer come build-arg al Docker per evitare errore MINVER1001
Il problema era che MinVer veniva eseguito dentro il container Docker dove la
directory .git non esiste, causando il warning MINVER1001 e l'uso di 0.0.0-alpha.0
(poi sostituito dal fallback hardcoded 2.1.0).

Soluzione:
- La versione viene calcolata sul runner CI/CD (dove git e' disponibile)
- Esportata come variabile d'ambiente APP_VERSION via GITHUB_ENV
- Passata al Docker build tramite --build-arg APP_VERSION
- Nei Dockerfile aggiunto ARG APP_VERSION e /p:MinVerVersionOverride per imporla
  a MinVer senza che tenti di accedere a git (assente nel container)
- ARG ridichiarato dopo ogni FROM in multi-stage build (comportamento Docker)
2026-03-20 18:25:54 +01:00
Alessio Dal Santo 4262fd6d71 [Fix] Correzione versioning CI/CD: fetch completo storia Git per MinVer
Aggiunto fetch-depth: 0 al checkout in tutti i job dei workflow GitHub Actions e Gitea Actions.
Rimosso --depth 1 dal clone manuale del job Windows in Gitea.
MinVer necessita della storia completa per risalire ai tag Git e calcolare la versione corretta.
Senza questa correzione la versione risultava sempre 2.1.0 (fallback hardcoded).
Aggiornato anche il valore di fallback da 2.1.0 a 2.3.2.
2026-03-20 18:07:34 +01:00
30 changed files with 1206 additions and 308 deletions
+36 -41
View File
@@ -30,34 +30,30 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
- name: Generate version.json with MinVer - name: Calcola versione e genera version.json
run: | run: |
# Fetch all tags for MinVer to work correctly # Calcola versione tramite git describe (non richiede dotnet build)
git fetch --tags --force git fetch --tags --force
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
# Build project to trigger MinVer (calcola versione automaticamente) if [ -z "$LATEST_TAG" ]; then
cd Data_Coupler echo "Warning: Nessun tag Git trovato. Uso fallback."
dotnet build -c Release /p:ContinuousIntegrationBuild=true VERSION="2.3.2"
else
# Extract version calculated by MinVer from build output VERSION="${LATEST_TAG#v}"
VERSION=$(dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>/dev/null | tail -1)
# Fallback if MinVer fails (no tags)
if [ -z "$VERSION" ] || [ "$VERSION" = "0.0.0-alpha.0" ]; then
echo "Warning: No git tags found. MinVer returned default. Using fallback."
VERSION="2.1.0-alpha.0.$(git rev-list --count HEAD)"
fi fi
echo "MinVer calculated version: $VERSION" echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
# Create version.json # Genera version.json
cat > wwwroot/version.json <<EOF cat > Data_Coupler/wwwroot/version.json <<EOF
{ {
"version": "${VERSION}", "version": "${VERSION}",
"commitSha": "${GITHUB_SHA:0:7}", "commitSha": "${GITHUB_SHA:0:7}",
@@ -68,8 +64,10 @@ jobs:
EOF EOF
echo "Generated version.json:" echo "Generated version.json:"
cat wwwroot/version.json cat Data_Coupler/wwwroot/version.json
cd ..
# Esporta la versione come variabile d'ambiente per il Docker build
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
shell: bash shell: bash
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -136,6 +134,7 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
# Aumenta timeout per registry lenti # Aumenta timeout per registry lenti
build-args: | build-args: |
APP_VERSION=${{ env.APP_VERSION }}
BUILDKIT_STEP_LOG_MAX_SIZE=50000000 BUILDKIT_STEP_LOG_MAX_SIZE=50000000
provenance: false provenance: false
sbom: false sbom: false
@@ -159,7 +158,7 @@ jobs:
steps: steps:
- name: Checkout repository with Git - name: Checkout repository with Git
run: | run: |
git clone --depth 1 --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git . git clone --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git .
if not exist Dockerfile.windows ( if not exist Dockerfile.windows (
echo ERROR: Dockerfile.windows not found echo ERROR: Dockerfile.windows not found
exit /b 1 exit /b 1
@@ -175,33 +174,26 @@ jobs:
dotnet --version dotnet --version
shell: pwsh shell: pwsh
- name: Generate version.json with MinVer - name: Calcola versione e genera version.json
run: | run: |
# Fetch all tags for MinVer to work correctly # Calcola versione tramite git describe (non richiede dotnet build)
git fetch --tags --force git fetch --tags --force
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
# Build project to trigger MinVer if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
cd Data_Coupler Write-Host "Warning: Nessun tag Git trovato. Uso fallback."
dotnet build -c Release /p:ContinuousIntegrationBuild=true $VERSION = "2.3.2"
} else {
# Extract version calculated by MinVer $VERSION = $LATEST_TAG -replace '^v', ''
$VERSION = dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>$null | Select-Object -Last 1
# Fallback if MinVer fails (no tags)
if ([string]::IsNullOrWhiteSpace($VERSION) -or $VERSION -eq "0.0.0-alpha.0") {
Write-Host "Warning: No git tags found. MinVer returned default. Using fallback."
$commitCount = git rev-list --count HEAD
$VERSION = "2.1.0-alpha.0.$commitCount"
} }
Write-Host "MinVer calculated version: $VERSION" Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
$COMMIT_SHA = "${{ github.sha }}" $COMMIT_SHA = "${{ github.sha }}"
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7) $SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
$BRANCH = "${{ github.ref_name }}" $BRANCH = "${{ github.ref_name }}"
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC") $BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
# Create version.json # Genera version.json
$versionJson = @{ $versionJson = @{
version = $VERSION version = $VERSION
commitSha = $SHORT_SHA commitSha = $SHORT_SHA
@@ -210,11 +202,14 @@ jobs:
buildEnvironment = "Gitea Actions" buildEnvironment = "Gitea Actions"
} | ConvertTo-Json } | ConvertTo-Json
$versionJson | Out-File -FilePath "wwwroot\version.json" -Encoding UTF8 $versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
Write-Host "Generated version.json:" Write-Host "Generated version.json:"
Get-Content "wwwroot\version.json" Get-Content "Data_Coupler\wwwroot\version.json"
cd ..
# Esporta la versione come variabile d'ambiente per il Docker build
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "APP_VERSION=$VERSION esportata per Docker build"
shell: pwsh shell: pwsh
- name: Debug - Verify files - name: Debug - Verify files
@@ -265,7 +260,7 @@ jobs:
) )
echo Building Windows Docker image... echo Building Windows Docker image...
docker build -t temp-windows -f Dockerfile.windows . docker build --build-arg APP_VERSION=%APP_VERSION% -t temp-windows -f Dockerfile.windows .
if errorlevel 1 ( if errorlevel 1 (
echo Build failed! echo Build failed!
exit /b 1 exit /b 1
+75 -1
View File
@@ -31,6 +31,42 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Calcola versione e genera version.json
run: |
git fetch --tags --force
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
VERSION="2.3.2"
else
VERSION="${LATEST_TAG#v}"
fi
echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
# Genera version.json
cat > Data_Coupler/wwwroot/version.json <<EOF
{
"version": "${VERSION}",
"commitSha": "${GITHUB_SHA:0:7}",
"branch": "${GITHUB_REF_NAME}",
"buildDate": "$(date -u +"%Y-%m-%d %H:%M:%S UTC")",
"buildEnvironment": "GitHub Actions"
}
EOF
echo "Generated version.json:"
cat Data_Coupler/wwwroot/version.json
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
shell: bash
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -75,6 +111,8 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64 platforms: linux/amd64
build-args: |
APP_VERSION=${{ env.APP_VERSION }}
- name: Generate artifact attestation - name: Generate artifact attestation
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -95,6 +133,42 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Calcola versione e genera version.json
run: |
git fetch --tags --force
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
Write-Host "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
$VERSION = "2.3.2"
} else {
$VERSION = $LATEST_TAG -replace '^v', ''
}
Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
$COMMIT_SHA = "${{ github.sha }}"
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
$BRANCH = "${{ github.ref_name }}"
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
# Genera version.json
$versionJson = @{
version = $VERSION
commitSha = $SHORT_SHA
branch = $BRANCH
buildDate = $BUILD_DATE
buildEnvironment = "GitHub Actions"
} | ConvertTo-Json
$versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
Write-Host "Generated version.json:"
Get-Content "Data_Coupler\wwwroot\version.json"
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
shell: pwsh
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -128,7 +202,7 @@ jobs:
$imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower() $imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower()
# Build with temporary tag # Build with temporary tag
docker build -t "${imageName}:temp-windows" -f Dockerfile.windows . docker build --build-arg "APP_VERSION=$env:APP_VERSION" -t "${imageName}:temp-windows" -f Dockerfile.windows .
# Parse and push all tags # Parse and push all tags
$tags = "${{ steps.meta.outputs.tags }}" -split "`n" $tags = "${{ steps.meta.outputs.tags }}" -split "`n"
+38
View File
@@ -13,6 +13,44 @@
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati - **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza - **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce OAuth2 Client Credentials Flow (2026)**
### Supporto `grant_type=client_credentials` per autenticazione server-to-server
**Data Aggiornamento**: 2026
#### **Panoramica**
Aggiunto supporto per il flusso OAuth2 `client_credentials` come alternativa al flusso `password` già esistente.
Completamente retrocompatibile: il default rimane `Password`.
#### **Enum `SalesforceGrantType`** (in `CredentialManager/Models/CredentialModels.cs`)
```csharp
public enum SalesforceGrantType
{
Password, // grant_type=password — richiede Username, Password, SecurityToken (+ClientId/ClientSecret)
ClientCredentials // grant_type=client_credentials — server-to-server, nessun utente
}
```
#### **Differenze tra i flussi**
| Aspetto | `password` | `client_credentials` |
|---|---|---|
| Richiede Username/Password | ✅ Sì | ❌ No |
| Richiede SecurityToken | ✅ Sì (se non Connected App) | ❌ No |
| ClientId / ClientSecret | Opzionale | ✅ Obbligatorio |
| Base URL | login/test.salesforce.com | **My Domain URL** (es. `https://myorg.my.salesforce.com`) |
| Utente Salesforce | Necessario | Integration User (assegnato nella Connected App) |
#### **File modificati**
- `CredentialManager/Models/CredentialModels.cs` — enum `SalesforceGrantType`, proprietà `GrantType` su `RestApiCredential` e `SalesforceCredential`
- `DataConnection/REST/Configuration/RestServiceOptions.cs` — proprietà `SalesforceGrantType`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs``AuthenticateWithPasswordAsync`, `AuthenticateWithClientCredentialsAsync`, `SendTokenRequestAsync`; `ILogger` iniettato; `NormalizeNumericValues` reso non-static
- `Data_Coupler/Services/DataConnectionFactory.cs` — mapping `GrantType`, logger passato al client
- `CredentialManager/Services/CredentialService.cs``GrantType` serializzato/deserializzato in `AdditionalParameters` JSON
- `DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs``TestSalesforceOAuthLogin` instrada per `GrantType`
- `Data_Coupler/Pages/CredentialManagement.razor` — dropdown "Tipo di Autenticazione OAuth2"; Username/Password/SecurityToken nascosti per `ClientCredentials`; warning My Domain URL
---
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)** ## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)**
### Salesforce Batch Describe via Composite API ### Salesforce Batch Describe via Composite API
@@ -22,6 +22,27 @@ public enum RestServiceType
Salesforce Salesforce
} }
/// <summary>
/// Tipo di flusso OAuth2 per Salesforce
/// </summary>
public enum SalesforceGrantType
{
/// <summary>
/// Flusso Username/Password (grant_type=password).
/// Richiede: ClientId, ClientSecret, Username, Password (+SecurityToken se non IP-trusted).
/// URL di login: https://login.salesforce.com o https://test.salesforce.com.
/// </summary>
Password,
/// <summary>
/// Flusso Client Credentials (grant_type=client_credentials) — server-to-server, senza utente.
/// Richiede: ClientId, ClientSecret.
/// URL obbligatorio: My Domain URL (es. https://myorg.my.salesforce.com).
/// La Connected App deve avere "Enable Client Credentials Flow" attivato e un Integration User assegnato.
/// </summary>
ClientCredentials
}
/// <summary> /// <summary>
/// Tipi di database supportati (allineato con DataConnection.Enums.DatabaseType) /// Tipi di database supportati (allineato con DataConnection.Enums.DatabaseType)
/// </summary> /// </summary>
@@ -106,6 +127,7 @@ public class RestApiCredential
public string? ApiVersion { get; set; } = "59.0"; public string? ApiVersion { get; set; } = "59.0";
public bool IsSandbox { get; set; } = false; public bool IsSandbox { get; set; } = false;
public bool UseSoapApi { get; set; } = false; public bool UseSoapApi { get; set; } = false;
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public string? AccessToken { get; set; } public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; } public DateTime? TokenExpiry { get; set; }
@@ -145,6 +167,8 @@ public class SalesforceCredential
public bool IsSandbox { get; set; } = false; // Se è un ambiente sandbox public bool IsSandbox { get; set; } = false; // Se è un ambiente sandbox
public int TimeoutSeconds { get; set; } = 120; public int TimeoutSeconds { get; set; } = 120;
public bool UseSoapApi { get; set; } = false; // Se usare SOAP invece di REST public bool UseSoapApi { get; set; } = false; // Se usare SOAP invece di REST
/// <summary>Tipo di flusso OAuth2 da utilizzare. Default: Password (retrocompatibile).</summary>
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public string? AccessToken { get; set; } public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; } public DateTime? TokenExpiry { get; set; }
@@ -233,6 +233,7 @@ public class CredentialService : ICredentialService
additionalParams["ApiVersion"] = credential.ApiVersion; additionalParams["ApiVersion"] = credential.ApiVersion;
additionalParams["IsSandbox"] = credential.IsSandbox.ToString(); additionalParams["IsSandbox"] = credential.IsSandbox.ToString();
additionalParams["UseSoapApi"] = credential.UseSoapApi.ToString(); additionalParams["UseSoapApi"] = credential.UseSoapApi.ToString();
additionalParams["GrantType"] = credential.GrantType.ToString();
if (!string.IsNullOrEmpty(credential.RefreshToken)) if (!string.IsNullOrEmpty(credential.RefreshToken))
additionalParams["RefreshToken"] = credential.RefreshToken; additionalParams["RefreshToken"] = credential.RefreshToken;
if (!string.IsNullOrEmpty(credential.AccessToken)) if (!string.IsNullOrEmpty(credential.AccessToken))
@@ -523,7 +524,8 @@ public class CredentialService : ICredentialService
["SecurityToken"] = credential.SecurityToken, ["SecurityToken"] = credential.SecurityToken,
["ApiVersion"] = credential.ApiVersion, ["ApiVersion"] = credential.ApiVersion,
["IsSandbox"] = credential.IsSandbox.ToString(), ["IsSandbox"] = credential.IsSandbox.ToString(),
["UseSoapApi"] = credential.UseSoapApi.ToString() ["UseSoapApi"] = credential.UseSoapApi.ToString(),
["GrantType"] = credential.GrantType.ToString()
}; };
// Aggiungi ClientId e ClientSecret se forniti // Aggiungi ClientId e ClientSecret se forniti
@@ -793,6 +795,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox; credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap)) if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap; credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken)) if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken; credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken)) if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -915,6 +919,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox; credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap)) if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap; credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken)) if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken; credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken)) if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -863,8 +863,22 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
try try
{ {
var tokenUrl = credential.LoginUrl.TrimEnd('/') + "/services/oauth2/token"; var tokenUrl = credential.LoginUrl.TrimEnd('/') + "/services/oauth2/token";
List<KeyValuePair<string, string>> tokenData;
var tokenData = new List<KeyValuePair<string, string>> if (credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
// Client Credentials flow — server-to-server, no user
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "client_credentials"),
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
}
else
{
// Password flow (default)
tokenData = new List<KeyValuePair<string, string>>
{ {
new("grant_type", "password"), new("grant_type", "password"),
new("username", credential.Username), new("username", credential.Username),
@@ -872,14 +886,16 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
new("client_id", credential.ClientId ?? ""), new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "") new("client_secret", credential.ClientSecret ?? "")
}; };
}
var tokenContent = new FormUrlEncodedContent(tokenData); var tokenContent = new FormUrlEncodedContent(tokenData);
var response = await httpClient.PostAsync(tokenUrl, tokenContent); var response = await httpClient.PostAsync(tokenUrl, tokenContent);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var responseContent = await response.Content.ReadAsStringAsync(); var flowLabel = credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2\n- Timeout: {credential.TimeoutSeconds}s"); ? "client_credentials" : "password";
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2 ({flowLabel})\n- Timeout: {credential.TimeoutSeconds}s");
} }
else else
{ {
@@ -41,6 +41,11 @@ namespace DataConnection.REST.Configuration
/// </summary> /// </summary>
public bool IgnoreSslErrors { get; set; } = false; public bool IgnoreSslErrors { get; set; } = false;
// Add other relevant configuration properties (e.g., OAuth settings, specific headers) /// <summary>
/// Salesforce OAuth2 grant type. Default: Password (retrocompatibile).
/// ClientCredentials = server-to-server, senza utente.
/// </summary>
public CredentialManager.Models.SalesforceGrantType SalesforceGrantType { get; set; }
= CredentialManager.Models.SalesforceGrantType.Password;
} }
} }
File diff suppressed because it is too large Load Diff
+28
View File
@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CredentialManager", "Creden
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuard", "MachineGuard\MachineGuard.csproj", "{AFF3AD52-0356-4879-A0C8-67819611445A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuardSetup", "MachineGuardSetup\MachineGuardSetup.csproj", "{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -69,6 +73,30 @@ Global
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU {B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU {B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU {B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -286,6 +286,8 @@ public class ScheduledJobService : BackgroundService
executionHistory.EndTime = DateTime.Now; executionHistory.EndTime = DateTime.Now;
executionHistory.Status = "failed"; executionHistory.Status = "failed";
executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}"; executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}";
// Memorizza il dettaglio completo (stack trace) solo per scopi diagnostici;
// la UI in produzione ne mostrerà una versione sanitizzata senza percorsi di file.
executionHistory.ErrorDetails = ex.ToString(); executionHistory.ErrorDetails = ex.ToString();
await scheduleService.UpdateExecutionHistoryAsync(executionHistory); await scheduleService.UpdateExecutionHistoryAsync(executionHistory);
} }
+1
View File
@@ -20,6 +20,7 @@
<ProjectReference Include="..\DataConnection\DataConnection.csproj" /> <ProjectReference Include="..\DataConnection\DataConnection.csproj" />
<ProjectReference Include="..\CredentialManager\CredentialManager.csproj" /> <ProjectReference Include="..\CredentialManager\CredentialManager.csproj" />
<ProjectReference Include="..\Components\Components.csproj" /> <ProjectReference Include="..\Components\Components.csproj" />
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+35 -9
View File
@@ -663,11 +663,30 @@ else
} <!-- Campi specifici per Salesforce --> } <!-- Campi specifici per Salesforce -->
@if (currentRestApiCredential.ServiceType == RestServiceType.Salesforce) @if (currentRestApiCredential.ServiceType == RestServiceType.Salesforce)
{ {
<div class="alert alert-info"> <div class="row mb-3">
<strong>Opzioni di Autenticazione:</strong><br/> <div class="col-md-12">
• <strong>Username/Password + Security Token:</strong> Autenticazione standard<br/> <label class="form-label fw-semibold">Tipo di Autenticazione OAuth2</label>
• <strong>Username/Password + Client ID/Secret:</strong> Autenticazione OAuth<br/> <InputSelect class="form-select" @bind-Value="currentRestApiCredential.GrantType">
• Il Security Token è richiesto solo se non si configura una Connected App (Client ID/Secret) <option value="Password">Password Flow — Username + Password + Security Token (grant_type=password)</option>
<option value="ClientCredentials">Client Credentials — Server-to-Server, nessun utente (grant_type=client_credentials)</option>
</InputSelect>
@if (currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="alert alert-warning mt-2 py-2">
<i class="fa fa-exclamation-triangle me-1"></i>
<strong>client_credentials</strong>: il <strong>Base URL</strong> deve essere il <strong>My Domain URL</strong> della tua org
(es. <code>https://myorg.my.salesforce.com</code>), <strong>non</strong> login.salesforce.com.<br/>
Richiede: Connected App con "Enable Client Credentials Flow" attivato e un Integration User assegnato.
</div>
}
else
{
<div class="alert alert-info mt-2 py-2">
<i class="fa fa-info-circle me-1"></i>
<strong>password flow</strong>: Username, Password + Security Token. Client ID/Secret facoltativi (Connected App).
</div>
}
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -687,11 +706,15 @@ else
<div class="form-text">Esempio: 59.0</div> <div class="form-text">Esempio: 59.0</div>
</div> </div>
</div> </div>
</div> <div class="mb-3"> </div>
@if (currentRestApiCredential.GrantType != CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="mb-3">
<label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label> <label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label>
<InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" /> <InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" />
<div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div> <div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div>
</div> </div>
}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -738,8 +761,9 @@ else
} }
<!-- Campi comuni per autenticazione username/password --> <!-- Campi comuni per autenticazione username/password -->
@if (currentRestApiCredential.ServiceType != RestServiceType.Generic || @if ((currentRestApiCredential.ServiceType != RestServiceType.Generic ||
(string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken))) (string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken))) &&
!(currentRestApiCredential.ServiceType == RestServiceType.Salesforce && currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials))
{ <div class="row"> { <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
@@ -1312,6 +1336,7 @@ else
ApiVersion = credential.ApiVersion, ApiVersion = credential.ApiVersion,
IsSandbox = credential.IsSandbox, IsSandbox = credential.IsSandbox,
UseSoapApi = credential.UseSoapApi, UseSoapApi = credential.UseSoapApi,
GrantType = credential.GrantType,
RefreshToken = credential.RefreshToken, RefreshToken = credential.RefreshToken,
AccessToken = credential.AccessToken, AccessToken = credential.AccessToken,
TokenExpiry = credential.TokenExpiry TokenExpiry = credential.TokenExpiry
@@ -1533,7 +1558,8 @@ else
ClientSecret = currentRestApiCredential.ClientSecret, ClientSecret = currentRestApiCredential.ClientSecret,
ApiVersion = currentRestApiCredential.ApiVersion, ApiVersion = currentRestApiCredential.ApiVersion,
IsSandbox = currentRestApiCredential.IsSandbox, IsSandbox = currentRestApiCredential.IsSandbox,
UseSoapApi = currentRestApiCredential.UseSoapApi UseSoapApi = currentRestApiCredential.UseSoapApi,
GrantType = currentRestApiCredential.GrantType
}; // Salviamo temporaneamente la credenziale per il test }; // Salviamo temporaneamente la credenziale per il test
await CredentialService.SaveRestApiCredentialAsync(tempCredential); await CredentialService.SaveRestApiCredentialAsync(tempCredential);
+23 -6
View File
@@ -2894,25 +2894,42 @@ public partial class DataCoupler : ComponentBase
if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)) if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
return false; return false;
// Lista di parole chiave vietate per sicurezza // Parole chiave complete: devono essere token SQL isolati (\bKEYWORD\b)
var forbiddenKeywords = new[] // Evita falsi positivi su nomi di colonne come UpdateDate, CreateDate, DeletedAt, ecc.
var forbiddenFullKeywords = new[]
{ {
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
"EXEC", "EXECUTE", "sp_", "xp_", "BULK", "OPENROWSET", "OPENDATASOURCE" "EXEC", "EXECUTE", "BULK", "OPENROWSET", "OPENDATASOURCE"
}; };
// Prefissi pericolosi: devono iniziare la parola (sp_anything, xp_anything)
// Non si usa \bprefix\b perché _ è un word-char e mancherebbe sp_executesql, xp_cmdshell, ecc.
var forbiddenPrefixes = new[] { "SP_", "XP_" };
var upperQuery = trimmedQuery.ToUpperInvariant(); var upperQuery = trimmedQuery.ToUpperInvariant();
// Verifica che non contenga parole chiave vietate // Verifica parole chiave complete con word boundary
foreach (var keyword in forbiddenKeywords) foreach (var keyword in forbiddenFullKeywords)
{ {
if (upperQuery.Contains(keyword)) var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(keyword)}\b";
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
{ {
Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword); Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword);
return false; return false;
} }
} }
// Verifica prefissi stored procedure: \bSP_ cattura sp_anything, xp_anything
foreach (var prefix in forbiddenPrefixes)
{
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(prefix)}";
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
{
Logger.LogWarning("Query rifiutata: contiene prefisso stored procedure vietato '{Prefix}'", prefix);
return false;
}
}
return true; return true;
} }
+19 -4
View File
@@ -311,14 +311,21 @@ public partial class Scheduling : ComponentBase
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed); await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed);
// Notifica l'utente (best-effort: la connessione browser potrebbe essere stata interrotta
// durante un'esecuzione lunga senza che questo invalidi il risultato già salvato).
try
{
if (result.IsSuccess) if (result.IsSuccess)
{
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi."); await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
}
else else
{
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}"); await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
} }
catch (OperationCanceledException)
{
// La connessione Blazor è stata interrotta durante l'esecuzione: il risultato è
// già stato salvato correttamente, la notifica non può essere recapitata.
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser interrotta durante l'esecuzione", scheduleId);
}
await LoadSchedules(); await LoadSchedules();
} }
@@ -326,7 +333,7 @@ public partial class Scheduling : ComponentBase
{ {
Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId); Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId);
// Aggiorna lo storico in caso di eccezione // Aggiorna lo storico in caso di eccezione durante l'esecuzione effettiva
if (executionHistory != null) if (executionHistory != null)
{ {
executionHistory.EndTime = DateTime.Now; executionHistory.EndTime = DateTime.Now;
@@ -337,8 +344,16 @@ public partial class Scheduling : ComponentBase
} }
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}"); await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}");
try
{
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message); await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
} }
catch (OperationCanceledException)
{
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser non disponibile", scheduleId);
}
}
finally finally
{ {
isExecuting = false; isExecuting = false;
@@ -235,7 +235,15 @@
{ {
<h6>Dettagli Errori</h6> <h6>Dettagli Errori</h6>
<div class="alert alert-danger"> <div class="alert alert-danger">
@if (IsDevelopment)
{
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre> <pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
}
else
{
<p class="mb-1">@GetSanitizedErrorMessage(selectedExecution.ErrorDetails)</p>
<small class="text-muted"><i class="fas fa-info-circle"></i> Per i dettagli tecnici completi consultare i log dell'applicazione.</small>
}
</div> </div>
} }
@@ -1,6 +1,7 @@
using CredentialManager.Models; using CredentialManager.Models;
using CredentialManager.Services; using CredentialManager.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -11,6 +12,29 @@ public partial class SchedulingHistory : ComponentBase
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!; [Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!; [Inject] private IJSRuntime JSRuntime { get; set; } = null!;
[Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!; [Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!;
[Inject] private IWebHostEnvironment WebHostEnvironment { get; set; } = null!;
protected bool IsDevelopment => WebHostEnvironment.IsDevelopment();
/// <summary>
/// Restituisce solo il messaggio dell'eccezione (senza stack trace) per la visualizzazione in produzione.
/// </summary>
protected static string GetSanitizedErrorMessage(string errorDetails)
{
if (string.IsNullOrEmpty(errorDetails))
return string.Empty;
// Prende solo le righe fino al primo stack frame (riga che inizia con " at")
var lines = errorDetails.Split('\n');
var messageLines = new System.Collections.Generic.List<string>();
foreach (var line in lines)
{
if (line.TrimStart().StartsWith("at ", StringComparison.Ordinal))
break;
messageLines.Add(line.TrimEnd());
}
return string.Join("\n", messageLines).Trim();
}
protected List<ScheduleExecutionHistory>? executionHistory; protected List<ScheduleExecutionHistory>? executionHistory;
protected ScheduleExecutionHistory? selectedExecution; protected ScheduleExecutionHistory? selectedExecution;
+36
View File
@@ -10,6 +10,7 @@ using CredentialManager;
using Data_Coupler.Services; using Data_Coupler.Services;
using Data_Coupler.BackgroundServices; using Data_Coupler.BackgroundServices;
using CredentialManager.Services; using CredentialManager.Services;
using MachineGuard;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -130,6 +131,9 @@ builder.Services.AddScoped<Data_Coupler.Services.IDeletionSyncService, Data_Coup
// Register Background Services (solo uno per evitare duplicazioni) // Register Background Services (solo uno per evitare duplicazioni)
builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>(); builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>();
// Register MachineGuard — protezione machine-binding tramite DPAPI
builder.Services.AddMachineGuard(builder.Configuration);
// Configurazione URL e timeout per servizio Windows // Configurazione URL e timeout per servizio Windows
var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550"; var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550";
builder.WebHost.UseUrls(urls); builder.WebHost.UseUrls(urls);
@@ -143,6 +147,38 @@ builder.WebHost.ConfigureKestrel(serverOptions =>
var app = builder.Build(); var app = builder.Build();
#region MachineGuard verifica autorizzazione macchina
// Questa verifica deve avvenire PRIMA di qualsiasi altra inizializzazione.
// Se la macchina non è autorizzata, l'applicazione viene arrestata immediatamente.
{
var machineGuard = app.Services.GetRequiredService<IMachineGuard>();
if (!machineGuard.Verify())
{
var critLogger = app.Services.GetRequiredService<ILogger<Program>>();
critLogger.LogCritical(
"MachineGuard: questa macchina NON è autorizzata a eseguire Data Coupler. " +
"Eseguire MachineGuardSetup.exe come Amministratore per configurare questa macchina. " +
"Applicazione arrestata.");
if (OperatingSystem.IsWindows())
{
try
{
using var eventLog = new System.Diagnostics.EventLog("Application");
eventLog.Source = "DataCouplerService";
eventLog.WriteEntry(
"MachineGuard: macchina non autorizzata. " +
"Eseguire MachineGuardSetup.exe come Amministratore. Applicazione arrestata.",
System.Diagnostics.EventLogEntryType.Error);
}
catch { /* Ignora errori di scrittura EventLog */ }
}
Environment.Exit(1);
}
}
#endregion
// Initialize database con timeout e retry // Initialize database con timeout e retry
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -133,7 +133,9 @@ namespace Data_Coupler.Services
// Per Salesforce usiamo i campi specifici ClientId e ClientSecret // Per Salesforce usiamo i campi specifici ClientId e ClientSecret
options.ApiKey = credential.ClientId; // ClientId -> ApiKey options.ApiKey = credential.ClientId; // ClientId -> ApiKey
options.AuthToken = credential.ClientSecret; // ClientSecret -> AuthToken options.AuthToken = credential.ClientSecret; // ClientSecret -> AuthToken
_logger.LogInformation("Salesforce mapping - ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}", options.SalesforceGrantType = credential.GrantType;
_logger.LogInformation("Salesforce mapping - GrantType: {GrantType}, ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}",
credential.GrantType,
credential.ClientId, credential.ClientId,
!string.IsNullOrEmpty(credential.ClientSecret), !string.IsNullOrEmpty(credential.ClientSecret),
credential.Username, credential.Username,
@@ -215,7 +217,9 @@ namespace Data_Coupler.Services
{ {
var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>(); var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(); var httpClient = httpClientFactory.CreateClient();
return new SalesforceServiceClient(httpClient, options); var loggerFactory = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
var sfLogger = loggerFactory?.CreateLogger<DataConnection.REST.Implementations.SalesforceServiceClient>();
return new DataConnection.REST.Implementations.SalesforceServiceClient(httpClient, options, sfLogger);
} }
/// <summary> /// <summary>
+7 -2
View File
@@ -3,6 +3,8 @@
# Stage 1: Build # Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
ARG APP_VERSION=0.0.0-alpha.0
WORKDIR /src WORKDIR /src
# Copia i file di progetto e ripristina le dipendenze # Copia i file di progetto e ripristina le dipendenze
@@ -20,15 +22,18 @@ COPY . .
# Build del progetto principale # Build del progetto principale
WORKDIR "/src/Data_Coupler" WORKDIR "/src/Data_Coupler"
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=${APP_VERSION}
# Stage 2: Publish # Stage 2: Publish
FROM build AS publish FROM build AS publish
# Necessario ridichiarare ARG dopo FROM in multi-stage build
ARG APP_VERSION=0.0.0-alpha.0
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \ RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \
/p:SelfContained=false \ /p:SelfContained=false \
/p:PublishTrimmed=false \ /p:PublishTrimmed=false \
/p:PublishSingleFile=false \ /p:PublishSingleFile=false \
/p:ContinuousIntegrationBuild=true /p:ContinuousIntegrationBuild=true \
/p:MinVerVersionOverride=${APP_VERSION}
# Stage 3: Runtime # Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
+6 -2
View File
@@ -3,6 +3,8 @@
# Stage 1: Build # Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
ARG APP_VERSION=0.0.0-alpha.0
WORKDIR /s WORKDIR /s
# Copia i file di progetto e ripristina le dipendenze con nomi originali # Copia i file di progetto e ripristina le dipendenze con nomi originali
@@ -23,11 +25,13 @@ COPY ["Components/", "Components/"]
# Build del progetto principale con output path corto # Build del progetto principale con output path corto
WORKDIR "/s/Data_Coupler" WORKDIR "/s/Data_Coupler"
RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
# Stage 2: Publish # Stage 2: Publish
FROM build AS publish FROM build AS publish
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false # Necessario ridichiarare ARG dopo FROM in multi-stage build
ARG APP_VERSION=0.0.0-alpha.0
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
# Stage 3: Runtime # Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final
+85
View File
@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace MachineGuard;
/// <summary>
/// Implementazione Windows-only della protezione machine-binding tramite DPAPI.
/// Utilizza <see cref="ProtectedData"/> con scope <see cref="DataProtectionScope.LocalMachine"/>:
/// il dato cifrato è legato fisicamente alla macchina e non può essere decifrato
/// su un'altra macchina, anche con gli stessi account o credenziali.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DpapiMachineGuard : IMachineGuard
{
private readonly MachineGuardOptions _options;
private readonly ILogger<DpapiMachineGuard> _logger;
public DpapiMachineGuard(IOptions<MachineGuardOptions> options, ILogger<DpapiMachineGuard> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc/>
public bool Verify()
{
var secretPath = ResolveSecretFilePath();
if (!File.Exists(secretPath))
{
_logger.LogError(
"MachineGuard: file secret non trovato in '{Path}'. " +
"Eseguire MachineGuardSetup.exe su questa macchina per inizializzare l'autorizzazione.",
secretPath);
return false;
}
try
{
var encryptedBytes = File.ReadAllBytes(secretPath);
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
if (!string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal))
{
_logger.LogError(
"MachineGuard: il token decifrato non corrisponde al token atteso. " +
"Questa macchina non è autorizzata a eseguire questa applicazione.");
return false;
}
_logger.LogInformation("MachineGuard: autorizzazione macchina verificata con successo.");
return true;
}
catch (CryptographicException ex)
{
_logger.LogError(ex,
"MachineGuard: decifrazione fallita. " +
"Il file secret potrebbe provenire da un'altra macchina o essere corrotto. " +
"Percorso: '{Path}'", secretPath);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex,
"MachineGuard: errore imprevisto durante la verifica. Percorso: '{Path}'", secretPath);
return false;
}
}
private string ResolveSecretFilePath()
{
if (!string.IsNullOrWhiteSpace(_options.SecretFilePath))
return _options.SecretFilePath;
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (string.IsNullOrEmpty(appData))
appData = @"C:\ProgramData";
return Path.Combine(appData, "DataCoupler", "machine.guard");
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MachineGuard;
/// <summary>
/// Interfaccia per il meccanismo di protezione machine-binding tramite DPAPI.
/// </summary>
public interface IMachineGuard
{
/// <summary>
/// Verifica che l'applicazione sia in esecuzione su una macchina autorizzata.
/// Restituisce <c>true</c> se l'autorizzazione è confermata, <c>false</c> altrimenti.
/// </summary>
bool Verify();
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
</ItemGroup>
</Project>
+75
View File
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MachineGuard;
/// <summary>
/// Metodi di estensione per la registrazione di MachineGuard nel container DI.
/// </summary>
public static class MachineGuardExtensions
{
/// <summary>
/// Registra il servizio <see cref="IMachineGuard"/> nel container DI.
/// <para>
/// Se <c>MachineGuard:Enabled</c> è <c>false</c> in appsettings, viene registrato
/// un guard no-op che approva sempre la verifica (utile per sviluppo/CI).
/// Su piattaforme non-Windows, DPAPI non è disponibile e il guard viene disabilitato automaticamente.
/// </para>
/// </summary>
public static IServiceCollection AddMachineGuard(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<MachineGuardOptions>(
configuration.GetSection(MachineGuardOptions.SectionName));
services.AddSingleton<IMachineGuard>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("MachineGuard.Startup");
#if DEBUG
// In build Debug la protezione è sempre disabilitata — nessuna configurazione richiesta.
logger.LogInformation(
"MachineGuard: build DEBUG — protezione machine-binding disabilitata automaticamente.");
return new NullMachineGuard();
#else
// In build Release la protezione è sempre attiva.
// Può essere disabilitata esplicitamente via MachineGuard:Enabled = false
// (utile per ambienti Linux/Docker o casi eccezionali).
var options = sp.GetRequiredService<IOptions<MachineGuardOptions>>();
if (!options.Value.Enabled)
{
logger.LogWarning(
"MachineGuard: protezione machine-binding DISABILITATA via configurazione. " +
"Impostare MachineGuard:Enabled = true in produzione.");
return new NullMachineGuard();
}
if (!OperatingSystem.IsWindows())
{
logger.LogWarning(
"MachineGuard: DPAPI non è disponibile su piattaforme non-Windows. " +
"La protezione machine-binding è bypassata automaticamente.");
return new NullMachineGuard();
}
return CreateWindowsGuard(sp, options);
#endif
});
return services;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static IMachineGuard CreateWindowsGuard(
IServiceProvider sp,
IOptions<MachineGuardOptions> options)
{
var logger = sp.GetRequiredService<ILogger<DpapiMachineGuard>>();
return new DpapiMachineGuard(options, logger);
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace MachineGuard;
/// <summary>
/// Opzioni di configurazione per MachineGuard.
/// Configurabili tramite appsettings.json nella sezione "MachineGuard".
/// </summary>
public sealed class MachineGuardOptions
{
/// <summary>Nome della sezione in appsettings.json.</summary>
public const string SectionName = "MachineGuard";
/// <summary>
/// Imposta a <c>false</c> per disabilitare completamente la protezione machine-binding.
/// Utile in ambienti di sviluppo o CI. Default: <c>true</c>.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Percorso del file secret cifrato.
/// Se vuoto, viene usato il percorso predefinito:
/// Windows: %ProgramData%\DataCoupler\machine.guard
/// Linux: /etc/datacoupler/machine.guard
/// </summary>
public string? SecretFilePath { get; set; }
}
+73
View File
@@ -0,0 +1,73 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace MachineGuard;
/// <summary>
/// Helper pubblico per la scrittura e la verifica del file secret di MachineGuard.
/// Usato da MachineGuardSetup e da eventuali script di deployment.
/// </summary>
public static class MachineGuardSetupHelper
{
/// <summary>
/// Cifra il token interno con DPAPI (LocalMachine scope) e lo scrive nel percorso specificato.
/// Crea la directory di destinazione se non esiste.
/// </summary>
/// <param name="secretFilePath">
/// Percorso completo del file in cui salvare il secret cifrato.
/// Usare <see cref="GetDefaultSecretFilePath"/> per il percorso di default.
/// </param>
[SupportedOSPlatform("windows")]
public static void WriteSecret(string secretFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(secretFilePath);
var tokenBytes = Encoding.UTF8.GetBytes(MachineGuardToken.ExpectedToken);
var encryptedBytes = ProtectedData.Protect(tokenBytes, null, DataProtectionScope.LocalMachine);
var directory = Path.GetDirectoryName(secretFilePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllBytes(secretFilePath, encryptedBytes);
}
/// <summary>
/// Verifica che il file secret nel percorso specificato decifrabile con successo prima del deployment.
/// </summary>
[SupportedOSPlatform("windows")]
public static bool VerifySecret(string secretFilePath)
{
if (!File.Exists(secretFilePath))
return false;
try
{
var encryptedBytes = File.ReadAllBytes(secretFilePath);
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
return string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Restituisce il percorso predefinito del file secret in base al sistema operativo corrente.
/// </summary>
public static string GetDefaultSecretFilePath()
{
if (OperatingSystem.IsWindows())
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (string.IsNullOrEmpty(appData))
appData = @"C:\ProgramData";
return Path.Combine(appData, "DataCoupler", "machine.guard");
}
return "/etc/datacoupler/machine.guard";
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MachineGuard;
/// <summary>
/// Contiene il token di machine-binding cablato nel codice.
/// Modificare questo valore per ogni distribuzione autorizzata,
/// quindi eseguire MachineGuardSetup per scrivere il secret su ogni macchina target.
/// </summary>
internal static class MachineGuardToken
{
/// <summary>
/// Token atteso. Deve corrispondere esattamente al valore firmato durante il setup.
/// </summary>
internal const string ExpectedToken = "DC-F47AC10B-58CC-4372-A567-0E02B2C3D479";
}
+13
View File
@@ -0,0 +1,13 @@
namespace MachineGuard;
/// <summary>
/// Implementazione no-op di <see cref="IMachineGuard"/>.
/// Usata quando la protezione è disabilitata via configurazione
/// o quando il sistema operativo non supporta DPAPI (es. Linux, macOS).
/// Restituisce sempre <c>true</c> senza eseguire alcuna verifica.
/// </summary>
internal sealed class NullMachineGuard : IMachineGuard
{
/// <inheritdoc/>
public bool Verify() => true;
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>MachineGuardSetup</AssemblyName>
<RootNamespace>MachineGuardSetup</RootNamespace>
<!-- Standalone: publish as single self-contained exe for easy deployment -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
</ItemGroup>
</Project>
+198
View File
@@ -0,0 +1,198 @@
using MachineGuard;
// ============================================================
// MachineGuardSetup — Strumento di configurazione machine-binding
// Utilizzo: eseguire come Amministratore su ogni server autorizzato
// Indipendente da Data Coupler — nessuna dipendenza dall'applicazione
// ============================================================
Console.OutputEncoding = System.Text.Encoding.UTF8;
PrintBanner();
if (!OperatingSystem.IsWindows())
{
PrintError("Questo strumento richiede Windows (DPAPI è un'API esclusiva di Windows).");
return 1;
}
if (!IsRunningAsAdministrator())
{
PrintWarning("Attenzione: l'applicazione non è in esecuzione come Amministratore.");
PrintWarning("La scrittura in C:\\ProgramData potrebbe fallire senza privilegi elevati.");
Console.WriteLine();
}
return RunSetupWindows();
// ─────────────────────────────────────────────────────────────
// Entry point per Windows (isolato per soddisfare l'analizzatore)
// ─────────────────────────────────────────────────────────────
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
static int RunSetupWindows()
{
var defaultPath = MachineGuardSetupHelper.GetDefaultSecretFilePath();
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
Console.WriteLine("║ CONFIGURAZIONE MACHINE-BINDING ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
Console.WriteLine();
Console.WriteLine($" Percorso predefinito secret: {defaultPath}");
Console.WriteLine();
// Chiedi conferma o percorso personalizzato
Console.Write(" Usare il percorso predefinito? [S/n]: ");
var input = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
string targetPath;
if (string.IsNullOrEmpty(input) || input == "S" || input == "Y")
{
targetPath = defaultPath;
}
else
{
Console.Write(" Inserire il percorso completo del file secret: ");
var customPath = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(customPath))
{
PrintError("Percorso non valido. Operazione annullata.");
return 1;
}
targetPath = customPath;
}
// Verifica se esiste già un secret
if (File.Exists(targetPath))
{
Console.WriteLine($" ⚠ Il file secret esiste già: {targetPath}");
Console.Write(" Sovrascrivere? [s/N]: ");
var overwrite = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
if (overwrite != "S" && overwrite != "Y")
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(" Operazione annullata dall'utente.");
Console.ResetColor();
return 0;
}
// Verifica se il secret attuale è già valido
Console.WriteLine(" Verifica del secret esistente...");
if (MachineGuardSetupHelper.VerifySecret(targetPath))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✓ Il secret esistente è GIÀ valido per questa macchina.");
Console.ResetColor();
Console.Write(" Continuare comunque e riscrivere? [s/N]: ");
var rewrite = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
if (rewrite != "S" && rewrite != "Y")
{
Console.WriteLine(" Operazione annullata. Il secret esistente rimane invariato.");
return 0;
}
}
}
// Scrittura del secret
Console.WriteLine($" Scrittura del secret cifrato con DPAPI (LocalMachine) in:");
Console.WriteLine($" {targetPath}");
Console.WriteLine();
try
{
MachineGuardSetupHelper.WriteSecret(targetPath);
}
catch (UnauthorizedAccessException ex)
{
PrintError($"Accesso negato: {ex.Message}");
PrintError("Riprovare eseguendo il programma come Amministratore (tasto destro → Esegui come amministratore).");
return 1;
}
catch (Exception ex)
{
PrintError($"Errore durante la scrittura del secret: {ex.Message}");
return 1;
}
// Verifica post-scrittura
Console.WriteLine(" Verifica del secret appena scritto...");
if (MachineGuardSetupHelper.VerifySecret(targetPath))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✓ Secret scritto e verificato con successo!");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine(" Questa macchina è ora autorizzata a eseguire Data Coupler.");
Console.WriteLine();
PrintFileInfo(targetPath);
}
else
{
PrintError("Verifica post-scrittura fallita. Il file potrebbe essere corrotto.");
return 1;
}
Console.WriteLine();
Console.WriteLine(" Premere un tasto per uscire...");
if (!Console.IsInputRedirected)
Console.ReadKey(intercept: true);
return 0;
}
// ─────────────────────────────────────────────────────────────
// Funzioni di utilità
// ─────────────────────────────────────────────────────────────
static void PrintBanner()
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine();
Console.WriteLine(" ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ██╗ ██╗██████╗ ██╗ ███████╗██████╗ ");
Console.WriteLine(" ██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ ██╔════╝██╔═══██╗██║ ██║██╔══██╗██║ ██╔════╝██╔══██╗");
Console.WriteLine(" ██║ ██║███████║ ██║ ███████║ ██║ ██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝");
Console.WriteLine(" ██║ ██║██╔══██║ ██║ ██╔══██║ ██║ ██║ ██║██║ ██║██╔═══╝ ██║ ██╔══╝ ██╔══██╗");
Console.WriteLine(" ██████╔╝██║ ██║ ██║ ██║ ██║ ╚██████╗╚██████╔╝╚██████╔╝██║ ███████╗███████╗██║ ██║");
Console.WriteLine(" ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝");
Console.ResetColor();
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine(" MachineGuardSetup — Configurazione protezione machine-binding per Data Coupler");
Console.WriteLine(" Versione: 1.0 | Tecnologia: DPAPI (DataProtectionScope.LocalMachine)");
Console.ResetColor();
Console.WriteLine(new string('─', 70));
Console.WriteLine();
}
static void PrintError(string message)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" ✗ ERRORE: {message}");
Console.ResetColor();
}
static void PrintWarning(string message)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" ⚠ {message}");
Console.ResetColor();
}
static void PrintFileInfo(string path)
{
var fi = new FileInfo(path);
Console.WriteLine(" Dettagli file:");
Console.WriteLine($" Percorso : {fi.FullName}");
Console.WriteLine($" Dimensione: {fi.Length} byte (cifrati con DPAPI)");
Console.WriteLine($" Creato : {fi.CreationTime:dd/MM/yyyy HH:mm:ss}");
}
static bool IsRunningAsAdministrator()
{
if (!OperatingSystem.IsWindows()) return false;
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}