Premier commit

This commit is contained in:
2026-09-11 14:55:41 +02:00
commit 8ed04aae89
134 changed files with 36627 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Envoie une requête minimale à SQL Server à intervalle régulier pour garder une
// connexion du pool active. Sans ça, une connexion inutilisée trop longtemps entre
// deux sessions de travail peut être coupée silencieusement par un équipement réseau
// (pare-feu/routeur) entre la machine de dev et le serveur SQL distant — la requête
// suivante doit alors attendre l'expiration de la connexion morte avant d'en ouvrir
// une nouvelle, d'où un premier chargement lent (plusieurs secondes) après une pause.
public class DbKeepAliveService(IServiceScopeFactory scopeFactory, ILogger<DbKeepAliveService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
// Le Dashboard (et d'autres pages) lancent plusieurs requêtes en parallèle au
// chargement (demandes/factures/avoirs) — chacune a besoin de sa propre connexion
// du pool. Ne garder qu'UNE connexion chaude ne profite qu'à la première requête
// servie ; les autres ouvrent quand même une connexion à froid en parallèle, avec
// le même délai. On chauffe donc plusieurs connexions à la fois.
private const int WarmConnections = 4;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Premier ping immédiat au démarrage : sans ça, PeriodicTimer attend un
// intervalle complet avant son premier tick, donc les toutes premières
// requêtes réelles de l'appli (juste après un dotnet run) n'en profitent
// jamais et paient quand même le coût d'ouverture de connexion à froid.
await PingMany(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await PingMany(stoppingToken);
}
}
private Task PingMany(CancellationToken ct) =>
Task.WhenAll(Enumerable.Range(0, WarmConnections).Select(_ => Ping(ct)));
private async Task Ping(CancellationToken ct)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SIFinanceDbContext>();
await db.Database.ExecuteSqlRawAsync("SELECT 1", ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec du ping de maintien de connexion vers SQL Server.");
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Le backend pilote lui-même le flux OAuth2 "authorization code + PKCE" avec Entra ID
// (plutôt que MSAL.js côté frontend) : /api/auth/microsoft redirige vers Microsoft,
// /api/auth/callback échange le code, résout l'utilisateur (déjà provisionné par
// EntraSyncService) et émet le jeton "maison" (JwtTokenService) consommé par le
// frontend pour tous les appels API suivants.
public static class EntraAuthEndpoints
{
// État PKCE en mémoire (code_verifier par "state"), expire après 10 min — un seul
// process API tourne en dev ; à revoir (cache distribué) si on scale horizontalement.
private static readonly ConcurrentDictionary<string, (string CodeVerifier, DateTime Expiry)> PkceStore = new();
public static IEndpointRouteBuilder MapEntraAuthEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/auth/microsoft", (IOptions<AzureAdOptions> azureOptions) =>
{
var opts = azureOptions.Value;
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
var codeVerifier = Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
var codeChallenge = Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)));
CleanupExpired();
PkceStore[state] = (codeVerifier, DateTime.UtcNow.AddMinutes(10));
var url = QueryHelpers.AddQueryString(
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/authorize",
new Dictionary<string, string?>
{
["client_id"] = opts.ClientId,
["response_type"] = "code",
["redirect_uri"] = opts.RedirectUri,
["response_mode"] = "query",
["scope"] = "openid profile email User.Read",
["state"] = state,
["prompt"] = "select_account",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
});
return Results.Redirect(url);
});
app.MapGet("/api/auth/callback", async (
string? code, string? state, string? error,
IOptions<AzureAdOptions> azureOptions,
IHttpClientFactory httpClientFactory,
SIFinanceDbContext db,
JwtTokenService jwtService,
IConfiguration config,
ILogger<Program> logger,
CancellationToken ct) =>
{
var frontendUrl = (config["FrontendUrl"] ?? "http://localhost:5173").TrimEnd('/');
if (!string.IsNullOrEmpty(error))
return Results.Redirect($"{frontendUrl}/login?error={Uri.EscapeDataString(error)}");
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)
|| !PkceStore.TryRemove(state, out var entry) || entry.Expiry < DateTime.UtcNow)
return Results.Redirect($"{frontendUrl}/login?error=invalid_state");
var opts = azureOptions.Value;
var http = httpClientFactory.CreateClient();
var tokenResponse = await http.PostAsync(
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = opts.ClientId,
["client_secret"] = opts.ClientSecret,
["code"] = code,
["redirect_uri"] = opts.RedirectUri,
["grant_type"] = "authorization_code",
["code_verifier"] = entry.CodeVerifier,
}), ct);
if (!tokenResponse.IsSuccessStatusCode)
{
var body = await tokenResponse.Content.ReadAsStringAsync(ct);
logger.LogWarning("Échange du code OAuth2 échoué ({Status}) : {Body}", tokenResponse.StatusCode, body);
return Results.Redirect($"{frontendUrl}/login?error=token_exchange_failed");
}
var tokenJson = JsonDocument.Parse(await tokenResponse.Content.ReadAsStringAsync(ct)).RootElement;
var accessToken = tokenJson.GetProperty("access_token").GetString();
var meRequest = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
meRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var meResponse = await http.SendAsync(meRequest, ct);
if (!meResponse.IsSuccessStatusCode)
{
var body = await meResponse.Content.ReadAsStringAsync(ct);
logger.LogWarning("Appel Graph /me échoué ({Status}) : {Body}", meResponse.StatusCode, body);
return Results.Redirect($"{frontendUrl}/login?error=graph_me_failed");
}
var me = JsonDocument.Parse(await meResponse.Content.ReadAsStringAsync(ct)).RootElement;
var email = GetStr(me, "mail") ?? GetStr(me, "userPrincipalName");
if (string.IsNullOrWhiteSpace(email))
return Results.Redirect($"{frontendUrl}/login?error=no_email");
var user = await db.Utilisateurs
.FirstOrDefaultAsync(u => u.Email == email && u.Actif, ct);
// Pas de création à la volée ici : l'utilisateur doit être membre du groupe
// Entra ID synchronisé par EntraSyncService pour pouvoir se connecter.
if (user is null)
{
logger.LogWarning("Connexion refusée — aucun utilisateur actif trouvé pour l'email {Email}.", email);
return Results.Redirect($"{frontendUrl}/login?error=user_not_found");
}
user.DateDerniereConnexion = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var roles = await db.UtilisateurRoles
.AsNoTracking()
.Where(r => r.AzureOid == user.AzureOid)
.Select(r => r.Role)
.ToListAsync(ct);
var estResponsable = await db.Utilisateurs.AsNoTracking().AnyAsync(u => u.ResponsableId == user.Id, ct);
var jwt = jwtService.GenerateToken(user, roles, estResponsable);
return Results.Redirect($"{frontendUrl}/auth/callback?token={Uri.EscapeDataString(jwt)}");
});
return app;
}
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static string? GetStr(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
private static void CleanupExpired()
{
foreach (var key in PkceStore.Where(kv => kv.Value.Expiry < DateTime.UtcNow).Select(kv => kv.Key).ToList())
PkceStore.TryRemove(key, out _);
}
}
+189
View File
@@ -0,0 +1,189 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Synchronise périodiquement les membres du groupe Entra ID (AzureAd:GroupId) dans
// core.utilisateurs : création des nouveaux membres (Profil par défaut "DEMANDEUR",
// à ajuster manuellement en base pour les profils Finance), mise à jour des noms/
// emails, désactivation de ceux qui ont quitté le groupe. Remplace la résolution
// "just-in-time" à la connexion — un utilisateur doit être membre du groupe pour
// pouvoir se connecter (voir EntraAuthEndpoints).
public class EntraSyncService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
EntraGraphTokenProvider tokenProvider,
IOptions<AzureAdOptions> options,
ILogger<EntraSyncService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SyncOnce(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await SyncOnce(stoppingToken);
}
}
private async Task SyncOnce(CancellationToken ct)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.GroupId) || string.IsNullOrWhiteSpace(opts.ClientSecret))
{
logger.LogWarning("Synchro Entra ID ignorée — configuration AzureAd incomplète (GroupId/ClientSecret manquants).");
return;
}
try
{
var token = await tokenProvider.GetTokenAsync(ct);
var http = httpClientFactory.CreateClient();
var membres = new List<JsonElement>();
string? url = $"https://graph.microsoft.com/v1.0/groups/{opts.GroupId}/members" +
"?$select=id,givenName,surname,mail,userPrincipalName,accountEnabled&$top=999";
while (url is not null)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(ct);
var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
membres.AddRange(json.RootElement.GetProperty("value").EnumerateArray().Select(e => e.Clone()));
url = json.RootElement.TryGetProperty("@odata.nextLink", out var next) ? next.GetString() : null;
}
var valides = membres.Where(EstMembreValide).ToList();
var emailsActifs = valides.Select(m => GetStr(m, "mail")!.Trim().ToLowerInvariant()).ToHashSet();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SIFinanceDbContext>();
var existants = await db.Utilisateurs.ToDictionaryAsync(u => u.Email.Trim().ToLowerInvariant(), ct);
var crees = 0;
var mis = 0;
foreach (var m in valides)
{
var email = GetStr(m, "mail")!.Trim().ToLowerInvariant();
var oid = GetStr(m, "id") ?? "";
var prenom = GetStr(m, "givenName") ?? "";
var nom = GetStr(m, "surname") ?? "";
if (existants.TryGetValue(email, out var existant))
{
existant.AzureOid = oid;
if (!string.IsNullOrWhiteSpace(nom)) existant.Nom = nom;
if (!string.IsNullOrWhiteSpace(prenom)) existant.Prenom = prenom;
existant.Actif = true;
mis++;
}
else
{
db.Utilisateurs.Add(new Utilisateur
{
AzureOid = oid,
Nom = nom,
Prenom = prenom,
Email = email,
Profil = "DEMANDEUR",
Actif = true,
DateCreation = DateTime.UtcNow,
});
crees++;
}
}
var desactives = 0;
foreach (var (email, existant) in existants)
{
if (existant.Actif && !emailsActifs.Contains(email))
{
existant.Actif = false;
desactives++;
}
}
// SaveChanges ici pour que les nouveaux utilisateurs aient un Id avant la
// résolution des liens responsable→collaborateur juste après (impossible de
// référencer un Utilisateur pas encore enregistré en base).
await db.SaveChangesAsync(ct);
var responsablesResolus = await ResoudreResponsablesAsync(db, valides, token, http, ct);
logger.LogInformation(
"Synchro Entra ID terminée : {Total} membres valides, {Crees} créés, {Mis} mis à jour, {Desactives} désactivés, {Responsables} liens responsable résolus.",
valides.Count, crees, mis, desactives, responsablesResolus);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec de la synchro Entra ID.");
}
}
// Interroge le manager Entra ID de chaque membre (GET /users/{id}/manager) et
// résout son Id interne via l'email — le manager doit lui-même déjà exister comme
// Utilisateur (généralement le cas, les managers étant aussi membres du groupe
// synchronisé). Un appel par membre plutôt qu'un batch Graph : plus simple/robuste,
// le coût (~une centaine d'appels toutes les 30 min, en tâche de fond) est négligeable.
private async Task<int> ResoudreResponsablesAsync(SIFinanceDbContext db, List<JsonElement> valides, string token, HttpClient http, CancellationToken ct)
{
var utilisateurs = await db.Utilisateurs.ToDictionaryAsync(u => u.Email.Trim().ToLowerInvariant(), ct);
var resolus = 0;
foreach (var m in valides)
{
var oid = GetStr(m, "id");
var email = GetStr(m, "mail")?.Trim().ToLowerInvariant();
if (oid is null || email is null || !utilisateurs.TryGetValue(email, out var collaborateur)) continue;
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"https://graph.microsoft.com/v1.0/users/{oid}/manager?$select=id,mail,userPrincipalName");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode) continue; // pas de manager (404) ou erreur ponctuelle — on laisse ResponsableId tel quel
using var managerStream = await response.Content.ReadAsStreamAsync(ct);
using var managerDoc = await JsonDocument.ParseAsync(managerStream, cancellationToken: ct);
var managerEmail = (GetStr(managerDoc.RootElement, "mail") ?? GetStr(managerDoc.RootElement, "userPrincipalName"))?.Trim().ToLowerInvariant();
if (managerEmail is null || !utilisateurs.TryGetValue(managerEmail, out var responsable)) continue;
if (responsable.Id == collaborateur.Id) continue; // garde-fou, ne devrait pas arriver
if (collaborateur.ResponsableId != responsable.Id)
{
collaborateur.ResponsableId = responsable.Id;
resolus++;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec de résolution du manager pour {Email}.", email);
}
}
await db.SaveChangesAsync(ct);
return resolus;
}
private static bool EstMembreValide(JsonElement m)
{
var mail = GetStr(m, "mail");
if (string.IsNullOrWhiteSpace(mail)) return false;
// accountEnabled absent (utilisateur non hydraté) est traité comme actif ;
// seule une valeur explicite "false" exclut le membre.
return !m.TryGetProperty("accountEnabled", out var p) || p.ValueKind != JsonValueKind.False;
}
private static string? GetStr(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
}
+11
View File
@@ -0,0 +1,11 @@
namespace SIFinance.Api;
// Section "Jwt" de appsettings.Development.json — clé de signature des jetons émis
// par SIFinance.Api après connexion Entra ID (voir JwtTokenService / EntraAuthEndpoints).
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Secret { get; set; } = "";
public int ExpiresHours { get; set; } = 8;
}
+52
View File
@@ -0,0 +1,52 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Émet le jeton "maison" utilisé pour authentifier les appels à l'API après la
// connexion Entra ID (voir EntraAuthEndpoints.MapGet("/api/auth/callback")). Le
// claim "id" porte directement l'Id de la ligne core.utilisateurs — CurrentUserService
// n'a donc plus besoin d'accéder à la base pour résoudre l'utilisateur courant.
public class JwtTokenService(IOptions<JwtOptions> options)
{
public string GenerateToken(Utilisateur user, IEnumerable<string>? roles = null, bool estResponsable = false)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.Secret))
throw new InvalidOperationException("Configuration Jwt:Secret manquante — voir appsettings.Development.json.");
var claims = new List<Claim>
{
new("id", user.Id.ToString()),
new(ClaimTypes.Email, user.Email),
new("nom", user.Nom),
new("prenom", user.Prenom),
new("profil", user.Profil),
// true seulement si au moins un utilisateur a ce compte comme responsable_id —
// pilote l'affichage "Responsable" côté frontend (Layout.tsx).
new("estResponsable", estResponsable ? "true" : "false"),
};
// Sert à scoper les vues (ex. Pilotage) au service du collaborateur connecté —
// absent si l'utilisateur n'est rattaché à aucun service.
if (user.ServiceId.HasValue)
claims.Add(new Claim("serviceId", user.ServiceId.Value.ToString()));
// Un utilisateur peut cumuler plusieurs rôles métier (ex. DEMANDEUR + FINANCE) —
// "profil" reste la colonne historique à valeur unique, "roles" (claim répétée)
// porte la liste réelle depuis core.utilisateur_roles, utilisée pour le bascule
// d'espace de travail côté frontend.
foreach (var role in roles ?? [])
claims.Add(new Claim("roles", role));
var key = new SymmetricSecurityKey(Convert.FromBase64String(opts.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddHours(opts.ExpiresHours <= 0 ? 8 : opts.ExpiresHours),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
+106
View File
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SIFinance.Api;
using SIFinance.Modules.Achats.Endpoints;
using SIFinance.Modules.Finance.Endpoints;
using SIFinance.Modules.SousTraitants.Endpoints;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddDbContext<SIFinanceDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddHostedService<DbKeepAliveService>();
builder.Services.AddHttpClient();
builder.Services.Configure<AzureAdOptions>(builder.Configuration.GetSection(AzureAdOptions.SectionName));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.AddSingleton<EntraGraphTokenProvider>();
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddHostedService<EntraSyncService>();
// Valide le jeton "maison" émis par SIFinance.Api après l'échange OAuth2 avec Entra ID
// (voir EntraAuthEndpoints) — pas de validation directe de jeton Entra ID : le backend
// pilote lui-même le flux de connexion, comme l'appli NDF existante (JWT maison +
// synchro périodique du groupe Entra ID via EntraSyncService), plutôt que MSAL.js +
// Microsoft.Identity.Web côté frontend.
var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Configuration Jwt:Secret manquante — voir appsettings.Development.json.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(jwtSecret)),
};
});
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserService>();
builder.Services.Configure<SharePointOptions>(builder.Configuration.GetSection(SharePointOptions.SectionName));
// Pas de client Graph enregistré directement en DI : tant que TenantId/ClientId/
// ClientSecret ne sont pas renseignés (ou invalides), ClientSecretCredential lève une
// ArgumentException dès sa construction — avant même le premier appel Graph. Si le
// client était un singleton construit par le conteneur DI, cette exception surviendrait
// pendant la résolution de la requête, hors de portée d'un try/catch dans l'endpoint,
// et remonterait comme une erreur 500 brute. SharePointGraphClientFactory construit le
// client à la demande, à l'intérieur du bloc try/catch de l'endpoint.
builder.Services.AddSingleton<SharePointGraphClientFactory>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<NotificationEmailService>();
const string FrontendCorsPolicy = "Frontend";
builder.Services.AddCors(options =>
{
options.AddPolicy(FrontendCorsPolicy, policy =>
policy.WithOrigins(
"http://localhost:5173", "http://localhost:5174",
"http://127.0.0.1:5173", "http://127.0.0.1:5174")
.AllowAnyHeader()
.AllowAnyMethod()
// Le navigateur ne mémorise le résultat d'un preflight OPTIONS que sur la durée
// indiquée ici (par défaut ~5s) : on l'étend pour éviter un aller-retour OPTIONS
// avant chaque POST/PUT/PATCH répété (création/édition) pendant la session.
.SetPreflightMaxAge(TimeSpan.FromMinutes(30)));
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseCors(FrontendCorsPolicy);
app.UseAuthentication();
app.UseAuthorization();
app.MapAchatsEndpoints();
app.MapContratsEndpoints();
app.MapBudgetsEndpoints();
app.MapFacturesEndpoints();
app.MapAvoirsEndpoints();
app.MapPaiementsEndpoints();
app.MapDashboardEndpoints();
app.MapSharePointEndpoints();
app.MapEntraAuthEndpoints();
app.MapOrganismesEndpoints();
app.MapContratsPrestatairesEndpoints();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:5272",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://0.0.0.0:7286;http://0.0.0.0:5272",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NuGetAuditMode>direct</NuGetAuditMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
<ProjectReference Include="..\SIFinance.Modules.Achats\SIFinance.Modules.Achats.csproj" />
<ProjectReference Include="..\SIFinance.Modules.Finance\SIFinance.Modules.Finance.csproj" />
<ProjectReference Include="..\SIFinance.Modules.SousTraitants\SIFinance.Modules.SousTraitants.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@SIFinance.Api_HostAddress = http://localhost:5272
GET {{SIFinance.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
+101
View File
@@ -0,0 +1,101 @@
using Azure.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Graph;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
public record SharePointSiteDto(string Id, string Name, string? WebUrl);
public record SharePointUploadDto(string WebUrl, string ItemId, long Size, string Nom);
// Endpoints de découverte SharePoint + upload de documents dans les dossiers modules
// (Achats / Sous-Traitants / Contrat Etudiant) du drive configuré (SharePoint:DriveId).
public static class SharePointEndpoints
{
// Whitelist stricte plutôt que d'accepter n'importe quel nom de dossier envoyé par le
// client — évite qu'un chemin arbitraire soit construit dans l'URL Graph.
private static readonly HashSet<string> DossiersAutorises =
new(StringComparer.OrdinalIgnoreCase) { "Achats", "Sous-Traitants", "Contrat Etudiant" };
public static IEndpointRouteBuilder MapSharePointEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/sharepoint").WithTags("SharePoint").RequireAuthorization();
group.MapGet("/sites", async (SharePointGraphClientFactory graphFactory, CancellationToken ct) =>
{
try
{
var graph = graphFactory.Create();
var result = await graph.Sites.GetAsync(cfg => cfg.QueryParameters.Search = "*", ct);
var sites = (result?.Value ?? [])
.Select(s => new SharePointSiteDto(s.Id ?? "", s.Name ?? s.DisplayName ?? "(sans nom)", s.WebUrl))
.ToList();
return Results.Ok(sites);
}
catch (Exception ex)
{
// Cas fréquents : secret pas encore renseigné, permission Sites.Read.All
// absente, ou admin consent pas encore accordé (403).
return Results.BadRequest(new
{
error = "Impossible de récupérer la liste des sites SharePoint — vérifie les identifiants et les permissions (Sites.Read.All ou Sites.ReadWrite.All, avec admin consent).",
detail = ex.Message,
});
}
});
group.MapPost("/upload", async (
HttpRequest request, SharePointUploadService uploadService,
SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!request.HasFormContentType)
return Results.BadRequest(new { error = "Contenu multipart attendu." });
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
var dossier = form["dossier"].ToString();
var service = form["service"].ToString();
if (file is null || file.Length == 0)
return Results.BadRequest(new { error = "Fichier manquant." });
if (!DossiersAutorises.Contains(dossier))
return Results.BadRequest(new { error = "Dossier de destination invalide." });
// Le sous-dossier "collaborateur" est résolu depuis l'utilisateur authentifié,
// jamais depuis une valeur envoyée par le client — sinon n'importe qui pourrait
// faire apparaître son upload dans le dossier d'un collègue.
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var user = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
var collaborateur = user is null ? "Autre" : $"{user.Prenom} {user.Nom}";
var segments = new List<string> { dossier };
if (!string.IsNullOrWhiteSpace(service)) segments.Add(SanitizeSegment(service));
segments.Add(SanitizeSegment(collaborateur));
var cheminDossier = string.Join('/', segments);
try
{
await using var stream = file.OpenReadStream();
var result = await uploadService.UploadAsync(cheminDossier, file.FileName, stream, file.ContentType, ct);
return Results.Ok(new SharePointUploadDto(result.WebUrl, result.ItemId, result.Size, file.FileName));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "Échec de l'upload vers SharePoint.", detail = ex.Message });
}
});
return app;
}
private static string SanitizeSegment(string value)
{
var trimmed = value.Trim();
if (trimmed.Length == 0) return "Autre";
var invalides = new[] { '"', '*', ':', '<', '>', '?', '/', '\\', '|' };
var nettoye = new string(trimmed.Select(c => invalides.Contains(c) ? '-' : c).ToArray());
return nettoye.Length == 0 ? "Autre" : nettoye;
}
}
@@ -0,0 +1,22 @@
using Azure.Identity;
using Microsoft.Extensions.Options;
using Microsoft.Graph;
namespace SIFinance.Api;
// Construit le GraphServiceClient à la demande plutôt qu'au démarrage : tant que les
// identifiants SharePoint (TenantId/ClientId/ClientSecret) ne sont pas renseignés,
// ClientSecretCredential lève une exception à la construction — on veut que ça arrive
// à l'intérieur du try/catch de l'endpoint qui l'appelle, pas pendant la résolution DI.
public class SharePointGraphClientFactory(IOptions<SharePointOptions> options)
{
public GraphServiceClient Create()
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.TenantId) || string.IsNullOrWhiteSpace(opts.ClientId) || string.IsNullOrWhiteSpace(opts.ClientSecret))
throw new InvalidOperationException("Configuration SharePoint incomplète (TenantId/ClientId/ClientSecret) — voir SIFinance.Api/appsettings.Development.json.");
var credential = new ClientSecretCredential(opts.TenantId, opts.ClientId, opts.ClientSecret);
return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace SIFinance.Api;
// Section "SharePoint" de appsettings.Development.json — identifiants de l'app
// registration Azure AD (flux client credentials, app-only, pas de connexion
// utilisateur). SiteId/DriveId sont renseignés une fois trouvés via
// GET /api/sharepoint/sites (et le futur endpoint pour les drives).
public class SharePointOptions
{
public const string SectionName = "SharePoint";
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string SiteId { get; set; } = "";
public string DriveId { get; set; } = "";
}
+80
View File
@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using SIFinance.Shared;
namespace SIFinance.Api;
public record SharePointUploadResult(string WebUrl, string ItemId, long Size);
// Dépose un fichier dans un sous-dossier du drive SharePoint configuré (SharePoint:DriveId)
// — un dossier par module (Achats / Sous-Traitants / Contrat Etudiant), créés lors du
// provisioning du site. Réutilise le jeton Graph "app-only" déjà mis en cache par
// EntraGraphTokenProvider plutôt que ClientSecretCredential/GraphServiceClient : plus
// simple pour un simple PUT de contenu, et le jeton couvre déjà Sites.ReadWrite.All.
public class SharePointUploadService(
IHttpClientFactory httpClientFactory,
EntraGraphTokenProvider tokenProvider,
IOptions<SharePointOptions> options)
{
// Upload "simple" (PUT direct) : suffisant jusqu'à 4 Mo, ce qui couvre largement les
// justificatifs (devis, factures, bons de commande scannés). Au-delà, Graph exige une
// session d'upload par blocs — pas implémenté ici, hors scope du premier jet.
// cheminDossier peut contenir plusieurs segments (ex: "Achats/Informatique/Jean Dupont") —
// Graph crée automatiquement les dossiers intermédiaires manquants lors d'un upload
// vers un chemin qui n'existe pas encore, pas besoin de les créer explicitement.
public async Task<SharePointUploadResult> UploadAsync(string cheminDossier, string fileName, Stream content, string? contentType, CancellationToken ct)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.DriveId))
throw new InvalidOperationException("Configuration SharePoint incomplète (DriveId) — voir appsettings.Development.json.");
var token = await tokenProvider.GetTokenAsync(ct);
var http = httpClientFactory.CreateClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var safeName = Uri.EscapeDataString(fileName);
var url = $"https://graph.microsoft.com/v1.0/drives/{opts.DriveId}/root:/{cheminDossier}/{safeName}:/content?@microsoft.graph.conflictBehavior=rename";
using var streamContent = new StreamContent(content);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
var response = await http.PutAsync(url, streamContent, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"Échec de l'upload SharePoint ({response.StatusCode}) : {body}");
var json = JsonDocument.Parse(body).RootElement;
var itemWebUrl = json.GetProperty("webUrl").GetString()!;
var itemId = json.GetProperty("id").GetString()!;
var size = json.TryGetProperty("size", out var sizeProp) ? sizeProp.GetInt64() : content.Length;
// L'URL brute de l'item n'est accessible qu'aux membres du groupe SharePoint
// sous-jacent — un lien de partage "organisation" permet à n'importe quel
// utilisateur du tenant, connecté avec son compte Microsoft, de l'ouvrir sans
// avoir besoin d'être membre du groupe. On le préfère donc à l'URL brute quand
// Graph accepte de le créer (si ça échoue, on retombe sur l'URL brute plutôt
// que de faire échouer tout l'upload).
var lienOrganisation = await CreerLienOrganisationAsync(opts.DriveId, itemId, http, ct);
return new SharePointUploadResult(lienOrganisation ?? itemWebUrl, itemId, size);
}
private static async Task<string?> CreerLienOrganisationAsync(string driveId, string itemId, HttpClient http, CancellationToken ct)
{
try
{
var response = await http.PostAsJsonAsync(
$"https://graph.microsoft.com/v1.0/drives/{driveId}/items/{itemId}/createLink",
new { type = "view", scope = "organization" }, ct);
if (!response.IsSuccessStatusCode) return null;
var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement;
return json.GetProperty("link").GetProperty("webUrl").GetString();
}
catch
{
return null;
}
}
}
@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AzureAd": {
"ClientSecret": "xA.8Q~fRieIg-HZEunz__xLws0a4E.hdJU88sb8s",
"GroupId": "c1ea877c-6bca-4f47-bfad-f223640813a0"
},
"Jwt": {
"Secret": "83/Ug9ginhRDJJYUWy2YWlAxu5egbzgewuYaV8elGPxlxXwzWI5GokMD7I9QvhbmA7tz9+IlPOkqy7/uInwUcg=="
},
"SharePoint": {
"TenantId": "9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
"ClientId": "277b070b-83f7-4c15-8787-7acb51c2f567",
"ClientSecret": "xA.8Q~fRieIg-HZEunz__xLws0a4E.hdJU88sb8s",
"SiteId": "ensup.sharepoint.com,9096beec-6c67-45a9-bba1-693f767c00d4,dc598513-c302-44ce-b25e-6b369c0ba602",
"DriveId": "b!7L6WkGdsqUW7oWk_dnwA1BOFWdwCw85Esl5rNpwLpgIIr8dZ90UYTqtD1bA3wKY7"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Server=192.168.0.3,1433;Database=SIFinance;Trusted_Connection=True;TrustServerCertificate=True;Connection Lifetime=60;Min Pool Size=4"
},
"AzureAd": {
"TenantId": "9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
"ClientId": "277b070b-83f7-4c15-8787-7acb51c2f567",
"RedirectUri": "http://localhost:5272/api/auth/callback"
},
"FrontendUrl": "http://localhost:5173"
}