Впровадження контролю доступу на основі ролей (RBAC) з трансформацією претензій у .NET Core

Вступ

Контроль доступу на основі ролей (Role-Based Access Control, RBAC) є важливою частиною сучасної безпеки додатків. Ця стаття демонструє, як реалізувати комплексну систему RBAC у .NET Core, поєднуючи управління ролями, трансформацію вимог і динамічне оброблення дозволів.

Основні функції, які розглядаються:

  • Управління ролями та дозволами за допомогою Entity Framework Core
  • Трансформація вимог для зовнішніх постачальників автентифікації
  • Користувацькі обробники авторизації
  • Динамічне генерування політик
  • Інтеграція з Keycloak для автентифікації

Компоненти системи

Система складається з кількох основних компонентів:

  1. Моделі даних
  • Ролі
  • Дозволи
  • Користувачі
  • Таблиці відображення (RolePermission, UserRole)

2. Компоненти авторизації

  • Трансформація вимог
  • Обробник дозволів
  • Динамічний постачальник політик

**3.

Інтеграція автентифікації

  • Конфігурація JWT Bearer
  • Інтеграція з Keycloak

Ролі

public class Role  
{  
 public required int Id { get; init; }  
 public required string Name { get; init; }  
 public ICollection Permissions { get; set; } = default!;  
 public ICollection Users { get; set; }= default!;  
}  

public class RoleEnum(int value, string name) : SmartEnum(name, value)  
{  
 public static readonly RoleEnum Admin = new (1,"Admin");  
 public static readonly RoleEnum User = new (2, "User");  
}  

public class RoleConfiguration:IEntityTypeConfiguration  
{  
 public void Configure(EntityTypeBuilder builder)  
 {  
 builder.ToTable("Roles");  
 builder.Property(r => r.Name).HasMaxLength(100).IsRequired();  
 builder.HasMany(r => r.Permissions)  
 .WithMany()  
 .UsingEntity();  
 builder.HasMany(r => r.Users)  
 .WithMany(u => u.Roles)  
 .UsingEntity();  
}  


builder.HasData(RoleEnum.List.Select(r => new Role  
 {  
 Id = r,  
 Name = r.Name  
 }));  
 }  
}

Дозволи

public class Permission  
{  
 public required int Id { get; init; }  
 public required string Name { get; init; }  
 public ICollection? Roles { get; set; }  
}  

public class PermissionEnum(string name, int value) : SmartEnum(name, value)  
{  
 public static readonly PermissionEnum ReadUser = new ("ReadUser", 1);  
 public static readonly PermissionEnum CreateUser = new ("CreateUser", 2);  
 public static readonly PermissionEnum ReadWeathers = new ("ReadWeathers", 3);  
}  

public class PermissionConfiguration:IEntityTypeConfiguration  
{  
 public void Configure(EntityTypeBuilder builder)  
 {  
 builder.ToTable("Permissions");  
 builder.Property(r => r.Name).HasMaxLength(100).IsRequired();  
 builder.HasMany(r => r.Roles)  
 .WithMany()  
 .UsingEntity();  
}  


builder.HasData(PermissionEnum.List.Select(r => new Permission  
 {  
 Id = r,  
 Name = r.Name  
 }));  
 }  
}

Роль-Дозвіл

public class RolePermission  
{  
 public int RoleId { get; init; }  
 public int PermissionId { get; init;}  
}  

public class RolePermissionConfiguration:IEntityTypeConfiguration  
{  
 public void Configure(EntityTypeBuilder builder)  
 {  
 builder.ToTable("RolePermission");  
 builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });  
 builder.HasData(Create(RoleEnum.Admin, PermissionEnum.ReadUser),  
 Create(RoleEnum.Admin, PermissionEnum.CreateUser),  
 Create(RoleEnum.User, PermissionEnum.ReadUser),  
 Create(RoleEnum.Admin, PermissionEnum.ReadWeathers));  
 }  

 private static RolePermission Create(RoleEnum role,PermissionEnum permission)  
 {  
 return new RolePermission  
 {  
 PermissionId = permission,  
 RoleId = role  
 };  
 }  
}

Користувачі

public class User  
{  


public required int Id { get; init; }  
 public required string Name { get; init; }  
 public ICollection Roles { get; set; }  
 public string? IdentityUserId { get; set; }  
}  
public class UserConfiguration:IEntityTypeConfiguration  
{  
 public void Configure(EntityTypeBuilder builder)  
 {  
 builder.ToTable("Users");  
 builder.Property(u => u.Name).HasMaxLength(255);  
 builder.HasMany(u => u.Roles)  
 .WithMany(r=>r.Users)  
 .UsingEntity();  
 builder.HasData(new User()  
 {  
 Id = 1,  
 Name = "Admin user",  
 });  
 }  
}

Роль-Користувач

public class UserRole  
{  
 public int UserId { get; set; }  
 public int RoleId { get; set; }  
}  
public class UserRoleConfiguration:IEntityTypeConfiguration  
{  
 public void Configure(EntityTypeBuilder builder)  
 {  
 builder.ToTable("UserRoles");  
 builder.HasKey(ur => new { ur.UserId, ur.RoleId });  
 builder.HasData(new UserRole()  
 {  
 UserId = 1,  


RoleId = RoleEnum.Admin  
 });  
 }  
}

ApplicationDbContext

public class ApplicationDbContext(DbContextOptions options) : DbContext(options)  
{  

 public DbSet Users { get; set; }  

 protected override void OnModelCreating(ModelBuilder modelBuilder)  
 {  
 modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());  
 base.OnModelCreating(modelBuilder);  
 }  
}  


private static IServiceCollection AddApplicationDbContext(this IServiceCollection serviceProvider,IConfiguration configuration)  
 {  
 return serviceProvider.AddDbContext(options =>  
 options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));  

 }

Налаштування стратегії автентифікації

Стратегії автентифікації зазвичай підтримують різноманітні конфігурації, які завантажуються через параметри.
Мінімальні програми підтримують завантаження параметрів конфігурації для наступних стратегій автентифікації:

Система використовує автентифікацію JWT Bearer із Keycloak як постачальником автентифікації:

private static IServiceCollection AddAuthentication(this IServiceCollection serviceProvider)  
 {  
 serviceProvider  
 .AddAuthentication(x =>  
 {  
 x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;  
 x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;  
 x.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;  

 })  
 .AddJwtBearer(  
 JwtBearerDefaults.AuthenticationScheme,  
 options =>  
 {  
 var baseAddress = "http://localhost:8080";  
 var realmName = "AuthorizationTest";  
 options.MetadataAddress = $"{baseAddress}/realms/{{realm_name}}/.well-known/openid-configuration";  
 options.RequireHttpsMetadata = false; // тільки для розробки  
 options.SaveToken = true;  

 options.TokenValidationParameters = new TokenValidationParameters  
 {  

 ValidateAudience = false,  
 ValidateLifetime = true,  

 ValidateIssuerSigningKey = true,  
 IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>  
 {  

 var keyClient = new HttpClient();  
 var response = keyClient.GetStringAsync($"{baseAddress}/realms/{realmName}/protocol/openid-connect/certs").Result;  

 var keys = new JsonWebKeySet(response);  
 return keys.GetSigningKeys();   
 },  
 ValidIssuer = $"{baseAddress}/realms/{realmName}",  
 ValidateIssuer = true,  
 ClockSkew = TimeSpan.FromMinutes(5)  
 };  
 options.Authority = baseAddress;  
 }  
 );  

 return serviceProvider;  

 }

Трансформація Claims

Тепер я створю власну трансформацію claims, щоб додати дозволи в наші claims для використання в обробнику авторизації. Зверніть увагу, що я використав трансформацію claims, оскільки використовую зовнішнього постачальника, який відповідає за автентифікацію користувача.
Не хочу додавати в JWT токен, тому що це збільшить його розмір, а також якщо час життя токену довгий, то це не є безпечним, якщо ролі користувача будуть відкликані.

Логіка трансформації claims в .NET, яка реалізується через IClaimsTransformation, виконується після автентифікації, але перед авторизацією.

Після того як проміжне програмне забезпечення для автентифікації перевіряє токен або облікові дані користувача та створює ClaimsPrincipal, до того як запит буде авторизовано або виконується контролер/дія.

pic

Потік виконання

1- Проміжне програмне забезпечення для автентифікації: Це проміжне програмне забезпечення (наприклад, JwtBearer, OpenIdConnect) автентифікує запит, перевіряє токен і створює ClaimsPrincipal.

2- Трансформація claims: Метод IClaimsTransformation.TransformAsync() викликається для ClaimsPrincipal, який повертається від проміжного програмного забезпечення для автентифікації.
Це відбувається для кожного запиту.

3- Проміжне програмне забезпечення для авторизації: Перевірки авторизації (наприклад, політики або ролі) використовують трансформований ClaimsPrincipal.

4- Контролер/Дія: Коли виконується дія контролера, трансформовані claims доступні через User.Claims.

Важливі зауваження:

Виконання для кожного запиту: Реалізація IClaimsTransformation викликається для кожного запиту, коли створюється ClaimsPrincipal.
Якщо необхідне кешування, щоб уникнути повторних обчислень claims, вам потрібно буде реалізувати це у вашій імплементації.

public class CustomClaimsTransformation(IServiceProvider serviceProvider):IClaimsTransformation  
{  
 public async Task TransformAsync(ClaimsPrincipal principal)  
 {  
 if (principal.HasClaim(c => c.Type == CustomClaims.Permission))  
 {  
 return principal;  
 }  

 using IServiceScope scope = serviceProvider.CreateScope();  

 var sender = scope.ServiceProvider.GetRequiredService();  
 var identityId = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;  

 var result = await sender.Send(  
 new GetUserPermissionsQuery(identityId));  

 if (principal.Identity is not ClaimsIdentity identity)  
 {  
 return principal;  
 }  

 foreach (var permission in result.Permissions)  
 {  
 identity.AddClaim(  
 new Claim(CustomClaims.Permission, permission));  
 }  

 return principal;  
 }  
}

Згенерувати запит для отримання поточних прав користувача

public record GetUserPermissionsQuery(string IdentityUserId) : IRequest;
public record GetUserPermissionsResponse(HashSet Permissions);
public class GetUserPermissionsQueryHandler(ApplicationDbContext context) : IRequestHandler
{
public async Task Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken)
{
var user = await context.Users.Include(u => u.Roles)
.ThenInclude(r=>r.Permissions)
.FirstOrDefaultAsync(u => u.IdentityUserId == request.IdentityUserId,cancellationToken);
if (user == null)
{
throw new InvalidOperationException($"Користувача з id '{request.IdentityUserId}' не знайдено.");
}

var permissions = user.Roles.SelectMany(r => r.Permissions).Select(p => p.Name)
.ToHashSet();
return new GetUserPermissionsResponse(permissions);
}
}

Після того як ми додали трансформацію заяв (claims transformation), тепер ми створимо обробник авторизації для прав доступу (Permission Authorization Handler), який відповідатиме за перевірку прав користувача на кінцевій точці (endpoint). Спочатку створимо вимогу для прав доступу (permission requirement):

public class PermissionRequirement(string permission) : IAuthorizationRequirement  
{  
 public string Permission { get; } = permission;  
}
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>  
{  
 protected override Task HandleRequirementAsync(  
 AuthorizationHandlerContext context,  
 PermissionRequirement requirement)  
 {  
 var permissions = context.User.GetPermissions();  
 if (permissions.Contains(requirement.Permission))  
 {  
 context.Succeed(requirement);  
 }  

 return Task.CompletedTask;  
 }  
}

Додаємо політику для прав доступу, але замість того щоб додавати політику для кожного права, додаємо її таким чином:

builder.Services.AddAuthorization(options =>  
{  

 options.AddPolicy("CanReadWeathers", policy =>  
 {
Політика буде виглядати так:

policy.Requirements.Add(new PermissionRequirement("ReadWeather")); // Замінити на динамічне значення пізніше
});
});
```

Тепер ми створимо динамічного постачальника політик (policy provider) для прав доступу на кінцевих точках (endpoints), ось так:

public class PermissionPolicyProvider(IOptions<AuthorizationOptions> options)  
 : DefaultAuthorizationPolicyProvider(options)  
{  
 public override async Task<AuthorizationPolicy> GetPolicyAsync(string policyName)  
 {  
 var policy = await base.GetPolicyAsync(policyName);  
 if (policy is not null)  
 {  
 return policy;  
 }  

 return new AuthorizationPolicyBuilder()  
 .AddRequirements(new PermissionRequirement(policyName))  
 .Build();  
 }  
}

Тепер зареєструємо всі залежності:

public static class DependencyInjection  
{  
 public static IServiceCollection AddServices(this IServiceCollection serviceProvider,IConfiguration configuration)  
 {  
 return serviceProvider.AddAuthentication()  
 .AddSystemAuthorization()
.AddApplicationDbContext(configuration)  
 .AddEndpoints(Assembly.GetExecutingAssembly());  
 }  
 private static IServiceCollection AddEndpoints(this IServiceCollection serviceProvider,  
 Assembly assembly)  
 {  
 var endpoints = assembly.DefinedTypes.Where(type => type is { IsAbstract: false, IsInterface: false } &&  
 type.IsAssignableTo(typeof(IEndpoint)))  
 .Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type)).ToArray();  
 serviceProvider.TryAddEnumerable(endpoints);  
 return serviceProvider;  
 }  

 public static IApplicationBuilder MapEndpoints(this WebApplication app,  
 RouteGroupBuilder? routeGroupBuilder = null)  
 {  
 IEnumerable<IEndpoint> endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();  
 IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder;  
 foreach (var endpoint in endpoints)  
 {  
 endpoint.MapEndpoint(builder);  
 }  

 return app;  
 }
private static IServiceCollection AddApplicationDbContext(this IServiceCollection serviceProvider, IConfiguration configuration)  
 {  
 return serviceProvider.AddDbContext(options =>  
 options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));  

 }  
 private static IServiceCollection AddSystemAuthorization(this IServiceCollection serviceProvider)  
 {  
 serviceProvider.AddAuthorization();  
 serviceProvider.AddScoped();   
 serviceProvider.AddSingleton();  
 serviceProvider.AddSingleton();  

 return serviceProvider;  
 }  

 private static IServiceCollection AddAuthentication(this IServiceCollection serviceProvider)  
 {  
 serviceProvider  
 .AddAuthentication(x =>  
 {  
 x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;  
x.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;  

})  
.AddJwtBearer(  
JwtBearerDefaults.AuthenticationScheme,  
options =>  
{  
var baseAddress = "http://localhost:8080";  
var realmName = "AuthorizationTest";  
options.MetadataAddress = $"{baseAddress}/realms/{{realm_name}}/.well-known/openid-configuration";  
options.RequireHttpsMetadata = false; // тільки для розробки  
options.SaveToken = true;  

options.TokenValidationParameters = new TokenValidationParameters  
{  

ValidateAudience = false,  
ValidateLifetime = true,  

ValidateIssuerSigningKey = true,  
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>  
{  

var keyClient = new HttpClient();  
var response = keyClient.GetStringAsync($"{baseAddress}/realms/{realmName}/protocol/openid-connect/certs").Result;  

var keys = new JsonWebKeySet(response);
return keys.GetSigningKeys();   
},  
ValidIssuer = $"{baseAddress}/realms/{realmName}",  
ValidateIssuer = true,  
ClockSkew = TimeSpan.FromMinutes(5)  
};  
options.Authority = baseAddress;  
}  
);  

return serviceProvider;  

}  

}  

І генеруємо ендпоінт для погоди, щоб протестувати ролі та дозволи. Я створив два: один без авторизації, а інший з нею.

public class GetWeather:IEndpoint  
{  
 public void MapEndpoint(IEndpointRouteBuilder app)  
 {  

 var summaries = new[]  
 {  
 "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"  
 };  

 app.MapGet("/weatherforecast", () =>  
 {  
 var forecast = Enumerable.Range(1, 5).Select(index =>  
 new WeatherForecast  
 (  
 DateOnly.FromDateTime(DateTime.Now.AddDays(index)),  
 Random.Shared.Next(-20, 55),  
 summaries[Random.Shared.Next(summaries.Length)]  
 ))  
 .ToArray();  
 return forecast;  
 })  
 .WithName("GetWeatherForecastNoAuthorized")
.WithTags(Tags.Weathers)  
.MapToApiVersion(1);  

app.MapGet("/weatherforecast", () =>  
{  
 var forecast = Enumerable.Range(1, 5).Select(index =>  
 new WeatherForecast  
 (  
 DateOnly.FromDateTime(DateTime.Now.AddDays(index)),  
 Random.Shared.Next(-20, 55),  
 summaries[Random.Shared.Next(summaries.Length)]  
 ))  
 .ToArray();  
 return forecast;  
})  
.WithName("GetWeatherForecastAuthorized")  
.WithTags(Tags.Weathers)  
.RequireAuthorization(PermissionEnum.ReadWeathers.Name)  
.MapToApiVersion(2);  

}  
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)  
{  
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);  
}  
}  

Тепер я використовую UI Keycloak для створення Realm та користувача замість того, щоб створювати API для реєстрації та входу користувача. Я також використовую Keycloak для швидкої демонстрації потоку виконання.

pic
Спочатку створіть realm з ім'ям, яке ми налаштували в аутентифікації, а потім створіть користувача.

pic

pic

З вкладки "Client" (Клієнт) я можу перейти за посиланням для входу та отримати access token (токен доступу). Не забудьте додати identityId (ідентифікатор користувача) до таблиці користувачів у базі даних. Ідентифікатор користувача можна отримати в Keycloak в деталях користувача.

pic

Ви можете перевірити та отримати токен через мережу.

pic

Ви можете отримати access token для користувача.

pic
Додайте токен в Authorize, налаштуйте Swagger та конфігуруйте minimal API для використання версій v1 та v2, а також IEndpoint. Ви можете побачити проект на репозиторії Github.

Не соромтеся додавати коментарі, якщо хочете запитати про будь-що 😊.

Дякую Milan Jovanović.

https://www.milanjovanovic.tech/blog/master-claims-transformation-for-flexible-aspnetcore-authorization

Перекладено з: Implementing Role-Based Access Control (RBAC) with Claims Transformation in .NET Core

Leave a Reply

Your email address will not be published. Required fields are marked *