151 lines
7.6 KiB
C#
151 lines
7.6 KiB
C#
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)!;
|
||
});
|
||
}
|
||
}
|