Premier commit
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
@SIFinance.Api_HostAddress = http://localhost:5272
|
||||
|
||||
GET {{SIFinance.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user