Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
T
gorbenko.pavel 8556626ff3
Tests / frontend-test (pull_request) Successful in 41s
Tests / backend-test (pull_request) Successful in 2m28s
должен читаться email из токена
2026-08-13 09:37:28 +05:00

151 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace Tnsu.ApprovalRoute.Api.Auth;
public static class AuthSetup
{
/// <summary>
/// Две схемы аутентификации вместо одной: помимо пользовательского токена
/// (админка) API должен принимать токен ПРИЛОЖЕНИЯ — эндпоинт напоминаний
/// вызывает Power Automate по расписанию, там нет ни пользователя, ни
/// интерактивного входа.
///
/// Сделано по образцу api-tnsu-portal/AuthRegistration.cs, включая настройки
/// Entra — приложение регистрируется то же самое.
/// </summary>
public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration)
{
var tenantId = configuration["Entra:TenantId"];
var audience = configuration["Entra:Audience"];
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
AddEntraAuth(services, configuration, tenantId, audience);
else
AddDevAuth(services);
return services;
}
private static void AddEntraAuth(
IServiceCollection services, IConfiguration configuration, string tenantId, string audience)
{
// Белый список приложений, которым разрешён app-only доступ. Пустой список
// означает «не ограничивать» — иначе на пустой конфигурации сломались бы
// вообще все app-only вызовы, что при выкладке выглядит как необъяснимый 401.
var allowedAppIds = configuration.GetSection("Entra:AllowedAppIds").Get<string[]>() ?? [];
var validationParameters = () => new TokenValidationParameters
{
ValidAudiences = [audience, $"api://{audience}"],
ValidIssuers =
[
$"https://login.microsoftonline.com/{tenantId}/v2.0",
$"https://sts.windows.net/{tenantId}/"
],
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true
};
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthSchemes.Bearer;
options.DefaultChallengeScheme = AuthSchemes.Bearer;
})
.AddJwtBearer(AuthSchemes.Bearer, options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.TokenValidationParameters = validationParameters();
// Без этого JwtBearerHandler переименовывает короткие имена claim'ов
// из токена в длинные ClaimTypes-URI (upn → ClaimTypes.Upn,
// unique_name → ClaimTypes.Name — а НЕ ClaimTypes.Email!) ещё до
// того, как код увидит ClaimsPrincipal. CurrentUserAccessor ищет
// ровно сырые имена ("upn", "unique_name") — с ремаппингом они не
// совпадали ни с чем, и email инициатора не находился вообще
// (не только у токенов без claim'а email, как этот — у ЛЮБЫХ).
options.MapInboundClaims = false;
});
services.AddAuthentication()
.AddJwtBearer(AuthSchemes.AppOnly, options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.TokenValidationParameters = validationParameters();
options.MapInboundClaims = false;
options.Events = new JwtBearerEvents
{
// Подпись, издатель и аудитория у app-only токена те же, что у
// пользовательского, поэтому единственное, что реально отличает
// «свой» вызывающий сервис от любого приложения тенанта, — appid.
OnTokenValidated = context =>
{
var claims = context.Principal?.Claims;
var appId = claims?.FirstOrDefault(c => c.Type == "appid")?.Value
?? claims?.FirstOrDefault(c => c.Type == "azp")?.Value;
if (allowedAppIds.Length > 0 && !allowedAppIds.Contains(appId))
{
context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("Auth.AppOnly")
.LogWarning("Приложение «{AppId}» отклонено — его нет в Entra:AllowedAppIds", appId);
context.Fail($"Приложение «{appId}» не входит в список разрешённых.");
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthPolicies.Delegated, policy =>
{
policy.AuthenticationSchemes.Add(AuthSchemes.Bearer);
policy.RequireAuthenticatedUser();
});
options.AddPolicy(AuthPolicies.AppOnly, policy =>
{
policy.AuthenticationSchemes.Add(AuthSchemes.AppOnly);
policy.RequireAuthenticatedUser();
});
// RequireAssertion, а не RequireAuthenticatedUser: политика с двумя
// схемами падает, если ОДНА из них отвергла токен, даже когда вторая
// его приняла. Здесь достаточно, чтобы аутентифицировала любая.
options.AddPolicy(AuthPolicies.Any, policy =>
{
policy.AddAuthenticationSchemes(AuthSchemes.Bearer, AuthSchemes.AppOnly);
policy.RequireAssertion(ctx => ctx.User.Identities.Any(i => i.IsAuthenticated));
});
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
});
}
private static void AddDevAuth(IServiceCollection services)
{
services.AddAuthentication(AuthSchemes.Dev)
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
// Те же имена политик, но поверх Dev-схемы: иначе контроллер с
// [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
services.AddAuthorization(options =>
{
foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
{
options.AddPolicy(name, policy =>
{
policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
policy.RequireAuthenticatedUser();
});
}
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
});
}
}