| | | 1 | | using System.Security.Claims; |
| | | 2 | | using Microsoft.AspNetCore.Authorization; |
| | | 3 | | using Microsoft.AspNetCore.Mvc; |
| | | 4 | | using Microsoft.AspNetCore.Mvc.Abstractions; |
| | | 5 | | using Microsoft.AspNetCore.Mvc.Controllers; |
| | | 6 | | using Microsoft.AspNetCore.Mvc.Filters; |
| | | 7 | | using Microsoft.EntityFrameworkCore; |
| | | 8 | | using Microsoft.Extensions.Caching.Memory; |
| | | 9 | | using Microsoft.Extensions.Logging; |
| | | 10 | | using Microsoft.Extensions.Options; |
| | | 11 | | using OzonDomains.Models.Authorization.Enums; |
| | | 12 | | using OzonRepositories.Context; |
| | | 13 | | using Services.Authorization; |
| | | 14 | | using Services.Authorization.Services; |
| | | 15 | | |
| | | 16 | | namespace OzonOrdersWeb.Authorization; |
| | | 17 | | |
| | | 18 | | /// <summary> |
| | | 19 | | /// Глобальный фильтр: если действие не помечено [AllowAnonymous] и не имеет [PageAuthorize], |
| | | 20 | | /// автоматически определяет код страницы по Area/Controller/Action и проверяет доступ. |
| | | 21 | | /// </summary> |
| | | 22 | | public sealed class AutoPageAuthorizationFilter : IAsyncAuthorizationFilter |
| | | 23 | | { |
| | | 24 | | private readonly ApplicationDbContext _db; |
| | | 25 | | private readonly IPermissionService _permissions; |
| | | 26 | | private readonly IMemoryCache _cache; |
| | | 27 | | private readonly ILogger<AutoPageAuthorizationFilter> _logger; |
| | | 28 | | private readonly AppAuthorizationOptions _options; |
| | | 29 | | |
| | | 30 | | private const string PageRegistryCacheKey = "__auth_page_registry"; |
| | | 31 | | |
| | 3 | 32 | | public AutoPageAuthorizationFilter( |
| | 3 | 33 | | ApplicationDbContext db, |
| | 3 | 34 | | IPermissionService permissions, |
| | 3 | 35 | | IMemoryCache cache, |
| | 3 | 36 | | ILogger<AutoPageAuthorizationFilter> logger, |
| | 3 | 37 | | IOptions<AppAuthorizationOptions> options) |
| | | 38 | | { |
| | 3 | 39 | | _db = db; |
| | 3 | 40 | | _permissions = permissions; |
| | 3 | 41 | | _cache = cache; |
| | 3 | 42 | | _logger = logger; |
| | 3 | 43 | | _options = options.Value; |
| | 3 | 44 | | } |
| | | 45 | | |
| | | 46 | | public async Task OnAuthorizationAsync(AuthorizationFilterContext context) |
| | | 47 | | { |
| | 3 | 48 | | if (!_options.EnableAutoPageFilter) return; |
| | | 49 | | |
| | 3 | 50 | | var endpoint = context.HttpContext.GetEndpoint(); |
| | 3 | 51 | | if (endpoint is null) return; |
| | | 52 | | |
| | | 53 | | // Корневую страницу не обрабатываем правилами AppPage. |
| | | 54 | | // Иначе при незарегистрированном root роуте можно получить Forbid на "/". |
| | 3 | 55 | | var requestPath = context.HttpContext.Request.Path.Value; |
| | 3 | 56 | | if (string.IsNullOrEmpty(requestPath) || requestPath == "/") |
| | 0 | 57 | | return; |
| | | 58 | | |
| | | 59 | | // AllowAnonymous — пропускаем |
| | 3 | 60 | | if (endpoint.Metadata.GetMetadata<AllowAnonymousAttribute>() is not null) |
| | 0 | 61 | | return; |
| | | 62 | | |
| | | 63 | | // Служебный AJAX-эндпоинт — не страница, прав на него не выдают. Роли из [Authorize] остаются. |
| | 3 | 64 | | if (IsServiceEndpoint(endpoint, context.ActionDescriptor)) |
| | 2 | 65 | | return; |
| | | 66 | | |
| | | 67 | | // Явный [PageAuthorize] — записываем в Items и доверяем policy |
| | 1 | 68 | | var explicitPa = endpoint.Metadata.GetMetadata<PageAuthorizeAttribute>(); |
| | 1 | 69 | | if (explicitPa is not null) |
| | | 70 | | { |
| | 0 | 71 | | context.HttpContext.Items[PageAuthorizeAttribute.PageCodeItemKey] = explicitPa.PageCode; |
| | 0 | 72 | | context.HttpContext.Items[PageAuthorizeAttribute.AccessTypeItemKey] = explicitPa.Access; |
| | 0 | 73 | | return; |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | // Без атрибута — только для аутентифицированных проверяем авто-страницу |
| | 1 | 77 | | if (context.HttpContext.User?.Identity?.IsAuthenticated != true) return; |
| | | 78 | | |
| | | 79 | | // Быстрый bypass для системных администраторов (SuperAdmin AppRole или Identity-роль Admin). |
| | 1 | 80 | | var user = context.HttpContext.User; |
| | 1 | 81 | | if (user.IsInRole(AuthorizationConstants.SuperAdminRoleName) |
| | 1 | 82 | | || user.IsInRole("Admin") |
| | 3 | 83 | | || user.HasClaim(c => c.Type == PermissionClaimsTransformation.SuperAdminClaimType && c.Value == "true")) |
| | 0 | 84 | | return; |
| | | 85 | | |
| | 1 | 86 | | var descriptor = context.ActionDescriptor as ControllerActionDescriptor; |
| | 1 | 87 | | if (descriptor is null) return; |
| | | 88 | | |
| | 1 | 89 | | var area = descriptor.RouteValues.TryGetValue("area", out var a) ? a ?? string.Empty : string.Empty; |
| | 1 | 90 | | var controller = descriptor.ControllerName; |
| | 1 | 91 | | var action = descriptor.ActionName; |
| | | 92 | | |
| | 1 | 93 | | var registry = await GetPageRegistryAsync(context.HttpContext.RequestAborted); |
| | 0 | 94 | | var key = BuildKey(area, controller, action); |
| | 0 | 95 | | if (!registry.TryGetValue(key, out var pageInfo)) |
| | | 96 | | { |
| | 0 | 97 | | if (_options.AllowUnregisteredPages) return; |
| | 0 | 98 | | _logger.LogWarning("Доступ запрещён: страница {Key} не зарегистрирована", key); |
| | 0 | 99 | | context.Result = new ForbidResult(); |
| | 0 | 100 | | return; |
| | | 101 | | } |
| | | 102 | | |
| | 0 | 103 | | var userId = context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; |
| | 0 | 104 | | if (string.IsNullOrWhiteSpace(userId)) |
| | | 105 | | { |
| | 0 | 106 | | context.Result = new ForbidResult(); |
| | 0 | 107 | | return; |
| | | 108 | | } |
| | | 109 | | |
| | 0 | 110 | | var accessType = InferAccessType(context.HttpContext.Request.Method, action, controller); |
| | | 111 | | |
| | 0 | 112 | | context.HttpContext.Items[PageAuthorizeAttribute.PageCodeItemKey] = pageInfo.Code; |
| | 0 | 113 | | context.HttpContext.Items[PageAuthorizeAttribute.AccessTypeItemKey] = accessType; |
| | | 114 | | |
| | 0 | 115 | | var allowed = await _permissions.HasPageAccessAsync(userId, pageInfo.Code, accessType, context.HttpContext.Reque |
| | 0 | 116 | | if (!allowed) |
| | | 117 | | { |
| | 0 | 118 | | _logger.LogInformation("Отказ в доступе: {User} -> {Page} ({Access})", userId, pageInfo.Code, accessType); |
| | 0 | 119 | | context.Result = new ForbidResult(); |
| | | 120 | | } |
| | 2 | 121 | | } |
| | | 122 | | |
| | | 123 | | /// <summary> |
| | | 124 | | /// [ServiceEndpoint] на действии или контроллере. Смотрим и метаданные эндпоинта, и сам метод — |
| | | 125 | | /// чтобы не зависеть от того, как атрибут попал в маршрут. |
| | | 126 | | /// </summary> |
| | | 127 | | private static bool IsServiceEndpoint(Endpoint endpoint, ActionDescriptor actionDescriptor) |
| | | 128 | | { |
| | 3 | 129 | | if (endpoint.Metadata.GetMetadata<ServiceEndpointAttribute>() is not null) |
| | 0 | 130 | | return true; |
| | | 131 | | |
| | 3 | 132 | | if (actionDescriptor is not ControllerActionDescriptor cad) |
| | 0 | 133 | | return false; |
| | | 134 | | |
| | 3 | 135 | | return cad.MethodInfo.GetCustomAttributes(typeof(ServiceEndpointAttribute), inherit: true).Length > 0 |
| | 3 | 136 | | || cad.ControllerTypeInfo.GetCustomAttributes(typeof(ServiceEndpointAttribute), inherit: true).Length > 0 |
| | | 137 | | } |
| | | 138 | | |
| | | 139 | | private static string BuildKey(string? area, string controller, string action) |
| | 0 | 140 | | => $"{(area ?? string.Empty).ToLowerInvariant()}|{controller.ToLowerInvariant()}|{action.ToLowerInvariant()}"; |
| | | 141 | | |
| | | 142 | | /// <summary>По HTTP-методу + имени действия догадаться о типе доступа.</summary> |
| | | 143 | | private static AccessType InferAccessType(string httpMethod, string actionName, string controllerName) |
| | | 144 | | { |
| | 0 | 145 | | var lower = actionName.ToLowerInvariant(); |
| | 0 | 146 | | if (lower.Contains("export") || lower.Contains("download")) return AccessType.Export; |
| | 0 | 147 | | if (lower.StartsWith("delete") || lower.Contains("remove")) return AccessType.Delete; |
| | 0 | 148 | | if (lower.StartsWith("create") || lower.Contains("add")) return AccessType.Create; |
| | 0 | 149 | | if (lower.StartsWith("edit") || lower.StartsWith("update") || lower.Contains("save")) |
| | 0 | 150 | | return AccessType.Edit; |
| | | 151 | | // Проведение транзакции (POST) в пресетах ролей выдаётся как CanCreate, не CanEdit. |
| | 0 | 152 | | if (httpMethod == "POST" |
| | 0 | 153 | | && controllerName.EndsWith("Transaction", StringComparison.OrdinalIgnoreCase) |
| | 0 | 154 | | && !controllerName.Equals("Transaction", StringComparison.OrdinalIgnoreCase)) |
| | | 155 | | { |
| | 0 | 156 | | return AccessType.Create; |
| | | 157 | | } |
| | 0 | 158 | | if (httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH") return AccessType.Edit; |
| | 0 | 159 | | if (httpMethod == "DELETE") return AccessType.Delete; |
| | 0 | 160 | | return AccessType.View; |
| | | 161 | | } |
| | | 162 | | |
| | | 163 | | private async Task<Dictionary<string, PageInfo>> GetPageRegistryAsync(CancellationToken ct) |
| | | 164 | | { |
| | 1 | 165 | | if (_cache.TryGetValue(PageRegistryCacheKey, out Dictionary<string, PageInfo>? cached) && cached is not null) |
| | 0 | 166 | | return cached; |
| | | 167 | | |
| | 0 | 168 | | var pages = await _db.AppPages.AsNoTracking().Where(p => p.IsActive).ToListAsync(ct); |
| | 0 | 169 | | var dict = pages.ToDictionary( |
| | 0 | 170 | | p => BuildKey(p.Area, p.Controller, p.Action), |
| | 0 | 171 | | p => new PageInfo(p.Id, p.Code)); |
| | | 172 | | |
| | 0 | 173 | | _cache.Set(PageRegistryCacheKey, dict, TimeSpan.FromMinutes(5)); |
| | 0 | 174 | | return dict; |
| | 0 | 175 | | } |
| | | 176 | | |
| | 0 | 177 | | public static void InvalidatePageRegistry(IMemoryCache cache) => cache.Remove(PageRegistryCacheKey); |
| | | 178 | | |
| | 0 | 179 | | private sealed record PageInfo(int Id, string Code); |
| | | 180 | | } |