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
+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 _);
}
}