154 lines
7.1 KiB
C#
154 lines
7.1 KiB
C#
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 _);
|
|
}
|
|
}
|