Premier commit
This commit is contained in:
@@ -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 _);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user