Авторизація: ролі, політики та resource-based доступ
Авторизація: ролі, політики та resource-based доступ
1. Три рівні авторизації
У ASP.NET Core авторизація має три рівні складності. Кожен наступний — потужніший:
| Рівень | Підхід | Приклад |
|---|---|---|
| 1️⃣ | Simple | Просто перевірити: аутентифікований чи ні? |
| 2️⃣ | Role-based | Перевірити роль: Admin, Editor, User? |
| 3️⃣ | Policy-based | Перевірити будь-яку складну умову: підписка, вік, чи автор ресурсу |
2. Simple — «Тільки для аутентифікованих»
Найпростіший рівень: ендпоінт доступний будь-кому, хто пройшов аутентифікацію:
// Будь-який аутентифікований користувач
app.MapGet("/orders", (ClaimsPrincipal user) =>
Results.Ok($"Orders for {user.Identity?.Name}"))
.RequireAuthorization();
Якщо запит без токена (або з невалідним) → 401 Unauthorized.
3. Role-based — авторизація за ролями
Концепція
Роль — це label, який визначає групу дозволів. Типові ролі: Admin, Moderator, User, Editor.
Ролі приходять як Claims у JWT-токені:
{
"sub": "42",
"role": ["Admin", "Editor"]
}
У C# кожна роль — окремий Claim типу ClaimTypes.Role.
Перевірка ролей у Minimal API
// Тільки Admin
app.MapDelete("/products/{id}", (int id) =>
Results.NoContent())
.RequireAuthorization(policy =>
policy.RequireRole("Admin"));
// Admin АБО Editor (будь-яка з ролей)
app.MapPut("/articles/{id}", (int id) =>
Results.Ok("Updated"))
.RequireAuthorization(policy =>
policy.RequireRole("Admin", "Editor"));
Ролі у Route Groups
// Всі ендпоінти адмін-панелі — тільки Admin
var admin = app.MapGroup("/admin")
.RequireAuthorization(policy =>
policy.RequireRole("Admin"));
admin.MapGet("/users", () => "List users");
admin.MapDelete("/users/{id}", (int id) =>
Results.NoContent());
admin.MapGet("/stats", () => "Statistics");
// Все захищено роллю Admin ✅
Перехоплення ролей у коді
Іноді потрібно перевірити роль всередині ендпоінту, а не на рівні маршруту:
app.MapGet("/orders/{id}", (int id,
ClaimsPrincipal user) =>
{
var order = GetOrder(id);
// Admin бачить усі деталі
if (user.IsInRole("Admin"))
return Results.Ok(new
{
order.Id, order.Items,
order.InternalNotes // Тільки для адміна
});
// Звичайний user — без internal notes
return Results.Ok(new
{
order.Id, order.Items
});
}).RequireAuthorization();
RequireAuthorization(policy => policy.RequireRole(...)) для блокування доступу. Використовуйте user.IsInRole(...) для фільтрації вмісту відповіді.4. Policy-based — гнучкі політики
Проблема ролей
Ролі — це просто мітки. А що, якщо потрібно перевірити складнішу умову?
- «Тільки користувачі з верифікованим email»
- «Тільки користувачі старше 18 років»
- «Тільки користувачі з активною Premium-підпискою»
Ролі для цього не підходять — не створювати ж роль "Over18WithPremiumAndVerifiedEmail". Тут допомагають Policy (політики).
Створення політик
Політика — це іменована вимога, яка зареєстрована в DI:
builder.Services.AddAuthorizationBuilder()
// Політика 1: потрібна певна роль
.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"))
// Політика 2: потрібен конкретний claim
.AddPolicy("VerifiedEmail", policy =>
policy.RequireClaim("email_verified", "true"))
// Політика 3: комбінація умов
.AddPolicy("PremiumUser", policy =>
policy
.RequireAuthenticatedUser()
.RequireClaim("subscription", "premium")
.RequireRole("User"))
// Політика 4: мінімальний вік
.AddPolicy("AdultOnly", policy =>
policy.RequireAssertion(context =>
{
var ageClaim = context.User
.FindFirst("age")?.Value;
return ageClaim is not null
&& int.Parse(ageClaim) >= 18;
}));
Використання політик
app.MapDelete("/products/{id}", (int id) =>
Results.NoContent())
.RequireAuthorization("AdminOnly");
app.MapPost("/reviews", (Review review) =>
Results.Created($"/reviews/1", review))
.RequireAuthorization("VerifiedEmail");
app.MapGet("/premium/content", () =>
Results.Ok("Exclusive content"))
.RequireAuthorization("PremiumUser");
app.MapPost("/casino/bet", (BetRequest bet) =>
Results.Ok(bet))
.RequireAuthorization("AdultOnly");
Вбудовані методи Policy Builder
.RequireAuthorization() без політики.RequireRole("Admin", "Editor") = Admin АБО Editor.RequireClaim("subscription", "premium").RequireClaim("department") — у користувача має бути цей claim.5. Custom Requirements та Handlers
Коли RequireAssertion недостатньо
RequireAssertion зручний для простих перевірок, але:
- Лямбда — нетестовна (не можна написати unit test)
- Логіка в лямбді — не перевикористовується
- Не підтримує DI (не можна ін'єктувати сервіси)
Для складних перевірок ASP.NET Core надає Requirement + Handler паттерн.
Крок 1: Requirement — «що перевіряємо»
using Microsoft.AspNetCore.Authorization;
public class MinAgeRequirement
: IAuthorizationRequirement
{
public int MinAge { get; }
public MinAgeRequirement(int minAge)
{
MinAge = minAge;
}
}
IAuthorizationRequirement — це маркерний інтерфейс. Клас містить параметри перевірки (мінімальний вік), але не логіку.
Крок 2: Handler — «як перевіряємо»
using Microsoft.AspNetCore.Authorization;
public class MinAgeHandler
: AuthorizationHandler<MinAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinAgeRequirement requirement)
{
var ageClaim = context.User
.FindFirst("date_of_birth")?.Value;
if (ageClaim is null)
{
// Claim відсутній — не можемо перевірити
// Не викликаємо Fail() — дозволяємо
// іншим handlers спробувати
return Task.CompletedTask;
}
var dateOfBirth = DateOnly.Parse(ageClaim);
var age = CalculateAge(dateOfBirth);
if (age >= requirement.MinAge)
{
context.Succeed(requirement); // ✅
}
return Task.CompletedTask;
}
private static int CalculateAge(DateOnly dob)
{
var today = DateOnly.FromDateTime(
DateTime.UtcNow);
var age = today.Year - dob.Year;
if (dob > today.AddYears(-age))
age--;
return age;
}
}
context.Succeed(requirement) якщо перевірка пройшла. Якщо НЕ пройшла — він не викликає нічого (або context.Fail() для жорсткої відмови). Це дозволяє мати кілька handlers для одного requirement (наприклад, один перевіряє claim, інший — базу даних).Крок 3: Реєстрація
builder.Services
.AddSingleton<IAuthorizationHandler,
MinAgeHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Adult", policy =>
policy.Requirements.Add(
new MinAgeRequirement(18)))
.AddPolicy("SeniorDiscount", policy =>
policy.Requirements.Add(
new MinAgeRequirement(60)));
Один Handler обслуговує обидві політики! Requirement задає параметри (18 чи 60), Handler — логіку перевірки.
Приклад: Handler з DI
Handlers повністю підтримують Dependency Injection. Наприклад, перевірка підписки через базу даних:
public class ActiveSubscriptionRequirement
: IAuthorizationRequirement
{
public string RequiredPlan { get; }
public ActiveSubscriptionRequirement(
string requiredPlan)
{
RequiredPlan = requiredPlan;
}
}
public class SubscriptionHandler
: AuthorizationHandler<
ActiveSubscriptionRequirement>
{
// DI: ін'єкція сервісу підписок
private readonly ISubscriptionService _subs;
public SubscriptionHandler(
ISubscriptionService subs)
{
_subs = subs;
}
protected override async Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveSubscriptionRequirement requirement)
{
var userId = context.User
.FindFirst("sub")?.Value;
if (userId is null)
return;
// Запит до БД через DI-сервіс
var subscription = await _subs
.GetActiveSubscription(
int.Parse(userId));
if (subscription?.Plan ==
requirement.RequiredPlan
&& subscription.ExpiresAt >
DateTime.UtcNow)
{
context.Succeed(requirement);
}
}
}
6. Resource-based авторизація
Проблема
Уявіть блог-платформу. Ендпоінт PUT /posts/{id} має бути доступний тільки автору поста. Але роль «Author» не допомагає — потрібно перевірити, чи конкретний пост належить конкретному користувачу.
Це resource-based авторизація — перевірка прав на конкретний ресурс.
Реалізація: Requirement + Resource Handler
public class ResourceOwnerRequirement
: IAuthorizationRequirement
{
}
public class PostOwnerHandler
: AuthorizationHandler<
ResourceOwnerRequirement, Post>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ResourceOwnerRequirement requirement,
Post resource) // ← конкретний ресурс!
{
var userId = context.User
.FindFirst("sub")?.Value;
// Перевіряємо, чи автор поста = поточний user
if (userId is not null
&& resource.AuthorId.ToString() == userId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Використання в ендпоінті
Resource-based авторизація не може бути в атрибуті — потрібен конкретний ресурс, який доступний тільки всередині ендпоінту:
app.MapPut("/posts/{id}",
async (int id,
UpdatePostRequest req,
IAuthorizationService authService,
ClaimsPrincipal user,
PostRepository repo) =>
{
// 1. Отримуємо ресурс
var post = await repo.GetById(id);
if (post is null)
return Results.NotFound();
// 2. Перевіряємо авторизацію НА ресурс
var authResult = await authService
.AuthorizeAsync(
user,
post, // ← конкретний об'єкт!
new ResourceOwnerRequirement());
if (!authResult.Succeeded)
return Results.Forbid(); // 403
// 3. Оновлюємо
post.Title = req.Title;
post.Content = req.Content;
await repo.Update(post);
return Results.Ok(post);
}).RequireAuthorization();
- 401 — «Я не знаю, хто ти» (відсутній або невалідний токен)
- 403 — «Я знаю, хто ти, але тобі заборонено» (є токен, але немає прав)
Results.Forbid() повертає саме 403.7. Fallback та Default Policy
Default Policy
Якщо ви викликаєте .RequireAuthorization() без аргументів, ASP.NET Core застосовує Default Policy. За замовчуванням — просто RequireAuthenticatedUser().
Ви можете змінити default policy:
builder.Services.AddAuthorizationBuilder()
.SetDefaultPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("email_verified", "true")
.Build());
Тепер кожен .RequireAuthorization() вимагатиме не тільки аутентифікації, але й верифікованого email.
Fallback Policy
Fallback Policy — ще потужніший інструмент. Він застосовується до всіх ендпоінтів, що не мають явної конфігурації авторизації:
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
Тепер кожен ендпоінт вимагає аутентифікацію, навіть без .RequireAuthorization(). Щоб зробити ендпоінт публічним, потрібно явно додати .AllowAnonymous():
// ✅ Публічний (явний виняток)
app.MapGet("/", () => "Welcome!")
.AllowAnonymous();
// 🔒 Захищений (автоматично через Fallback)
app.MapGet("/orders", () => "Orders");
// 🔒 Теж захищений (автоматично)
app.MapGet("/profile", () => "Profile");
.RequireAuthorization() до нового ендпоінту — він все одно буде захищений. Публічним можна зробити тільки явно.8. Практичні завдання
Рівень 1: Базовий
Створіть API з трьома ролями:
- Додайте roles у JWT:
"Admin","Editor","User" GET /products— доступний усім аутентифікованимPOST /products— тількиAdminабоEditorDELETE /products/{id}— тількиAdminGET /admin/stats— тількиAdmin- Протестуйте: запит з роллю
UserнаDELETE→ який статус-код? 401 чи 403?
Створіть і зареєструйте 3 політики:
"VerifiedUser"— вимагає claim"email_verified"="true""PremiumAccess"— вимагає claim"subscription"="premium"і роль"User""InternalApi"— вимагає claim"department"(будь-яке значення)- Створіть ендпоінти, захищені кожною політикою
- Протестуйте з токеном, де claims відсутні, та з коректними claims
Рівень 2: Проєктування
Реалізуйте кастомну авторизацію «робочий час»:
- Створіть
WorkingHoursRequirement(int startHour, int endHour) - Створіть
WorkingHoursHandler— дозволяє доступ тільки в робочий час (наприклад, 9:00–18:00 UTC) - Зареєструйте політику
"WorkingHoursOnly"для ендпоінтів типу/admin/deploy - Зробіть Handler тестовним: час отримуйте через
ISystemClockабо інтерфейсITimeProvider - Перевірте: запит о 3 ночі →
403, о 14:00 →200
Реалізуйте блог-платформу:
- Модель
Post { Id, Title, Content, AuthorId } POST /posts— створити пост (AuthorId = поточний user)PUT /posts/{id}— редагувати пост (тільки автор!)DELETE /posts/{id}— видалити пост (автор АБО Admin)- Використайте
IAuthorizationService.AuthorizeAsync()зResourceOwnerRequirement - Перевірте: редагування чужого поста →
403
Рівень 3: Архітектура
Побудуйте API з повною системою авторизації:
- Fallback Policy — все закрито за замовчуванням
- Публічні ендпоінти:
GET /(home),POST /auth/login,POST /auth/register— з.AllowAnonymous() - User-ендпоінти:
GET /me,GET /orders,POST /orders— Default Policy (аутентифікація) - Editor-ендпоінти:
POST /articles,PUT /articles/{id}— політика"Editor" - Admin-ендпоінти:
GET /admin/users,DELETE /admin/users/{id}— політика"AdminOnly" - Resource-based:
PUT /orders/{id}— тільки автор замовлення - Не менше 3 іменованих політик
- Переконайтесь: новий ендпоінт без конфігурації → автоматично захищений
9. Резюме
Role-based — просто і швидко
Policy-based — гнучкість
Requirement + Handler
Resource-based — найточніший
Далі: у наступній статті ми розглянемо Cookie-аутентифікацію та ASP.NET Core Identity — вбудовану систему управління користувачами з готовою базою даних, хешуванням паролів та .MapIdentityApi().
JWT-аутентифікація
Повна реалізація JWT Bearer у Minimal API: генерація токенів, login/refresh ендпоінти, TokenValidationParameters, зберігання секретів, Access + Refresh Token стратегія.
Cookie-аутентифікація та ASP.NET Core Identity
Cookie Authentication у Minimal API, порівняння з JWT, ASP.NET Core Identity: UserManager, SignInManager, хешування паролів, MapIdentityApi та структура БД Identity.